From 6500d5694406a58a74857a1e0c5c87d0773a79c2 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Thu, 12 Nov 2026 14:53:26 -0800 Subject: [PATCH 01/38] feat(plugins): load directory plugins that generate, present, search, and change their own Artifacts Implements the Artifact plugin part of RFC #2549 and the plugin APIs and Dashboard pages of RFC #2579, and replaces the managed Artifact plugin framework (#2246). - A plugin is a directory with plugin.py assigning an ArtifactPlugin. Server checks it in a child process; generate runs in Supervisor Workers, and render_artifact, search_artifacts, user changes, Source writes of the plugin's own types, and plugin routes run in a per-plugin host process. - Plugins present (GET .../rendering) and search their own families: generic search passes the complete request to search_artifacts, and Prepared Context sections may list plugin families. Core builds no index for them. - Revision v1_5_0_005 removes the previous framework's storage only when it is empty, adds pc_artifacts.metadata (plugin name and code digest), and accepts plugin: bindings; frozen revision resources are unchanged. Portable revision records move to schema version 3 and still restore version 2. - Plugin APIs mount at /v1/plugins/{name}/api with a configurable body ceiling, and plugin pages run in a sandboxed Dashboard frame. Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/transport/operations.json | 2 +- desktop/src-tauri/src/transport/wire.rs | 57 - desktop/ui/src/generated/api.d.ts | 984 ++++++----- desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/develop/index.md | 1 + docs/en/docs/develop/meta.json | 3 +- docs/en/docs/develop/plugins.md | 452 +++++ docs/en/docs/operate/configuration.md | 1 + docs/en/docs/operate/troubleshoot.md | 10 +- docs/zh/docs/develop/index.md | 1 + docs/zh/docs/develop/meta.json | 3 +- docs/zh/docs/develop/plugins.md | 421 +++++ docs/zh/docs/operate/configuration.md | 1 + docs/zh/docs/operate/troubleshoot.md | 10 +- examples/artifact_plugins/README.md | 221 --- .../incident_report/plugin.py | 97 -- .../artifact_plugins/ticket_summary/plugin.py | 95 -- .../plugins/powercontext/hooks/bind_tools.py | 4 + .../powercontext/src/operations.generated.ts | 15 +- .../powercontext/src/operations.generated.ts | 15 +- .../powercontext/src/operations.generated.ts | 15 +- openapi/powercontext.yaml | 662 +++++--- release/upgrade-support.json | 4 +- scripts/ci_role_smoke.py | 5 - scripts/generate_api.py | 48 + src/powercontext/__init__.py | 17 - src/powercontext/artifact_plugins.py | 149 -- src/powercontext/artifacts/indexes.py | 177 -- .../builtin/artifact_plugin_validation.py | 260 --- src/powercontext/builtin/authz/profiles.py | 4 +- src/powercontext/builtin/authz/service.py | 6 - .../persistence/artifact_governance.py | 30 - .../builtin/persistence/artifact_indexes.py | 1053 ------------ .../persistence/artifact_search_schema.py | 248 --- .../persistence/artifact_vector_space.py | 243 --- .../builtin/persistence/artifacts.py | 12 +- .../builtin/persistence/custom_artifacts.py | 96 -- .../persistence/migrations/maintenance.py | 12 +- .../builtin/persistence/migrations/mysql.py | 4 - .../persistence/migrations/mysql_schema.py | 43 +- .../migrations/server_readiness.py | 14 +- .../migrations/server_resources/manifest.json | 36 +- .../mysql/versions/v1_5_0_005.py | 22 +- .../plugin_artifact_readiness.py | 172 ++ .../schemas/mysql-v1_5_0_005.json | 302 ++++ .../schemas/sqlite-v1_5_0_005.json | 21 + .../versions/v1_5_0_005_plugin_interfaces.py | 29 + .../server_sources/1.4.0/manifest.json | 36 +- .../persistence/processing_migration.py | 3 - .../builtin/persistence/records.py | 44 +- .../persistence/revision_metadata_schema.py | 2 +- .../builtin/persistence/schema.py | 88 +- .../builtin/persistence/scope_reads.py | 3 +- .../builtin/persistence/sources.py | 20 +- .../persistence/sqlite/experience_index.py | 3 +- .../builtin/persistence/tables.py | 36 +- src/powercontext/builtin/persistence/tags.py | 12 +- .../powercontext/builtin/plugins/__init__.py | 13 +- src/powercontext/builtin/plugins/executor.py | 210 +++ src/powercontext/builtin/plugins/families.py | 124 ++ src/powercontext/builtin/plugins/host.py | 628 +++++++ .../builtin/plugins/host_protocol.py | 100 ++ src/powercontext/builtin/plugins/manifest.py | 151 ++ src/powercontext/builtin/plugins/storage.py | 150 ++ src/powercontext/builtin/plugins/writer.py | 772 +++++++++ .../builtin/portability/bundle.py | 21 +- src/powercontext/builtin/records.py | 2 - .../builtin/runtime/application.py | 30 + .../builtin/runtime/artifact_merge.py | 34 +- .../builtin/runtime/artifact_plugins.py | 347 ---- .../builtin/runtime/artifact_reads.py | 206 --- .../builtin/runtime/artifact_search.py | 5 +- .../builtin/runtime/atomic_memory_rebuild.py | 3 +- .../builtin/runtime/composition.py | 11 +- .../builtin/runtime/custom_artifact_search.py | 463 ----- src/powercontext/builtin/runtime/models.py | 25 +- .../builtin/runtime/prepared_context.py | 28 +- .../builtin/runtime/prepared_text.py | 19 +- src/powercontext/builtin/runtime/receipt.py | 3 +- src/powercontext/builtin/tags.py | 32 +- src/powercontext/client/client.py | 45 +- src/powercontext/http/__init__.py | 54 +- src/powercontext/http/_generated/models.py | 421 +++-- .../http/_generated/operations.py | 377 ++-- src/powercontext/http/_generated/schema.py | 1512 +++++++++++------ src/powercontext/plugins.py | 788 +++++++++ src/powercontext/server/app.py | 846 +++++++-- src/powercontext/server/artifact_plugins.py | 661 ------- src/powercontext/server/dashboard/__init__.py | 11 + .../server/dashboard/labels.en.json | 4 +- src/powercontext/server/dashboard/labels.json | 4 +- .../server/dashboard/plugin_pages.py | 182 ++ src/powercontext/server/dashboard/routes.py | 4 + .../server/dashboard/static/plugin-client.js | 73 + .../templates/components/navigation.html | 10 +- .../server/dashboard/templates/macros.html | 2 +- .../server/dashboard/templates/plugin.html | 98 ++ .../server/dashboard/templates/workspace.html | 2 + src/powercontext/server/factory.py | 87 +- src/powercontext/server/http_limits.py | 23 +- src/powercontext/server/middleware.py | 8 + src/powercontext/server/ownership.py | 16 +- src/powercontext/server/plugin_hosts.py | 221 +++ src/powercontext/server/plugins.py | 453 +++++ src/powercontext/server/settings.py | 4 +- .../persistence/test_artifact_indexes.py | 834 --------- .../test_artifact_search_schema.py | 130 -- .../test_artifact_storage_lifecycle.py | 362 ---- .../test_atomic_description_vector_layout.py | 23 +- .../test_atomic_evidence_upgrade_history.py | 7 +- .../test_custom_artifact_search.py | 121 -- .../test_full_server_migrations.py | 3 +- .../test_mysql_initialization_preflight.py | 2 +- .../test_mysql_migration_runner.py | 16 +- .../test_owner_recovery_migration.py | 3 +- .../test_plugin_storage_migration.py | 111 ++ .../test_portable_storage_migration.py | 23 +- .../test_release140_server_migrations.py | 3 +- tests/builtin/persistence/test_sources.py | 42 +- tests/builtin/plugins/test_plugin_inputs.py | 47 + tests/builtin/plugins/test_plugin_writer.py | 313 ++++ .../runtime/test_artifact_plugin_worker.py | 119 -- .../runtime/test_artifact_read_context.py | 482 ------ .../builtin/runtime/test_prepared_context.py | 3 +- tests/e2e/plugin_server.py | 137 ++ .../test_artifact_authorization_startup.py | 498 ------ tests/e2e/test_artifact_index_backends.py | 105 -- tests/e2e/test_artifact_plugin_search.py | 412 ----- tests/e2e/test_artifact_plugins.py | 1141 ------------- tests/e2e/test_artifact_tags.py | 2 +- tests/e2e/test_artifact_vector_http.py | 131 -- .../test_atomic_memory_description_upgrade.py | 4 +- tests/e2e/test_capture_coverage.py | 2 +- tests/e2e/test_context_text_assembly.py | 2 +- tests/e2e/test_plugin_pages.py | 136 ++ tests/e2e/test_plugins.py | 411 +++++ tests/e2e/test_prepared_context_receipt.py | 2 +- tests/e2e/test_prompt_management_cleanup.py | 122 -- tests/e2e/test_source_artifact_plugins.py | 91 - tests/e2e/test_source_citation_locators.py | 11 +- tests/plugin_support.py | 111 ++ tests/test_api_contract.py | 35 +- tests/test_artifact_index_maintenance.py | 266 --- tests/test_artifact_plugin_leases.py | 63 - tests/test_artifact_plugin_validation.py | 88 - tests/test_managed_atomic_maintenance.py | 4 +- tests/test_managed_processing_maintenance.py | 2 +- tests/test_migration_revision_names.py | 6 +- tests/test_plugin_capabilities.py | 38 + .../test_registered_source_maintenance_cli.py | 2 +- 150 files changed, 10554 insertions(+), 11521 deletions(-) create mode 100644 docs/en/docs/develop/plugins.md create mode 100644 docs/zh/docs/develop/plugins.md delete mode 100644 examples/artifact_plugins/README.md delete mode 100644 examples/artifact_plugins/incident_report/plugin.py delete mode 100644 examples/artifact_plugins/ticket_summary/plugin.py delete mode 100644 src/powercontext/artifact_plugins.py delete mode 100644 src/powercontext/artifacts/indexes.py delete mode 100644 src/powercontext/builtin/artifact_plugin_validation.py delete mode 100644 src/powercontext/builtin/persistence/artifact_indexes.py delete mode 100644 src/powercontext/builtin/persistence/artifact_search_schema.py delete mode 100644 src/powercontext/builtin/persistence/artifact_vector_space.py delete mode 100644 src/powercontext/builtin/persistence/custom_artifacts.py rename examples/artifact_plugins/incident_report/plugin.yml => src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_005.py (68%) create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/plugin_artifact_readiness.py create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_005.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_005.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_005_plugin_interfaces.py rename examples/artifact_plugins/ticket_summary/plugin.yml => src/powercontext/builtin/plugins/__init__.py (69%) create mode 100644 src/powercontext/builtin/plugins/executor.py create mode 100644 src/powercontext/builtin/plugins/families.py create mode 100644 src/powercontext/builtin/plugins/host.py create mode 100644 src/powercontext/builtin/plugins/host_protocol.py create mode 100644 src/powercontext/builtin/plugins/manifest.py create mode 100644 src/powercontext/builtin/plugins/storage.py create mode 100644 src/powercontext/builtin/plugins/writer.py delete mode 100644 src/powercontext/builtin/runtime/artifact_plugins.py delete mode 100644 src/powercontext/builtin/runtime/artifact_reads.py delete mode 100644 src/powercontext/builtin/runtime/custom_artifact_search.py create mode 100644 src/powercontext/plugins.py delete mode 100644 src/powercontext/server/artifact_plugins.py create mode 100644 src/powercontext/server/dashboard/plugin_pages.py create mode 100644 src/powercontext/server/dashboard/static/plugin-client.js create mode 100644 src/powercontext/server/dashboard/templates/plugin.html create mode 100644 src/powercontext/server/plugin_hosts.py create mode 100644 src/powercontext/server/plugins.py delete mode 100644 tests/builtin/persistence/test_artifact_indexes.py delete mode 100644 tests/builtin/persistence/test_artifact_search_schema.py delete mode 100644 tests/builtin/persistence/test_artifact_storage_lifecycle.py delete mode 100644 tests/builtin/persistence/test_custom_artifact_search.py create mode 100644 tests/builtin/persistence/test_plugin_storage_migration.py create mode 100644 tests/builtin/plugins/test_plugin_inputs.py create mode 100644 tests/builtin/plugins/test_plugin_writer.py delete mode 100644 tests/builtin/runtime/test_artifact_plugin_worker.py delete mode 100644 tests/builtin/runtime/test_artifact_read_context.py create mode 100644 tests/e2e/plugin_server.py delete mode 100644 tests/e2e/test_artifact_authorization_startup.py delete mode 100644 tests/e2e/test_artifact_index_backends.py delete mode 100644 tests/e2e/test_artifact_plugin_search.py delete mode 100644 tests/e2e/test_artifact_plugins.py delete mode 100644 tests/e2e/test_artifact_vector_http.py create mode 100644 tests/e2e/test_plugin_pages.py create mode 100644 tests/e2e/test_plugins.py delete mode 100644 tests/e2e/test_prompt_management_cleanup.py delete mode 100644 tests/e2e/test_source_artifact_plugins.py create mode 100644 tests/plugin_support.py delete mode 100644 tests/test_artifact_index_maintenance.py delete mode 100644 tests/test_artifact_plugin_leases.py delete mode 100644 tests/test_artifact_plugin_validation.py create mode 100644 tests/test_plugin_capabilities.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b69e0843d7..247b28d34d 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "382b67847c63cf8cdd6561a85053ddf1bde2673a03934e03f208dde6b8430acc", + "contractSha256": "6dc5209c2067cb104797f073386fa50dcad1cfbbf90b4f08b86de56ce4febbd9", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 5d379bf463..14edf5c501 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -444,16 +444,6 @@ pub struct ArtifactRevision { pub family: String, #[serde(rename = "artifact_id")] pub artifact_id: String, - /// Persisted schema version for a custom Artifact family. - #[ts(optional)] - #[ts(type = "string | null")] - #[serde( - rename = "schema_version", - default, - with = "::serde_with::rust::double_option", - skip_serializing_if = "Option::is_none" - )] - pub schema_version: Option>, #[serde(rename = "revision")] pub revision: i64, #[ts(type = "Record")] @@ -523,7 +513,6 @@ impl ArtifactRevision { scope_id, family, artifact_id, - schema_version: None, revision, content, sources, @@ -872,12 +861,6 @@ impl CandidateInventoryStatistics { #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct Capabilities { - #[ts(optional)] - #[serde( - rename = "custom_artifact_schemas", - skip_serializing_if = "Option::is_none" - )] - pub custom_artifact_schemas: Option>, #[ts(optional)] #[serde(rename = "prompts", skip_serializing_if = "Option::is_none")] pub prompts: Option>, @@ -946,7 +929,6 @@ impl Capabilities { context_versions: Vec, ) -> Capabilities { Capabilities { - custom_artifact_schemas: None, prompts: None, artifact_dreaming: None, artifact_dreaming_operations: None, @@ -1109,44 +1091,6 @@ impl ContractVersion { } } -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(deny_unknown_fields)] -pub struct CustomArtifactFamilySchema { - #[serde(rename = "family")] - pub family: String, - #[serde(rename = "schema_version")] - pub schema_version: String, - #[ts(type = "Record")] - #[serde(rename = "content_schema")] - pub content_schema: std::collections::HashMap, - /// Legacy persisted text fields; new plugins use search. - #[serde(rename = "search_fields")] - pub search_fields: Vec, - /// Persistent declarative indexes and supported search modes. - #[ts(type = "Record")] - #[ts(optional)] - #[serde(rename = "search", skip_serializing_if = "Option::is_none")] - pub search: Option>, -} - -impl CustomArtifactFamilySchema { - #[allow(clippy::too_many_arguments)] - pub fn new( - family: String, - schema_version: String, - content_schema: std::collections::HashMap, - search_fields: Vec, - ) -> CustomArtifactFamilySchema { - CustomArtifactFamilySchema { - family, - schema_version, - content_schema, - search_fields, - search: None, - } - } -} - #[derive( Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, )] @@ -3046,7 +2990,6 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), - ::decl(config), ::decl(config), ::decl(config), ::decl(config), diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index c422a2185f..3ed0ad6cd5 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -261,131 +261,6 @@ export interface paths { patch?: never; trace?: never; }; - "/v1/admin/artifact-plugins": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List Artifact plugin registrations */ - get: operations["list_artifact_plugins"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/admin/artifact-plugins/{binding_name}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get an Artifact plugin registration */ - get: operations["get_artifact_plugin"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/admin/artifact-plugins/enable-all": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Enable the currently disabled Artifact plugins - * @description Captures registrations and generations at request start, then accepts each eligible enable independently. Enabled, unloaded, failed and transitioning plugins are skipped. Concurrent changes and validation failures are reported per plugin without rolling back successes. Does not discover or reload plugins; poll each status URL for the asynchronous outcome. - */ - post: operations["enable_all_artifact_plugins"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/admin/artifact-plugins/{binding_name}/reload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Load or update a Worker-loaded Artifact plugin - * @description Reads the binding's direct child entry under POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR and fixes its resolved immutable directory. The manifest binding_name must match the directory entry and request path. Saves validated configuration and declarations without creating a pipeline. Existing enabled or disabled plugins keep their state; initial, unloaded or failed registrations become disabled. This initial path supports the global SQLite Supervisor. Plugin code is loaded only in child processes. - */ - post: operations["reload_artifact_plugin"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/admin/artifact-plugins/{binding_name}/enable": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Enable an Artifact plugin */ - post: operations["enable_artifact_plugin"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/admin/artifact-plugins/{binding_name}/disable": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Disable an Artifact plugin */ - post: operations["disable_artifact_plugin"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/admin/artifact-plugins/{binding_name}/unload": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Unload an Artifact plugin */ - post: operations["unload_artifact_plugin"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/v1/capabilities": { parameters: { query?: never; @@ -1003,6 +878,143 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/plugins": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List loaded plugins + * @description Lists the plugins checked at Server startup, whether each loaded or failed, and what each one declares. Plugins have no persisted lifecycle state; change the plugin directory and restart Server. + */ + get: operations["list_plugins"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/plugins/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one loaded plugin */ + get: operations["get_plugin"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/plugins/{name}/scopes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a plugin's progress per Scope + * @description Reads the plugin's pending work and consumption cursor in each Scope from the database, so every Server role returns the same answer. Call outcomes are reported through logs and metrics, not here. + */ + get: operations["list_plugin_scopes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/artifacts/{family}/merges": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Merge exact current Artifacts into a new Artifact + * @description Freezes every input into a new result Artifact in one transaction. Available for every Family registered with the shared merge service, including plugin families; other families return 422. The caller needs write access to each input. + */ + post: operations["merge_artifacts"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read an Artifact's current governance state + * @description Returns the lifecycle state, state version and merge target that forget, merge and restoration check. Available for every Family registered with the shared merge service. + */ + get: operations["get_artifact_governance"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Forget one Artifact without changing its content revision + * @description Deprecates the exact current Artifact without a replacement so it leaves search; restoration brings it back. Available for every Family registered with the shared merge service. + */ + post: operations["forget_artifact"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restore an Artifact or undo the merge that produced it + * @description `restore` reactivates the Artifact, optionally at an earlier content revision, and undoes the merges that froze it. `undo_merge` restores a merge result's inputs and retires the result. Each runs as one group. Available for every Family registered with the shared merge service. + */ + post: operations["restore_artifact"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/scopes/{scope_id}/artifacts/{family}/search": { parameters: { query?: never; @@ -2039,7 +2051,7 @@ export interface paths { put?: never; /** * Create a durable Source - * @description Persist one Source without synchronously deriving Artifacts. The Server generates source_id. + * @description Persist one Source without synchronously deriving Artifacts. The Server generates source_id. A Source type defined by a loaded plugin is resolved by that plugin from the submitted input; unknown types return 404. */ post: operations["create_source"]; delete?: never; @@ -2076,7 +2088,7 @@ export interface paths { put?: never; /** * Create an Artifact - * @description Dispatch the family-specific creation command through the owning Family writer and atomically create revision one, its derived Family state, and its system provenance Source. Handoff is the Scope singleton: Create returns 409 when it already exists and callers must use Replace to update it. Creating a Prompt requires scope.admin because its configuration affects the whole Scope; other families require scope.contribute. Topic Memory accepts complete title, summary, and detail text without semantic generation; its active head, chunks, and configured retrieval indexes are committed atomically. + * @description Dispatch the family-specific creation command through the owning Family writer and atomically create revision one, its derived Family state, and its system provenance Source. Handoff is the Scope singleton: Create returns 409 when it already exists and callers must use Replace to update it. Creating a Prompt requires scope.admin because its configuration affects the whole Scope; other families require scope.contribute. Topic Memory accepts complete title, summary, and detail text without semantic generation; its active head, chunks, and configured retrieval indexes are committed atomically. A family provided by a loaded plugin takes the complete content object; core allocates the Artifact ID and the plugin's create_artifact method writes revision one. */ post: operations["create_artifact"]; delete?: never; @@ -2140,7 +2152,7 @@ export interface paths { get: operations["get_artifact"]; /** * Replace the current Artifact head - * @description Commit a complete next revision when If-Match identifies the current head. Replacing a Prompt requires current scope.admin authority, including switching to Auto and restoring an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation. Profile replacement uses the same conditional write. To roll back Profile, Prompt, Experience, Skill, Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted canonical content matches that revision and differs from the current head, then commits a new revision. Atomic Memory replacement rejects both fields; restore Atomic Memory through its restoration flow. + * @description Commit a complete next revision when If-Match identifies the current head. Replacing a Prompt requires current scope.admin authority, including switching to Auto and restoring an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation. Profile replacement uses the same conditional write. To roll back Profile, Prompt, Experience, Skill, Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted canonical content matches that revision and differs from the current head, then commits a new revision. Atomic Memory replacement rejects both fields; restore Atomic Memory through its restoration flow. Plugin families accept their own content unchanged; the plugin's next invocation sees the new revision. */ put: operations["replace_artifact"]; post?: never; @@ -2235,6 +2247,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Present a plugin Artifact + * @description Returns the default presentation of one revision of a plugin family as the plugin renders it, in Markdown or plain text, for people and for agent context. The current revision is used unless revision is given. Built-in families have no rendering and return 422. + */ + get: operations["get_artifact_rendering"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions": { parameters: { query?: never; @@ -3102,8 +3134,6 @@ export interface components { scope_id: string; family: components["schemas"]["ArtifactReadFamily"]; artifact_id: string; - /** @description Persisted schema version for a custom Artifact family. */ - schema_version?: string | null; revision: number; sources: components["schemas"]["SourceTypeReference"][]; artifacts: components["schemas"]["ArtifactReference"][]; @@ -3127,7 +3157,7 @@ export interface components { }; ArtifactCreated: { scope_id: string; - family: components["schemas"]["BaseArtifactFamily"]; + family: components["schemas"]["ArtifactReadFamily"]; artifact_id: string; revision: number; sources: components["schemas"]["SourceTypeReference"][]; @@ -3145,8 +3175,6 @@ export interface components { scope_id: string; family: components["schemas"]["ArtifactReadFamily"]; artifact_id: string; - /** @description Persisted schema version for a custom Artifact family. */ - schema_version?: string | null; revision: number; content: { [key: string]: unknown; @@ -3481,58 +3509,105 @@ export interface components { /** @enum {string} */ effect: "review_then_publish" | "review_then_publish_configuration" | "review_then_replace_tags"; }; - CustomArtifactFamilySchema: { - family: string; - schema_version: string; - content_schema: { - [key: string]: unknown; - }; - /** @description Legacy persisted text fields; new plugins use search. */ - search_fields: string[]; - /** @description Persistent declarative indexes and supported search modes. */ - search?: { + /** @enum {string} */ + PluginKind: "artifact"; + /** @enum {string} */ + PluginLoadStatus: "loaded" | "failed"; + /** @enum {string} */ + PluginTrigger: "schedule" | "sources"; + PluginState: { + name: string; + kind: components["schemas"]["PluginKind"]; + status: components["schemas"]["PluginLoadStatus"]; + /** @description Safe failure category from the startup check. */ + error: string | null; + family: string | null; + binding_name: string | null; + scopes: string[] | null; + /** @description Source types delivered to the plugin; null when it consumes no Sources. */ + source_types: string[] | null; + triggers: components["schemas"]["PluginTrigger"][]; + }; + PluginPage: { + items: components["schemas"]["PluginState"][]; + }; + PluginScopeState: { + scope_id: string; + /** @description New Sources or a schedule request are waiting for an invocation. */ + pending: boolean; + /** @description Source journal position the plugin has consumed through; null when it consumes no Sources. */ + consumed_position: number | null; + /** @description Latest Source journal position in the Scope, of any Source type. */ + source_position: number | null; + }; + ListPluginScopesRequest: { + cursor?: string; + /** @default 50 */ + limit: number; + }; + PluginScopePage: { + items: components["schemas"]["PluginScopeState"][]; + next_cursor: string | null; + }; + /** @enum {string} */ + ArtifactLifecycleState: "active" | "deprecated" | "retired"; + /** @description An exact current Artifact the caller has read; a later change rejects the request. */ + ArtifactGovernanceInput: { + artifact_id: string; + revision: number; + state_version: number; + }; + MergeArtifactsRequest: { + inputs: components["schemas"]["ArtifactGovernanceInput"][]; + /** @description Complete content of the result in the Family's content shape. */ + content: { [key: string]: unknown; }; + source_refs?: components["schemas"]["SourceReference"][]; + artifact_refs?: components["schemas"]["ArtifactReference"][]; }; - ReloadArtifactPluginRequest: { - expected_generation: number; + ForgetArtifactRequest: { + revision: number; + state_version: number; }; - ChangeArtifactPluginStateRequest: { - expected_generation: number; + /** @enum {string} */ + ArtifactRestorationOperation: "restore" | "undo_merge"; + RestoreArtifactRequest: { + /** @default restore */ + operation: components["schemas"]["ArtifactRestorationOperation"]; + /** @description Content revision to restore; omitted restores the current one. Not accepted with undo_merge. */ + revision?: number | null; }; - ArtifactPluginState: { - binding_name: string; - plugin_id: string; - family: string; - binding_generation: number; - /** @enum {string} */ - desired_state: "enabled" | "disabled" | "unloaded"; - /** @enum {string} */ - phase: "activating" | "enabled" | "stopping" | "disabled" | "unloading" | "unloaded" | "failed"; - last_error: string | null; + ArtifactGovernanceRecord: { + artifact: components["schemas"]["ArtifactReference"]; + lifecycle_state: components["schemas"]["ArtifactLifecycleState"]; + state_version: number; + merged_into_id: string | null; }; - ArtifactPluginPage: { - items: components["schemas"]["ArtifactPluginState"][]; + ArtifactGovernanceResponse: { + changed: boolean; + records: components["schemas"]["ArtifactGovernanceRecord"][]; }; - EnableArtifactPluginResult: { - binding_name: string; - /** @enum {string} */ - outcome: "accepted" | "skipped" | "failed"; - /** @description Registration status URL for the asynchronous result. */ - status_url: string; - state: components["schemas"]["ArtifactPluginState"]; - /** - * @description Safe per-plugin failure code; null for accepted or skipped entries. - * @enum {string|null} - */ - error: "conflict" | "invalid_plugin" | "unavailable" | null; + ArtifactRestorationResult: { + changed: boolean; + restored: components["schemas"]["ArtifactReference"][]; + retired: components["schemas"]["ArtifactReference"][]; + undo_merge_results: string[]; }; - EnableAllArtifactPluginsResponse: { - items: components["schemas"]["EnableArtifactPluginResult"][]; + /** @description Create an Artifact of a family a loaded plugin provides. The family must not be a built-in family name; core allocates the Artifact ID and stores the content the plugin writes. */ + CreatePluginArtifactRequest: { + family: string; + content: { + [key: string]: unknown; + }; + }; + /** @description Complete content of a plugin Artifact; core stores it unchanged and indexes the declared fields. */ + ReplacePluginArtifactRequest: { + content: { + [key: string]: unknown; + }; }; Capabilities: { - /** @default [] */ - custom_artifact_schemas: components["schemas"]["CustomArtifactFamilySchema"][]; /** @default {} */ prompts: { [key: string]: components["schemas"]["PromptCapability"]; @@ -4255,7 +4330,8 @@ export interface components { }; PreparedContextSearchGroup: { round: number; - family: components["schemas"]["PreparedContextFamily"]; + /** @description atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family. */ + family: string; /** @enum {string} */ mode: "fts" | "vector" | "hybrid" | "snapshot" | "code" | "none"; /** @enum {string} */ @@ -4301,8 +4377,6 @@ export interface components { fallback_reason: "none" | "empty_selection" | "inference_unavailable" | "inference_timeout" | "invalid_output"; count: number; }; - /** @enum {string} */ - PreparedContextFamily: "atomic-memory" | "topic-memory" | "experience" | "profile" | "code"; PreparedContextCodeEvidence: { scope_id: string; fingerprint: string; @@ -4313,14 +4387,16 @@ export interface components { snippet_sha256: string; }; PreparedContextSelectedItem: { - kind: components["schemas"]["PreparedContextFamily"]; + /** @description atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family. */ + kind: string; artifact_address?: components["schemas"]["ArtifactAddress"]; code_evidence?: components["schemas"]["PreparedContextCodeEvidence"]; rendered_bytes: number; truncated: boolean; }; PreparedContextOmittedGroup: { - family: components["schemas"]["PreparedContextFamily"]; + /** @description atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family. */ + family: string; /** @enum {string} */ reason: "duplicate" | "blank" | "family_limit" | "entry_limit" | "below_min_bytes" | "no_fitting_truncation" | "rerank_not_selected" | "not_retrieved"; count: number; @@ -5233,7 +5309,12 @@ export interface components { outcome: "injected" | "failed"; }; ContextAssemblySection: { - family: components["schemas"]["ContextAssemblyFamily"]; + /** @description A built-in family (memory, experience, profile, topic-memory) or a loaded plugin family that implements search. A plugin family's entries come from the plugin's search with the request query and this limit; a family that is neither returns HTTP 422. */ + family: string; + /** @description Additional generic search fields passed to the plugin's search unchanged, such as mode, filters, fusion, or rerank. Only plugin families accept it; the query and limit come from the request and this section. */ + search?: { + [key: string]: unknown; + }; /** @description Maximum included entries. Each Profile entry is one Scope snapshot. Experience is limited to two. All section limits together must not exceed the Server's runtime.context_assembly_max_entries policy (default 8); exceeding it returns HTTP 422 before recall. The byte budget may reduce the actual output count. */ limit: number; }; @@ -5259,8 +5340,6 @@ export interface components { show: components["schemas"]["ContextAssemblyMetadata"][]; }; /** @enum {string} */ - ContextAssemblyFamily: "memory" | "experience" | "profile" | "topic-memory"; - /** @enum {string} */ ContextAssemblyFormat: "markdown"; /** @enum {string} */ ContextAssemblyMetadata: "confidence" | "recall_rank"; @@ -5439,6 +5518,14 @@ export interface components { [key: string]: components["schemas"]["ArtifactChannelScore"]; }; }; + ArtifactRendering: { + family: string; + artifact_id: string; + revision: number; + /** @enum {string} */ + format: "markdown" | "text"; + text: string; + }; ArtifactSearchItem: { family: string; artifact_id: string; @@ -5502,7 +5589,9 @@ export interface components { detail: string; source_refs: components["schemas"]["SourceReference"][]; }; - CreateArtifactRequest: components["schemas"]["CreateAtomicMemoryArtifactRequest"] | components["schemas"]["CreateTopicMemoryArtifactRequest"] | components["schemas"]["CreateMemoryArtifactRequest"] | components["schemas"]["CreateExperienceArtifactRequest"] | components["schemas"]["CreateSkillArtifactRequest"] | components["schemas"]["CreateHandoffArtifactRequest"] | components["schemas"]["CreatePromptArtifactRequest"] | components["schemas"]["CreateProfileArtifactRequest"]; + /** @description A built-in family's creation request, or the complete content of an Artifact in a loaded plugin's family. The family name selects the branch: built-in names select the built-in branch. */ + CreateArtifactRequest: components["schemas"]["CreateBuiltinArtifactRequest"] | components["schemas"]["CreatePluginArtifactRequest"]; + CreateBuiltinArtifactRequest: components["schemas"]["CreateAtomicMemoryArtifactRequest"] | components["schemas"]["CreateTopicMemoryArtifactRequest"] | components["schemas"]["CreateMemoryArtifactRequest"] | components["schemas"]["CreateExperienceArtifactRequest"] | components["schemas"]["CreateSkillArtifactRequest"] | components["schemas"]["CreateHandoffArtifactRequest"] | components["schemas"]["CreatePromptArtifactRequest"] | components["schemas"]["CreateProfileArtifactRequest"]; TopicMemoryWriteContent: { title: string; summary: string; @@ -5571,20 +5660,22 @@ export interface components { /** @description Durable non-empty Memory entry text used to create the Entry Version and search projection. */ text: string; }; + /** + * @description The built-in Source type that create_source accepts. + * @enum {string} + */ + SourceType: "content"; CreateSourceRequest: { /** + * @description The built-in content type, or a Source type that a loaded plugin defines. A plugin type's content is that type's input; the plugin resolves it into the stored Source. * @default content - * @enum {string} */ - source_type: "content"; - /** @description JSON value persisted by the built-in content Source adapter. Server-reserved payload schemas, including handoff receipts, are rejected and must be created through their dedicated workflow. String values that are empty or whitespace-only are rejected. */ + source_type: string; + /** @description JSON value persisted by the built-in content Source adapter, or the input of a plugin Source type. Server-reserved payload schemas, including handoff receipts, are rejected and must be created through their dedicated workflow. String values that are empty or whitespace-only are rejected. */ content: unknown; }; - /** - * @description All readable Artifact families support logical tags on persisted Artifacts. - * @enum {string} - */ - TaggableArtifactFamily: "memory" | "atomic-memory" | "experience" | "skill" | "handoff" | "profile" | "prompt" | "topic-memory"; + /** @description A built-in Family or a loaded plugin family; every readable Family supports logical tags on persisted Artifacts. The collection-level memory Family tags entries instead. */ + TaggableArtifactFamily: string; /** @enum {string} */ TagMatch: "all" | "any"; /** @enum {string} */ @@ -5658,12 +5749,15 @@ export interface components { limit: number; cursor?: string | null; }; - ReplaceArtifactRequest: components["schemas"]["ReplaceAtomicMemoryArtifactRequest"] | components["schemas"]["ReplaceTopicMemoryArtifactRequest"] | components["schemas"]["ReplaceMemoryArtifactRequest"] | components["schemas"]["ReplaceExperienceArtifactRequest"] | components["schemas"]["ReplaceSkillArtifactRequest"] | components["schemas"]["ReplaceHandoffArtifactRequest"] | components["schemas"]["ReplacePromptArtifactRequest"] | components["schemas"]["ReplaceProfileArtifactRequest"]; + ReplaceArtifactRequest: components["schemas"]["ReplaceAtomicMemoryArtifactRequest"] | components["schemas"]["ReplaceTopicMemoryArtifactRequest"] | components["schemas"]["ReplaceMemoryArtifactRequest"] | components["schemas"]["ReplaceExperienceArtifactRequest"] | components["schemas"]["ReplaceSkillArtifactRequest"] | components["schemas"]["ReplaceHandoffArtifactRequest"] | components["schemas"]["ReplacePromptArtifactRequest"] | components["schemas"]["ReplaceProfileArtifactRequest"] | components["schemas"]["ReplacePluginArtifactRequest"]; ReplacePromptArtifactRequest: { content: components["schemas"]["PromptContent"]; restored_from_revision?: number | null; reason?: string | null; }; + GetArtifactRenderingRequest: { + revision?: number | null; + }; ListArtifactRevisionsRequest: { /** @default 50 */ limit: number; @@ -6982,266 +7076,55 @@ export interface operations { get_readiness: { parameters: { query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Required Server bindings are ready; optional capabilities may be degraded. */ - 200: { - headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReadinessResponse"]; - }; - }; - /** @description Required Server bindings are not ready. */ - 503: { - headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ReadinessResponse"]; - }; - }; - }; - }; - get_server_info: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Stable deployment identity and protocol compatibility metadata. */ - 200: { - headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ServerInfo"]; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 503: components["responses"]["Unavailable"]; - }; - }; - list_artifact_plugins: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Durable registration states. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ArtifactPluginPage"]; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 503: components["responses"]["Unavailable"]; - }; - }; - get_artifact_plugin: { - parameters: { - query?: never; - header?: never; - path: { - binding_name: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Durable registration state without plugin configuration secrets. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ArtifactPluginState"]; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 503: components["responses"]["Unavailable"]; - }; - }; - enable_all_artifact_plugins: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Per-plugin acceptance, skip or failure results. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["EnableAllArtifactPluginsResponse"]; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 503: components["responses"]["Unavailable"]; - }; - }; - reload_artifact_plugin: { - parameters: { - query?: never; - header?: never; - path: { - binding_name: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ReloadArtifactPluginRequest"]; - }; - }; - responses: { - /** @description Definition saved; poll its registration state until transition completes. */ - 202: { - headers: { - /** @description Registration status URL. */ - Location?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ArtifactPluginState"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - enable_artifact_plugin: { - parameters: { - query?: never; - header?: never; - path: { - binding_name: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ChangeArtifactPluginStateRequest"]; - }; - }; - responses: { - /** @description Target saved; poll its registration state until transition completes. */ - 202: { - headers: { - /** @description Registration status URL. */ - Location?: string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ArtifactPluginState"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - disable_artifact_plugin: { - parameters: { - query?: never; - header?: never; - path: { - binding_name: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ChangeArtifactPluginStateRequest"]; - }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Target saved; poll its registration state until transition completes. */ - 202: { + /** @description Required Server bindings are ready; optional capabilities may be degraded. */ + 200: { headers: { - /** @description Registration status URL. */ - Location?: string; + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ArtifactPluginState"]; + "application/json": components["schemas"]["ReadinessResponse"]; + }; + }; + /** @description Required Server bindings are not ready. */ + 503: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadinessResponse"]; }; }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; }; }; - unload_artifact_plugin: { + get_server_info: { parameters: { query?: never; header?: never; - path: { - binding_name: string; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ChangeArtifactPluginStateRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Target saved; poll its registration state until transition completes. */ - 202: { + /** @description Stable deployment identity and protocol compatibility metadata. */ + 200: { headers: { - /** @description Registration status URL. */ - Location?: string; + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ArtifactPluginState"]; + "application/json": components["schemas"]["ServerInfo"]; }; }; - 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; 503: components["responses"]["Unavailable"]; }; }; @@ -8291,6 +8174,214 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + list_plugins: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Plugins found in the plugin directory at startup. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PluginPage"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 503: components["responses"]["Unavailable"]; + }; + }; + get_plugin: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The plugin's startup check result and declaration. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PluginState"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 503: components["responses"]["Unavailable"]; + }; + }; + list_plugin_scopes: { + parameters: { + query?: { + cursor?: string; + limit?: number; + }; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Scopes the plugin has been asked to process, ordered by Scope ID. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PluginScopePage"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 503: components["responses"]["Unavailable"]; + }; + }; + merge_artifacts: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeArtifactsRequest"]; + }; + }; + responses: { + /** @description The merge result and the frozen inputs. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactGovernanceResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + get_artifact_governance: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The current governance state. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactGovernanceRecord"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + forget_artifact: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ForgetArtifactRequest"]; + }; + }; + responses: { + /** @description The Artifact's governance state after forgetting. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactGovernanceResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + restore_artifact: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RestoreArtifactRequest"]; + }; + }; + responses: { + /** @description The revisions restored and retired by the operation. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactRestorationResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; search_artifacts: { parameters: { query?: never; @@ -10222,7 +10313,7 @@ export interface operations { }; path: { scope_id: string; - family: "memory" | "atomic-memory" | "experience" | "skill" | "handoff" | "prompt" | "topic-memory" | "profile"; + family: components["schemas"]["ArtifactReadFamily"]; artifact_id: string; }; cookie?: never; @@ -10487,6 +10578,39 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + get_artifact_rendering: { + parameters: { + query?: { + revision?: number; + }; + header?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The rendering of the exact revision. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactRendering"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; list_artifact_revisions: { parameters: { query?: { diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 4a645e0d66..221ce7f2ef 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 = "382b67847c63cf8cdd6561a85053ddf1bde2673a03934e03f208dde6b8430acc"; +export const contractSha256 = "6dc5209c2067cb104797f073386fa50dcad1cfbbf90b4f08b86de56ce4febbd9"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/develop/index.md b/docs/en/docs/develop/index.md index a36d6d28d2..9f0947799c 100644 --- a/docs/en/docs/develop/index.md +++ b/docs/en/docs/develop/index.md @@ -12,6 +12,7 @@ Choose an [interface](interfaces.md) for your application: the async Python Clie Server, while the Core SDK supports in-process composition. MCP exposes a curated subset for Agents. - [HTTP behavior](http-api.md): authentication, request IDs, errors, and concurrency. +- [Write a plugin](plugins.md): generate your own Artifacts, define Source types, and provide APIs and Dashboard pages with plugins. - [HTTP API](/api): generated endpoints and schemas. - [Python API](/en/modules): generated Python documentation. - [Python Agent frameworks](../integrations/index.md#python-agent-frameworks): community adapters with their own lifecycles. diff --git a/docs/en/docs/develop/meta.json b/docs/en/docs/develop/meta.json index ff7e8d52f5..c2f62e3c4c 100644 --- a/docs/en/docs/develop/meta.json +++ b/docs/en/docs/develop/meta.json @@ -4,6 +4,7 @@ "index", "api-quickstart", "interfaces", - "http-api" + "http-api", + "plugins" ] } diff --git a/docs/en/docs/develop/plugins.md b/docs/en/docs/develop/plugins.md new file mode 100644 index 0000000000..f170395c7c --- /dev/null +++ b/docs/en/docs/develop/plugins.md @@ -0,0 +1,452 @@ +--- +title: Write a plugin +description: Extend PowerContext with a directory and plugin.py — generate, present, and search your own Artifacts, define Source types, and provide APIs and Dashboard pages. +--- + +# Write a plugin + +A plugin is a folder in the Server's plugin directory, with `plugin.py` as its entry point. A plugin can: + +| Capability | What it means | +| --- | --- | +| Generate its own Artifacts | Declare an Artifact family, read Sources, and write Artifacts of that family; callers read and tag them through the existing Artifact APIs | +| Present and search | Give each revision's default presentation, and optionally search, so the generic search API and Prepared Context can use the plugin's data | +| Support user changes | Implement create, update, merge, forget, and restore methods so the generic Artifact APIs work on the plugin's family | +| Define Source types | Declare the plugin's own Source types and Projections to record what users asked for, such as an upload | +| Provide APIs | Declare HTTP routes under `/v1/plugins//api/`; core authenticates callers and forwards requests to the plugin | +| Provide Dashboard pages | Ship static pages that the Dashboard shows in a sandboxed iframe | +| Use dedicated storage and models | Keep the plugin's own data in separate storage that core allocates, and call the embedding and generation models Server configures | + +Plugin code runs in the Server's Python environment and is treated as trusted code with the deployer's permissions. Load +only plugins you trust. + +## Enable the plugin directory + +Set the plugin directory and restart Server: + +```bash +export POWERCONTEXT_SERVER_PLUGINS_DIR=/srv/powercontext/plugins +powercontext server run +``` + +- Each subdirectory is a plugin, and its name is the plugin name (it starts with a lowercase letter and contains only + lowercase letters, digits, and `-`). +- Install the third-party libraries a plugin needs into the Server's Python environment, for example + `uv pip install pypdf`. A `requirements.txt` in the plugin directory only informs deployers; Server does not install it. +- Restart Server after changing a plugin. Server records a content fingerprint of the plugin directory when it loads and + refuses to run plugin files that changed afterward, so old and new code never mix. + +Check what loaded with administrator credentials: + +```bash +curl -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8000/v1/plugins +``` + +Each plugin's `status` is `loaded` or `failed`; for failures, `error` gives the reason, such as an invalid declaration +field or a failed import. + +## A first plugin: turn text into notes + +Create `notes/plugin.py` in the plugin directory: + +```python +from powercontext.plugins import ( + ArtifactPlugin, ArtifactWriter, GenerateRequest, Inputs, Rendering, RenderRequest, SearchHit, SearchRequest, + SearchResult, +) + + +class Notes: + def __init__(self, ctx): + self.ctx = ctx + + async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None: + async with writer.transaction() as tx: + for source in request.sources: + text = source.projection["content"] + await tx.put(writer.stable_id(source.ref.source_id), content={"text": text}, source_refs=[source.ref]) + + async def render_artifact(self, request: RenderRequest) -> Rendering: + return Rendering(text=request.artifact.content["text"]) + + async def search_artifacts(self, request: SearchRequest) -> SearchResult: + query = request.search["query"] + page = await self.ctx.reads(request.scope_id).list(limit=200) + hits = [ + SearchHit(ref=item.ref, text=item.content["text"]) + for item in page.items + if query in item.content["text"] + ] + return SearchResult(hits=hits[: request.search.get("limit", 10)]) + + +async def setup(ctx): + return Notes(ctx) + + +plugin = ArtifactPlugin( + family="notes", + inputs=Inputs(source_types=("content",), projection="powercontext.text-evidence@1"), + on_sources=True, + setup=setup, +) +``` + +After restarting Server, write a text Source; a few seconds later the plugin's Artifact can be read: + +```bash +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/sources -d '{"content": "Deploys go to the east region first"}' + +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes/$ARTIFACT_ID/rendering +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes/search -d '{"query": "east"}' +``` + +This plugin has the basic parts of a plugin: + +- **The declaration** `plugin = ArtifactPlugin(...)` tells core which family the plugin owns, which Sources it consumes, + and when it runs. +- **The factory** `setup(ctx)` returns the plugin object. Read the plugin's own settings from environment variables and + open the resources it needs here. +- **Generation** `generate(request, writer)` receives a batch of new Sources and writes Artifacts. +- **Presentation** `render_artifact(request)` gives a revision's default presentation; it is required. +- **Search** `search_artifacts(request)` searches in the plugin's own way; it is optional. This example only matches + substrings; a real plugin uses its own full-text or vector index. + +## The declaration + +| Field | Meaning | +| --- | --- | +| `family` | The Artifact family the plugin owns; it must not reuse a built-in family or another plugin's | +| `inputs` | `Inputs(source_types=..., projection=..., projections={...}, max_sources=50)`: the Source types consumed, the Projection each type arrives in, and the most Sources delivered at once. `projection` applies to every type; `projections` names one per type | +| `on_sources` | Run when new Sources arrive | +| `schedule` | `Interval(seconds=...)` or `Cron(expression=..., timezone=...)` to run on a schedule. With neither `schedule` nor `on_sources`, the plugin has no background generation and need not implement `generate`; its Artifacts are written only by user changes and its own APIs | +| `scopes` | Serve only these Scopes; required for plugins that set `schedule` but consume no Sources | +| `timeout_seconds`, `transaction_timeout_seconds` | Time limits for one generation run and its write transaction | +| `change_timeout_seconds` | Time limit for one user change or API call | +| `definitions` | The plugin's own Source Definitions; see "Define Source types" | +| `api` | The plugin's APIs; see "Provide APIs" | +| `pages` | Dashboard pages; see "Dashboard pages" | +| `setup` | The factory; it must be an async function | + +Server imports `plugin.py` and checks the declaration in a separate child process. If the import fails or the declaration +is invalid, the plugin is `failed`, without affecting Server or other plugins. After the host process calls `setup`, it +checks the required methods: a plugin without `render_artifact`, or one that declares `schedule` or `on_sources` without +`generate`, is also `failed`. + +Methods of the plugin object: + +| Method | Required | When it is called | +| --- | --- | --- | +| `render_artifact` | Yes | The presentation API | +| `generate` | When `schedule` or `on_sources` is set | On schedule, or when new Sources arrive | +| `search_artifacts` | No | The generic search API; Prepared Context sections that list the family | +| `create_artifact`, `update_artifact`, `merge_artifacts`, `forget_artifact`, `restore_artifact` | No | The matching generic change APIs | +| Route handlers | When routes are declared | The plugin's own APIs | + +Listing, reading revisions and history, governance state, and tags call no plugin method; core serves them directly. + +## Generate Artifacts + +`generate` runs in a separate Worker process that Server starts, once per Scope: + +- `request.scope_id` is the call's Scope; `request.sources` are the Sources delivered, each with a `ref` (its reference) + and a `projection` (its content in the declared Projection). +- Put writes inside `async with writer.transaction() as tx:`. The writes in the block commit in one transaction, together + with the record that this batch of Sources was handled; if generation fails or times out, the batch is delivered again. +- Do slow work such as model calls and file parsing outside the transaction, and only write inside it. +- `tx.put(artifact_id, content=..., source_refs=[...])` writes a new revision and records its provenance in + `source_refs`. Content identical to the current revision adds no revision, so repeated delivery never writes duplicate + revisions. `writer.stable_id(...)` derives a stable ID from its inputs, so the same Source always writes to the same + Artifact. +- Other operations include `tx.set_lifecycle`, `tx.merge`, `tx.revert`, and `tx.set_tags`; read current state with + `writer.get`, `writer.list`, and `writer.by_source`. +- Core records on each revision the plugin name and the content digest of the plugin code that wrote it, so revisions can + be traced when several Servers load different plugin versions. + +Generated Artifacts behave like built-in ones: read and tag them through the `/v1/scopes/{scope_id}/artifacts/{family}` +APIs. + +## Presentation and search + +Core does not understand the content structure of plugin Artifacts and builds no index for them; plugins present and +search their own families. + +**Presentation**: `render_artifact(request)` receives one exact revision (`request.artifact`) and returns +`Rendering(format="markdown", text=...)`; `format` can also be `"text"`. Callers fetch it through the API below; without +`revision`, the current revision is used: + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes/$ARTIFACT_ID/rendering?revision=2" +``` + +The presentation of a revision should be stable, so callers can cache it by family, ID, revision, and plugin version. + +**Search**: `search_artifacts(request)` receives the complete request of the generic search API (`request.search`, with +every field the caller sent, such as `query`, `limit`, `mode`, `filters`, and `fusion`; core handles `include_scores` and does +not pass it on), searches with the plugin's own +indexes, and returns `SearchResult(hits=[SearchHit(ref=..., text=..., score=...)])`: + +- Raise `InvalidContent` for fields you do not support; the API returns 422 instead of silently ignoring them. +- Core keeps only hits the caller may read that are still the current, active revision, in the plugin's order and with its + scores. +- `text` is the passage placed into agent context: part of the content or its presentation text. +- When the plugin does not implement `search_artifacts`, the generic search API and Prepared Context return 422 for the + family. + +**Prepared Context**: `assembly.sections` of `POST /v1/context/prepare` can list plugin families. Core calls +`search_artifacts` with the request's `query` and the section's `limit`; `request.max_bytes` is the request's byte budget. +Hits fill the context in section order and cite exact revisions. A section can carry `search`, whose fields reach the +plugin unchanged: + +```json +{ + "scope_id": "team-a", + "query": "deploy region", + "assembly": {"sections": [{"family": "memory", "limit": 4}, {"family": "notes", "limit": 3, "search": {"mode": "hybrid"}}]} +} +``` + +## Support user changes + +When the plugin object implements the methods below, the generic Artifact APIs work on the plugin's family; APIs whose +method is missing return 422: + +| Method | API | +| --- | --- | +| `create_artifact(request, writer)` | `POST /v1/scopes/{scope_id}/artifacts` with body `{"family": "", "content": {...}}` | +| `update_artifact(request, writer)` | `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` with `If-Match` | +| `merge_artifacts(request, writer)` | `POST /v1/scopes/{scope_id}/artifacts/{family}/merges` | +| `forget_artifact(request, writer)` | `POST .../{artifact_id}/forget` | +| `restore_artifact(request, writer)` | `POST .../{artifact_id}/restorations` | + +```python +from powercontext.plugins import InvalidContent + + +class Notes: + async def update_artifact(self, request, writer) -> None: + if not request.content.get("text"): + raise InvalidContent("text is required", field="text") + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=request.content, expected_revision=request.expected_revision) + + async def forget_artifact(self, request, writer) -> None: + async with writer.transaction() as tx: + await tx.set_lifecycle(request.artifact_id, "deprecated") +``` + +- Core checks the caller's permissions and the revision before calling the method; the method only validates content and + makes its one agreed write. +- Raising `InvalidContent` returns 422 with the message shown to the caller. +- User changes record provenance as the person who made them, as edits of built-in Artifacts do. + +## Define Source types + +Sources record what happened and what users asked for. A plugin can define its own Source types, such as "a file was +uploaded" or "some content was edited", and consume them in generation: + +```python +from pydantic import BaseModel +from powercontext.sources import Source, SourceMaterialization + + +class TicketInput(BaseModel): # the input when submitting + key: str + title: str + + +class TicketSource(Source): # the Source core stores + title: str + + +class TicketProjection: # what generate receives + name = "tickets.ticket" + version = "1" + source_class = TicketSource + output_class = TicketInput + + def project(self, source, /): + return TicketInput(key=source.name, title=source.title) + + +class TicketDefinition: + name = "ticket" # the Source type name + version = "1" + input_class = TicketInput + source_class = TicketSource + projections = (TicketProjection(),) + + async def resolve(self, value, /): + return TicketSource( + name=value.key, definition_version=self.version, + materialization=SourceMaterialization.CAPTURED, description="A ticket", title=value.title, + ) + + async def read(self, source, /): + return TicketProjection().project(source) + + +plugin = ArtifactPlugin( + family="tickets", + inputs=Inputs(source_types=("ticket",), projection="tickets.ticket@1"), + on_sources=True, + definitions=(TicketDefinition(),), + setup=setup, +) +``` + +- When the plugin loads, core registers these Source types. Callers can then write them through the generic Source API: + `POST /v1/scopes/{scope_id}/sources` with body `{"source_type": "ticket", "content": {"key": "OPS-7", "title": "..."}}`. + The plugin validates the input and resolves it into a Source; invalid input returns 422. +- The plugin's own APIs submit them with `ctx.sources.submit(scope_id, "ticket", TicketInput(...))`. +- A Source's `name` is its identity in the Scope; submitting the same `name` again yields the same Source. +- When a Source or Projection changes shape, raise the definition's `version` and set `definition_version` to it in + `resolve`. Core registers definitions by name and version, so old and new versions coexist, and stored Sources keep + their old version's meaning. +- A plugin that consumes several Source types, each needing its own Projection, names them in `projections`: + + ```python + Inputs(source_types=("upload", "edit"), projections={"upload": "kb.upload@1", "edit": "kb.edit@1"}) + ``` + + In `generate`, branch on `source.ref.source_type`. + +## Provide APIs + +A plugin can declare its own HTTP routes; handlers are async methods of the plugin object: + +```python +from pydantic import BaseModel +from powercontext.plugins import ApiError, ApiRequest, PluginApi, Route + + +class NewTicket(BaseModel): + scope_id: str + key: str + title: str + + +class Tickets: + def __init__(self, ctx): + self.ctx = ctx + + async def open_ticket(self, request: ApiRequest) -> dict: + body: NewTicket = request.body + await self.ctx.require("scope.contribute", body.scope_id) + ref = await self.ctx.sources.submit(body.scope_id, "ticket", TicketInput(key=body.key, title=body.title)) + return {"source_id": ref.source_id} + + +plugin = ArtifactPlugin( + ..., + api=PluginApi(routes=( + Route(method="POST", path="/tickets", handler="open_ticket", access="authenticated", + body="json", request_model=NewTicket), + )), +) +``` + +- Routes mount at `/v1/plugins//api`, for example `POST /v1/plugins/tickets/api/tickets`. Callers use + the same credentials as for every other API. +- `access` sets the check core runs before calling the handler: + - `authenticated`: the caller must be signed in; + - `scope.read` / `scope.contribute`: the caller needs that permission on the Scope in the path parameter named by + `scope_param`. +- In handlers, `ctx.require(action, scope_id)` checks further permissions and makes the API return 403 when the caller + lacks them; `ctx.allowed(...)` only answers true or false. +- Reading and writing core data: `ctx.reads(scope_id)` reads with the caller's permissions; `ctx.writer(scope_id)` and + `ctx.sources.submit(...)` work only for Scopes whose `scope.contribute` was confirmed earlier in the request (by the + route's `access` or a prior `ctx.require`). +- Writes in APIs do not record who did what. When you need that provenance, submit the operation as a Source, as in the + example above, and let `generate` write the new revision referencing it. +- To create a Scope, call `ctx.scopes.create(title=..., summary=..., idempotency_key=...)`; the caller needs + `server.admin`. +- `request.body` is the validated `request_model` instance, `bytes`, or `None`, according to `body`; + `request.path_params`, `request.query`, and `request.caller` are the path parameters, query parameters, and caller. +- Return a Pydantic model or a dict for JSON, `BinaryResponse(content=..., content_type=..., filename=...)` for a file, + or `None` for 204. Raise `ApiError(status, code, message)` for a 4xx error. +- Request body limits default to 1 MiB for JSON and 32 MiB for binary bodies. A route can declare `max_body_bytes`, which + never exceeds the deployer's `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES`. Core returns 413 without calling the + plugin when a body is too large. +- Each call is bounded by `timeout_seconds` (30 seconds by default) and returns 504 when it runs over. + +APIs, presentation, search, and user-change methods run in a long-lived host process that starts once the plugin loads; +the factory `setup(ctx)` runs once in it, and the plugin object serves every request. A host process that crashes or times out is +restarted, and APIs return 503 meanwhile. + +## Dedicated storage and models + +```python +from sqlalchemy import text + + +async def setup(ctx): + engine = ctx.storage.engine() # points only to this plugin's storage + async with engine.begin() as connection: + await connection.execute(text("CREATE TABLE IF NOT EXISTS notes_index (id TEXT PRIMARY KEY, body TEXT)")) + return Notes(ctx) +``` + +- `ctx.storage.engine()` returns an async SQLAlchemy engine for `plugins/.sqlite3` in the data directory. The + plugin owns its tables and migrations; core never reads or backs up this storage. Only SQLite deployments are supported + for now; on Servers using OceanBase or seekdb, accessing `ctx.storage` raises an error. +- This storage is outside core's transactions. Store large content such as files and vectors under its content digest and + reference the digest from Artifacts: retried writes and rolled-back transactions then leave only unreferenced records, + never Artifacts pointing at the wrong data. +- The storage belongs to the whole plugin and is not split by Scope. Partition data by `scope_id` and confirm on reads that + data belongs to the request's Scope. +- `ctx.models` holds the models Server configures, or `None` when it configures neither: + - `await ctx.models.embed(["..."])` returns vectors; `ctx.models.embedding_dimension` is their dimension; + - `await ctx.models.generate([ChatMessage(role="user", content="...")], max_tokens=500)` returns generated text; + - check `embedding_available` and `generation_available`; calling a model Server lacks raises `ModelUnavailable`. + +## Dashboard pages + +Plugins that declare APIs can ship pages: + +```python +plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html", title={"zh": "工单", "en": "Tickets"})) +``` + +`ui/index.html` loads the client script core provides and calls the plugin's APIs through it: + +```html + + +``` + +- The page appears in the Dashboard's side navigation at `/dashboard/plugins/`. +- The page runs in a sandboxed iframe without the user's session; the Dashboard forwards its requests as the current user, + only to routes the plugin declares. +- `client.request(method, path, options)` accepts `json`, `body` (such as a file's `ArrayBuffer`), `contentType`, and + `query`, and returns `status`, `ok`, `json`, `blob`, and `filename`. +- Build the page as a single-page app: after the page navigates, the Dashboard stops forwarding and asks the user to reopen + it. Browser dialogs such as `alert` and `confirm` are unavailable in the sandbox. +- Pages are static files only (HTML, JavaScript, CSS, images, fonts), up to 5 MiB each; scripts may come only from the + plugin's own directory and `/dashboard/plugins/_client.js`. + +## Troubleshooting + +| Symptom | What to do | +| --- | --- | +| The plugin is `failed` in `/v1/plugins` | Read `error`: for import failures, check that dependencies are installed in the Server environment; for a missing required method, add it | +| APIs return 503 | The host process is starting or restarting; check the Server log for plugin errors | +| APIs return 500 `plugin_error` | A handler raised an unhandled exception; the Server log records its type | +| Workers stop running after a plugin change | Plugin files changed while Server was running; restart Server | +| Generated Artifacts do not appear | Check `GET /v1/plugins//scopes` for Scopes with pending Sources | + +## Current limits + +- Updating a plugin requires a Server restart; hot reload is not available yet. +- Dedicated plugin storage supports only SQLite deployments for now. +- Deployers install plugin dependencies into the Server environment by hand. +- Plugin code has the Server's permissions; host processes and Workers isolate failures, not untrusted code. diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 1f622e2c2e..a08339d313 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -118,6 +118,7 @@ still occupies an HTTP slot until it finishes; reserve other slots for concurren | `POWERCONTEXT_SERVER_HTTP_UDS` | unset | HTTP Unix socket path for `server run` on Linux/macOS; selects UDS only, with no TCP listener; absolute path, at most 103 filesystem-encoded bytes, without `..` or NUL | | `POWERCONTEXT_SERVER_HTTP_UDS_EXTERNAL_SCHEME` | unset | Operator-declared external `http` or `https` scheme for a trusted proxy on the private UDS; requires `HTTP_UDS`. Set `https` only with trusted TLS termination. Forwarded scheme and client-IP headers remain ignored | | `POWERCONTEXT_SERVER_HTTP_MAX_REQUEST_BODY_BYTES` | `16777216` | Maximum external HTTP body bytes before parsing, including chunked uploads | +| `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` | `33554432` | Request and response body limit of plugin routes (`/v1/plugins/{name}/api/...` and their Dashboard page forwarding), in place of the previous setting; a plugin route may declare a lower limit, never a higher one | | `POWERCONTEXT_SERVER_HTTP_MAX_CONCURRENT_REQUESTS` | `64` | Per-process external HTTP requests in flight, including full streaming responses | | `POWERCONTEXT_SERVER_WORKSPACE` | Server startup directory | Resolution root for local project Agent Skill folders | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | Enable Streamable HTTP MCP | diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 0171e17eb1..90f011aac9 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -269,7 +269,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_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,pc_artifact_family_schemas,pc_artifact_vector_spaces,pc_artifact_search_states' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_source_write_plugins,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ -f ``` @@ -277,7 +277,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_handles,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions,pc_artifact_vector_indexes,pc_artifact_index_changes' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_handles,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` @@ -293,17 +293,13 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_profile_policies,pc_artifact_index_coverage,pc_artifact_search_text' \ + --table 'pc_profile_policies' \ -f ``` Wait for each invocation to complete successfully before starting the next. Treat any OBLoader error, bad record, or conflict record as a failed restore. Order within a layer is irrelevant because no table in a layer references another table in the same layer. - `pc_artifact_vectors` is SQLite-only. For OceanBase/seekDB, restore registered `pc_av_` row data after Layer 4: - use database initialization to create and validate the restored registered spaces first. Preserve the exact - `space_id` and `build_id` identities; never regenerate them from restored content before importing vector rows. - Keep all writers stopped until the fixed schema, native spaces and restored index state are verified. 6. If the installation has additional PowerContext-managed tables not listed above, these tested layers do not classify them. Inspect their foreign-key constraints and place each table after all of its parents; do not add them to an all-table invocation. diff --git a/docs/zh/docs/develop/index.md b/docs/zh/docs/develop/index.md index 250b817d16..773bf43c9e 100644 --- a/docs/zh/docs/develop/index.md +++ b/docs/zh/docs/develop/index.md @@ -12,6 +12,7 @@ description: 通过 HTTP API、Python Client 或 Core SDK 接入应用。 Core SDK 支持进程内组合。MCP 向 Agent 暴露经过选择的工具子集。 - [HTTP 行为](http-api.md):认证、请求 ID、错误与并发。 +- [编写插件](plugins.md):用插件生成自己的 Artifact、定义 Source 类型、提供接口和 Dashboard 页面。 - [HTTP API](/api):生成的端点与结构。 - [Python API](/zh/modules):生成的 Python 文档。 - [Python Agent 框架](../integrations/index.md#python-agent-框架):遵循各自生命周期的社区适配器。 diff --git a/docs/zh/docs/develop/meta.json b/docs/zh/docs/develop/meta.json index cf325e0d8b..7b9049cf24 100644 --- a/docs/zh/docs/develop/meta.json +++ b/docs/zh/docs/develop/meta.json @@ -4,6 +4,7 @@ "index", "api-quickstart", "interfaces", - "http-api" + "http-api", + "plugins" ] } diff --git a/docs/zh/docs/develop/plugins.md b/docs/zh/docs/develop/plugins.md new file mode 100644 index 0000000000..7d8aed7428 --- /dev/null +++ b/docs/zh/docs/develop/plugins.md @@ -0,0 +1,421 @@ +--- +title: 编写插件 +description: 用一个目录和 plugin.py 扩展 PowerContext:生成、展示和检索自己的 Artifact,定义 Source 类型,提供接口和 Dashboard 页面。 +--- + +# 编写插件 + +插件是放在 Server 插件目录下的一个文件夹,入口是其中的 `plugin.py`。它可以: + +| 能力 | 说明 | +| --- | --- | +| 生成自己的 Artifact | 声明一个 Artifact family,读取 Source,写出这个 family 的 Artifact;结果通过现有的 Artifact 接口读取、打标签 | +| 展示和检索 | 给出每个版本的默认展示;按需实现检索,让通用检索接口和 Prepared Context 能用上插件的数据 | +| 支持用户修改 | 实现新建、修改、合并、遗忘、恢复方法,让通用 Artifact 接口作用于插件的 family | +| 定义 Source 类型 | 声明插件自己的 Source 类型和 Projection,记录用户要求做的事,例如一次上传 | +| 提供接口 | 声明 HTTP 路由,挂在 `/v1/plugins/<插件名>/api/` 下,由 core 认证并转发给插件 | +| 提供 Dashboard 页面 | 随插件提供静态页面,在 Dashboard 中以沙箱 iframe 展示 | +| 使用专属存储和模型 | 在 core 分配的独立存储中保存自己的数据,调用 Server 配置的 embedding 和生成模型 | + +插件代码在 Server 的 Python 环境中运行,被当作可信代码,权限与部署者相同。只加载你信任的插件。 + +## 启用插件目录 + +设置插件目录,然后重启 Server: + +```bash +export POWERCONTEXT_SERVER_PLUGINS_DIR=/srv/powercontext/plugins +powercontext server run +``` + +- 目录下每个子目录是一个插件,子目录名就是插件名(小写字母开头,只含小写字母、数字和 `-`)。 +- 插件依赖的第三方库需要装进 Server 的 Python 环境,例如 `uv pip install pypdf`。插件目录中的 `requirements.txt` 只是给 + 部署者看的说明,Server 不会自动安装。 +- 修改插件后需要重启 Server。Server 在加载时记录插件目录的内容指纹,运行中发现插件文件变了会拒绝执行,避免新旧代码混用。 + +用管理员凭据查看加载结果: + +```bash +curl -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8000/v1/plugins +``` + +每个插件的 `status` 为 `loaded` 或 `failed`;失败时 `error` 给出原因,例如声明字段不合法、导入失败。 + +## 第一个插件:把文本整理成笔记 + +在插件目录下新建 `notes/plugin.py`: + +```python +from powercontext.plugins import ( + ArtifactPlugin, ArtifactWriter, GenerateRequest, Inputs, Rendering, RenderRequest, SearchHit, SearchRequest, + SearchResult, +) + + +class Notes: + def __init__(self, ctx): + self.ctx = ctx + + async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None: + async with writer.transaction() as tx: + for source in request.sources: + text = source.projection["content"] + await tx.put(writer.stable_id(source.ref.source_id), content={"text": text}, source_refs=[source.ref]) + + async def render_artifact(self, request: RenderRequest) -> Rendering: + return Rendering(text=request.artifact.content["text"]) + + async def search_artifacts(self, request: SearchRequest) -> SearchResult: + query = request.search["query"] + page = await self.ctx.reads(request.scope_id).list(limit=200) + hits = [ + SearchHit(ref=item.ref, text=item.content["text"]) + for item in page.items + if query in item.content["text"] + ] + return SearchResult(hits=hits[: request.search.get("limit", 10)]) + + +async def setup(ctx): + return Notes(ctx) + + +plugin = ArtifactPlugin( + family="notes", + inputs=Inputs(source_types=("content",), projection="powercontext.text-evidence@1"), + on_sources=True, + setup=setup, +) +``` + +重启 Server 后写入一条文本 Source,几秒后就能读到插件生成的 Artifact: + +```bash +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/sources -d '{"content": "发布先进入华东区域"}' + +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes/$ARTIFACT_ID/rendering +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes/search -d '{"query": "华东"}' +``` + +这个插件包含了插件的基本部分: + +- **声明** `plugin = ArtifactPlugin(...)`:告诉 core 插件拥有哪个 family、消费哪些 Source、什么时候运行。 +- **工厂** `setup(ctx)`:返回插件对象。从环境变量读取插件自己的配置、打开需要的资源,都放在这里。 +- **生成** `generate(request, writer)`:收到一批新 Source,写出 Artifact。 +- **展示** `render_artifact(request)`:给出一个版本的默认展示,必须实现。 +- **检索** `search_artifacts(request)`:用插件自己的方式检索,可选。这个例子只做了子串匹配;正式的插件会用自己的全文或 + 向量索引。 + +## 声明 + +| 字段 | 说明 | +| --- | --- | +| `family` | 插件拥有的 Artifact family,不能与内置 family 或其他插件重复 | +| `inputs` | `Inputs(source_types=..., projection=..., projections={...}, max_sources=50)`:消费哪些 Source 类型、每种类型以哪个 Projection 交付、一次最多交付多少条。`projection` 适用于所有类型,`projections` 按类型单独指定 | +| `on_sources` | 有新 Source 时运行 | +| `schedule` | `Interval(seconds=...)` 或 `Cron(expression=..., timezone=...)`,定时运行。`schedule` 和 `on_sources` 都不填时插件没有后台生成,不需要实现 `generate`,Artifact 只由用户修改和插件接口写入 | +| `scopes` | 只服务这些 Scope;配置了 `schedule` 但不消费 Source 的插件必须填写 | +| `timeout_seconds`、`transaction_timeout_seconds` | 一次生成和其中写事务的时限 | +| `change_timeout_seconds` | 一次用户修改或接口调用的时限 | +| `definitions` | 插件自己的 Source 定义,见"定义 Source 类型" | +| `api` | 插件的接口,见"提供接口" | +| `pages` | Dashboard 页面,见"Dashboard 页面" | +| `setup` | 工厂函数,必须是 async 函数 | + +Server 在一个独立的子进程中导入 `plugin.py` 并检查声明,导入失败或声明不合法时插件为 `failed`,不影响 Server 和其他插件。 +宿主进程调用 `setup` 之后检查必须实现的方法:缺少 `render_artifact`,或者声明了 `schedule`、`on_sources` 却缺少 +`generate`,插件同样为 `failed`。 + +插件对象上的方法: + +| 方法 | 必须实现 | 何时调用 | +| --- | --- | --- | +| `render_artifact` | 是 | 展示接口 | +| `generate` | 声明了 `schedule` 或 `on_sources` 时 | 定时,或有新 Source | +| `search_artifacts` | 否 | 通用检索接口;Prepared Context 中列出该 family | +| `create_artifact`、`update_artifact`、`merge_artifacts`、`forget_artifact`、`restore_artifact` | 否 | 对应的通用修改接口 | +| 路由的处理函数 | 声明了路由时 | 插件自己的接口 | + +列出、读取版本和历史、治理状态和标签不调用插件方法,由 core 直接提供。 + +## 生成 Artifact + +`generate` 在 Server 启动的独立 Worker 进程中运行,每个 Scope 一次调用: + +- `request.scope_id` 是本次调用的 Scope;`request.sources` 是这次交付的 Source,每条带 `ref`(来源引用)和 `projection` + (按声明的 Projection 转换后的内容)。 +- 写入放在 `async with writer.transaction() as tx:` 里。块内的写操作在一个事务中提交,并与"这批 Source 已处理"的记录一起 + 提交;生成失败或超时,这批 Source 会再次交付。 +- 调用模型、解析文件等慢操作放在事务之外,事务里只做写入。 +- `tx.put(artifact_id, content=..., source_refs=[...])` 写一个新版本,`source_refs` 记录出处。内容与当前版本相同时不产生新版本, + 所以重复交付不会写出重复的版本。`writer.stable_id(...)` 按输入生成稳定的 ID,便于同一条 Source 总是写到同一个 Artifact。 +- 还可以用 `tx.set_lifecycle`、`tx.merge`、`tx.revert`、`tx.set_tags` 等方法,以及 `writer.get`、`writer.list`、 + `writer.by_source` 读取当前状态。 +- core 在每个版本上记录写入它的插件名和插件代码的内容摘要,多个 Server 加载了不同版本的插件时可以据此追溯。 + +生成的 Artifact 与内置 Artifact 一样,可以通过 `/v1/scopes/{scope_id}/artifacts/{family}` 系列接口读取、打标签。 + +## 展示和检索 + +core 不理解插件 Artifact 的内容结构,也不为插件建立索引;展示和检索由插件自己完成。 + +**展示**:`render_artifact(request)` 收到一个确切版本(`request.artifact`),返回 `Rendering(format="markdown", text=...)`, +`format` 也可以是 `"text"`。调用方通过下面的接口获取,不带 `revision` 时取当前版本: + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/v1/scopes/$SCOPE_ID/artifacts/notes/$ARTIFACT_ID/rendering?revision=2" +``` + +同一个版本的展示应该稳定,调用方可以按 family、ID、版本和插件版本缓存。 + +**检索**:`search_artifacts(request)` 收到通用检索接口的完整请求(`request.search`,包含 `query`、`limit`、`mode`、 +`filters`、`fusion` 等调用方发送的全部字段;`include_scores` 由 core 处理,不转交),用插件自己的索引检索,返回 `SearchResult(hits=[SearchHit(ref=..., text=..., +score=...)])`: + +- 不支持的字段请抛出 `InvalidContent`,接口返回 422,而不是静默忽略。 +- core 只保留调用者有权读取、仍是当前版本且处于 active 状态的命中,保持插件给出的顺序和分数。 +- `text` 是放进智能体上下文的片段,可以是内容的一部分,也可以是展示文本。 +- 插件没有实现 `search_artifacts` 时,通用检索接口和 Prepared Context 对该 family 返回 422。 + +**Prepared Context**:`POST /v1/context/prepare` 的 `assembly.sections` 可以列出插件 family。core 用请求的 `query` 和该项的 +`limit` 调用 `search_artifacts`,`request.max_bytes` 是本次请求的字节预算;命中按 section 顺序装进上下文,并引用确切版本。 +该项可以带 `search`,其中的字段原样交给插件: + +```json +{ + "scope_id": "team-a", + "query": "发布区域", + "assembly": {"sections": [{"family": "memory", "limit": 4}, {"family": "notes", "limit": 3, "search": {"mode": "hybrid"}}]} +} +``` + +## 支持用户修改 + +插件对象上实现下列方法后,通用 Artifact 接口就对插件的 family 生效;没有实现的方法,对应接口返回 422: + +| 方法 | 对应接口 | +| --- | --- | +| `create_artifact(request, writer)` | `POST /v1/scopes/{scope_id}/artifacts`,请求体 `{"family": "", "content": {...}}` | +| `update_artifact(request, writer)` | `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}`,带 `If-Match` | +| `merge_artifacts(request, writer)` | `POST /v1/scopes/{scope_id}/artifacts/{family}/merges` | +| `forget_artifact(request, writer)` | `POST .../{artifact_id}/forget` | +| `restore_artifact(request, writer)` | `POST .../{artifact_id}/restorations` | + +```python +from powercontext.plugins import InvalidContent + + +class Notes: + async def update_artifact(self, request, writer) -> None: + if not request.content.get("text"): + raise InvalidContent("text is required", field="text") + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=request.content, expected_revision=request.expected_revision) + + async def forget_artifact(self, request, writer) -> None: + async with writer.transaction() as tx: + await tx.set_lifecycle(request.artifact_id, "deprecated") +``` + +- core 先检查调用者的权限和版本,再调用方法;方法只负责校验内容并完成约定的那一次写入。 +- 抛出 `InvalidContent` 时接口返回 422,错误信息展示给调用者。 +- 用户修改会以修改人的身份记录出处,与内置 Artifact 的修改相同。 + +## 定义 Source 类型 + +Source 记录"发生了什么、用户要求做什么"。插件可以定义自己的 Source 类型,例如"上传了一个文件"或"编辑了一段内容", +并在生成时消费它们: + +```python +from pydantic import BaseModel +from powercontext.sources import Source, SourceMaterialization + + +class TicketInput(BaseModel): # 提交时的输入 + key: str + title: str + + +class TicketSource(Source): # 保存在 core 中的 Source + title: str + + +class TicketProjection: # 交付给 generate 的内容 + name = "tickets.ticket" + version = "1" + source_class = TicketSource + output_class = TicketInput + + def project(self, source, /): + return TicketInput(key=source.name, title=source.title) + + +class TicketDefinition: + name = "ticket" # Source 类型名 + version = "1" + input_class = TicketInput + source_class = TicketSource + projections = (TicketProjection(),) + + async def resolve(self, value, /): + return TicketSource( + name=value.key, definition_version=self.version, + materialization=SourceMaterialization.CAPTURED, description="A ticket", title=value.title, + ) + + async def read(self, source, /): + return TicketProjection().project(source) + + +plugin = ArtifactPlugin( + family="tickets", + inputs=Inputs(source_types=("ticket",), projection="tickets.ticket@1"), + on_sources=True, + definitions=(TicketDefinition(),), + setup=setup, +) +``` + +- 插件加载时,core 登记这些 Source 类型。之后调用方可以用通用 Source 接口写入: + `POST /v1/scopes/{scope_id}/sources`,请求体 `{"source_type": "ticket", "content": {"key": "OPS-7", "title": "..."}}`。 + 插件校验输入并解析成 Source;输入不合法返回 422。 +- 插件自己的接口中用 `ctx.sources.submit(scope_id, "ticket", TicketInput(...))` 提交。 +- Source 的 `name` 是它在 Scope 中的身份,同一个 `name` 重复提交得到同一条 Source。 +- 修改了 Source 或 Projection 的结构时,提高定义的 `version`,并在 `resolve` 中把 `definition_version` 设为新版本。core 按 + 名称和版本登记,新旧版本并存;已经保存的旧 Source 仍按旧版本解读。 +- 一个插件消费多种 Source 类型、各自需要不同的 Projection 时,用 `projections` 分别指定: + + ```python + Inputs(source_types=("upload", "edit"), projections={"upload": "kb.upload@1", "edit": "kb.edit@1"}) + ``` + + `generate` 中按 `source.ref.source_type` 区分。 + +## 提供接口 + +插件可以声明自己的 HTTP 路由,处理函数是插件对象上的 async 方法: + +```python +from pydantic import BaseModel +from powercontext.plugins import ApiError, ApiRequest, PluginApi, Route + + +class NewTicket(BaseModel): + scope_id: str + key: str + title: str + + +class Tickets: + def __init__(self, ctx): + self.ctx = ctx + + async def open_ticket(self, request: ApiRequest) -> dict: + body: NewTicket = request.body + await self.ctx.require("scope.contribute", body.scope_id) + ref = await self.ctx.sources.submit(body.scope_id, "ticket", TicketInput(key=body.key, title=body.title)) + return {"source_id": ref.source_id} + + +plugin = ArtifactPlugin( + ..., + api=PluginApi(routes=( + Route(method="POST", path="/tickets", handler="open_ticket", access="authenticated", + body="json", request_model=NewTicket), + )), +) +``` + +- 路由挂在 `/v1/plugins/<插件名>/api`,例如 `POST /v1/plugins/tickets/api/tickets`。调用方使用与其他接口相同的凭据。 +- `access` 决定 core 在调用前做的检查: + - `authenticated`:只要求已登录; + - `scope.read` / `scope.contribute`:要求调用者对 `scope_param` 指定的路径参数中的 Scope 具备该权限。 +- 处理函数里用 `ctx.require(action, scope_id)` 检查其他权限,不具备时接口返回 403;`ctx.allowed(...)` 只返回真假。 +- 读写核心数据:`ctx.reads(scope_id)` 按调用者权限读取;`ctx.writer(scope_id)` 和 `ctx.sources.submit(...)` 只能用于本次 + 请求中已经确认 `scope.contribute` 的 Scope(路由声明了该权限,或处理函数先调用了 `ctx.require`)。 +- 接口中的写入不会自动记录"谁做了什么"。需要这类出处时,像上面的例子一样把操作作为 Source 提交,由 `generate` 写入新版本并 + 引用它。 +- 需要创建 Scope 时用 `ctx.scopes.create(title=..., summary=..., idempotency_key=...)`,要求调用者具备 `server.admin`。 +- `request.body` 按 `body` 声明是校验后的 `request_model` 实例、`bytes` 或 `None`;`request.path_params`、`request.query`、 + `request.caller` 分别是路径参数、查询参数和调用者。 +- 返回值:Pydantic 模型或 dict 返回 JSON;`BinaryResponse(content=..., content_type=..., filename=...)` 返回文件; + `None` 返回 204。抛出 `ApiError(status, code, message)` 返回对应的 4xx 错误。 +- 请求体上限:JSON 默认 1 MiB,二进制默认 32 MiB,可以用 `max_body_bytes` 为单个路由声明,但不会超过部署者设置的 + `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES`。超出时 core 返回 413,不调用插件。 +- 每次调用受 `timeout_seconds`(默认 30 秒)约束,超时返回 504。 + +接口、展示、检索和用户修改方法在一个常驻的宿主进程中执行,插件加载后就启动;工厂 `setup(ctx)` 在宿主进程中只执行一次, +插件对象服务所有请求。宿主进程崩溃或超时会被重启,期间接口返回 503。 + +## 专属存储和模型 + +```python +from sqlalchemy import text + + +async def setup(ctx): + engine = ctx.storage.engine() # 只指向本插件的存储 + async with engine.begin() as connection: + await connection.execute(text("CREATE TABLE IF NOT EXISTS notes_index (id TEXT PRIMARY KEY, body TEXT)")) + return Notes(ctx) +``` + +- `ctx.storage.engine()` 返回一个 SQLAlchemy 异步引擎,指向数据目录下的 `plugins/<插件名>.sqlite3`。表结构和迁移由插件自己 + 管理,core 不读取、不备份这块存储。目前只支持 SQLite 部署;使用 OceanBase 或 seekdb 的 Server 上,访问 `ctx.storage` + 会报错。 +- 这块存储不在 core 的事务里。大块内容(文件、向量)建议按内容摘要保存,再在 Artifact 中引用摘要:写入重试或事务回滚只会 + 留下没人引用的记录,不会让 Artifact 指向错误的数据。 +- 存储属于整个插件,不按 Scope 隔离。按 `scope_id` 划分数据,并在读取时确认数据属于本次请求的 Scope。 +- `ctx.models` 是 Server 配置的模型,两种都没配置时为 `None`: + - `await ctx.models.embed(["..."])` 返回向量,`ctx.models.embedding_dimension` 是维度; + - `await ctx.models.generate([ChatMessage(role="user", content="...")], max_tokens=500)` 返回生成的文本; + - 用 `embedding_available`、`generation_available` 判断是否配置;调用未配置的模型时抛出 `ModelUnavailable`。 + +## Dashboard 页面 + +声明了接口的插件可以提供页面: + +```python +plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html", title={"zh": "工单", "en": "Tickets"})) +``` + +`ui/index.html` 中引用 core 提供的客户端脚本,通过它调用本插件的接口: + +```html + + +``` + +- 页面出现在 Dashboard 左侧导航中,地址是 `/dashboard/plugins/<插件名>`。 +- 页面运行在沙箱 iframe 中,拿不到用户的登录会话;请求由 Dashboard 以当前用户的身份转发,只能调用本插件声明的路由。 +- `client.request(method, path, options)` 的 `options` 可以有 `json`、`body`(例如文件的 `ArrayBuffer`)、`contentType` 和 + `query`;返回 `status`、`ok`、`json`、`blob`、`filename`。 +- 页面要做成单页应用:页面跳转后 Dashboard 会停止转发,提示用户重新打开。沙箱中不能使用 `alert`、`confirm` 等浏览器对话框。 +- 只提供静态文件(HTML、JavaScript、CSS、图片、字体),单个文件不超过 5 MiB;脚本只能来自插件自己的目录和 + `/dashboard/plugins/_client.js`。 + +## 排错 + +| 现象 | 处理 | +| --- | --- | +| `/v1/plugins` 中插件为 `failed` | 查看 `error`:导入失败时检查依赖是否装进了 Server 环境;缺少必须实现的方法时补上方法 | +| 接口返回 503 | 宿主进程正在启动或重启;查看 Server 日志中的插件错误 | +| 接口返回 500 `plugin_error` | 处理函数抛出了未处理的异常;Server 日志记录了异常类型 | +| 修改插件后 Worker 不再执行 | 插件文件在运行中被修改,重启 Server | +| 生成的 Artifact 没有出现 | 用 `GET /v1/plugins/<插件名>/scopes` 查看各 Scope 是否还有待处理的 Source | + +## 当前限制 + +- 插件更新需要重启 Server,还不支持热加载。 +- 插件专属存储目前只支持 SQLite 部署。 +- 插件依赖由部署者手动安装到 Server 环境。 +- 插件代码与 Server 有相同的权限,宿主进程和 Worker 隔离的是故障,不是不可信代码。 diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 4b75792ee6..7da1bfb9cd 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -104,6 +104,7 @@ MCP 使用 stateless Streamable HTTP,每次工具执行都采用当前请求 | `POWERCONTEXT_SERVER_HTTP_UDS` | 未设置 | Linux/macOS 上 `server run` 的 HTTP Unix socket 路径;仅监听 UDS,不创建 TCP listener;必须是绝对路径,不含 `..` 或 NUL,按文件系统编码计算不超过 103 字节 | | `POWERCONTEXT_SERVER_HTTP_UDS_EXTERNAL_SCHEME` | 未设置 | 私有 UDS 上可信代理的外部协议,只接受 `http` 或 `https`,必须同时设置 `HTTP_UDS`;仅在可信代理终止 TLS 时设置 `https`。仍忽略转发的协议与客户端 IP header | | `POWERCONTEXT_SERVER_HTTP_MAX_REQUEST_BODY_BYTES` | `16777216` | 解析前允许的外部 HTTP 请求体字节数,包括分块上传 | +| `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` | `33554432` | 插件接口(`/v1/plugins/{name}/api/...` 及 Dashboard 插件页面的转发)的请求体和响应体上限,替代上一项;插件路由可以声明更小的上限,不能超过此值 | | `POWERCONTEXT_SERVER_HTTP_MAX_CONCURRENT_REQUESTS` | `64` | 每进程进行中的外部 HTTP 请求上限,包括完整流式响应 | | `POWERCONTEXT_SERVER_WORKSPACE` | Server 启动目录 | 本机项目级 Agent Skill 目录的解析根目录 | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | 启用 Streamable HTTP MCP | diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index d5fada4e30..e8b27f5fa3 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -260,7 +260,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_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,pc_artifact_family_schemas,pc_artifact_vector_spaces,pc_artifact_search_states' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_source_write_plugins,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_recall_effort_daily,pc_receipt_migration_review,pc_decision_observations,pc_lexical_projection_versions' \ -f ``` @@ -268,7 +268,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_handles,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions,pc_artifact_vector_indexes,pc_artifact_index_changes' \ + --table 'pc_dream_runs,pc_context_bootstrap_receipts,pc_scope_context_references,pc_scope_external_references,pc_scope_creation_requests,pc_scope_settings,pc_scope_bindings,pc_artifact_handles,pc_artifact_heads,pc_artifact_lineage_sources,pc_artifact_lineage_artifacts,pc_artifact_publications,pc_candidate_versions,pc_topic_memory_revision_publications,pc_memory_entry_versions' \ -f ``` @@ -284,16 +284,12 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_profile_policies,pc_artifact_index_coverage,pc_artifact_search_text' \ + --table 'pc_profile_policies' \ -f ``` 每个命令成功完成后才能开始下一层。OBLoader 出现任何错误、bad record 或 conflict record 时,都应判定恢复 失败。同一层内的表互不引用,因此层内顺序无关。 - `pc_artifact_vectors` 仅用于 SQLite。OceanBase/seekDB 在第 4 层之后恢复已登记的 `pc_av_` 行数据: - 先通过数据库初始化创建并校验已恢复登记的空间物理表。 - 保留精确的 `space_id` 和 `build_id`;导入向量行之前,不从内容重新分配这些身份。 - 固定 schema、原生空间和已恢复索引状态全部验证完成前,保持所有写入方停止。 6. 如果安装中还存在上面未列出的 PowerContext 管理表,则这些已测试的层并未对它们分类。检查其外键约束,将每张 表放在其所有父表之后;不要把它们加入全表导入命令。 7. 逐表比较源数据库和目标数据库的记录数,检查 identity column collation,并测试仅大小写或重音不同的 diff --git a/examples/artifact_plugins/README.md b/examples/artifact_plugins/README.md deleted file mode 100644 index 70526c943d..0000000000 --- a/examples/artifact_plugins/README.md +++ /dev/null @@ -1,221 +0,0 @@ -# 用户自定义 Artifact 插件示例 - -用户把插件部署到统一发现目录。PowerContext Server 启动时校验新插件并保存定义,首次加载为 `disabled`;管理员启用后,Supervisor 按清单参数派发 Worker。Worker 加载 pipeline,框架保存生成结果,应用通过公开 API 读取。 - -| 模板目录 | family / binding | 生成结果 | 业务配置 | -| --- | --- | --- | --- | -| `incident_report` | `incident-report` / `incident-reports` | 每条 Source 一份工单报告 | `title_prefix` | -| `ticket_summary` | `ticket-summary` / `ticket-summaries` | 每个 Source 窗口一份文本摘要 | `language` | - -本示例使用文件型 SQLite、global Supervisor、Server 的 all 角色、Worker 内加载 Python 和声明式文本检索,不依赖 LLM。内存型 SQLite 可以加载声明,但启用生成会返回 `422`,因为 Worker 子进程无法共享 Server 的内存数据库。 - -## 1. 配置并启动 Server - -在仓库根目录执行: - -```bash -uv sync --extra server --extra cli -export POWERCONTEXT_HOME="$PWD/.powercontext/artifact-plugin-example" -export POWERCONTEXT_SERVER_DATABASE='{"kind":"sqlite","url":"sqlite+aiosqlite:///.powercontext/artifact-plugin-example.db"}' -export POWERCONTEXT_SERVER_RUNTIME='{"artifact_processing_families":[]}' -export POWERCONTEXT_SERVER_AUTH_ENABLED=true -export POWERCONTEXT_SERVER_AUTH_TOKEN=local-example-admin -export POWERCONTEXT_SERVER_ACCESS_MODE=enforced -export POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR="$POWERCONTEXT_HOME/plugins" -mkdir -p "$POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR" -uv run powercontext server run --no-env-file --host 127.0.0.1 --port 8000 -``` - -Server 的 `.env` / envfile 保存数据库、监听、鉴权、共用模型和统一插件目录等全局设置。`POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR` 沿用现有加载规则:环境变量优先于 envfile。各插件的 Scope、批次、调度和业务参数只保存在各自的 `plugin.yml`。 - -以下命令在仓库根目录的另一个终端运行,需要 `curl` 和 `jq`。首次使用先创建 Scope;已有 Scope 的部署可以在启动前准备好插件目录。 - -```bash -export PC_URL=http://127.0.0.1:8000 -export PC_AUTH='Authorization: Bearer local-example-admin' -export PC_PLUGIN_ROOT="$PWD/.powercontext/artifact-plugin-example/plugins" -export PC_VERSION_ROOT="$PWD/.powercontext/artifact-plugin-example/versions" -export PC_REPORT_SCOPE=$(curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/scopes" -d '{"title":"Reports","summary":"Incident reports","idempotency_key":"artifact-reports-example"}' \ - | jq -r .scope_id) -export PC_SUMMARY_SCOPE=$(curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/scopes" -d '{"title":"Summaries","summary":"Ticket summaries","idempotency_key":"artifact-summaries-example"}' \ - | jq -r .scope_id) -``` - -## 2. 部署插件清单和代码 - -每个插件目录有一份完整清单。报告插件示例如下,显式填写的 `scope_ids` 必须对应实际存在的 Scope: - -```yaml -id: example-incident-report -version: "1" -entry: ./plugin.py:create_pipeline -binding_name: incident-reports -scope_ids: [team-a] -max_sources: 20 -processing: - interval_seconds: 1 - max_workers: 1 - timeout_seconds: 60 -config: - title_prefix: 工单快照报告 -``` - -`entry` 相对清单所在目录解析。统一目录只发现直接子目录,目录项名称必须等于 `binding_name`。版本目录放在发现目录之外,通过符号链接提供当前入口;Server 保存解析后的实际目录,切换链接不会改变已有 Worker 使用的代码。 - -Scope、窗口上限、发现间隔、并行 Worker 数和 Worker 超时只作用于对应 binding。数值必须为正数,批次和 Worker 数必须为整数。`config` 按插件的 `Config` 模型校验后交给 pipeline。清单顶层禁止 `enable` / `enabled` 字段,无论值为 true 还是 false;运行状态由管理 API 控制。含这些字段的清单会被拒绝,reload 返回 `422` 并保留已有有效定义和状态。 - -`scope_ids` 可省略:框架动态发现已有输入积压和后续新增的 Scope,并按后台身份校验访问权限。显式填写非空名单时,该 binding 只处理名单内的已有 Scope。处理范围不授予权限。空数组 `scope_ids: []` 会被拒绝。 - -摘要插件使用自己的 Scope、`max_sources=4`、5 秒间隔、2 个 Worker、90 秒超时和 `language` 配置。插件通过安装包中的 `powercontext.artifact_plugins` 声明 schema、输入契约和可选 FTS 能力。 - -```bash -mkdir -p "$PC_PLUGIN_ROOT" "$PC_VERSION_ROOT" -cp -R examples/artifact_plugins/incident_report "$PC_VERSION_ROOT/incident-report-v1" -cp -R examples/artifact_plugins/ticket_summary "$PC_VERSION_ROOT/ticket-summary-v1" -uv run python - <<'PY' -import os -from pathlib import Path -import yaml - -root = Path(os.environ["PC_VERSION_ROOT"]) -for name, scope in ( - ("incident-report-v1", os.environ["PC_REPORT_SCOPE"]), - ("ticket-summary-v1", os.environ["PC_SUMMARY_SCOPE"]), -): - path = root / name / "plugin.yml" - manifest = yaml.safe_load(path.read_text(encoding="utf-8")) - manifest["scope_ids"] = [scope] - path.write_text(yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding="utf-8") -PY -ln -s "$PC_VERSION_ROOT/incident-report-v1" "$PC_PLUGIN_ROOT/incident-reports" -ln -s "$PC_VERSION_ROOT/ticket-summary-v1" "$PC_PLUGIN_ROOT/ticket-summaries" -``` - -Artifact 插件通过输入映射选择每种 Source 的 Projection。两个示例均消费主线已有的 `content` Source: - -```python -inputs = InputContract(projections={"content": TEXT_EVIDENCE_PROJECTION_KEY}) -``` - -映射必须非空,每种类型选择一个合法的 ProjectionKey。`GenerationSource` 包含 `ref`、`projection` 和完整 JSON `value`;对象、数组、嵌套字段、数字、布尔值和 null 保持原有结构。示例调用 `TextEvidence.model_validate(source.value).content` 读取文本。 - -一个 family 可消费多个 Source 类型,一个类型也可供多个 family 消费。关联由上述输入声明和输出 family 决定,与两端插件 ID 或 binding 名称无关。`max_sources` 限制 journal 窗口条目数;未声明类型被过滤,batch 不保证所有类型齐全,不等待凑齐或自动配对。draft 只列出实际使用的精确 SourceRef。 - -对于外部 Source,Worker 读取已保存的 Projection,不加载 Source 插件代码,不请求外部最新数据。已接受类型缺少指定名称或版本时整个窗口失败,游标不推进,也不使用其他投影代替。输入预算按完整批次 JSON 证据计量,当前上限为 1 MiB;超限不截断或部分提交,可通过新目录和 reload 调整窗口大小。 - -## 3. 加载并启用 - -启动扫描会加载首次发现的插件。Server 已运行时,新增插件需要显式 reload;请求只携带 `expected_generation`,首次为 0: - -```bash -for binding in incident-reports ticket-summaries; do - curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/admin/artifact-plugins/$binding/reload" -d '{"expected_generation":0}' -done -curl -fsS -H "$PC_AUTH" "$PC_URL/v1/admin/artifact-plugins" | jq -curl -fsS -X POST -H "$PC_AUTH" "$PC_URL/v1/admin/artifact-plugins/enable-all" | jq -``` - -reload 返回 `202` 和 `Location`。首次加载只校验入口、Config、输入输出声明、Scope 与身份,保存完整定义和读取能力;不调用生成工厂或生成方法。查询状态到 `disabled` 后即可启用。 - -enable-all 无需请求体,只处理请求开始时已加载且 disabled 的插件,不扫描目录。逐插件返回 `accepted`、`skipped` 或 `failed`、安全错误码和 `status_url`。一个失败不回滚其他成功项;已启用、卸载、failed 或转换中的插件跳过。通过状态 URL 查询到 `phase=enabled` 才表示异步装配完成。首次加载失败不产生注册记录,启动日志报告该目录的失败,其他合法插件继续加载。 - -## 4. 写入 Source 并读取结果 - -```bash -for scope in "$PC_REPORT_SCOPE" "$PC_SUMMARY_SCOPE"; do - jq -n --arg scope "$scope" \ - '{scope_id:$scope,source_id:"ticket-1",content:"Outage: database recovered after retry."}' \ - | curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/sources/content" --data-binary @- -done -curl -fsS -H "$PC_AUTH" "$PC_URL/v1/scopes/$PC_REPORT_SCOPE/artifacts/incident-report" | jq -curl -fsS -H "$PC_AUTH" "$PC_URL/v1/scopes/$PC_SUMMARY_SCOPE/artifacts/ticket-summary" | jq -curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/scopes/$PC_REPORT_SCOPE/artifacts/incident-report/search" -d '{"query":"Outage"}' | jq -export PC_ARTIFACT=$(curl -fsS -H "$PC_AUTH" \ - "$PC_URL/v1/scopes/$PC_REPORT_SCOPE/artifacts/incident-report" | jq -r '.items[0].artifact_id') -curl -fsS -H "$PC_AUTH" "$PC_URL/v1/scopes/$PC_REPORT_SCOPE/artifacts/incident-report/$PC_ARTIFACT" | jq -``` - -生成异步执行,尚无产物时 list / search 可返回空结果;已有产物但首次索引尚未就绪时,search 返回 `503`,稍后重试。list 返回 Artifact 身份,get 返回内容、schema 版本和精确 lineage。启用时从持久游标消费历史与停用期间积压;重复写入相同 Source 不产生重复修订,生成失败不推进游标或提交部分结果。 - -两个示例通过 `ArtifactFamily.search` 和 `TextIndex` 声明检索字段。文本检索支持中文、英文和混合文本,只匹配当前 revision 对应 schema 声明的字段。统一 `/search` 返回 `results`,包含完整 `content`、精确 revision 和 lineage;`include_scores` 可附带评分。 - -Server 根据持久声明恢复索引,不要求生成插件启用或代码目录存在。首次构建尚未就绪时返回 `503`;已发布索引随写入同步维护。检索表由统一数据库初始化创建,无需单独安装。 - -向量、多片段及 hybrid 通过 `ArtifactSearchDeclaration`、`VectorIndex`(`source="text"` 或 `source="content"`) 声明。OceanBase/seekDB 热加载新向量 profile 后,需要重新初始化数据库(例如重启 Server)以创建对应空间。 - -## 5. 启停、更新、卸载与恢复 - -单插件管理使用最新 GET 返回的 `binding_generation`,过期代次返回 `409`: - -```bash -export PC_GENERATION=$(curl -fsS -H "$PC_AUTH" \ - "$PC_URL/v1/admin/artifact-plugins/incident-reports" | jq -r .binding_generation) -curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/admin/artifact-plugins/incident-reports/disable" \ - -d "{\"expected_generation\":$PC_GENERATION}" -``` - -查询到 `disabled` 后再读取新代次,可调用 `enable` 或 `unload`。启停不修改清单。卸载回收 Worker 并移除执行定义,保留身份、代次、schema、读取能力、消费进度和已有结果;确认 `unloaded` 后可删除专属版本目录,再次加载需 reload。 - -更新代码或配置时准备完整的新版本目录,再切换发现入口并调用 reload: - -```bash -cp -R "$PC_VERSION_ROOT/incident-report-v1" "$PC_VERSION_ROOT/incident-report-v2" -uv run python - <<'PY' -import os -from pathlib import Path -import yaml - -path = Path(os.environ["PC_VERSION_ROOT"]) / "incident-report-v2/plugin.yml" -manifest = yaml.safe_load(path.read_text(encoding="utf-8")) -manifest["version"] = "2" -manifest["config"]["title_prefix"] = "新版工单报告" -path.write_text(yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding="utf-8") -PY -ln -s "$PC_VERSION_ROOT/incident-report-v2" "$PC_VERSION_ROOT/incident-reports-next" -mv -Tf "$PC_VERSION_ROOT/incident-reports-next" "$PC_PLUGIN_ROOT/incident-reports" -export PC_GENERATION=$(curl -fsS -H "$PC_AUTH" \ - "$PC_URL/v1/admin/artifact-plugins/incident-reports" | jq -r .binding_generation) -curl -fsS -H "$PC_AUTH" -H 'Content-Type: application/json' \ - "$PC_URL/v1/admin/artifact-plugins/incident-reports/reload" \ - -d "{\"expected_generation\":$PC_GENERATION}" -``` - -候选校验失败保留原定义和状态。接受更新后撤销旧代次提交资格,回收旧 Worker,再装配新定义;确认完成前保留旧目录。reload 更新正常插件保留原启停状态;卸载或 failed 后加载为 disabled,需要再次启用。同一 `(family, schema_version)` 的内容 schema 不可变。已有检索 family 的 schema 身份、索引定义和版本冻结;无检索 family 可以演进内容 schema,但不能补加检索。允许调整 `modes`、`default_mode`、`defaults`、`candidate_limit`、`rrf_k`、`text_weight`、`vector_weight` 查询策略,更新不会触发索引重建、停搜或重新编码。 - -Server 重启恢复持久快照与已保存的启停、卸载状态,并继续未完成转换。扫描只发现新插件,不覆盖已有配置或重新加载卸载记录;failed 等待管理员 reload 修复。恢复 enabled 会更新代次,管理操作始终先 GET。无需每次重启调用 enable-all,结果读取不依赖插件进程。 - -## 6. 通用检索与精确证据读取 - -上层应用 / Agent 通过公开的 Artifact get / list / 精确 revision 和 FTS 接口获取数据,自行理解问题、组合证据并计算距离、次数或排名。PowerContext 提供存储、生成、版本、鉴权、检索和精确证据读取,不执行插件业务查询回调。 - -生成插件通过 `GenerationContext` 使用同一套读取与检索语义: - -| 接口 | 语义 | -| --- | --- | -| `read_latest(family, artifact_id)` | 读取当前 revision,缺失时返回 `None`;独立调用不形成跨 family 快照 | -| `read_revision(ref)` | 读取精确 revision,支持已被新 head 替代的历史内容;缺失时报错 | -| `list_artifacts(family, limit=50, cursor=None)` | 分页返回可见 active head 的引用;每页上限 100,游标只在本次生成上下文内使用 | -| `search_artifacts(family, query=None, **parameters)` | 经共享 ArtifactSearch 返回完整 Artifact,支持已声明模式和索引;limit 默认 10,上限 200 | - -生成默认可读自己的输出 family;需要跨 family 时,使用 `InputContract(..., artifact_families=(...))` 声明额外范围。所有读取固定在当前 Scope,并受 Worker 身份权限约束。get / list 支持已注册 Artifact family;内置 family 的快照没有自定义 `schema_version` 时返回 `None`。生成 Worker 与 HTTP / SDK 遵守相同的检索语义;无检索能力的已知 family 明确返回不支持。 - -list / search 不自动登记为生成证据。search 返回完整正文;需要用于 draft 的 lineage 或更新基准时,插件仍须显式调用 read_revision 读取所选精确版本,登记实际使用的证据。完整枚举应遍历分页 list 或持久清单,不能用 top-k 表示完整集合。历史读取先固定发布根,再沿清单读取精确引用;当前 FTS 不包含旧 revision,不能据此排除历史对象。 - -统一 HTTP search 复用共享检索服务。插件停用或卸载后,已保存内容的 get / list / 声明式检索继续可用。普通读取与检索不推进 Source 消费进度。 - -## 端到端验收 - -```bash -uv run pytest tests/e2e/test_artifact_plugins.py \ - tests/e2e/test_artifact_plugin_search.py -q -``` - -测试启动安装包提供的 `powercontext server run`,通过真实 envfile / 环境变量、部署目录和 HTTP 完成加载、启用、消费与查询。覆盖首次加载不生成、多插件独立配置、批量部分失败和并发冲突、reload、单插件启停 / 卸载、重启与失败恢复。 - -测试只使用产品的公开路径。 diff --git a/examples/artifact_plugins/incident_report/plugin.py b/examples/artifact_plugins/incident_report/plugin.py deleted file mode 100644 index d612266039..0000000000 --- a/examples/artifact_plugins/incident_report/plugin.py +++ /dev/null @@ -1,97 +0,0 @@ -# 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. - -"""Generate one deterministic report for each exact ticket snapshot.""" - -import hashlib -import json - -from pydantic import BaseModel, ConfigDict, Field - -from powercontext.artifact_plugins import ( - ArtifactDraft, - ArtifactFamily, - ArtifactSearchDeclaration, - GenerationBatch, - GenerationContext, - InputContract, - TextIndex, -) -from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, TextEvidence - -plugin_api_version = "1" - - -class Config(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - title_prefix: str = "Incident report" - fail_on_text: str | None = None - - -class IncidentReport(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - title: str = Field(min_length=1) - evidence_text: str = Field(min_length=1) - - -families = ( - ArtifactFamily( - family="incident-report", - schema_version="1", - content_model=IncidentReport, - search=ArtifactSearchDeclaration( - version="1", - indexes=(TextIndex(name="lexical", projection_version="1", fields=("title", "evidence_text")),), - modes=("text",), - default_mode="text", - ), - ), -) -inputs = InputContract( - projections={ - "content": TEXT_EVIDENCE_PROJECTION_KEY, - } -) - - -class IncidentReportPipeline: - def __init__(self, config: Config) -> None: - self.config = config - - async def generate(self, batch: GenerationBatch) -> list[ArtifactDraft]: - drafts = [] - for source in batch.sources: - evidence = TextEvidence.model_validate(source.value) - if self.config.fail_on_text and self.config.fail_on_text in evidence.content: - raise ValueError("Demo generation failure: no drafts from this window will be committed") # noqa: TRY003 - identity = json.dumps(source.ref.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) - drafts.append( - ArtifactDraft( - family="incident-report", - schema_version="1", - artifact_id=hashlib.sha256(identity.encode()).hexdigest(), - content=IncidentReport( - title=f"{self.config.title_prefix}: {source.ref.source_id}", - evidence_text=evidence.content, - ).model_dump(mode="json"), - source_refs=(source.ref,), - ) - ) - return drafts - - -async def create_pipeline(ctx: GenerationContext, config: Config) -> IncidentReportPipeline: - return IncidentReportPipeline(config) diff --git a/examples/artifact_plugins/ticket_summary/plugin.py b/examples/artifact_plugins/ticket_summary/plugin.py deleted file mode 100644 index cbb66d54fc..0000000000 --- a/examples/artifact_plugins/ticket_summary/plugin.py +++ /dev/null @@ -1,95 +0,0 @@ -# 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. - -"""Summarize each Source window without an external model dependency.""" - -import hashlib -import json - -from pydantic import BaseModel, ConfigDict, Field - -from powercontext.artifact_plugins import ( - ArtifactDraft, - ArtifactFamily, - ArtifactSearchDeclaration, - GenerationBatch, - GenerationContext, - InputContract, - TextIndex, -) -from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, TextEvidence - -plugin_api_version = "1" - - -class Config(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - language: str = Field(default="简体中文", min_length=1) - - -class TicketSummary(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - language: str - source_ids: list[str] = Field(min_length=1) - summary_text: str = Field(min_length=1) - - -families = ( - ArtifactFamily( - family="ticket-summary", - schema_version="1", - content_model=TicketSummary, - search=ArtifactSearchDeclaration( - version="1", - indexes=(TextIndex(name="lexical", projection_version="1", fields=("summary_text",)),), - modes=("text",), - default_mode="text", - ), - ), -) -inputs = InputContract( - projections={ - "content": TEXT_EVIDENCE_PROJECTION_KEY, - } -) - - -class TicketSummaryPipeline: - def __init__(self, config: Config) -> None: - self.config = config - - async def generate(self, batch: GenerationBatch) -> list[ArtifactDraft]: - refs = tuple(source.ref for source in batch.sources) - identity = json.dumps([ref.model_dump(mode="json") for ref in refs], sort_keys=True, separators=(",", ":")) - return [ - ArtifactDraft( - family="ticket-summary", - schema_version="1", - artifact_id=hashlib.sha256(identity.encode()).hexdigest(), - content=TicketSummary( - language=self.config.language, - source_ids=[ref.source_id for ref in refs], - summary_text="\n".join( - TextEvidence.model_validate(source.value).content for source in batch.sources - ), - ).model_dump(mode="json"), - source_refs=refs, - ) - ] - - -async def create_pipeline(ctx: GenerationContext, config: Config) -> TicketSummaryPipeline: - return TicketSummaryPipeline(config) diff --git a/integrations/codex/plugins/powercontext/hooks/bind_tools.py b/integrations/codex/plugins/powercontext/hooks/bind_tools.py index cc3674c719..baec64eda9 100644 --- a/integrations/codex/plugins/powercontext/hooks/bind_tools.py +++ b/integrations/codex/plugins/powercontext/hooks/bind_tools.py @@ -66,10 +66,12 @@ "download_skill_package", "finalize_handoff", "flush_memory", + "forget_artifact", "flush_topic_memory", "generate_experience", "generate_skill", "get_artifact", + "get_artifact_governance", "get_candidate", "get_candidate_history", "get_artifact_revision", @@ -90,6 +92,7 @@ "list_managed_skills", "list_memory_entries", "list_remote_skill_targets", + "merge_artifacts", "merge_atomic_memories", "prepare_context", "prepare_bootstrap_context", @@ -109,6 +112,7 @@ "replace_artifact", "rename_remote_skill_target", "resolve_external_skill", + "restore_artifact", "restore_atomic_memory", "retry_topic_memory_processing", "revise_candidate", diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 8971e49d50..f07db20a78 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -32,13 +32,6 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_server_info: { method: 'GET', path: '/v1/server-info', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { method: 'GET', path: '/v1/admin/artifact-plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - get_artifact_plugin: { method: 'GET', path: '/v1/admin/artifact-plugins/{binding_name}', location: null, scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - enable_all_artifact_plugins: { method: 'POST', path: '/v1/admin/artifact-plugins/enable-all', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - reload_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/reload', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - enable_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/enable', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - disable_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/disable', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - unload_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/unload', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scopeMode: 'none', pathParameters: [], queryParams: ['query','query_field','parent_scope_id','external_reference_kind','binding_integration','binding_kind','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, @@ -73,6 +66,13 @@ export const OPERATIONS = { continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_handoff_hint: { method: 'POST', path: '/v1/handoff/hint', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, flush_topic_memory: { method: 'POST', path: '/v1/topic-memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + forget_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + restore_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/search', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_topic_memory_processing_status: { method: 'POST', path: '/v1/topic-memory/processing/status', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_topic_memory_processing_block: { method: 'POST', path: '/v1/topic-memory/processing/blocks/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -140,6 +140,7 @@ export const OPERATIONS = { replace_memory_entry_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, query_artifact_tags: { method: 'POST', path: '/v1/scopes/{scope_id}/artifact-tags/query', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: ['revision'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_artifact_revisions: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_prompt_configuration: { method: 'GET', path: '/v1/scopes/{scope_id}/prompts/{prompt_key}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'prompt_key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, generate_prompt_demonstrations: { method: 'POST', path: '/v1/scopes/{scope_id}/prompts/{prompt_key}/demonstrations', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'prompt_key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 8971e49d50..f07db20a78 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -32,13 +32,6 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_server_info: { method: 'GET', path: '/v1/server-info', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { method: 'GET', path: '/v1/admin/artifact-plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - get_artifact_plugin: { method: 'GET', path: '/v1/admin/artifact-plugins/{binding_name}', location: null, scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - enable_all_artifact_plugins: { method: 'POST', path: '/v1/admin/artifact-plugins/enable-all', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - reload_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/reload', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - enable_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/enable', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - disable_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/disable', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - unload_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/unload', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scopeMode: 'none', pathParameters: [], queryParams: ['query','query_field','parent_scope_id','external_reference_kind','binding_integration','binding_kind','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, @@ -73,6 +66,13 @@ export const OPERATIONS = { continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_handoff_hint: { method: 'POST', path: '/v1/handoff/hint', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, flush_topic_memory: { method: 'POST', path: '/v1/topic-memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + forget_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + restore_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/search', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_topic_memory_processing_status: { method: 'POST', path: '/v1/topic-memory/processing/status', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_topic_memory_processing_block: { method: 'POST', path: '/v1/topic-memory/processing/blocks/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -140,6 +140,7 @@ export const OPERATIONS = { replace_memory_entry_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, query_artifact_tags: { method: 'POST', path: '/v1/scopes/{scope_id}/artifact-tags/query', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: ['revision'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_artifact_revisions: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_prompt_configuration: { method: 'GET', path: '/v1/scopes/{scope_id}/prompts/{prompt_key}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'prompt_key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, generate_prompt_demonstrations: { method: 'POST', path: '/v1/scopes/{scope_id}/prompts/{prompt_key}/demonstrations', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'prompt_key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 8971e49d50..f07db20a78 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -32,13 +32,6 @@ export const OPERATIONS = { get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_server_info: { method: 'GET', path: '/v1/server-info', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { method: 'GET', path: '/v1/admin/artifact-plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - get_artifact_plugin: { method: 'GET', path: '/v1/admin/artifact-plugins/{binding_name}', location: null, scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - enable_all_artifact_plugins: { method: 'POST', path: '/v1/admin/artifact-plugins/enable-all', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, - reload_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/reload', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - enable_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/enable', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - disable_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/disable', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, - unload_artifact_plugin: { method: 'POST', path: '/v1/admin/artifact-plugins/{binding_name}/unload', location: "body", scopeMode: 'none', pathParameters: ['binding_name'], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scopeMode: 'none', pathParameters: [], queryParams: ['query','query_field','parent_scope_id','external_reference_kind','binding_integration','binding_kind','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, @@ -73,6 +66,13 @@ export const OPERATIONS = { continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, prepare_handoff_hint: { method: 'POST', path: '/v1/handoff/hint', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, flush_topic_memory: { method: 'POST', path: '/v1/topic-memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + forget_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + restore_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, search_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/search', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_topic_memory_processing_status: { method: 'POST', path: '/v1/topic-memory/processing/status', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_topic_memory_processing_block: { method: 'POST', path: '/v1/topic-memory/processing/blocks/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -140,6 +140,7 @@ export const OPERATIONS = { replace_memory_entry_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, query_artifact_tags: { method: 'POST', path: '/v1/scopes/{scope_id}/artifact-tags/query', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: ['revision'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_artifact_revisions: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_prompt_configuration: { method: 'GET', path: '/v1/scopes/{scope_id}/prompts/{prompt_key}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'prompt_key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, generate_prompt_demonstrations: { method: 'POST', path: '/v1/scopes/{scope_id}/prompts/{prompt_key}/demonstrations', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'prompt_key'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index b133b1bf0a..b99a0ffc2a 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -583,167 +583,6 @@ paths: $ref: "#/components/responses/Forbidden" "503": $ref: "#/components/responses/Unavailable" - /v1/admin/artifact-plugins: - get: - tags: [artifact-plugins] - summary: List Artifact plugin registrations - operationId: list_artifact_plugins - x-powercontext-access: {action: server.admin, resource: {type: server}} - responses: - "200": - description: Durable registration states. - content: - application/json: - schema: {$ref: '#/components/schemas/ArtifactPluginPage'} - "401": {$ref: '#/components/responses/Unauthorized'} - "403": {$ref: '#/components/responses/Forbidden'} - "503": {$ref: '#/components/responses/Unavailable'} - /v1/admin/artifact-plugins/{binding_name}: - get: - tags: [artifact-plugins] - summary: Get an Artifact plugin registration - operationId: get_artifact_plugin - x-powercontext-access: {action: server.admin, resource: {type: server}} - parameters: - - {name: binding_name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$'}} - responses: - "200": - description: Durable registration state without plugin configuration secrets. - content: - application/json: - schema: {$ref: '#/components/schemas/ArtifactPluginState'} - "401": {$ref: '#/components/responses/Unauthorized'} - "403": {$ref: '#/components/responses/Forbidden'} - "404": {$ref: '#/components/responses/NotFound'} - "503": {$ref: '#/components/responses/Unavailable'} - /v1/admin/artifact-plugins/enable-all: - post: - tags: [artifact-plugins] - summary: Enable the currently disabled Artifact plugins - description: Captures registrations and generations at request start, then accepts each eligible enable independently. Enabled, unloaded, failed and transitioning plugins are skipped. Concurrent changes and validation failures are reported per plugin without rolling back successes. Does not discover or reload plugins; poll each status URL for the asynchronous outcome. - operationId: enable_all_artifact_plugins - x-powercontext-access: {action: server.admin, resource: {type: server}} - responses: - "200": - description: Per-plugin acceptance, skip or failure results. - content: - application/json: - schema: {$ref: '#/components/schemas/EnableAllArtifactPluginsResponse'} - "401": {$ref: '#/components/responses/Unauthorized'} - "403": {$ref: '#/components/responses/Forbidden'} - "503": {$ref: '#/components/responses/Unavailable'} - /v1/admin/artifact-plugins/{binding_name}/reload: - post: - tags: [artifact-plugins] - summary: Load or update a Worker-loaded Artifact plugin - description: Reads the binding's direct child entry under POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR and fixes its resolved immutable directory. The manifest binding_name must match the directory entry and request path. Saves validated configuration and declarations without creating a pipeline. Existing enabled or disabled plugins keep their state; initial, unloaded or failed registrations become disabled. This initial path supports the global SQLite Supervisor. Plugin code is loaded only in child processes. - operationId: reload_artifact_plugin - x-powercontext-access: {action: server.admin, resource: {type: server}} - parameters: - - {name: binding_name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$'}} - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/ReloadArtifactPluginRequest'} - responses: - "202": - description: Definition saved; poll its registration state until transition completes. - headers: - Location: {schema: {type: string}, description: Registration status URL.} - content: - application/json: - schema: {$ref: '#/components/schemas/ArtifactPluginState'} - "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/admin/artifact-plugins/{binding_name}/enable: - post: - tags: [artifact-plugins] - summary: Enable an Artifact plugin - operationId: enable_artifact_plugin - x-powercontext-access: {action: server.admin, resource: {type: server}} - parameters: - - {name: binding_name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$'}} - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/ChangeArtifactPluginStateRequest'} - responses: - "202": - description: Target saved; poll its registration state until transition completes. - headers: - Location: {schema: {type: string}, description: Registration status URL.} - content: - application/json: - schema: {$ref: '#/components/schemas/ArtifactPluginState'} - "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/admin/artifact-plugins/{binding_name}/disable: - post: - tags: [artifact-plugins] - summary: Disable an Artifact plugin - operationId: disable_artifact_plugin - x-powercontext-access: {action: server.admin, resource: {type: server}} - parameters: - - {name: binding_name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$'}} - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/ChangeArtifactPluginStateRequest'} - responses: - "202": - description: Target saved; poll its registration state until transition completes. - headers: - Location: {schema: {type: string}, description: Registration status URL.} - content: - application/json: - schema: {$ref: '#/components/schemas/ArtifactPluginState'} - "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/admin/artifact-plugins/{binding_name}/unload: - post: - tags: [artifact-plugins] - summary: Unload an Artifact plugin - operationId: unload_artifact_plugin - x-powercontext-access: {action: server.admin, resource: {type: server}} - parameters: - - {name: binding_name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$'}} - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/ChangeArtifactPluginStateRequest'} - responses: - "202": - description: Target saved; poll its registration state until transition completes. - headers: - Location: {schema: {type: string}, description: Registration status URL.} - content: - application/json: - schema: {$ref: '#/components/schemas/ArtifactPluginState'} - "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/capabilities: get: tags: [capabilities] @@ -2087,6 +1926,184 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/plugins: + get: + tags: [plugins] + summary: List loaded plugins + description: >- + Lists the plugins checked at Server startup, whether each loaded or failed, and what each one declares. + Plugins have no persisted lifecycle state; change the plugin directory and restart Server. + operationId: list_plugins + x-powercontext-access: {action: server.admin, resource: {type: server}} + responses: + "200": + description: Plugins found in the plugin directory at startup. + content: + application/json: + schema: {$ref: '#/components/schemas/PluginPage'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/plugins/{name}: + get: + tags: [plugins] + summary: Get one loaded plugin + operationId: get_plugin + x-powercontext-access: {action: server.admin, resource: {type: server}} + parameters: + - {name: name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'}} + responses: + "200": + description: The plugin's startup check result and declaration. + content: + application/json: + schema: {$ref: '#/components/schemas/PluginState'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/plugins/{name}/scopes: + get: + tags: [plugins] + summary: List a plugin's progress per Scope + description: >- + Reads the plugin's pending work and consumption cursor in each Scope from the database, so every Server role + returns the same answer. Call outcomes are reported through logs and metrics, not here. + operationId: list_plugin_scopes + x-powercontext-access: {action: server.admin, resource: {type: server}} + parameters: + - {name: name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'}} + - {name: cursor, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 256}} + - {name: limit, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 50}} + responses: + "200": + description: Scopes the plugin has been asked to process, ordered by Scope ID. + content: + application/json: + schema: {$ref: '#/components/schemas/PluginScopePage'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/artifacts/{family}/merges: + post: + tags: [artifacts] + summary: Merge exact current Artifacts into a new Artifact + description: >- + Freezes every input into a new result Artifact in one transaction. Available for every Family registered with + the shared merge service, including plugin families; other families return 422. The caller needs write access + to each input. + operationId: merge_artifacts + x-powercontext-scope-mode: current + x-powercontext-access: {resolver: artifact_merge_domain_access} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + - {name: family, in: path, required: true, schema: {$ref: '#/components/schemas/ArtifactReadFamily'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/MergeArtifactsRequest'} + responses: + "200": + description: The merge result and the frozen inputs. + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactGovernanceResponse'} + "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/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance: + get: + tags: [artifacts] + summary: Read an Artifact's current governance state + description: >- + Returns the lifecycle state, state version and merge target that forget, merge and restoration check. + Available for every Family registered with the shared merge service. + operationId: get_artifact_governance + x-powercontext-scope-mode: current + x-powercontext-access: {resolver: artifact_merge_domain_access} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + - {name: family, in: path, required: true, schema: {$ref: '#/components/schemas/ArtifactReadFamily'}} + - {name: artifact_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'}} + responses: + "200": + description: The current governance state. + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactGovernanceRecord'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "422": {$ref: '#/components/responses/InvalidRequest'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget: + post: + tags: [artifacts] + summary: Forget one Artifact without changing its content revision + description: >- + Deprecates the exact current Artifact without a replacement so it leaves search; restoration brings it back. + Available for every Family registered with the shared merge service. + operationId: forget_artifact + x-powercontext-scope-mode: current + x-powercontext-access: {resolver: artifact_merge_domain_access} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + - {name: family, in: path, required: true, schema: {$ref: '#/components/schemas/ArtifactReadFamily'}} + - {name: artifact_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ForgetArtifactRequest'} + responses: + "200": + description: The Artifact's governance state after forgetting. + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactGovernanceResponse'} + "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/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations: + post: + tags: [artifacts] + summary: Restore an Artifact or undo the merge that produced it + description: >- + `restore` reactivates the Artifact, optionally at an earlier content revision, and undoes the merges that froze + it. `undo_merge` restores a merge result's inputs and retires the result. Each runs as one group. Available for + every Family registered with the shared merge service. + operationId: restore_artifact + x-powercontext-scope-mode: current + x-powercontext-access: {resolver: artifact_merge_domain_access} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + - {name: family, in: path, required: true, schema: {$ref: '#/components/schemas/ArtifactReadFamily'}} + - {name: artifact_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RestoreArtifactRequest'} + responses: + "200": + description: The revisions restored and retired by the operation. + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactRestorationResult'} + "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/scopes/{scope_id}/artifacts/{family}/search: post: tags: [artifacts] @@ -4184,7 +4201,9 @@ paths: post: tags: [sources] summary: Create a durable Source - description: Persist one Source without synchronously deriving Artifacts. The Server generates source_id. + description: >- + Persist one Source without synchronously deriving Artifacts. The Server generates source_id. A Source type + defined by a loaded plugin is resolved by that plugin from the submitted input; unknown types return 404. operationId: create_source x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} parameters: @@ -4291,6 +4310,8 @@ paths: other families require scope.contribute. Topic Memory accepts complete title, summary, and detail text without semantic generation; its active head, chunks, and configured retrieval indexes are committed atomically. + A family provided by a loaded plugin takes the complete content object; core allocates the Artifact ID + and the plugin's create_artifact method writes revision one. operationId: create_artifact x-powercontext-access: {resolver: create_artifact_access} parameters: @@ -4558,6 +4579,7 @@ paths: Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted canonical content matches that revision and differs from the current head, then commits a new revision. Atomic Memory replacement rejects both fields; restore Atomic Memory through its restoration flow. + Plugin families accept their own content unchanged; the plugin's next invocation sees the new revision. operationId: replace_artifact x-powercontext-scope-mode: current x-powercontext-access: {resolver: path_artifact_write_access} @@ -4573,9 +4595,7 @@ paths: - name: family in: path required: true - schema: - type: string - enum: [memory, atomic-memory, experience, skill, handoff, prompt, topic-memory, profile] + schema: {$ref: '#/components/schemas/ArtifactReadFamily'} - name: artifact_id in: path required: true @@ -4925,6 +4945,64 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering: + get: + tags: [artifacts] + summary: Present a plugin Artifact + description: >- + Returns the default presentation of one revision of a plugin family as the plugin renders it, in Markdown or + plain text, for people and for agent context. The current revision is used unless revision is given. Built-in + families have no rendering and return 422. + operationId: get_artifact_rendering + x-powercontext-access: {resolver: path_artifact_read_access} + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path + required: true + schema: {$ref: '#/components/schemas/ArtifactReadFamily'} + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: revision + in: query + required: false + schema: + type: integer + minimum: 1 + responses: + "200": + description: The rendering of the exact revision. + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactRendering" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions: get: tags: [artifacts] @@ -7063,10 +7141,6 @@ components: minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - schema_version: - type: string - nullable: true - description: Persisted schema version for a custom Artifact family. revision: type: integer minimum: 1 @@ -7125,7 +7199,7 @@ components: maxLength: 256 pattern: '.*\S.*' family: - $ref: "#/components/schemas/BaseArtifactFamily" + $ref: "#/components/schemas/ArtifactReadFamily" artifact_id: type: string minLength: 1 @@ -7181,10 +7255,6 @@ components: minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - schema_version: - type: string - nullable: true - description: Persisted schema version for a custom Artifact family. revision: type: integer minimum: 1 @@ -8124,76 +8194,156 @@ components: effect: type: string enum: [review_then_publish, review_then_publish_configuration, review_then_replace_tags] - CustomArtifactFamilySchema: + PluginKind: {type: string, enum: [artifact]} + PluginLoadStatus: {type: string, enum: [loaded, failed]} + PluginTrigger: {type: string, enum: [schedule, sources]} + PluginState: type: object additionalProperties: false - required: [family, schema_version, content_schema, search_fields] + required: [name, kind, status, error, family, binding_name, scopes, source_types, triggers] properties: - family: {type: string} - schema_version: {type: string} - content_schema: {type: object, additionalProperties: true} - search_fields: {type: array, items: {type: string}, description: Legacy persisted text fields; new plugins use search.} - search: {type: object, additionalProperties: true, description: Persistent declarative indexes and supported search modes.} - ReloadArtifactPluginRequest: + name: {type: string} + kind: {$ref: '#/components/schemas/PluginKind'} + status: {$ref: '#/components/schemas/PluginLoadStatus'} + error: {type: string, nullable: true, description: Safe failure category from the startup check.} + family: {type: string, nullable: true} + binding_name: {type: string, nullable: true} + scopes: {type: array, nullable: true, items: {type: string}} + source_types: + type: array + nullable: true + items: {type: string} + description: Source types delivered to the plugin; null when it consumes no Sources. + triggers: {type: array, items: {$ref: '#/components/schemas/PluginTrigger'}} + PluginPage: type: object additionalProperties: false - required: [expected_generation] + required: [items] properties: - expected_generation: {type: integer, minimum: 0} - ChangeArtifactPluginStateRequest: + items: {type: array, items: {$ref: '#/components/schemas/PluginState'}} + PluginScopeState: type: object additionalProperties: false - required: [expected_generation] + required: [scope_id, pending, consumed_position, source_position] properties: - expected_generation: {type: integer, minimum: 1} - ArtifactPluginState: + scope_id: {type: string} + pending: {type: boolean, description: New Sources or a schedule request are waiting for an invocation.} + consumed_position: + type: integer + minimum: 0 + nullable: true + description: Source journal position the plugin has consumed through; null when it consumes no Sources. + source_position: + type: integer + minimum: 0 + nullable: true + description: Latest Source journal position in the Scope, of any Source type. + ListPluginScopesRequest: type: object additionalProperties: false - required: [binding_name, plugin_id, family, binding_generation, desired_state, phase, last_error] properties: - binding_name: {type: string} - plugin_id: {type: string} - family: {type: string} - binding_generation: {type: integer, minimum: 1} - desired_state: {type: string, enum: [enabled, disabled, unloaded]} - phase: {type: string, enum: [activating, enabled, stopping, disabled, unloading, unloaded, failed]} - last_error: {type: string, nullable: true} - ArtifactPluginPage: + cursor: {type: string, minLength: 1, maxLength: 256} + limit: {type: integer, minimum: 1, maximum: 200, default: 50} + PluginScopePage: type: object additionalProperties: false - required: [items] + required: [items, next_cursor] properties: - items: {type: array, items: {$ref: '#/components/schemas/ArtifactPluginState'}} - EnableArtifactPluginResult: + items: {type: array, items: {$ref: '#/components/schemas/PluginScopeState'}} + next_cursor: {type: string, nullable: true} + ArtifactLifecycleState: {type: string, enum: [active, deprecated, retired]} + ArtifactGovernanceInput: type: object additionalProperties: false - required: [binding_name, outcome, status_url, state, error] + required: [artifact_id, revision, state_version] + description: An exact current Artifact the caller has read; a later change rejects the request. properties: - binding_name: {type: string} - outcome: {type: string, enum: [accepted, skipped, failed]} - status_url: {type: string, description: Registration status URL for the asynchronous result.} - state: {$ref: '#/components/schemas/ArtifactPluginState'} - error: - type: string + artifact_id: {type: string, minLength: 1, maxLength: 128} + revision: {type: integer, minimum: 1} + state_version: {type: integer, minimum: 0} + MergeArtifactsRequest: + type: object + additionalProperties: false + required: [inputs, content] + properties: + inputs: + type: array + minItems: 2 + items: {$ref: '#/components/schemas/ArtifactGovernanceInput'} + content: + type: object + additionalProperties: true + description: Complete content of the result in the Family's content shape. + source_refs: {type: array, items: {$ref: '#/components/schemas/SourceReference'}} + artifact_refs: {type: array, items: {$ref: '#/components/schemas/ArtifactReference'}} + ForgetArtifactRequest: + type: object + additionalProperties: false + required: [revision, state_version] + properties: + revision: {type: integer, minimum: 1} + state_version: {type: integer, minimum: 0} + ArtifactRestorationOperation: {type: string, enum: [restore, undo_merge]} + RestoreArtifactRequest: + type: object + additionalProperties: false + properties: + operation: + $ref: '#/components/schemas/ArtifactRestorationOperation' + default: restore + revision: + type: integer + minimum: 1 nullable: true - enum: [conflict, invalid_plugin, unavailable, null] - description: Safe per-plugin failure code; null for accepted or skipped entries. - EnableAllArtifactPluginsResponse: + description: Content revision to restore; omitted restores the current one. Not accepted with undo_merge. + ArtifactGovernanceRecord: type: object additionalProperties: false - required: [items] + required: [artifact, lifecycle_state, state_version, merged_into_id] + properties: + artifact: {$ref: '#/components/schemas/ArtifactReference'} + lifecycle_state: {$ref: '#/components/schemas/ArtifactLifecycleState'} + state_version: {type: integer, minimum: 0} + merged_into_id: {type: string, nullable: true} + ArtifactGovernanceResponse: + type: object + additionalProperties: false + required: [changed, records] properties: - items: {type: array, items: {$ref: '#/components/schemas/EnableArtifactPluginResult'}} + changed: {type: boolean} + records: {type: array, items: {$ref: '#/components/schemas/ArtifactGovernanceRecord'}} + ArtifactRestorationResult: + type: object + additionalProperties: false + required: [changed, restored, retired, undo_merge_results] + properties: + changed: {type: boolean} + restored: {type: array, items: {$ref: '#/components/schemas/ArtifactReference'}} + retired: {type: array, items: {$ref: '#/components/schemas/ArtifactReference'}} + undo_merge_results: {type: array, items: {type: string}} + CreatePluginArtifactRequest: + type: object + additionalProperties: false + required: [family, content] + description: >- + Create an Artifact of a family a loaded plugin provides. The family must not be a built-in family name; + core allocates the Artifact ID and stores the content the plugin writes. + properties: + family: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$'} + content: {type: object, additionalProperties: true} + ReplacePluginArtifactRequest: + type: object + additionalProperties: false + required: [content] + description: Complete content of a plugin Artifact; core stores it unchanged and indexes the declared fields. + properties: + content: {type: object, additionalProperties: true} Capabilities: type: object additionalProperties: false required: [source_types, artifact_families, memory_extraction, handoff_generation, search_modes, context_versions] properties: - custom_artifact_schemas: - type: array - default: [] - items: {$ref: '#/components/schemas/CustomArtifactFamilySchema'} prompts: type: object default: {} @@ -9939,7 +10089,7 @@ components: required: [round, family, mode, outcome, fallback_reason, count] properties: round: {type: integer, minimum: 0, maximum: 2} - family: {$ref: "#/components/schemas/PreparedContextFamily"} + family: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$', description: 'atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family.'} mode: {type: string, enum: [fts, vector, hybrid, snapshot, code, none]} outcome: {type: string, enum: [completed, not_run, failed]} fallback_reason: {type: string, enum: [none, inference_unavailable, inference_timeout, reused_fts_fallback]} @@ -10008,9 +10158,6 @@ components: type: string enum: [none, empty_selection, inference_unavailable, inference_timeout, invalid_output] count: {type: integer, minimum: 1} - PreparedContextFamily: - type: string - enum: [atomic-memory, topic-memory, experience, profile, code] PreparedContextCodeEvidence: type: object additionalProperties: false @@ -10028,7 +10175,7 @@ components: additionalProperties: false required: [kind, rendered_bytes, truncated] properties: - kind: {$ref: "#/components/schemas/PreparedContextFamily"} + kind: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$', description: 'atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family.'} artifact_address: {$ref: "#/components/schemas/ArtifactAddress"} code_evidence: {$ref: "#/components/schemas/PreparedContextCodeEvidence"} rendered_bytes: {type: integer, minimum: 0} @@ -10038,7 +10185,7 @@ components: additionalProperties: false required: [family, reason, count] properties: - family: {$ref: "#/components/schemas/PreparedContextFamily"} + family: {type: string, minLength: 1, maxLength: 128, pattern: '^[a-z][a-z0-9-]*$', description: 'atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family.'} reason: type: string enum: [duplicate, blank, family_limit, entry_limit, below_min_bytes, @@ -12357,7 +12504,21 @@ components: required: [family, limit] properties: family: - $ref: "#/components/schemas/ContextAssemblyFamily" + type: string + minLength: 1 + maxLength: 128 + pattern: '^[a-z][a-z0-9-]*$' + description: >- + A built-in family (memory, experience, profile, topic-memory) or a loaded plugin family that implements + search. A plugin family's entries come from the plugin's search with the request query and this limit; + a family that is neither returns HTTP 422. + search: + type: object + additionalProperties: true + description: >- + Additional generic search fields passed to the plugin's search unchanged, such as mode, filters, + fusion, or rerank. Only plugin families accept it; the query and limit come from the request and this + section. limit: type: integer minimum: 1 @@ -12388,9 +12549,6 @@ components: default: [] items: $ref: "#/components/schemas/ContextAssemblyMetadata" - ContextAssemblyFamily: - type: string - enum: [memory, experience, profile, topic-memory] ContextAssemblyFormat: type: string enum: [markdown] @@ -12821,6 +12979,16 @@ components: type: object additionalProperties: $ref: "#/components/schemas/ArtifactChannelScore" + ArtifactRendering: + type: object + additionalProperties: false + required: [family, artifact_id, revision, format, text] + properties: + family: {type: string, minLength: 1} + artifact_id: {type: string, minLength: 1, maxLength: 128} + revision: {type: integer, minimum: 1} + format: {type: string, enum: [markdown, text]} + text: {type: string} ArtifactSearchItem: type: object additionalProperties: false @@ -12991,6 +13159,14 @@ components: items: $ref: "#/components/schemas/SourceReference" CreateArtifactRequest: + description: >- + A built-in family's creation request, or the complete content of an Artifact in a loaded plugin's family. + The family name selects the branch: built-in names select the built-in branch. + x-powercontext-family-dispatch: {builtin: CreateBuiltinArtifactRequest, plugin: CreatePluginArtifactRequest} + oneOf: + - $ref: "#/components/schemas/CreateBuiltinArtifactRequest" + - $ref: "#/components/schemas/CreatePluginArtifactRequest" + CreateBuiltinArtifactRequest: oneOf: - $ref: "#/components/schemas/CreateAtomicMemoryArtifactRequest" - $ref: "#/components/schemas/CreateTopicMemoryArtifactRequest" @@ -13116,6 +13292,10 @@ components: maxLength: 8192 pattern: '.*\S.*' description: Durable non-empty Memory entry text used to create the Entry Version and search projection. + SourceType: + type: string + description: The built-in Source type that create_source accepts. + enum: [content] CreateSourceRequest: type: object additionalProperties: false @@ -13123,17 +13303,26 @@ components: properties: source_type: type: string - enum: [content] + description: >- + The built-in content type, or a Source type that a loaded plugin defines. A plugin type's content is + that type's input; the plugin resolves it into the stored Source. + minLength: 1 + maxLength: 128 + pattern: '^[a-z][a-z0-9._-]*$' default: content content: description: >- - JSON value persisted by the built-in content Source adapter. Server-reserved payload schemas, including - handoff receipts, are rejected and must be created through their dedicated workflow. String values that are - empty or whitespace-only are rejected. + JSON value persisted by the built-in content Source adapter, or the input of a plugin Source type. + Server-reserved payload schemas, including handoff receipts, are rejected and must be created through + their dedicated workflow. String values that are empty or whitespace-only are rejected. TaggableArtifactFamily: type: string - description: All readable Artifact families support logical tags on persisted Artifacts. - enum: [memory, atomic-memory, experience, skill, handoff, profile, prompt, topic-memory] + description: >- + A built-in Family or a loaded plugin family; every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries instead. + minLength: 1 + maxLength: 128 + pattern: '^[a-z][a-z0-9-]*$' TagMatch: type: string enum: [all, any] @@ -13293,6 +13482,7 @@ components: - $ref: "#/components/schemas/ReplaceHandoffArtifactRequest" - $ref: "#/components/schemas/ReplacePromptArtifactRequest" - $ref: "#/components/schemas/ReplaceProfileArtifactRequest" + - $ref: "#/components/schemas/ReplacePluginArtifactRequest" ReplacePromptArtifactRequest: type: object additionalProperties: false @@ -13310,6 +13500,14 @@ components: maxLength: 1024 pattern: '.*\S.*' nullable: true + GetArtifactRenderingRequest: + type: object + additionalProperties: false + properties: + revision: + type: integer + minimum: 1 + nullable: true ListArtifactRevisionsRequest: type: object additionalProperties: false diff --git a/release/upgrade-support.json b/release/upgrade-support.json index ed3f9baafb..3710a3e5d1 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "537eabccaa905b96b566522770409197187e82f3d662795dba8ce7d0d957c46f", - "rationale": "Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." + "persistence_sha256": "4f61f94576cc809671b04482534b464fd3f5f1a13b8b4c72de9e914a98753c03", + "rationale": "Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces." }, "cases": [], "automatic_sources": { diff --git a/scripts/ci_role_smoke.py b/scripts/ci_role_smoke.py index 9c79cd3461..5bae91230c 100644 --- a/scripts/ci_role_smoke.py +++ b/scripts/ci_role_smoke.py @@ -109,11 +109,6 @@ def main() -> None: raise RuntimeError("Core public exports are unavailable") # noqa: TRY003 if not extras and importlib.util.find_spec("httpx") is not None: raise RuntimeError("Core installation unexpectedly includes httpx") # noqa: TRY003 - if extras: - from powercontext import artifact_plugins - - if powercontext.artifact_plugins is not artifact_plugins or artifact_plugins.ArtifactFamily is None: - raise RuntimeError("Plugin public export is unavailable") # noqa: TRY003 if "cli" in extras: extras.add("builtin") with tempfile.TemporaryDirectory(prefix="powercontext-role-") as temporary: diff --git a/scripts/generate_api.py b/scripts/generate_api.py index ea0d0a1b89..8834144cd7 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -196,6 +196,7 @@ def _generate_models( source = _with_source_locator_validation(source, contract_schemas) source = _with_exclusive_fields(source, exclusive_schemas) source = _with_artifact_search_validation(source, contract_schemas) + source = _with_family_dispatch(source, contract_schemas) return _with_candidate_evidence_limits(source, evidence_models) @@ -250,6 +251,53 @@ def _validate_exclusive_fields(self): return source +def _with_family_dispatch(source: str, schemas: dict[str, Schema | Reference]) -> str: + """Select a built-in or plugin branch by family name, so built-in bodies keep their own validation. + + A plugin branch accepts any family name and content object, so plain union matching would + let an invalid built-in body pass as a plugin body. + """ + + for name, schema in schemas.items(): + if not isinstance(schema, Schema): + continue + dispatch = (schema.model_extra or {}).get("x-powercontext-family-dispatch") + if dispatch is None: + continue + builtin, plugin = dispatch["builtin"], dispatch["plugin"] + builtin_schema = schemas[builtin] + if not isinstance(builtin_schema, Schema) or builtin_schema.discriminator is None: + raise ContractGenerationError("x-powercontext-family-dispatch", builtin) + families = sorted((builtin_schema.discriminator.mapping or {}).keys()) + snake = "".join(f"_{char.lower()}" if char.isupper() else char for char in name) + union = f" {builtin} | {plugin},\n" + start = source.find(f"class {name}(RootModel[{builtin} | {plugin}]):") + at = source.find(union, start) + if start < 0 or at < 0: + raise ContractGenerationError("generated family dispatch model", name) # noqa: TRY003 + source = ( + source[:at] + + f' Annotated[{builtin}, UnionTag("builtin")] | Annotated[{plugin}, UnionTag("plugin")],\n' + + f" Discriminator({snake}_branch),\n" + + source[at + len(union) :] + ) + helper = f""" + +{snake.upper()}_BUILTIN_FAMILIES = frozenset({families!r}) + + +def {snake}_branch(value: Any) -> str: + family = value.get("family") if isinstance(value, dict) else getattr(value, "family", None) + return "plugin" if isinstance(family, str) and family not in {snake.upper()}_BUILTIN_FAMILIES else "builtin" +""" + source = source[:start].rstrip() + "\n" + helper + "\n\n" + source[start:] + # The contract has its own Tag schema, so pydantic's Tag needs another name here. + for imported in ("Discriminator", "Tag as UnionTag"): + if f" {imported},\n" not in source: + source = source.replace("from pydantic import (\n", f"from pydantic import (\n {imported},\n", 1) + return source + + def _with_artifact_search_validation(source: str, schemas: dict[str, Schema | Reference]) -> str: """Keep Artifact search's omission, trim, and finite-number semantics in generated models.""" diff --git a/src/powercontext/__init__.py b/src/powercontext/__init__.py index 719775f1cf..738a2a3f89 100644 --- a/src/powercontext/__init__.py +++ b/src/powercontext/__init__.py @@ -14,10 +14,6 @@ """Stable public exports for PowerContext clients.""" -from importlib import import_module -from types import ModuleType -from typing import TYPE_CHECKING - from powercontext.artifacts import ( Artifact, ArtifactAddress, @@ -129,17 +125,4 @@ "SourceStore", "Sources", "Trigger", - "artifact_plugins", ] - - -if TYPE_CHECKING: - from powercontext import artifact_plugins - - -def __getattr__(name: str) -> ModuleType: - if name == "artifact_plugins": - module = import_module("powercontext.artifact_plugins") - globals()[name] = module - return module - raise AttributeError(name) diff --git a/src/powercontext/artifact_plugins.py b/src/powercontext/artifact_plugins.py deleted file mode 100644 index ade7db50ea..0000000000 --- a/src/powercontext/artifact_plugins.py +++ /dev/null @@ -1,149 +0,0 @@ -# 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. - -"""Declarations and generation contracts for Worker-loaded Artifact plugins.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from contextlib import AbstractAsyncContextManager, AsyncExitStack -from dataclasses import dataclass -from typing import TypeVar - -from pydantic import BaseModel, ConfigDict, Field, JsonValue - -from powercontext.artifacts import ArtifactLineage, ArtifactRef -from powercontext.artifacts.indexes import ArtifactSearchDeclaration, FragmentProjection, TextIndex, VectorIndex -from powercontext.artifacts.search import ArtifactSearchOutcome -from powercontext.sources import SourceProjectionKey, SourceRef - -ResourceT = TypeVar("ResourceT") - - -@dataclass(frozen=True) -class ArtifactFamily: - family: str - schema_version: str - content_model: type[BaseModel] - search: ArtifactSearchDeclaration | None = None - - -@dataclass(frozen=True) -class InputContract: - projections: dict[str, SourceProjectionKey] - artifact_families: tuple[str, ...] = () - - -class GenerationSource(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - ref: SourceRef - projection: SourceProjectionKey - value: JsonValue - - -@dataclass(frozen=True) -class GenerationBatch: - sources: tuple[GenerationSource, ...] - - -class ArtifactDraft(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - family: str - schema_version: str - artifact_id: str = Field(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$") - expected_revision: int = Field(default=0, ge=0) - content: dict[str, JsonValue] - source_refs: tuple[SourceRef, ...] - artifact_refs: tuple[ArtifactRef, ...] = () - - -class ArtifactSnapshot(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - ref: ArtifactRef - lineage: ArtifactLineage - schema_version: str | None - content: dict[str, JsonValue] - source_refs: tuple[SourceRef, ...] = () - artifact_refs: tuple[ArtifactRef, ...] = () - - -class ArtifactReferencePage(BaseModel): - items: tuple[ArtifactRef, ...] - next_cursor: str | None = None - - -class GenerationContext: - """Scope-bound reads whose exact evidence can be used by generated drafts.""" - - def __init__( - self, - scope_id: str, - resources: AsyncExitStack, - read_artifact: Callable[[str, str, int | None], Awaitable[ArtifactSnapshot | None]], - *, - list_artifacts: Callable[..., Awaitable[ArtifactReferencePage]] | None = None, - search_artifacts: Callable[..., Awaitable[ArtifactSearchOutcome]] | None = None, - ) -> None: - self.scope_id = scope_id - self._resources = resources - self._read_artifact = read_artifact - self._list_artifacts = list_artifacts - self._search_artifacts = search_artifacts - - async def enter_async_context(self, resource: AbstractAsyncContextManager[ResourceT]) -> ResourceT: - return await self._resources.enter_async_context(resource) - - async def read_latest(self, family: str, artifact_id: str) -> ArtifactSnapshot | None: - """Read one current Artifact; separate reads do not establish a cross-family snapshot.""" - - return await self._read_artifact(family, artifact_id, None) - - async def read_revision(self, ref: ArtifactRef) -> ArtifactSnapshot: - """Read and register an exact revision, including a superseded historical revision.""" - - result = await self._read_artifact(ref.family, ref.artifact_id, ref.revision) - if result is None: - raise LookupError(ref) - return result - - async def list_artifacts(self, family: str, *, limit: int = 50, cursor: str | None = None) -> ArtifactReferencePage: - """Page visible current references; use a fixed manifest for historical enumeration.""" - if self._list_artifacts is None: - raise NotImplementedError("Artifact listing is unavailable") - return await self._list_artifacts(family, limit=limit, cursor=cursor) - - async def search_artifacts(self, family: str, query: str | None = None, **parameters) -> ArtifactSearchOutcome: - """Retrieve complete exact Artifacts through the shared search contract.""" - if self._search_artifacts is None: - raise NotImplementedError("Artifact search is unavailable") - return await self._search_artifacts(family, query, **parameters) - - -__all__ = [ - "ArtifactDraft", - "ArtifactFamily", - "ArtifactReferencePage", - "ArtifactSearchDeclaration", - "ArtifactSnapshot", - "FragmentProjection", - "GenerationBatch", - "GenerationContext", - "GenerationSource", - "InputContract", - "TextIndex", - "VectorIndex", -] diff --git a/src/powercontext/artifacts/indexes.py b/src/powercontext/artifacts/indexes.py deleted file mode 100644 index 91ee498e2e..0000000000 --- a/src/powercontext/artifacts/indexes.py +++ /dev/null @@ -1,177 +0,0 @@ -# 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. - -"""Declarative, persistent search projections for ordinary Artifact families.""" - -from __future__ import annotations - -from typing import Annotated, Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from powercontext.builtin.artifacts.memory.models import EmbeddingProfile - -Name = Annotated[str, Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")] -FieldName = Annotated[str, Field(min_length=1, max_length=128)] -Version = Annotated[str, Field(min_length=1, max_length=128, pattern=r".*\S.*")] -SearchMode = Literal["text", "vector", "hybrid"] - - -class _Declaration(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) - - -class FragmentProjection(_Declaration): - """Project an explicit persistent array; the framework does not split content.""" - - array_field: FieldName - id_field: FieldName - - -class TextIndex(_Declaration): - name: Name - kind: Literal["text"] = "text" - projection_version: Version - fields: tuple[FieldName, ...] = Field(min_length=1) - - @model_validator(mode="after") - def unique_fields(self): - if len(set(self.fields)) != len(self.fields): - raise ValueError("text fields must be distinct") # noqa: TRY003 - return self - - -class VectorIndex(_Declaration): - """Index persistent vectors or encode persistent text in one exact profile. - - For content vectors, profile_field names a persistent EmbeddingProfile object - alongside the vector. This prevents a producer replacing P1 with P2 in place. - Fragment projections resolve fields within each persistent array item. - """ - - name: Name - kind: Literal["vector"] = "vector" - projection_version: Version - source: Literal["text", "content"] - profile: EmbeddingProfile - fields: tuple[FieldName, ...] = () - vector_field: FieldName | None = None - profile_field: FieldName | None = None - fragments: FragmentProjection | None = None - - @model_validator(mode="after") - def coherent_source(self): - if not self.profile.profile_id.strip() or not self.profile.model.strip() or self.profile.dimension < 1: - raise ValueError("an explicit model, profile identity and positive dimension are required") # noqa: TRY003 - if self.source == "text": - if not self.fields or self.vector_field is not None or self.profile_field is not None: - raise ValueError("text vectors require text fields and cannot declare content vector fields") # noqa: TRY003 - if len(set(self.fields)) != len(self.fields): - raise ValueError("text fields must be distinct") # noqa: TRY003 - elif self.fields or self.vector_field is None or self.profile_field is None: - raise ValueError("content vectors require vector_field and profile_field, without text fields") # noqa: TRY003 - return self - - -SearchIndex = Annotated[TextIndex | VectorIndex, Field(discriminator="kind")] - - -class ArtifactSearchDeclaration(_Declaration): - version: Version - indexes: tuple[SearchIndex, ...] = Field(min_length=1) - modes: tuple[SearchMode, ...] = Field(min_length=1) - default_mode: SearchMode - defaults: dict[SearchMode, dict[Literal["text", "vector"], tuple[Name, ...]]] = Field(default_factory=dict) - candidate_limit: int = Field(default=200, ge=1, le=2000, strict=True) - rrf_k: int = Field(default=60, gt=0, strict=True) - text_weight: float = Field(default=1, ge=0) - vector_weight: float = Field(default=1, ge=0) - - @model_validator(mode="after") - def coherent_modes(self): - by_name = {index.name: index for index in self.indexes} - if len(by_name) != len(self.indexes) or len(set(self.modes)) != len(self.modes): - raise ValueError("index names and modes must be distinct") # noqa: TRY003 - if self.default_mode not in self.modes or set(self.defaults) - set(self.modes): - raise ValueError("defaults must refer to supported modes") # noqa: TRY003 - if self.text_weight + self.vector_weight <= 0: - raise ValueError("at least one fusion channel must have positive weight") # noqa: TRY003 - for mode in self.modes: - self.select_indexes(mode, self.defaults.get(mode)) - return self - - def select_indexes(self, mode, selection=None): # noqa: C901 - Keep the complete projection or publication invariant together. - if mode not in self.modes: - raise ValueError("search mode is not declared") # noqa: TRY003 - channels = {"text", "vector"} if mode == "hybrid" else {mode} - selection = self.defaults.get(mode) if selection is None else selection - if selection is None: - selection = {} - for channel in channels: - names = tuple(index.name for index in self.indexes if index.kind == channel) - if len(names) != 1: - raise ValueError("multiple indexes require explicit mode defaults") # noqa: TRY003 - selection[channel] = names - if set(selection) != channels: - raise ValueError("index selection must match the requested channels") # noqa: TRY003 - by_name = {index.name: index for index in self.indexes} - result = [] - for channel, names in selection.items(): - if not names or len(set(names)) != len(names): - raise ValueError("each channel requires distinct index names") # noqa: TRY003 - for name in names: - if name not in by_name or by_name[name].kind != channel: - raise ValueError("index is not declared for this channel") # noqa: TRY003 - result.append(by_name[name]) - profiles = [index.profile for index in result if isinstance(index, VectorIndex)] - if profiles and any(profile != profiles[0] for profile in profiles): - raise ValueError("selected vector indexes require the same complete EmbeddingProfile") # noqa: TRY003 - return tuple(result) - - -def validate_index_schema(index: TextIndex | VectorIndex, schema: dict[str, Any]) -> None: # noqa: C901 - Keep the complete projection or publication invariant together. - """Validate explicit required fields; no JSONPath or producer code is executed.""" - - def resolve(value): - seen = set() - while "$ref" in value: - ref = value["$ref"] - if not ref.startswith("#/$defs/") or ref in seen: - raise ValueError("index fields require local, nonrecursive schema definitions") # noqa: TRY003 - seen.add(ref) - value = schema["$defs"][ref.removeprefix("#/$defs/")] - return value - - def field(obj, name, kind): - obj = resolve(obj) - value = resolve(obj.get("properties", {}).get(name, {})) - if name not in obj.get("required", ()) or value.get("type") != kind: - raise ValueError(f"index field {name!r} must be a required {kind}") # noqa: TRY003 - return value - - obj = schema - if isinstance(index, VectorIndex) and index.fragments is not None: - array = field(obj, index.fragments.array_field, "array") - obj = resolve(array.get("items", {})) - if obj.get("type") != "object": - raise ValueError("fragments must be persistent objects") # noqa: TRY003 - field(obj, index.fragments.id_field, "string") - if isinstance(index, TextIndex) or index.source == "text": - for name in index.fields: - field(obj, name, "string") - else: - vector = field(obj, index.vector_field, "array") - if resolve(vector.get("items", {})).get("type") not in {"number", "integer"}: - raise ValueError("vectors must contain numbers") # noqa: TRY003 - field(obj, index.profile_field, "object") diff --git a/src/powercontext/builtin/artifact_plugin_validation.py b/src/powercontext/builtin/artifact_plugin_validation.py deleted file mode 100644 index cb0de0474e..0000000000 --- a/src/powercontext/builtin/artifact_plugin_validation.py +++ /dev/null @@ -1,260 +0,0 @@ -# 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. - -"""Isolated Artifact declaration validation without starting the generation runtime.""" - -from __future__ import annotations - -import asyncio -import hashlib -import importlib.util -import inspect -import json -import re -import sys -from datetime import timedelta -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Annotated, Any - -import yaml -from jsonschema import Draft202012Validator -from pydantic import BaseModel, ConfigDict, Field, JsonValue, SecretBytes, SecretStr, field_validator - -from powercontext.artifact_plugins import InputContract -from powercontext.artifacts.indexes import ArtifactSearchDeclaration, validate_index_schema -from powercontext.sources import SourceProjectionKey, SourceRef - -_MAX_INPUT_BYTES = 1024 * 1024 -_MAX_OUTPUT_BYTES = 1024 * 1024 -_BUILTIN_FAMILIES = frozenset({ - "memory", - "atomic-memory", - "experience", - "skill", - "handoff", - "profile", - "prompt", - "topic-memory", -}) - - -class _ProcessingConfig(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True, allow_inf_nan=False) - - interval_seconds: float = Field(gt=0) - max_workers: int = Field(ge=1) - timeout_seconds: float = Field(gt=0) - - @field_validator("interval_seconds") - @classmethod - def representable_interval(cls, value: float) -> float: - try: - interval = timedelta(seconds=value) - except OverflowError as error: - raise ValueError("interval_seconds exceeds the supported duration") from error # noqa: TRY003 - if interval.total_seconds() <= 0: - raise ValueError("interval_seconds must be at least one microsecond") # noqa: TRY003 - return value - - -class _PluginManifest(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - id: str = Field(pattern=r"^[a-z][a-z0-9-]{0,127}$") - version: str = Field(min_length=1, max_length=128, pattern=r".*\S.*") - entry: str = Field(min_length=3, max_length=4096) - binding_name: str = Field(pattern=r"^[a-z][a-z0-9-]{0,127}$") - scope_ids: list[Annotated[str, Field(min_length=1, max_length=256)]] | None = Field( - default=None, min_length=1, max_length=1000 - ) - max_sources: int = Field(ge=1) - processing: _ProcessingConfig - config: dict[str, JsonValue] - - @field_validator("scope_ids") - @classmethod - def unique_scopes(cls, value: list[str] | None) -> list[str] | None: - if value is not None and len(set(value)) != len(value): - raise ValueError("Scope IDs must be unique") # noqa: TRY003 - return None if value is None else sorted(value) - - -def read_manifest(directory: str) -> _PluginManifest: - path = Path(directory) / "plugin.yml" - if path.stat().st_size > _MAX_INPUT_BYTES: - raise ValueError("plugin manifest is too large") # noqa: TRY003 - return _PluginManifest.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) - - -def code_fingerprint(directory: str) -> str: - root = Path(directory) - digest = hashlib.sha256() - files = sorted( - path - for path in root.rglob("*") - if path.is_file() and not {"__pycache__", ".venv", ".git"}.intersection(path.relative_to(root).parts) - ) - for path in files: - digest.update(str(path.relative_to(root)).encode()) - digest.update(b"\0") - digest.update(path.read_bytes()) - digest.update(b"\0") - return digest.hexdigest() - - -def load_plugin(directory: str, manifest: _PluginManifest): - root = Path(directory) - entry, factory = manifest.entry.rsplit(":", 1) - path = (root / entry).resolve() - if Path(entry).is_absolute() or not path.is_relative_to(root.resolve()) or path.suffix != ".py": - raise ValueError("entry must be a Python file inside the plugin directory") # noqa: TRY003 - module_name = "powercontext_user_artifact_plugin" - spec = importlib.util.spec_from_file_location(module_name, path) - if spec is None or spec.loader is None: - raise ValueError("plugin entry is unavailable") # noqa: TRY003 - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - sys.path.insert(0, str(root)) - spec.loader.exec_module(module) - module.__dict__["create_pipeline"] = getattr(module, factory) - return module - - -def describe_plugin(directory: str, snapshot: dict[str, Any] | None = None) -> dict[str, Any]: - manifest = read_manifest(directory) if snapshot is None else _PluginManifest.model_validate(snapshot) - return describe_module(load_plugin(directory, manifest), manifest, directory) - - -def _input_declaration(inputs: InputContract) -> dict[str, Any]: - if not isinstance(inputs, InputContract) or not isinstance(inputs.projections, dict) or not inputs.projections: - raise ValueError("inputs must declare a nonempty Source projection mapping") # noqa: TRY003 - projections = {} - for name, projection in inputs.projections.items(): - SourceRef(source_type=name, source_id="validation") - projections[name] = SourceProjectionKey.model_validate(projection).model_dump(mode="json") - families = _read_families(inputs.artifact_families) - return {"projections": projections, **({"artifact_families": families} if families else {})} - - -def _read_families(families): - if not isinstance(families, tuple): - raise TypeError("read families must be a tuple") # noqa: TRY003 - for family in families: - if not isinstance(family, str) or re.fullmatch(r"[a-z][a-z0-9-]{0,127}", family) is None or family == "memory": - raise ValueError("read families must name canonical Artifact families") # noqa: TRY003 - if len(set(families)) != len(families): - raise ValueError("read families must be distinct") # noqa: TRY003 - return sorted(families) - - -def _configuration_json(value, serialized): - # Config stays private to management/Workers. Preserve credentials instead of - # persisting SecretStr's display mask, while retaining JSON normalization. - if isinstance(value, SecretStr): - return value.get_secret_value() - if isinstance(value, SecretBytes): - return value.get_secret_value().decode("utf-8") - if isinstance(value, dict) and isinstance(serialized, dict): - return {key: _configuration_json(value[key], item) for key, item in serialized.items()} - if isinstance(value, (list, tuple)) and isinstance(serialized, list): - return [_configuration_json(original, item) for original, item in zip(value, serialized, strict=True)] - return serialized - - -def _validate_factory(factory) -> None: - if not inspect.iscoroutinefunction(factory): - raise ValueError("plugin factory must be async") # noqa: TRY003 - try: - inspect.signature(factory).bind(None, None) - except (TypeError, ValueError) as error: - raise ValueError("plugin factory must accept context and configuration") from error # noqa: TRY003 - - -def describe_module(module, manifest: _PluginManifest, directory: str) -> dict[str, Any]: - if module.plugin_api_version != "1" or len(module.families) != 1: - raise ValueError("API version 1 requires exactly one output family") # noqa: TRY003 - _validate_factory(module.create_pipeline) - family = module.families[0] - if re.fullmatch(r"[a-z][a-z0-9-]{0,127}", family.family) is None or family.family in _BUILTIN_FAMILIES: - raise ValueError("a plugin needs a custom canonical family") # noqa: TRY003 - if not family.schema_version or len(family.schema_version) > 128: - raise ValueError("a bounded schema_version is required") # noqa: TRY003 - schema = family.content_model.model_json_schema() - Draft202012Validator.check_schema(schema) - if schema.get("type") != "object": - raise ValueError("Artifact content must be a JSON object") # noqa: TRY003 - fields = () - search = family.search - if search is not None: - search = ArtifactSearchDeclaration.model_validate(search) - for index in search.indexes: - validate_index_schema(index, schema) - # JSON validation restores strict Config types from the serialized definition snapshot. - validated_config = module.Config.model_validate_json(json.dumps(manifest.config, ensure_ascii=False)) - snapshot = manifest.model_dump(mode="json") - snapshot["config"] = _configuration_json( - validated_config.model_dump(mode="python", by_alias=True), - validated_config.model_dump(mode="json", by_alias=True), - ) - return { - "manifest": snapshot, - "declaration": { - "plugin_id": manifest.id, - "version": manifest.version, - "family": family.family, - "schema_version": family.schema_version, - "content_schema": schema, - "search_fields": list(fields), - **({"search": search.model_dump(mode="json")} if search is not None else {}), - **_input_declaration(module.inputs), - "fingerprint": code_fingerprint(directory), - }, - } - - -async def validate_plugin(directory: str, snapshot: dict[str, Any] | None = None) -> dict[str, Any]: - # Result files keep user import-time stdout out of the declaration protocol. - with TemporaryDirectory(prefix="powercontext-artifact-validation-") as temporary: - root = Path(temporary) - (root / "manifest.json").write_text(json.dumps(snapshot, ensure_ascii=False), encoding="utf-8") - process = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - __name__, - directory, - str(root / "manifest.json"), - str(root / "result.json"), - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - ) - try: - async with asyncio.timeout(15): - await process.wait() - except BaseException: - if process.returncode is None: - process.kill() - await process.wait() - raise - if process.returncode: - raise ValueError("plugin declaration or configuration validation failed") # noqa: TRY003 - output = root / "result.json" - if output.stat().st_size > _MAX_OUTPUT_BYTES: - raise ValueError("plugin declaration is too large") # noqa: TRY003 - return json.loads(output.read_text()) - - -if __name__ == "__main__": - result = describe_plugin(sys.argv[1], json.loads(Path(sys.argv[2]).read_text())) - Path(sys.argv[3]).write_text(json.dumps(result, ensure_ascii=False)) diff --git a/src/powercontext/builtin/authz/profiles.py b/src/powercontext/builtin/authz/profiles.py index 84623fd972..68d2151a63 100644 --- a/src/powercontext/builtin/authz/profiles.py +++ b/src/powercontext/builtin/authz/profiles.py @@ -140,8 +140,8 @@ def subject_compatibility(self) -> dict[AccessRole, frozenset[str]]: } -def register_custom_artifact_access_family(family: str) -> None: - """Use framework-owned read/write semantics for a persisted custom family.""" +def register_plugin_artifact_family(family: str) -> None: + """Give a loaded plugin family the framework's read and write semantics.""" if family not in ARTIFACT_FAMILY_PROFILES: ARTIFACT_FAMILY_PROFILES[family] = ArtifactFamilyAccessProfile( diff --git a/src/powercontext/builtin/authz/service.py b/src/powercontext/builtin/authz/service.py index 09b3407d27..56c0644d7a 100644 --- a/src/powercontext/builtin/authz/service.py +++ b/src/powercontext/builtin/authz/service.py @@ -1450,7 +1450,6 @@ async def revoke_binding( expected_version: int, idempotency_key: str, context: AccessAuditContext, - prepare_resource: Callable[[ResourceRef], Awaitable[None]] | None = None, ) -> AccessBinding: _validate_idempotency_key(idempotency_key) actor = _required_principal(principal) @@ -1461,8 +1460,6 @@ async def revoke_binding( if decision.allowed: raise AccessBindingNotFoundError raise AccessDeniedError - if prepare_resource is not None: - await prepare_resource(binding.resource) await self.require_any( actor, _administrative_checks(binding.resource, deployment_id=self.deployment_id), @@ -1493,7 +1490,6 @@ async def replace_binding( request: ReplaceBinding, *, context: AccessAuditContext, - prepare_resource: Callable[[ResourceRef], Awaitable[None]] | None = None, ) -> BindingReplacement: actor = _required_principal(principal) current = await _access_call(self._relationship_reader().get_binding(request.binding_id)) @@ -1507,8 +1503,6 @@ async def replace_binding( if decision.allowed: raise AccessBindingNotFoundError raise AccessDeniedError - if prepare_resource is not None: - await prepare_resource(current.resource) validate_binding_subject(current.resource, current.role, request.subject.type) if isinstance(request.subject, GroupRef) and not self.provider_capabilities.group_subjects: raise AccessInvalidRequestError("group-subjects-unavailable") diff --git a/src/powercontext/builtin/persistence/artifact_governance.py b/src/powercontext/builtin/persistence/artifact_governance.py index 8d5b11c594..a4aadc36ba 100644 --- a/src/powercontext/builtin/persistence/artifact_governance.py +++ b/src/powercontext/builtin/persistence/artifact_governance.py @@ -17,7 +17,6 @@ from __future__ import annotations from enum import StrEnum -from typing import get_args from pydantic import BaseModel, ConfigDict, Field, model_validator from sqlalchemy import select, update @@ -26,7 +25,6 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE -from powercontext.builtin.records import BaseArtifactFamily from powercontext.errors import PowerContextError @@ -69,30 +67,6 @@ def validate_replacement(self) -> ArtifactGovernance: class ArtifactGovernanceRepository: """Read and transition governance state without changing the head Revision.""" - def __init__(self, *, prepared_indexes=None): - self.prepared_indexes = prepared_indexes or {} - - async def _prepare_custom_transition(self, connection, scope_id, family, artifact_id, lifecycle_state): - if family in get_args(BaseArtifactFamily): - return None - from powercontext.builtin.persistence.artifact_indexes import ArtifactProjectionPublisher, lock_state - from powercontext.builtin.persistence.custom_artifacts import custom_artifact_repository - - await lock_state(connection, scope_id, family) - artifact = await custom_artifact_repository(family).latest(connection, scope_id, family, artifact_id) - publisher = ArtifactProjectionPublisher(None) - prepared = self.prepared_indexes.get((scope_id, family, artifact_id)) - if lifecycle_state is ArtifactLifecycleState.ACTIVE: - await publisher.validate_prepared( - connection, scope_id, family, artifact.content.schema_version, artifact.content.content, prepared - ) - return publisher, artifact, prepared - - async def _publish_custom_transition(self, connection, scope_id, custom): - if custom is not None: - publisher, artifact, prepared = custom - await publisher.publish(connection, scope_id, artifact, prepared) - async def get( self, connection: AsyncConnection, @@ -141,7 +115,6 @@ async def transition_merge( ) -> ArtifactGovernance: """Apply a service-validated group transition while retaining unchanged state generations.""" - custom = await self._prepare_custom_transition(connection, scope_id, family, artifact_id, lifecycle_state) _validate_transition(expected.lifecycle_state, lifecycle_state) current = await self.require_summary(connection, scope_id, family, artifact_id, expected) if ( @@ -174,7 +147,6 @@ async def transition_merge( ) if result.rowcount != 1: raise StoredPayloadConflictError("artifact-governance", (scope_id, family, artifact_id)) - await self._publish_custom_transition(connection, scope_id, custom) return requested async def transition( @@ -188,7 +160,6 @@ async def transition( replacement_artifact_id: str | None, /, ) -> ArtifactGovernance: - custom = await self._prepare_custom_transition(connection, scope_id, family, artifact_id, lifecycle_state) current = await self.get(connection, scope_id, family, artifact_id) if current.merged_into_id is not None: raise InvalidArtifactLifecycleError("frozen merge inputs require whole-group restoration") # noqa: TRY003 @@ -235,7 +206,6 @@ async def transition( raise StoredPayloadConflictError( "artifact-governance", (scope_id, family, artifact_id, expected_generation) ) - await self._publish_custom_transition(connection, scope_id, custom) return requested diff --git a/src/powercontext/builtin/persistence/artifact_indexes.py b/src/powercontext/builtin/persistence/artifact_indexes.py deleted file mode 100644 index 1599f615a9..0000000000 --- a/src/powercontext/builtin/persistence/artifact_indexes.py +++ /dev/null @@ -1,1053 +0,0 @@ -# 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. - -"""Transactional custom Artifact projections, build coordination and rebuilds.""" - -from __future__ import annotations - -import json -import math -import struct -from copy import deepcopy -from dataclasses import dataclass -from hashlib import sha256 -from typing import Any -from uuid import uuid4 - -from sqlalchemy import delete, insert, select, text, update - -from powercontext.artifacts import ArtifactRef -from powercontext.artifacts.indexes import ArtifactSearchDeclaration, TextIndex, VectorIndex -from powercontext.artifacts.search import ArtifactSearchFamilyNotFound, ArtifactSearchUnavailable -from powercontext.builtin.artifacts.memory.canonical import canonical_embedding, canonical_json -from powercontext.builtin.artifacts.memory.models import EmbeddingProfile -from powercontext.builtin.artifacts.search import TEXT_ANALYZER_VERSION, analyze_text -from powercontext.builtin.persistence.sqlite.profile import ensure_sqlite_vector_extension -from powercontext.builtin.persistence.tables import ( - ARTIFACT_FAMILY_SCHEMAS_TABLE as SCHEMAS, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_HEADS_TABLE as HEADS, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_INDEX_CHANGES_TABLE as CHANGES, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_INDEX_COVERAGE_TABLE as COVERAGE, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_SEARCH_STATES_TABLE as STATES, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_SEARCH_TEXT_TABLE as TEXTS, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_VECTOR_INDEXES_TABLE as DEFINITIONS, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_VECTOR_SPACES_TABLE as SPACES, -) -from powercontext.builtin.persistence.tables import ( - ARTIFACT_VECTORS_TABLE as VECTORS, -) - -FORMAT = "float32-le-v1" - - -def identity_json(value): - # IDs, raw content and fragment locators must not be Unicode-normalized by hashing. - return json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode() - - -def digest(value): - return sha256(identity_json(value)).hexdigest() - - -def search_declaration(durable): - if durable.get("search") is not None: - return ArtifactSearchDeclaration.model_validate(durable["search"]) - fields = durable.get("search_fields", []) - if fields: - return ArtifactSearchDeclaration( - version="legacy-fts-v1", - indexes=(TextIndex(name="lexical", projection_version="1", fields=tuple(fields)),), - modes=("text",), - default_mode="text", - ) - return None - - -def key(table, scope, family): - return (table.c.scope_id == scope) & (table.c.family == family) - - -async def lock_state(connection, scope, family): - values = { - "scope_id": scope, - "family": family, - "build_id": None, - "state": "pending", - "manifest": {}, - "cleanup_targets": [], - "start_seq": 0, - "applied_seq": 0, - "change_seq": 0, - } - if connection.dialect.name == "sqlite": - from sqlalchemy.dialects.sqlite import insert as dialect_insert - - stmt = dialect_insert(STATES).values(**values).on_conflict_do_nothing(index_elements=["scope_id", "family"]) - else: - from sqlalchemy.dialects.mysql import insert as dialect_insert - - stmt = dialect_insert(STATES).values(**values).on_duplicate_key_update(family=family) - await connection.execute(stmt) - # This is a real write on SQLite, and the same row lock as SELECT FOR UPDATE on MySQL. - await connection.execute(update(STATES).where(key(STATES, scope, family)).values(change_seq=STATES.c.change_seq)) - return ( - (await connection.execute(select(STATES).where(key(STATES, scope, family)).with_for_update())).mappings().one() - ) - - -async def read_head(connection, scope, family, artifact_id, *, for_update=False): - statement = select(HEADS).where(key(HEADS, scope, family), HEADS.c.artifact_id == artifact_id) - if for_update: - statement = statement.with_for_update() - return (await connection.execute(statement)).mappings().one_or_none() - - -async def record_change(connection, scope, family, artifact_id, schema_version, operation): - state = await lock_state(connection, scope, family) - head = await read_head(connection, scope, family, artifact_id) - sequence = state["change_seq"] + 1 - await connection.execute(update(STATES).where(key(STATES, scope, family)).values(change_seq=sequence)) - await connection.execute( - insert(CHANGES).values( - scope_id=scope, - family=family, - seq=sequence, - artifact_id=artifact_id, - revision=None if head is None else head["revision"], - schema_version=schema_version, - governance_generation=0 if head is None else head["governance_generation"], - operation=operation, - ) - ) - if state["state"] == "ready": - await connection.execute(update(STATES).where(key(STATES, scope, family)).values(applied_seq=sequence)) - await prune_changes(connection, scope, family) - return sequence - - -async def prune_changes(connection, scope, family): - applied = await connection.scalar(select(STATES.c.applied_seq).where(key(STATES, scope, family))) - await connection.execute(delete(CHANGES).where(key(CHANGES, scope, family), CHANGES.c.seq <= applied)) - - -async def register_definition(connection, family, version, index): - spec = index.model_dump(mode="json") - if isinstance(index, TextIndex): - spec["analyzer_version"] = TEXT_ANALYZER_VERSION - space = None - if isinstance(index, VectorIndex): - fingerprint = sha256(canonical_json(index.profile.model_dump(mode="json"))).hexdigest() - backend = connection.dialect.name - space = uuid4().hex - values = { - "space_id": space, - "profile_fingerprint": fingerprint, - "profile_json": index.profile.model_dump(mode="json"), - "dimension": index.profile.dimension, - "distance": index.profile.distance, - "normalization": index.profile.normalization, - "storage_format_version": FORMAT, - "backend": backend, - "physical_table": "pc_artifact_vectors" if backend == "sqlite" else f"pc_av_{space}", - "provision_state": "ready" if backend == "sqlite" else "declared", - } - if backend == "sqlite": - from sqlalchemy.dialects.sqlite import insert as space_insert - - statement = ( - space_insert(SPACES) - .values(**values) - .on_conflict_do_nothing(index_elements=["backend", "profile_fingerprint", "storage_format_version"]) - ) - else: - from sqlalchemy.dialects.mysql import insert as space_insert - - statement = space_insert(SPACES).values(**values).on_duplicate_key_update(backend=backend) - await connection.execute(statement) - space_row = ( - ( - await connection.execute( - select(SPACES) - .where( - SPACES.c.backend == backend, - SPACES.c.profile_fingerprint == fingerprint, - SPACES.c.storage_format_version == FORMAT, - ) - .with_for_update() - ) - ) - .mappings() - .one() - ) - if space_row["profile_json"] != index.profile.model_dump(mode="json"): - raise ValueError("vector space fingerprint collision") # noqa: TRY003 - space = space_row["space_id"] - identity = { - "family": family, - "schema_version": version, - "index_name": index.name, - "projection_version": index.projection_version, - "space_id": space, - } - existing = ( - ( - await connection.execute( - select(DEFINITIONS) - .where(*(DEFINITIONS.c[name] == value for name, value in identity.items())) - .order_by(DEFINITIONS.c.definition_fingerprint) - ) - ) - .mappings() - .first() - ) - fingerprint = digest(spec) - exact = ( - ( - await connection.execute( - select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == digest({**identity, "spec": spec})) - ) - ) - .mappings() - .first() - ) - if exact is not None: - if any(exact[name] != value for name, value in identity.items()) or exact["projection_spec"] != spec: - raise ValueError("index definition identity collision") # noqa: TRY003 - return dict(exact) - if existing is not None: - prior_spec = {k: v for k, v in existing["projection_spec"].items() if k != "analyzer_version"} - current_spec = {k: v for k, v in spec.items() if k != "analyzer_version"} - if prior_spec != current_spec: - raise ValueError("index projection version cannot be redefined") # noqa: TRY003 - if existing["definition_fingerprint"] == fingerprint: - return dict(existing) - row = dict( - index_def_id=digest({**identity, "spec": spec}), - **identity, - source_kind="text" if isinstance(index, TextIndex) else index.source, - projection_spec=spec, - definition_fingerprint=fingerprint, - ) - await connection.execute(insert(DEFINITIONS).values(**row)) - return row - - -def parse_index(definition): - spec = {k: v for k, v in definition["projection_spec"].items() if k != "analyzer_version"} - return (TextIndex if spec["kind"] == "text" else VectorIndex).model_validate(spec) - - -async def target_manifest(connection, scope, family, *, rebuild=False): # noqa: C901 - Initial build and repair share one fenced state transition. - """Initialize a fixed target or restart repair from its persisted declaration.""" - state = await lock_state(connection, scope, family) - manifest = deepcopy(state["manifest"]) - if manifest: - if not rebuild: - return manifest - for _, entry in entries(manifest): - entry.update(scan_cursor=None, scan_complete=False) - else: - rows = ( - ( - await connection.execute( - select(SCHEMAS).where(SCHEMAS.c.family == family).order_by(SCHEMAS.c.schema_version) - ) - ) - .mappings() - .all() - ) - if not rows: - raise ArtifactSearchFamilyNotFound(family) - manifest = {"schemas": {}, "config": None, "default_schema": None} - for row in rows: - declaration = search_declaration(row["declaration"]) - if declaration is None: - continue - schema = row["schema_version"] - if manifest["config"] is None: - manifest["config"] = declaration.model_dump(mode="json") - manifest["default_schema"] = schema - indexes = {} - for index in declaration.indexes: - definition = await register_definition(connection, family, schema, index) - indexes[index.name] = { - "index_def_id": definition["index_def_id"], - "space_id": definition["space_id"], - "scan_cursor": None, - "scan_complete": False, - } - manifest["schemas"][schema] = {"indexes": indexes} - targets = list(state["cleanup_targets"]) - if state["build_id"] is not None: - for _, entry in entries(state["manifest"]): - target = { - "build_id": state["build_id"], - "index_def_id": entry["index_def_id"], - "space_id": entry["space_id"], - } - if target not in targets: - targets.append(target) - await connection.execute( - update(STATES) - .where(key(STATES, scope, family)) - .values( - build_id=uuid4().hex, - state="cleaning" if targets else "building", - manifest=manifest, - cleanup_targets=targets, - validation_token=None, - start_seq=state["change_seq"], - applied_seq=state["change_seq"], - ) - ) - await prune_changes(connection, scope, family) - return manifest - - -def entries(manifest): - return tuple( - (schema, entry) - for schema, config in manifest.get("schemas", {}).items() - for entry in config["indexes"].values() - ) - - -def vector_bytes(values, profile): - if any(isinstance(v, bool) or not isinstance(v, (int, float)) for v in values): - raise ValueError("vectors must contain finite numbers") # noqa: TRY003 - vector = canonical_embedding(values, dimension=profile.dimension, normalization=profile.normalization) - try: - encoded = struct.pack(f"<{len(vector)}f", *vector) - except (OverflowError, struct.error) as error: - raise ValueError("vector exceeds float32 range") from error # noqa: TRY003 - if not all(math.isfinite(v) for v in struct.unpack(f"<{len(vector)}f", encoded)): - raise ValueError("vector exceeds float32 range") # noqa: TRY003 - return encoded - - -def index_parts(index, content): - if isinstance(index, VectorIndex) and index.fragments is not None: - parts = content[index.fragments.array_field] - if not isinstance(parts, list): - raise ValueError("fragment projection requires a persistent array") # noqa: TRY003 - else: - parts = [content] - seen = set() - for offset, part in enumerate(parts): - fragment_id = "__whole__" - locator = {"fields": list(index.fields)} - if isinstance(index, VectorIndex) and index.fragments is not None: - fragment_id = part[index.fragments.id_field] - if not isinstance(fragment_id, str) or not 1 <= len(fragment_id) <= 128 or fragment_id == "__whole__": - raise ValueError("fragment identity must be a bounded persistent string") # noqa: TRY003 - locator = {"array": index.fragments.array_field, "id": fragment_id, "offset": offset} - if fragment_id in seen: - raise ValueError("fragment identities must be unique") # noqa: TRY003 - seen.add(fragment_id) - yield part, fragment_id, locator - - -def content_vector(index, part): - if EmbeddingProfile.model_validate(part[index.profile_field]) != index.profile: - raise ValueError("persistent vector profile differs from the declared index") # noqa: TRY003 - return vector_bytes(part[index.vector_field], index.profile) - - -@dataclass(frozen=True) -class PreparedIndex: - definition: dict[str, Any] - fragments: tuple[dict[str, Any], ...] - input_hash: str - - -@dataclass(frozen=True) -class PreparedArtifactIndexes: - build_id: str | None - content_hash: str - projections: dict[str, PreparedIndex] - - -class ArtifactProjectionPublisher: - """Prepare without a transaction; publish only under the build coordinator.""" - - def __init__(self, database, embedding_model=None): - self.database = database - self.embedding_model = embedding_model - - async def prepare_index(self, definition, content): - index = parse_index(definition) - fragments = [] - texts = [] - for part, fragment_id, locator in index_parts(index, content): - value = {"fragment_id": fragment_id, "fragment_locator": identity_json(locator).decode()} - if isinstance(index, TextIndex) or index.source == "text": - fields = [part[field] for field in index.fields] - if any(not isinstance(field, str) for field in fields): - raise ValueError("text projection requires strings") # noqa: TRY003 - original = "\n".join(fields) - if isinstance(index, TextIndex): - value["searchable_text"] = analyze_text(original) - else: - texts.append(original) - else: - value["embedding"] = content_vector(index, part) - value["input_hash"] = digest({"projection": definition["definition_fingerprint"], "part": part}) - fragments.append(value) - if texts: - model = self.embedding_model - if model is None or model.profile != index.profile: - raise ArtifactSearchUnavailable( - definition["family"], "embedding resource unavailable for declared profile" - ) - result = await model.embed(tuple(texts)) - if len(result.vectors) != len(texts): - raise ValueError("embedding result must match the prepared input count") # noqa: TRY003 - for fragment, vector in zip(fragments, result.vectors, strict=True): - fragment["embedding"] = vector_bytes(vector, index.profile) - return PreparedIndex( - definition, - tuple(fragments), - digest({"projection": definition["definition_fingerprint"], "content": content}), - ) - - async def prepare(self, scope, family, schema_version, content): - content = deepcopy(content) - async with self.database.transaction(consistent_snapshot=True) as connection: - state = (await connection.execute(select(STATES).where(key(STATES, scope, family)))).mappings().first() - if state is None or state["state"] != "ready": - return PreparedArtifactIndexes(None, digest(content), {}) - definitions = {} - for schema, entry in entries(state["manifest"]): - if schema == schema_version: - definitions[entry["index_def_id"]] = dict( - ( - await connection.execute( - select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == entry["index_def_id"]) - ) - ) - .mappings() - .one() - ) - projections = {} - try: - for identity, definition in definitions.items(): - projections[identity] = await self.prepare_index(definition, content) - except Exception: - # A rebuild may have suspended this index while encoding was in flight. - # Content writes then require only the durable change log. - async with self.database.transaction(consistent_snapshot=True) as connection: - current = (await connection.execute(select(STATES).where(key(STATES, scope, family)))).mappings().one() - if current["state"] == "ready": - raise - return PreparedArtifactIndexes(None, digest(content), {}) - return PreparedArtifactIndexes(state["build_id"], digest(content), projections) - - async def validate_prepared(self, connection, scope, family, schema_version, content, prepared): - state = await lock_state(connection, scope, family) - if state["state"] != "ready": - durable = await connection.scalar( - select(SCHEMAS.c.declaration).where( - SCHEMAS.c.family == family, SCHEMAS.c.schema_version == schema_version - ) - ) - declaration = None if durable is None else search_declaration(durable) - if declaration is not None: - # Resource-independent content invariants hold even while projections are suspended. - for index in declaration.indexes: - if isinstance(index, VectorIndex): - for part, _, _ in index_parts(index, content): - if index.source == "content": - content_vector(index, part) - return state - expected = {entry["index_def_id"] for schema, entry in entries(state["manifest"]) if schema == schema_version} - if prepared is None: - if expected: - raise ValueError("ready Artifact indexes must be prepared before the write transaction") # noqa: TRY003 - elif ( - prepared.build_id != state["build_id"] - or prepared.content_hash != digest(content) - or set(prepared.projections) != expected - ): - raise ValueError("Artifact content or search build changed; prepare again before committing") # noqa: TRY003 - return state - - async def publish(self, connection, scope, artifact, prepared): - family, identity = artifact.family, artifact.artifact_id - state = await lock_state(connection, scope, family) - head = await read_head(connection, scope, family, identity, for_update=True) - if state["state"] == "ready": - if head["lifecycle_state"] == "active": - await self.validate_prepared( - connection, scope, family, artifact.content.schema_version, artifact.content.content, prepared - ) - for _, entry in entries(state["manifest"]): - projection = ( - None - if prepared is None or head["lifecycle_state"] != "active" - else prepared.projections.get(entry["index_def_id"]) - ) - await self.replace( - connection, - scope, - family, - identity, - artifact.revision, - head["governance_generation"], - state["build_id"], - entry["index_def_id"], - projection, - ) - await record_change(connection, scope, family, identity, artifact.content.schema_version, "upsert") - - async def replace( - self, connection, scope, family, artifact_id, revision, governance, build_id, index_def_id, prepared - ): - state = await lock_state(connection, scope, family) - if ( - state["build_id"] != build_id - or state["state"] not in {"ready", "building"} - or state["validation_token"] is not None - ): - raise ValueError("projection build is not writable") # noqa: TRY003 - if index_def_id not in {entry["index_def_id"] for _, entry in entries(state["manifest"])}: - raise ValueError("projection definition is not in the current build") # noqa: TRY003 - definition = dict( - (await connection.execute(select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == index_def_id))) - .mappings() - .one() - ) - index = parse_index(definition) - table = await projection_table(connection, definition) - await connection.execute( - delete(table).where( - projection_key(table, scope, family, build_id, index_def_id), table.c.artifact_id == artifact_id - ) - ) - await connection.execute( - delete(COVERAGE).where( - projection_key(COVERAGE, scope, family, build_id, index_def_id), COVERAGE.c.artifact_id == artifact_id - ) - ) - if prepared is None: - return - if prepared.definition != definition: - raise ValueError("prepared definition does not match its index") # noqa: TRY003 - schema = definition["schema_version"] - rows = [] - for fragment in prepared.fragments: - identity = ( - scope, - family, - artifact_id, - revision, - schema, - definition["index_def_id"], - build_id, - fragment["fragment_id"], - ) - row = dict( - scope_id=scope, - family=family, - artifact_id=artifact_id, - revision=revision, - schema_version=schema, - index_def_id=definition["index_def_id"], - build_id=build_id, - governance_generation=governance, - **fragment, - ) - if isinstance(index, VectorIndex): - row.update( - vector_id=digest(identity), space_id=definition["space_id"], dimension=index.profile.dimension - ) - if connection.dialect.name != "sqlite": - row["embedding"] = list(struct.unpack(f"<{index.profile.dimension}f", row["embedding"])) - else: - row["text_id"] = digest(identity) - rows.append(row) - if rows: - await connection.execute(insert(table), rows) - await connection.execute( - insert(COVERAGE).values( - scope_id=scope, - family=family, - index_def_id=index_def_id, - build_id=build_id, - artifact_id=artifact_id, - revision=revision, - schema_version=schema, - fragment_count=len(rows), - projection_input_hash=prepared.input_hash, - governance_generation=governance, - ) - ) - - -def projection_key(table, scope, family, build_id, index_def_id): - return key(table, scope, family) & (table.c.build_id == build_id) & (table.c.index_def_id == index_def_id) - - -async def projection_table(connection, definition): - if definition["space_id"] is None: - return TEXTS - space = ( - (await connection.execute(select(SPACES).where(SPACES.c.space_id == definition["space_id"]))).mappings().one() - ) - if space["provision_state"] != "ready": - raise ArtifactSearchUnavailable(definition["family"], "vector space preparation is incomplete") - if connection.dialect.name == "sqlite": - return VECTORS - from powercontext.builtin.persistence.artifact_vector_space import native_vector_table - - try: - return native_vector_table(space) - except ValueError as error: - raise ArtifactSearchUnavailable(definition["family"], "registered vector space is incompatible") from error - - -class ArtifactIndexBuilder: - """Resume cleanup, scans and replay while the complete family search is unavailable.""" - - def __init__(self, database, embedding_model=None): - self.database = database - self.publisher = ArtifactProjectionPublisher(database, embedding_model) - - async def refresh(self, scope, family, *, rebuild=False): - if not rebuild: - async with self.database.transaction(consistent_snapshot=True) as connection: - manifest = await connection.scalar(select(STATES.c.manifest).where(key(STATES, scope, family))) - if manifest: - return manifest - async with self.database.transaction() as connection: - return await target_manifest(connection, scope, family, rebuild=rebuild) - - async def clean(self, scope, family, build_id, *, limit=1000): - """Delete a bounded batch, fenced by the current build and its coordinator lock.""" - async with self.database.transaction() as connection: - state = await lock_state(connection, scope, family) - if state["build_id"] != build_id or state["state"] != "cleaning": - return False - targets = list(state["cleanup_targets"]) - remaining = limit - while targets and remaining: - target = targets[0] - definition = ( - ( - await connection.execute( - select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == target["index_def_id"]) - ) - ) - .mappings() - .one() - ) - tables = [COVERAGE] - # An unprovisioned space has never accepted projection rows. - if ( - target["space_id"] is None - or await connection.scalar( - select(SPACES.c.provision_state).where(SPACES.c.space_id == target["space_id"]) - ) - == "ready" - ): - tables.insert(0, await projection_table(connection, definition)) - complete = True - for table in tables: - where = projection_key(table, scope, family, target["build_id"], target["index_def_id"]) - primary = table.c.artifact_id if table is COVERAGE else next(iter(table.primary_key.columns)) - ids = (await connection.execute(select(primary).where(where).limit(remaining))).scalars().all() - if ids: - await connection.execute(delete(table).where(where, primary.in_(ids))) - remaining -= len(ids) - if not remaining: - complete = False - break - if complete: - targets.pop(0) - await connection.execute( - update(STATES) - .where(key(STATES, scope, family)) - .values(cleanup_targets=targets, state="cleaning" if targets else "building") - ) - return not targets - - async def build(self, scope, family): # noqa: C901 - Resume ordered cleanup, scans, replay and validation. - async with self.database.transaction(consistent_snapshot=True) as connection: - state = (await connection.execute(select(STATES).where(key(STATES, scope, family)))).mappings().one() - if state["state"] == "ready": - return True - if not state["manifest"]: - return False - build_id = state["build_id"] - if state["state"] == "cleaning" and not await self.clean(scope, family, build_id): - return False - # Reclaim an interrupted validator. Any previous validator must still present - # its own token at commit, so it cannot publish after this recovery. - async with self.database.transaction() as connection: - current = await lock_state(connection, scope, family) - if current["build_id"] != build_id or current["state"] != "building": - return False - await connection.execute(update(STATES).where(key(STATES, scope, family)).values(validation_token=None)) - manifest = current["manifest"] - for _, entry in entries(manifest): - if entry["scan_complete"]: - continue - cursor = entry["scan_cursor"] or "" - while True: - async with self.database.transaction(consistent_snapshot=True) as connection: - identities = ( - ( - await connection.execute( - select(HEADS.c.artifact_id) - .where(key(HEADS, scope, family), HEADS.c.artifact_id > cursor) - .order_by(HEADS.c.artifact_id) - .limit(100) - ) - ) - .scalars() - .all() - ) - if not identities: - async with self.database.transaction() as connection: - current = await lock_state(connection, scope, family) - if not self._writable(current, build_id): - return False - progress = deepcopy(current["manifest"]) - for _, item in entries(progress): - if item["index_def_id"] == entry["index_def_id"]: - item["scan_complete"] = True - await connection.execute( - update(STATES).where(key(STATES, scope, family)).values(manifest=progress) - ) - break - for identity in identities: - if not await self._project_current( - scope, family, build_id, entry["index_def_id"], identity, cursor=identity - ): - return False - cursor = identity - for _ in range(8): - if not await self._catch_up(scope, family, build_id): - return False - if await self._validate_and_publish(scope, family, build_id): - return True - return False - - @staticmethod - def _writable(state, build_id): - return state["build_id"] == build_id and state["state"] == "building" and state["validation_token"] is None - - async def _project_current(self, scope, family, build_id, index_def_id, identity, *, cursor=None): - from powercontext.builtin.persistence.custom_artifacts import custom_artifact_repository - - repository = custom_artifact_repository(family) - for _ in range(8): - async with self.database.transaction(consistent_snapshot=True) as connection: - state = (await connection.execute(select(STATES).where(key(STATES, scope, family)))).mappings().one() - if not self._writable(state, build_id): - return False - definition = dict( - (await connection.execute(select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == index_def_id))) - .mappings() - .one() - ) - head = await read_head(connection, scope, family, identity) - artifact = None - if head is not None and head["lifecycle_state"] == "active": - artifact = await repository.get( - connection, scope, ArtifactRef(family=family, artifact_id=identity, revision=head["revision"]) - ) - prepared = None - if artifact is not None and artifact.content.schema_version == definition["schema_version"]: - prepared = await self.publisher.prepare_index(definition, artifact.content.content) - async with self.database.transaction() as connection: - current = await lock_state(connection, scope, family) - if not self._writable(current, build_id): - return False - if await read_head(connection, scope, family, identity, for_update=True) != head: - continue - await self.publisher.replace( - connection, - scope, - family, - identity, - 0 if head is None else head["revision"], - 0 if head is None else head["governance_generation"], - build_id, - index_def_id, - prepared, - ) - if cursor is not None: - progress = deepcopy(current["manifest"]) - for _, entry in entries(progress): - if entry["index_def_id"] == index_def_id: - entry["scan_cursor"] = max(cursor, entry["scan_cursor"] or "") - await connection.execute(update(STATES).where(key(STATES, scope, family)).values(manifest=progress)) - return True - return False - - async def _catch_up(self, scope, family, build_id): - while True: - async with self.database.transaction(consistent_snapshot=True) as connection: - state = (await connection.execute(select(STATES).where(key(STATES, scope, family)))).mappings().one() - if not self._writable(state, build_id): - return False - changes = ( - ( - await connection.execute( - select(CHANGES) - .where(key(CHANGES, scope, family), CHANGES.c.seq > state["applied_seq"]) - .order_by(CHANGES.c.seq) - .limit(100) - ) - ) - .mappings() - .all() - ) - if not changes: - return True - for change in changes: - for _, entry in entries(state["manifest"]): - if not await self._project_current( - scope, family, build_id, entry["index_def_id"], change["artifact_id"] - ): - return False - async with self.database.transaction() as connection: - current = await lock_state(connection, scope, family) - if not self._writable(current, build_id): - return False - await connection.execute( - update(STATES) - .where(key(STATES, scope, family)) - .values(applied_seq=max(change["seq"], current["applied_seq"])) - ) - await prune_changes(connection, scope, family) - - async def _validate_and_publish(self, scope, family, build_id): - token = uuid4().hex - async with self.database.transaction() as connection: - state = await lock_state(connection, scope, family) - if not self._writable(state, build_id) or state["applied_seq"] != state["change_seq"]: - return False - if not all(entry["scan_complete"] for _, entry in entries(state["manifest"])): - return False - manifest, sequence = state["manifest"], state["change_seq"] - await connection.execute(update(STATES).where(key(STATES, scope, family)).values(validation_token=token)) - try: - async with self.database.transaction(consistent_snapshot=True) as connection: - valid = await self._coverage_valid(connection, scope, family, build_id, manifest) - if not valid: - return False - async with self.database.transaction() as connection: - state = await lock_state(connection, scope, family) - if ( - state["build_id"] != build_id - or state["state"] != "building" - or state["validation_token"] != token - or state["change_seq"] != sequence - or state["manifest"] != manifest - ): - return False - await connection.execute( - update(STATES).where(key(STATES, scope, family)).values(state="ready", validation_token=None) - ) - await prune_changes(connection, scope, family) - return True - finally: - async with self.database.transaction() as connection: - await lock_state(connection, scope, family) - await connection.execute( - update(STATES) - .where( - key(STATES, scope, family), STATES.c.build_id == build_id, STATES.c.validation_token == token - ) - .values(validation_token=None) - ) - - async def _coverage_valid(self, connection, scope, family, build_id, manifest): # noqa: C901 - Keep the complete projection or publication invariant together. - from powercontext.builtin.persistence.custom_artifacts import custom_artifact_repository - - repository = custom_artifact_repository(family) - heads = ( - ( - await connection.execute( - select(HEADS).where(key(HEADS, scope, family), HEADS.c.lifecycle_state == "active") - ) - ) - .mappings() - .all() - ) - artifacts = {} - for head in heads: - artifact = await repository.get( - connection, - scope, - ArtifactRef(family=family, artifact_id=head["artifact_id"], revision=head["revision"]), - ) - artifacts[head["artifact_id"]] = (head, artifact) - for schema, entry in entries(manifest): - definition = dict( - ( - await connection.execute( - select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == entry["index_def_id"]) - ) - ) - .mappings() - .one() - ) - table = await projection_table(connection, definition) - index = parse_index(definition) - if isinstance(index, VectorIndex): - probe = vector_bytes([1.0] * index.profile.dimension, index.profile) - if connection.dialect.name == "sqlite": - await ensure_sqlite_vector_extension(connection) - await connection.execute(text("SELECT vec_distance_l2(:vector, :vector)"), {"vector": probe}) - else: - from sqlalchemy import bindparam - - values = list(struct.unpack(f"<{index.profile.dimension}f", probe)) - await connection.execute( - text("SELECT l2_distance(:probe, :probe)").bindparams( - bindparam("probe", type_=table.c.embedding.type) - ), - {"probe": values}, - ) - coverage = { - row["artifact_id"]: row - for row in ( - await connection.execute( - select(COVERAGE).where(projection_key(COVERAGE, scope, family, build_id, entry["index_def_id"])) - ) - ).mappings() - } - expected = { - identity for identity, (_, artifact) in artifacts.items() if artifact.content.schema_version == schema - } - if set(coverage) != expected: - return False - rows = ( - ( - await connection.execute( - select(table).where(projection_key(table, scope, family, build_id, entry["index_def_id"])) - ) - ) - .mappings() - .all() - ) - by_artifact = {} - for row in rows: - if row["artifact_id"] not in expected: - return False - by_artifact.setdefault(row["artifact_id"], []).append(row) - for identity in expected: - head, artifact = artifacts[identity] - content = artifact.content.content - row = coverage[identity] - parts = ( - content[index.fragments.array_field] - if isinstance(index, VectorIndex) and index.fragments - else [content] - ) - if ( - row["revision"], - row["schema_version"], - row["governance_generation"], - row["fragment_count"], - row["projection_input_hash"], - ) != ( - head["revision"], - schema, - head["governance_generation"], - len(parts), - digest({"projection": definition["definition_fingerprint"], "content": content}), - ): - return False - projected = by_artifact.get(identity, []) - if len(projected) != len(parts): - return False - expected_parts = { - ( - part[index.fragments.id_field] - if isinstance(index, VectorIndex) and index.fragments - else "__whole__" - ): (offset, part) - for offset, part in enumerate(parts) - } - if {item["fragment_id"] for item in projected} != set(expected_parts): - return False - for item in projected: - offset, part = expected_parts[item["fragment_id"]] - locator = ( - {"array": index.fragments.array_field, "id": item["fragment_id"], "offset": offset} - if isinstance(index, VectorIndex) and index.fragments - else {"fields": list(index.fields)} - ) - if ( - item["input_hash"] != digest({"projection": definition["definition_fingerprint"], "part": part}) - or item["fragment_locator"] != identity_json(locator).decode() - ): - return False - if isinstance(index, TextIndex): - if item["searchable_text"] != analyze_text("\n".join(part[field] for field in index.fields)): - return False - else: - if (item["space_id"], item["dimension"]) != (definition["space_id"], index.profile.dimension): - return False - vector = ( - item["embedding"] - if connection.dialect.name == "sqlite" - else struct.pack(f"<{index.profile.dimension}f", *item["embedding"]) - ) - if len(vector) != 4 * index.profile.dimension or not all( - math.isfinite(value) for value in struct.unpack(f"<{index.profile.dimension}f", vector) - ): - return False - if index.source == "content" and vector != vector_bytes( - part[index.vector_field], index.profile - ): - return False - if any( - ( - item["scope_id"], - item["family"], - item["revision"], - item["schema_version"], - item["governance_generation"], - item["index_def_id"], - ) - != ( - scope, - family, - head["revision"], - schema, - head["governance_generation"], - definition["index_def_id"], - ) - for item in projected - ): - return False - return True diff --git a/src/powercontext/builtin/persistence/artifact_search_schema.py b/src/powercontext/builtin/persistence/artifact_search_schema.py deleted file mode 100644 index b60e65568d..0000000000 --- a/src/powercontext/builtin/persistence/artifact_search_schema.py +++ /dev/null @@ -1,248 +0,0 @@ -# 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 search build state and exact, index-isolated projections.""" - -from sqlalchemy import ( - JSON, - BigInteger, - CheckConstraint, - Column, - ForeignKeyConstraint, - Index, - Integer, - LargeBinary, - Table, - Text, - UniqueConstraint, -) - - -def artifact_search_tables(metadata, identity): - spaces = Table( - "pc_artifact_vector_spaces", - metadata, - Column("space_id", identity(32), primary_key=True), - Column("profile_fingerprint", identity(64), nullable=False), - Column("profile_json", JSON, nullable=False), - Column("dimension", Integer, nullable=False), - Column("distance", identity(16), nullable=False), - Column("normalization", identity(16), nullable=False), - Column("storage_format_version", identity(64), nullable=False), - Column("backend", identity(32), nullable=False), - Column("physical_table", identity(128), nullable=False), - Column("provision_state", identity(32), nullable=False), - UniqueConstraint( - "backend", "profile_fingerprint", "storage_format_version", name="uq_pc_artifact_vector_space" - ), - CheckConstraint("dimension > 0", name="ck_pc_artifact_vector_dimension"), - ) - definitions = Table( - "pc_artifact_vector_indexes", - metadata, - Column("index_def_id", identity(64), primary_key=True), - Column("family", identity(128), nullable=False), - Column("index_name", identity(128), nullable=False), - Column("schema_version", identity(128), nullable=False), - Column("projection_version", identity(128), nullable=False), - Column("space_id", identity(32)), - Column("source_kind", identity(16), nullable=False), - Column("projection_spec", JSON, nullable=False), - Column("definition_fingerprint", identity(64), nullable=False), - UniqueConstraint( - "family", - "index_name", - "schema_version", - "projection_version", - "space_id", - "definition_fingerprint", - name="uq_pc_artifact_index_definition", - ), - ForeignKeyConstraint(("space_id",), ("pc_artifact_vector_spaces.space_id",)), - ForeignKeyConstraint( - ("family", "schema_version"), - ("pc_artifact_family_schemas.family", "pc_artifact_family_schemas.schema_version"), - ), - ) - states = Table( - "pc_artifact_search_states", - metadata, - Column("scope_id", identity(256), primary_key=True), - Column("family", identity(128), primary_key=True), - Column("build_id", identity(32)), - Column("state", identity(16), nullable=False), - Column("manifest", JSON, nullable=False), - Column("cleanup_targets", JSON, nullable=False), - Column("validation_token", identity(32)), - Column("start_seq", BigInteger, nullable=False, server_default="0"), - Column("applied_seq", BigInteger, nullable=False, server_default="0"), - Column("change_seq", BigInteger, nullable=False, server_default="0"), - ) - coverage = Table( - "pc_artifact_index_coverage", - metadata, - Column("scope_id", identity(256), primary_key=True), - Column("family", identity(128), primary_key=True), - Column("build_id", identity(32), primary_key=True), - Column("index_def_id", identity(64), primary_key=True), - Column("artifact_id", identity(128), primary_key=True), - Column("revision", Integer, nullable=False), - Column("schema_version", identity(128), nullable=False), - Column("fragment_count", Integer, nullable=False), - Column("projection_input_hash", identity(64), nullable=False), - Column("governance_generation", BigInteger, nullable=False), - ForeignKeyConstraint( - ("scope_id", "family"), ("pc_artifact_search_states.scope_id", "pc_artifact_search_states.family") - ), - CheckConstraint("fragment_count >= 0", name="ck_pc_artifact_index_fragment_count"), - ) - changes = Table( - "pc_artifact_index_changes", - metadata, - Column("scope_id", identity(256), primary_key=True), - Column("family", identity(128), primary_key=True), - Column("seq", BigInteger, primary_key=True), - Column("artifact_id", identity(128), nullable=False), - Column("revision", Integer), - Column("operation", identity(16), nullable=False), - Column("schema_version", identity(128)), - Column("governance_generation", BigInteger, nullable=False), - ForeignKeyConstraint( - ("scope_id", "family"), ("pc_artifact_search_states.scope_id", "pc_artifact_search_states.family") - ), - ) - - def rows(name, vector): - return Table( - name, - metadata, - Column("vector_id" if vector else "text_id", identity(64), primary_key=True), - Column("scope_id", identity(256), nullable=False), - Column("family", identity(128), nullable=False), - Column("artifact_id", identity(128), nullable=False), - Column("revision", Integer, nullable=False), - Column("schema_version", identity(128), nullable=False), - Column("index_def_id", identity(64), nullable=False), - Column("build_id", identity(32), nullable=False), - Column("fragment_id", identity(128), nullable=False), - Column("fragment_locator", Text, nullable=False), - Column("input_hash", identity(64), nullable=False), - Column("governance_generation", BigInteger, nullable=False), - *( - ( - Column("space_id", identity(32), nullable=False), - Column("dimension", Integer, nullable=False), - Column("embedding", LargeBinary, nullable=False), - ) - if vector - else (Column("searchable_text", Text, nullable=False),) - ), - UniqueConstraint("build_id", "index_def_id", "artifact_id", "fragment_id", name=f"uq_{name}_fragment"), - ForeignKeyConstraint( - ("scope_id", "family", "artifact_id", "revision"), - ("pc_artifacts.scope_id", "pc_artifacts.family", "pc_artifacts.artifact_id", "pc_artifacts.revision"), - ), - ForeignKeyConstraint( - ("scope_id", "family"), ("pc_artifact_search_states.scope_id", "pc_artifact_search_states.family") - ), - Index( - f"ix_{name}_search", - "scope_id", - "family", - "build_id", - *(("space_id",) if vector else ()), - "index_def_id", - "artifact_id", - ), - *( - ( - CheckConstraint( - "dimension > 0 AND length(embedding) = 4 * dimension", name="ck_pc_artifact_vectors_encoding" - ), - ) - if vector - else () - ), - ) - - return ( - spaces, - definitions, - states, - coverage, - changes, - rows("pc_artifact_vectors", True), - rows("pc_artifact_search_text", False), - ) - - -def ensure_search_support(connection): # noqa: C901 - Validate the fixed layout before repairing derived FTS support. - """Create missing backend full-text support during shared database initialization.""" - from sqlalchemy import inspect, text - - from powercontext.builtin.persistence.tables import ARTIFACT_SEARCH_TABLES - - inspector = inspect(connection) - for table in ARTIFACT_SEARCH_TABLES: - if table.name == "pc_artifact_vectors" and connection.dialect.name != "sqlite": - continue - name, expected = table.name, set(table.c.keys()) - if {column["name"] for column in inspector.get_columns(name)} != expected: - raise ValueError("Artifact search schema is incompatible; a database migration is required") # noqa: TRY003 - if inspector.has_table("pc_artifact_index_generations"): - raise ValueError("Legacy Artifact search generations require an explicit database migration") # noqa: TRY003 - if connection.dialect.name == "sqlite" and inspector.has_table("pc_artifact_search_fts"): - columns = {column["name"] for column in inspector.get_columns("pc_artifact_search_fts")} - if not {"build_id", "index_def_id"}.issubset(columns): - raise ValueError("Artifact full-text schema is incompatible; a database migration is required") # noqa: TRY003 - if connection.dialect.name != "sqlite": - indexes = {index["name"] for index in inspect(connection).get_indexes("pc_artifact_search_text")} - if "ix_pc_artifact_search_fts" not in indexes: - connection.exec_driver_sql( - "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts " - "ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" - ) - return - helpers = ( - ( - "pc_artifact_search_fts", - "CREATE VIRTUAL TABLE pc_artifact_search_fts USING fts5(text_id UNINDEXED, scope_id UNINDEXED, family UNINDEXED, artifact_id UNINDEXED, revision UNINDEXED, build_id UNINDEXED, index_def_id UNINDEXED, searchable_text, tokenize='unicode61')", - ), - ( - "pc_artifact_search_fts_insert", - "CREATE TRIGGER pc_artifact_search_fts_insert AFTER insert ON pc_artifact_search_text BEGIN INSERT INTO pc_artifact_search_fts (text_id, scope_id, family, artifact_id, revision, build_id, index_def_id, searchable_text) VALUES (new.text_id, new.scope_id, new.family, new.artifact_id, new.revision, new.build_id, new.index_def_id, new.searchable_text); END", - ), - ( - "pc_artifact_search_fts_update", - "CREATE TRIGGER pc_artifact_search_fts_update AFTER update ON pc_artifact_search_text BEGIN DELETE FROM pc_artifact_search_fts WHERE text_id = old.text_id;INSERT INTO pc_artifact_search_fts (text_id, scope_id, family, artifact_id, revision, build_id, index_def_id, searchable_text) VALUES (new.text_id, new.scope_id, new.family, new.artifact_id, new.revision, new.build_id, new.index_def_id, new.searchable_text); END", - ), - ( - "pc_artifact_search_fts_delete", - "CREATE TRIGGER pc_artifact_search_fts_delete AFTER delete ON pc_artifact_search_text BEGIN DELETE FROM pc_artifact_search_fts WHERE text_id = old.text_id; END", - ), - ) - missing = False - for name, ddl in helpers: - if connection.scalar(text("SELECT 1 FROM sqlite_master WHERE name=:name"), {"name": name}) is None: - connection.exec_driver_sql(ddl) - missing = True - if missing: - # Repair derived FTS rows only when support was missing, never on normal startup. - connection.exec_driver_sql("DELETE FROM pc_artifact_search_fts") - connection.exec_driver_sql( - "INSERT INTO pc_artifact_search_fts " - "(text_id, scope_id, family, artifact_id, revision, build_id, index_def_id, searchable_text) " - "SELECT text_id, scope_id, family, artifact_id, revision, build_id, index_def_id, searchable_text " - "FROM pc_artifact_search_text" - ) diff --git a/src/powercontext/builtin/persistence/artifact_vector_space.py b/src/powercontext/builtin/persistence/artifact_vector_space.py deleted file mode 100644 index 0ed113723f..0000000000 --- a/src/powercontext/builtin/persistence/artifact_vector_space.py +++ /dev/null @@ -1,243 +0,0 @@ -# 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 OceanBase/seekDB VECTOR spaces, prepared from durable declarations.""" - -import re -from hashlib import sha256 - -from pyobvector import VECTOR -from sqlalchemy import ( - Column, - ForeignKeyConstraint, - Index, - MetaData, - Table, - UniqueConstraint, - select, - update, -) - -from powercontext.builtin.artifacts.memory.canonical import canonical_json -from powercontext.builtin.artifacts.memory.models import EmbeddingProfile -from powercontext.builtin.persistence.tables import ( - ARTIFACT_VECTOR_INDEXES_TABLE, - ARTIFACT_VECTOR_SPACES_TABLE, - ARTIFACT_VECTORS_TABLE, - SHARED_METADATA, -) - - -def native_vector_table(space): - identity = space["space_id"] - if re.fullmatch(r"[0-9a-f]{32}", identity) is None or space["physical_table"] != f"pc_av_{identity}": - raise ValueError("invalid registered vector space table identity") # noqa: TRY003 - profile = EmbeddingProfile.model_validate(space["profile_json"]) - fingerprint = sha256(canonical_json(profile.model_dump(mode="json"))).hexdigest() - if ( - (profile.dimension, profile.distance, profile.normalization, fingerprint) - != (space["dimension"], space["distance"], space["normalization"], space["profile_fingerprint"]) - or profile.dimension < 1 - or space["storage_format_version"] != "float32-le-v1" - ): - raise ValueError("inconsistent vector space profile or storage format") # noqa: TRY003 - metadata = MetaData() - # Foreign key targets resolve in isolated metadata without provisioning them here. - for name in ("pc_artifacts", "pc_artifact_search_states"): - target = SHARED_METADATA.tables[name] - Table( - name, - metadata, - *(Column(column.name, column.type, primary_key=True) for column in target.primary_key.columns), - ) - return Table( - space["physical_table"], - metadata, - *( - Column( - column.name, - VECTOR(profile.dimension) if column.name == "embedding" else column.type, - primary_key=column.primary_key, - nullable=column.nullable, - ) - for column in ARTIFACT_VECTORS_TABLE.columns - ), - UniqueConstraint("build_id", "index_def_id", "artifact_id", "fragment_id", name=f"uq_av_{identity}"), - ForeignKeyConstraint( - ("scope_id", "family", "artifact_id", "revision"), - ("pc_artifacts.scope_id", "pc_artifacts.family", "pc_artifacts.artifact_id", "pc_artifacts.revision"), - ), - ForeignKeyConstraint( - ("scope_id", "family"), ("pc_artifact_search_states.scope_id", "pc_artifact_search_states.family") - ), - Index(f"ix_av_{identity}", "scope_id", "family", "build_id", "space_id", "index_def_id", "artifact_id"), - ) - - -def native_space_layout(space): - """The one exact framework template shared by preparation and migration.""" - from sqlalchemy.dialects import mysql - from sqlalchemy.schema import CreateTable - - from powercontext.builtin.persistence.migrations.mysql_schema import _parse_table - - table = native_vector_table(space) - _, layout = _parse_table(str(CreateTable(table).compile(dialect=mysql.dialect()))) - for index in table.indexes: - layout["indexes"][index.name] = { - "columns": [[column.name, None, "asc"] for column in index.columns], - "unique": index.unique, - } - return layout - - -def inspect_native_space(space, actual, *, allow_incomplete=False): - """Return missing indexes only for an exact, declared creation intermediate.""" - import copy - - from powercontext.builtin.persistence.migrations.mysql_schema import _matches - - expected = native_space_layout(space) - if space["backend"] != "mysql" or space["provision_state"] not in {"declared", "ready"}: - raise ValueError("invalid native vector space declaration") # noqa: TRY003 - incomplete = allow_incomplete and space["provision_state"] == "declared" - if actual is None: - if incomplete: - return tuple(expected["indexes"]) - raise ValueError("vector space physical table is absent") # noqa: TRY003 - partial = copy.deepcopy(expected) - missing = tuple(name for name in expected["indexes"] if name not in actual["indexes"]) - # Unique constraints are part of CREATE TABLE, only standalone indexes can - # be absent after interruption between table and index creation. - allowed_missing = {index.name for index in native_vector_table(space).indexes} - if missing and (not incomplete or not set(missing) <= allowed_missing): - raise ValueError("vector space required indexes are absent") # noqa: TRY003 - for name in missing: - del partial["indexes"][name] - if not _matches({"tables": {"space": actual}}, {"tables": {"space": partial}}): - raise ValueError("vector space physical structure differs from its registered format") # noqa: TRY003 - return missing - - -def registered_native_layouts(declarations, tables, *, allow_declared=False): - """Admit only declared identities with the exact supported physical format.""" - layouts = {} - names = set() - for space in declarations: - name = space["physical_table"] - if name in names or (space["provision_state"] != "ready" and not allow_declared): - raise ValueError("native vector space preparation is incomplete or duplicated") # noqa: TRY003 - names.add(name) - actual = tables.get(name) - inspect_native_space(space, actual, allow_incomplete=allow_declared) - if actual is not None: - layouts[name] = actual - return layouts - - -def _physical_layout(connection, name): - from sqlalchemy import inspect - - from powercontext.builtin.persistence.migrations.mysql_schema import _parse_table - - if not inspect(connection).has_table(name): - return None - # The name has already passed native_vector_table's fixed hex identity check. - row = connection.exec_driver_sql(f"SHOW CREATE TABLE `{name}`").one() - database = connection.exec_driver_sql("SELECT DATABASE()").scalar_one() - return _parse_table(row[1], database=database)[1] - - -def verify_native_space(connection, space): - table = native_vector_table(space) - inspect_native_space(space, _physical_layout(connection, table.name)) - - -def provision_native_space(connection, space): - """Converge using atomic database DDL, then inspect before publishing ready.""" - from sqlalchemy.exc import DBAPIError - from sqlalchemy.schema import CreateTable - - table = native_vector_table(space) - actual = _physical_layout(connection, table.name) - missing = inspect_native_space(space, actual, allow_incomplete=True) - if space["provision_state"] == "ready": - return - if actual is None: - # IF NOT EXISTS serializes competing CREATEs at the backend. It is not - # admission: the resulting definition must still match exactly. - connection.execute(CreateTable(table, if_not_exists=True)) - missing = inspect_native_space(space, _physical_layout(connection, table.name), allow_incomplete=True) - for index in table.indexes: - if index.name not in missing: - continue - try: - index.create(connection) - except DBAPIError as error: - # A concurrent creator may have won this particular index. No other - # failure is normalized, and the full structure is checked below. - arguments = getattr(error.orig, "args", ()) - if not arguments or arguments[0] != 1061: - raise - verify_native_space(connection, space) - connection.execute( - update(ARTIFACT_VECTOR_SPACES_TABLE) - .where( - ARTIFACT_VECTOR_SPACES_TABLE.c.space_id == space["space_id"], - ARTIFACT_VECTOR_SPACES_TABLE.c.provision_state == "declared", - ) - .values(provision_state="ready") - ) - - -async def prepare_family_spaces(database, family): - """Await only this family's durable spaces, outside registration transactions.""" - if database.engine.dialect.name == "sqlite": - return - spaces = ARTIFACT_VECTOR_SPACES_TABLE - definitions = ARTIFACT_VECTOR_INDEXES_TABLE - async with database.transaction() as connection: - declarations = ( - ( - await connection.execute( - select(spaces).where( - spaces.c.space_id.in_(select(definitions.c.space_id).where(definitions.c.family == family)) - ) - ) - ) - .mappings() - .all() - ) - for space in declarations: - # DDL may commit independently. This connection owns no content or - # plugin registration writes; ready is committed only after verification. - async with database.transaction() as connection: - current = ( - (await connection.execute(select(spaces).where(spaces.c.space_id == space["space_id"]))) - .mappings() - .one() - ) - await connection.run_sync(lambda sync, current=current: provision_native_space(sync, current)) - - -def provision_native_spaces(connection): - """Prepare persisted native declarations during unmanaged initialization.""" - spaces = ( - connection - .execute(select(ARTIFACT_VECTOR_SPACES_TABLE).where(ARTIFACT_VECTOR_SPACES_TABLE.c.backend == "mysql")) - .mappings() - .all() - ) - for space in spaces: - provision_native_space(connection, space) diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 1a48624a76..9601f6f046 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -56,6 +56,7 @@ ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_REVISION_COLUMNS, ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACTS_TABLE, ) @@ -237,6 +238,11 @@ def families(self) -> frozenset[str]: return frozenset(self._by_family) + def content_type(self, family: str) -> type[BaseModel]: + """Return the content model this repository validates a Family's revisions against.""" + + return self._content_types[family] + @property def source_repository(self) -> Any | None: """Expose the composed Source repository to services recording revision provenance.""" @@ -599,7 +605,7 @@ async def get( _require_scope(scope_id) if not for_update: return (await self.get_many(connection, scope_id, (ref,)))[0] - statement = select(ARTIFACTS_TABLE).where( + statement = select(*ARTIFACT_REVISION_COLUMNS).where( ARTIFACTS_TABLE.c.scope_id == scope_id, ARTIFACTS_TABLE.c.family == ref.family, ARTIFACTS_TABLE.c.artifact_id == ref.artifact_id, @@ -639,7 +645,7 @@ async def get_many( for offset in range(0, len(keys), 100): rows = ( await connection.execute( - select(ARTIFACTS_TABLE).where( + select(*ARTIFACT_REVISION_COLUMNS).where( ARTIFACTS_TABLE.c.scope_id == scope_id, identity.in_(keys[offset : offset + 100]), ) @@ -783,7 +789,7 @@ async def revisions( ArtifactRef(family=family, artifact_id=artifact_id, revision=1) rows = ( await connection.execute( - select(ARTIFACTS_TABLE) + select(*ARTIFACT_REVISION_COLUMNS) .where( ARTIFACTS_TABLE.c.scope_id == scope_id, ARTIFACTS_TABLE.c.family == family, diff --git a/src/powercontext/builtin/persistence/custom_artifacts.py b/src/powercontext/builtin/persistence/custom_artifacts.py deleted file mode 100644 index 976c8819f0..0000000000 --- a/src/powercontext/builtin/persistence/custom_artifacts.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Durable declarations and JSON storage for user-defined Artifact families.""" - -from __future__ import annotations - -from pydantic import BaseModel, ConfigDict, JsonValue -from sqlalchemy import inspect -from sqlalchemy.ext.asyncio import AsyncConnection -from sqlalchemy.schema import CreateColumn -from typing_extensions import override - -from powercontext.artifacts import Artifact -from powercontext.builtin.persistence.artifacts import ArtifactRepository -from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.persistence.tables import ARTIFACT_PROCESSING_BINDING_STATES_TABLE - - -class StoredArtifactContent(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - schema_version: str - content: dict[str, JsonValue] - - -def custom_artifact_repository( - family: str, sources: SourceRepository | None = None, *, prepared=None -) -> ArtifactRepository: - artifact_type = type("CustomArtifact", (Artifact[StoredArtifactContent],), {"family": family}) - return _CustomArtifactRepository((artifact_type,), sources=sources, prepared=prepared) - - -class _CustomArtifactRepository(ArtifactRepository): - def __init__(self, artifact_types, *, sources=None, prepared=None): - super().__init__(artifact_types, sources=sources) - self.prepared = prepared or {} - - @override - async def create(self, connection, scope_id, artifact_id, draft, /, **kwargs): - from powercontext.builtin.persistence.artifact_indexes import ArtifactProjectionPublisher - - publisher = ArtifactProjectionPublisher(None) - prepared = self.prepared.get(artifact_id) - await publisher.validate_prepared( - connection, scope_id, draft.family, draft.content.schema_version, draft.content.content, prepared - ) - result = await super().create(connection, scope_id, artifact_id, draft, **kwargs) - await publisher.publish(connection, scope_id, result, prepared) - return result - - @override - async def lock_heads(self, connection, scope_id, refs, /): - from powercontext.builtin.persistence.artifact_indexes import lock_state - - for family in sorted({ref.family for ref in refs}): - await lock_state(connection, scope_id, family) - return await super().lock_heads(connection, scope_id, refs) - - @override - async def _revise(self, connection, scope_id, artifact, draft, **kwargs): - from powercontext.builtin.persistence.artifact_indexes import ArtifactProjectionPublisher - - publisher = ArtifactProjectionPublisher(None) - prepared = self.prepared.get(artifact.artifact_id) - await publisher.validate_prepared( - connection, scope_id, draft.family, draft.content.schema_version, draft.content.content, prepared - ) - result = await super()._revise(connection, scope_id, artifact, draft, **kwargs) - await publisher.publish(connection, scope_id, result, prepared) - return result - - -async def ensure_artifact_plugin_schema(connection: AsyncConnection) -> None: - """Add registration fields to existing databases without replacing processing state.""" - - existing = await connection.run_sync( - lambda sync: {item["name"] for item in inspect(sync).get_columns(ARTIFACT_PROCESSING_BINDING_STATES_TABLE.name)} - ) - for column in ARTIFACT_PROCESSING_BINDING_STATES_TABLE.columns: - if column.name not in existing: - definition = str(CreateColumn(column).compile(dialect=connection.dialect)) - await connection.exec_driver_sql( - f"ALTER TABLE pc_artifact_processing_binding_states ADD COLUMN {definition}" - ) diff --git a/src/powercontext/builtin/persistence/migrations/maintenance.py b/src/powercontext/builtin/persistence/migrations/maintenance.py index e55a7e01b9..eb44bca878 100644 --- a/src/powercontext/builtin/persistence/migrations/maintenance.py +++ b/src/powercontext/builtin/persistence/migrations/maintenance.py @@ -207,22 +207,12 @@ def execute(connection, identity, guard): backup_state="pending", ) journal.write(evidence) - backup_objects = self.bundle.affected_objects - if identity.product != "sqlite": - from .mysql_schema import mysql_inventory - - backup_objects = tuple( - dict.fromkeys(( - *backup_objects, - *(space["physical_table"] for space in mysql_inventory(connection).get("artifact_spaces", [])), - )) - ) context = BackupContext( database_id=identity.database_id, source_revision=self.bundle.head, bundle_checksum=self.checksum, maintenance_window_id=evidence.maintenance_window_id, - objects=backup_objects, + objects=self.bundle.affected_objects, writers_stopped=True, ) provider = self._provider(connection, identity) diff --git a/src/powercontext/builtin/persistence/migrations/mysql.py b/src/powercontext/builtin/persistence/migrations/mysql.py index 08c2493371..7d085e735a 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql.py +++ b/src/powercontext/builtin/persistence/migrations/mysql.py @@ -312,7 +312,6 @@ def plan( backup_policy: BackupPolicy = "auto", service: dict[str, Any] | None = None, shared_database: bool = False, - _startup: bool = False, require_existing_database: bool = False, ) -> MigrationPlan: self._assert_unchanged() @@ -326,7 +325,6 @@ def inspect( backup_policy=backup_policy, service=service, shared_database=shared_database, - allow_declared_spaces=_startup, ) if isinstance(self.connections.config, SeekDBConfig) and not require_existing_database: @@ -608,7 +606,6 @@ def _inspect( # noqa: C901 -- Keep ordered admission checks with their operatio backup_policy: BackupPolicy, service: dict[str, Any] | None = None, shared_database: bool = False, - allow_declared_spaces: bool = False, ) -> MigrationPlan: if backup_policy not in {"auto", "manual", "skip"}: raise MigrationError("invalid_backup_policy", "Choose auto, manual or skip.") @@ -659,7 +656,6 @@ def _inspect( # noqa: C901 -- Keep ordered admission checks with their operatio "server_migration_unavailable", f"The complete {source.release} Server schema is recognized, but its full upgrade is unavailable.", ) - self.schema.allow_declared_spaces = allow_declared_spaces and current == self.bundle.head if evidence is not None and not evidence.compatibility_complete: from .mysql_compatibility import validate_resume diff --git a/src/powercontext/builtin/persistence/migrations/mysql_schema.py b/src/powercontext/builtin/persistence/migrations/mysql_schema.py index 7a3d943bdb..4529331d3e 100644 --- a/src/powercontext/builtin/persistence/migrations/mysql_schema.py +++ b/src/powercontext/builtin/persistence/migrations/mysql_schema.py @@ -94,19 +94,17 @@ def mysql_inventory(connection: Connection, *, exclude: frozenset[str] = frozens key=lambda row: str(row.get("Trigger", "")), ) inventory["views"].sort() - if "pc_artifact_vector_spaces" in inventory["tables"]: - from sqlalchemy import select - - from powercontext.builtin.persistence.tables import ARTIFACT_VECTOR_SPACES_TABLE - - declarations = ( - connection - .execute(select(ARTIFACT_VECTOR_SPACES_TABLE).order_by(ARTIFACT_VECTOR_SPACES_TABLE.c.space_id)) - .mappings() - .all() + # Native tables of the removed Artifact plugin framework are named per registered space and have no + # registered layout here; v1_5_0_005 drops the framework only when nothing was registered. + if ( + "pc_artifact_vector_spaces" in inventory["tables"] + and connection.exec_driver_sql("SELECT 1 FROM pc_artifact_vector_spaces LIMIT 1").first() + ): + raise MigrationError( + "unsupported_plugin_data", + "Plugins of the removed Artifact plugin framework still hold data; export it and remove those" + " plugins on the previous version before upgrading.", ) - if declarations: - inventory["artifact_spaces"] = [dict(space) for space in declarations] except DBAPIError as error: raise MigrationError( "schema_reflection_failed", "Complete database schema reflection is unavailable." @@ -156,9 +154,8 @@ class MySQLSchema: """Validate source, intermediate and post-DDL shapes from frozen resources.""" def __init__( # noqa: C901 -- Keep ordered admission checks with their operation. - self, bundle: MigrationBundle, *, embedded_code: bool = False, allow_declared_spaces: bool = False + self, bundle: MigrationBundle, *, embedded_code: bool = False ) -> None: - self.allow_declared_spaces = allow_declared_spaces self.bundle = bundle self.revisions = bundle.revisions manifest = json.loads((bundle.snapshot_directory / "manifest.json").read_text()) @@ -399,24 +396,6 @@ def _extend_vectors(self) -> None: def _matches(self, inventory: dict[str, Any], expected: dict[str, Any]) -> bool: actual = {**inventory, "tables": dict(inventory["tables"])} - declarations = actual.pop("artifact_spaces", []) - if declarations: - from powercontext.builtin.persistence.artifact_vector_space import registered_native_layouts - - if "pc_artifact_vector_spaces" not in expected["tables"]: - return False - try: - layouts = registered_native_layouts( - declarations, actual["tables"], allow_declared=self.allow_declared_spaces - ) - except (ValueError, TypeError, KeyError) as error: - raise MigrationError( - "incompatible_schema", "A registered native vector space is incomplete or incompatible." - ) from error - expected = {**expected, "tables": dict(expected["tables"])} - expected["tables"].update(layouts) - self.affected_objects = tuple(dict.fromkeys((*self.affected_objects, *sorted(layouts)))) - # Compare table sets after adding the exactly validated registered spaces. if actual.keys() != expected.keys() or ( self.bundle.readiness_scope != "server" and actual["tables"].keys() != expected["tables"].keys() ): diff --git a/src/powercontext/builtin/persistence/migrations/server_readiness.py b/src/powercontext/builtin/persistence/migrations/server_readiness.py index 5c7ec570d5..4894319bb6 100644 --- a/src/powercontext/builtin/persistence/migrations/server_readiness.py +++ b/src/powercontext/builtin/persistence/migrations/server_readiness.py @@ -24,7 +24,7 @@ def check_server_schema(connection, *, _archive_recovery=False, _startup=False): - bundle = check_server_layout(connection, allow_declared_spaces=_startup) + bundle = check_server_layout(connection) if _startup and connection.dialect.name == "sqlite": _check_sqlite_startup_history(connection, bundle) pending_restore = connection.exec_driver_sql( @@ -47,7 +47,7 @@ def check_server_schema(connection, *, _archive_recovery=False, _startup=False): bundle.domains.validate(connection, bundle.head, backend=backend, configuration=configuration) -def check_server_layout(connection, *, allow_declared_spaces=False): +def check_server_layout(connection): """Validate the exact managed layout before a registered domain repair.""" from .deployment import server_bundle from .mysql_schema import MySQLSchema, mysql_inventory @@ -85,7 +85,7 @@ def check_server_layout(connection, *, allow_declared_spaces=False): embedded_code = connection.engine.url.drivername == "mysql+aseekdb" and any( name.startswith("pc_code_") for name in inventory["tables"] ) - schema = MySQLSchema(bundle, embedded_code=embedded_code, allow_declared_spaces=allow_declared_spaces) + schema = MySQLSchema(bundle, embedded_code=embedded_code) schema.verify(inventory, bundle.head) return bundle @@ -144,18 +144,16 @@ def verify(connection, identity, guard): raise MigrationError( "migration_required", "Initialize the remote database with an explicit db-migrate job." ) - bundle = check_server_layout(connection, allow_declared_spaces=True) + bundle = check_server_layout(connection) stored = bundle.vector_profile.profile if bundle.vector_profile is not None else None if stored != profile: raise MigrationError("embedding_profile_mismatch", "Server configuration differs from managed storage.") - check_server_schema(connection, _startup=True) + check_server_schema(connection) MaintenanceConnections(database).run(verify) return runner = deployment_runner(settings, embedding_profile=profile) - from .mysql import MySQLMigrationRunner - - plan = runner.plan(_startup=True) if isinstance(runner, MySQLMigrationRunner) else runner.plan() + plan = runner.plan() if plan.state == "uninitialized" and database.kind in {"sqlite", "seekdb"}: runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) elif plan.state != "ready": diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index b6ec054ab1..201e266aad 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -48,6 +48,10 @@ "v1_5_0_004": [ "schemas/sqlite-v1_5_0_004.json", "schemas/mysql-v1_5_0_004.json" + ], + "v1_5_0_005": [ + "schemas/sqlite-v1_5_0_005.json", + "schemas/mysql-v1_5_0_005.json" ] }, "sqlite_baselines": { @@ -86,6 +90,9 @@ ], "v1_5_0_004": [ "cfafc8c1d4f0207e5d04febca1e3f9f9a91948d15fb85678de3765d8e772efdc" + ], + "v1_5_0_005": [ + "50bff3a823c596084264f6b8bfbe495677217aa7f419b342966d287387058741" ] }, "affected_objects": [ @@ -293,6 +300,27 @@ "seekdb", "oceanbase" ] + }, + { + "id": "plugin_artifacts", + "revision": "v1_5_0_005", + "kind": "data", + "module": "plugin_artifact_readiness.py", + "resources": [ + "server_domain.py", + "server_vector_domain.py", + "atomic_memory_readiness_1300.py", + "dream/upgrade.py", + "dream/schema.json" + ], + "depends_on": [ + "artifact_storage" + ], + "backends": [ + "sqlite", + "seekdb", + "oceanbase" + ] } ], "domain_readiness": { @@ -336,6 +364,11 @@ "artifact_storage", "portable_storage", "atomic_memory_evidence_projection" + ], + "v1_5_0_005": [ + "plugin_artifacts", + "portable_storage", + "atomic_memory_evidence_projection" ] }, "retained_fingerprints": { @@ -357,7 +390,8 @@ "schemas/mysql-v1_5_0_001.json", "schemas/mysql-v1_5_0_002.json", "schemas/mysql-v1_5_0_003.json", - "schemas/mysql-v1_5_0_004.json" + "schemas/mysql-v1_5_0_004.json", + "schemas/mysql-v1_5_0_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/examples/artifact_plugins/incident_report/plugin.yml b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_005.py similarity index 68% rename from examples/artifact_plugins/incident_report/plugin.yml rename to src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_005.py index 68313a4b35..6d7aa4fe48 100644 --- a/examples/artifact_plugins/incident_report/plugin.yml +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_005.py @@ -12,15 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -id: example-incident-report -version: "1" -entry: ./plugin.py:create_pipeline -binding_name: incident-reports -scope_ids: [team-a] -max_sources: 20 -processing: - interval_seconds: 1 - max_workers: 1 - timeout_seconds: 60 -config: - title_prefix: 工单快照报告 +"""Drop the removed plugin framework and add revision metadata through the shared DDL executor.""" + +from alembic import context + +revision = "v1_5_0_005" +down_revision = "v1_5_0_004" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/plugin_artifact_readiness.py b/src/powercontext/builtin/persistence/migrations/server_resources/plugin_artifact_readiness.py new file mode 100644 index 0000000000..1f24499739 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/plugin_artifact_readiness.py @@ -0,0 +1,172 @@ +# 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 readiness for directory-loaded plugins at v1.5.0.005. + +This revision removes the storage of the previous Artifact plugin framework and +adds revision metadata. Plugin bindings are named "plugin:" and are never +registered in the database; their intents stay valid while the plugin is absent. +Plugin families keep no rows in the built-in full-text helper, so the frozen +helper check still applies unchanged. +""" + +import hashlib +import json +import runpy +from pathlib import Path + +from powercontext.builtin.persistence.experience_index import APPROVED_ARTIFACT_PROJECTION_VERSION +from powercontext.builtin.persistence.lexical_projection import LEXICAL_ANALYZER_VERSION +from powercontext.builtin.persistence.migrations.models import MigrationError +from powercontext.builtin.persistence.migrations.pinned import finish, pinned + +core = runpy.run_path(str(Path(__file__).parent / "server_domain.py")) +vectors = runpy.run_path(str(Path(__file__).parent / "server_vector_domain.py")) + +# Tables of the removed framework that hold plugin data when they have any row. +_RETIRED_DATA = ( + "pc_artifact_family_schemas", + "pc_artifact_vector_spaces", + "pc_artifact_vector_indexes", + "pc_artifact_search_states", + "pc_artifact_search_text", +) + + +def preflight(connection, *, configuration): + """Refuse to drop the previous plugin framework while it still holds data.""" + from sqlalchemy import inspect + + names = set(inspect(connection).get_table_names()) + retained = [name for name in _RETIRED_DATA if name in names and _has_rows(connection, name)] + states = "pc_artifact_processing_binding_states" + columns = {column["name"] for column in inspect(connection).get_columns(states)} if states in names else set() + if ( + "plugin_id" in columns + and connection.exec_driver_sql( + "SELECT 1 FROM pc_artifact_processing_binding_states WHERE plugin_id IS NOT NULL LIMIT 1" + ).first() + ): + retained.append("pc_artifact_processing_binding_states") + if retained: + raise MigrationError( + "unsupported_plugin_data", + "Plugins of the removed Artifact plugin framework still hold data in " + + ", ".join(retained) + + "; export it and remove those plugins on the previous version before upgrading.", + ) + + +def apply(connection, *, configuration): + """The schema transition drops only empty tables; no records are rewritten.""" + + +def verify(connection, *, configuration): + validate(connection, configuration=configuration) + + +def validate(connection, *, configuration): # noqa: C901 -- Preserve the complete versioned Server readiness contract. + vector = configuration.get("embedding_profile") is not None + if vector: + vectors["_validate_vector_configuration"](connection, configuration) + vectors["_require_declaration"](connection, configuration) + finish(core["assert_atomic_memory_migration_ready"](pinned(connection))) + expected = { + "pc_atomic_memory_current": LEXICAL_ANALYZER_VERSION, + "pc_topic_memory_active_topics": LEXICAL_ANALYZER_VERSION, + "pc_topic_memory_active_chunks": LEXICAL_ANALYZER_VERSION, + "pc_artifact_heads": APPROVED_ARTIFACT_PROJECTION_VERSION, + } + actual = dict( + connection + .exec_driver_sql("SELECT projection, analyzer_version FROM pc_lexical_projection_versions") + .tuples() + .all() + ) + if actual != expected: + raise MigrationError("projection_migration_required", "The registered search projections are not current.") + frozen = core["dream"]() + manifest = frozen["_processing_manifest"](connection, frozen["_schema"](), upgraded=True) + _validate_intent_bindings(connection) + target = configuration.get("processing_manifest") + if target and any(manifest[key] != target[key] for key in ("bindings", "mode", "capabilities")): + raise MigrationError( + "incompatible_processing_configuration", "Processing ownership differs from the migrated declaration." + ) + from powercontext.builtin.persistence.dream import DreamRepository + + for row in connection.exec_driver_sql( + "SELECT scope_id,run_id,status,operation,generation,request_generation,principal_key,idempotency_key FROM pc_dream_runs" + ).mappings(): + record = finish(DreamRepository().get(pinned(connection), row["scope_id"], row["run_id"])) + if ( + any(getattr(record.run, key) != row[key] for key in ("scope_id", "run_id", "status", "operation")) + or record.generation != row["generation"] + or record.request_generation != row["request_generation"] + or hashlib.sha256(record.principal_id.encode()).hexdigest() != row["principal_key"] + or (record.request is not None and record.request.idempotency_key != row["idempotency_key"]) + ): + raise MigrationError("unsupported_task_format", "A Dream payload disagrees with its durable task identity.") + from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository + + for scope, binding in connection.exec_driver_sql( + "SELECT scope_id,binding_name FROM pc_artifact_processing_intents" + ): + finish(ArtifactProcessingIntentRepository().load(pinned(connection), scope, binding)) + from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex + from powercontext.builtin.persistence.sqlite.atomic_memory_index import SQLiteAtomicMemoryIndex + from powercontext.builtin.runtime.atomic_memory_rebuild import verify_atomic_memory_current + + profile = vectors["_profile"](configuration) if vector else None + atomic_index = ( + SQLiteAtomicMemoryIndex(profile) if connection.dialect.name == "sqlite" else OceanBaseAtomicMemoryIndex(profile) + ) + report = finish(verify_atomic_memory_current(pinned(connection), atomic_index)) + if not report.ready: + raise MigrationError( + "projection_verification_failed", "Atomic Memory projections differ from their authoritative records." + ) + + from powercontext.builtin.persistence.topic_memory import TopicMemoryRepository + + if connection.dialect.name == "sqlite": + from powercontext.builtin.persistence.sqlite.topic_memory_index import SQLiteTopicMemoryFTSIndex + + index = SQLiteTopicMemoryFTSIndex() + core["_validate_sqlite_fts"](connection) + if vector: + vectors["_validate_atomic_helper"](connection, configuration) + else: + from powercontext.builtin.persistence.oceanbase.topic_memory_index import OceanBaseTopicMemoryFTSIndex + + index = OceanBaseTopicMemoryFTSIndex() + if vector: + index = vectors["_topic_index"](connection, configuration) + finish(TopicMemoryRepository(index=index).initialize(pinned(connection), validate_only=True)) + + +def _validate_intent_bindings(connection): + manifest = json.loads( + connection.exec_driver_sql( + "SELECT config_manifest FROM pc_artifact_processing_schema WHERE singleton=1" + ).scalar_one() + ) + for (binding,) in connection.exec_driver_sql("SELECT DISTINCT binding_name FROM pc_artifact_processing_intents"): + if binding not in manifest["bindings"] and not binding.startswith("plugin:"): + raise MigrationError("unsupported_task_format", "A pending Processing intent has no registered binding.") + + +def _has_rows(connection, table): + # Only the fixed retired table names above are interpolated. + return connection.exec_driver_sql(f"SELECT 1 FROM {table} LIMIT 1").first() is not None # noqa: S608 diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_005.json new file mode 100644 index 0000000000..28ab11e9e0 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_005.json @@ -0,0 +1,302 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_5_0_005": { + "statements": [ + "DROP TABLE pc_artifact_search_text", + "DROP TABLE pc_artifact_index_changes", + "DROP TABLE pc_artifact_index_coverage", + "DROP TABLE pc_artifact_search_states", + "DROP TABLE pc_artifact_vector_indexes", + "DROP TABLE pc_artifact_vector_spaces", + "DROP TABLE pc_artifact_family_schemas", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN plugin_id, DROP COLUMN plugin_family, DROP COLUMN plugin_spec, DROP COLUMN binding_generation, DROP COLUMN desired_state, DROP COLUMN phase, DROP COLUMN last_error", + "ALTER TABLE pc_artifacts ADD COLUMN metadata MEDIUMBLOB" + ], + "changes": [ + { + "pc_artifact_search_text": null + }, + { + "pc_artifact_index_changes": null + }, + { + "pc_artifact_index_coverage": null + }, + { + "pc_artifact_search_states": null + }, + { + "pc_artifact_vector_indexes": null + }, + { + "pc_artifact_vector_spaces": null + }, + { + "pc_artifact_family_schemas": null + }, + { + "pc_artifact_processing_binding_states": { + "columns": { + "binding_name": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "last_auto_wave_completed_at": { + "type": [ + "datetime", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "last_schedule_checkpoint_at": { + "type": [ + "datetime", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "scan_generation": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": [ + "number", + 0 + ], + "charset": null, + "collation": null, + "extra": [] + }, + "scan_in_progress": { + "type": [ + "tinyint", + [ + "1" + ] + ], + "nullable": false, + "default": [ + "literal", + "0" + ], + "charset": null, + "collation": null, + "extra": [] + }, + "scan_upper_pending_sequence": { + "type": [ + "bigint", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "binding_name", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": { + "ck_pc_processing_scan_generation": { + "expression": [ + "binary", + ">=", + [ + "column", + "scan_generation" + ], + [ + "number", + 0 + ] + ], + "enforced": true + } + }, + "indexes": {} + } + }, + { + "pc_artifacts": { + "columns": { + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "family": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "artifact_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "revision": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "content": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "created_by": { + "type": [ + "varchar", + [ + "2048" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": null, + "extra": [] + }, + "restored_from_revision": { + "type": [ + "int", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "rollback_reason": { + "type": [ + "varchar", + [ + "1024" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": null, + "extra": [] + }, + "metadata": { + "type": [ + "mediumblob", + [] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "scope_id", + null, + "asc" + ], + [ + "family", + null, + "asc" + ], + [ + "artifact_id", + null, + "asc" + ], + [ + "revision", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": {}, + "indexes": {} + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_005.json new file mode 100644 index 0000000000..5eb6898b4e --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_005.json @@ -0,0 +1,21 @@ +{ + "statements": [ + "DROP TABLE pc_artifact_search_fts", + "DROP TABLE pc_artifact_search_text", + "DROP TABLE pc_artifact_vectors", + "DROP TABLE pc_artifact_index_changes", + "DROP TABLE pc_artifact_index_coverage", + "DROP TABLE pc_artifact_search_states", + "DROP TABLE pc_artifact_vector_indexes", + "DROP TABLE pc_artifact_vector_spaces", + "DROP TABLE pc_artifact_family_schemas", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN plugin_id", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN plugin_family", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN plugin_spec", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN binding_generation", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN desired_state", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN phase", + "ALTER TABLE pc_artifact_processing_binding_states DROP COLUMN last_error", + "ALTER TABLE pc_artifacts ADD COLUMN metadata BLOB" + ] +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_005_plugin_interfaces.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_005_plugin_interfaces.py new file mode 100644 index 0000000000..146b01e376 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_005_plugin_interfaces.py @@ -0,0 +1,29 @@ +# 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. + +"""Drop the empty storage of the removed Artifact plugin framework and add revision metadata.""" + +import json +from pathlib import Path + +from alembic import op + +revision = "v1_5_0_005" +down_revision = "v1_5_0_004" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_5_0_005.json" + for statement in json.loads(resource.read_text(encoding="utf-8"))["statements"]: + op.get_bind().exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index f02495e3a8..32d714a6d2 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -47,6 +47,10 @@ "v1_5_0_004": [ "schemas/sqlite-v1_5_0_004.json", "schemas/mysql-v1_5_0_004.json" + ], + "v1_5_0_005": [ + "schemas/sqlite-v1_5_0_005.json", + "schemas/mysql-v1_5_0_005.json" ] }, "sqlite_baselines": { @@ -85,6 +89,9 @@ ], "v1_5_0_004": [ "cfafc8c1d4f0207e5d04febca1e3f9f9a91948d15fb85678de3765d8e772efdc" + ], + "v1_5_0_005": [ + "50bff3a823c596084264f6b8bfbe495677217aa7f419b342966d287387058741" ] }, "affected_objects": [ @@ -297,6 +304,27 @@ "seekdb", "oceanbase" ] + }, + { + "id": "plugin_artifacts", + "revision": "v1_5_0_005", + "kind": "data", + "module": "plugin_artifact_readiness.py", + "resources": [ + "server_domain.py", + "server_vector_domain.py", + "atomic_memory_readiness_1300.py", + "dream/upgrade.py", + "dream/schema.json" + ], + "depends_on": [ + "artifact_storage" + ], + "backends": [ + "sqlite", + "seekdb", + "oceanbase" + ] } ], "domain_readiness": { @@ -340,6 +368,11 @@ "artifact_storage", "portable_storage", "atomic_memory_evidence_projection" + ], + "v1_5_0_005": [ + "plugin_artifacts", + "portable_storage", + "atomic_memory_evidence_projection" ] }, "retained_fingerprints": { @@ -361,7 +394,8 @@ "schemas/mysql-v1_5_0_001.json", "schemas/mysql-v1_5_0_002.json", "schemas/mysql-v1_5_0_003.json", - "schemas/mysql-v1_5_0_004.json" + "schemas/mysql-v1_5_0_004.json", + "schemas/mysql-v1_5_0_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/processing_migration.py b/src/powercontext/builtin/persistence/processing_migration.py index 9c17544769..15e5fad831 100644 --- a/src/powercontext/builtin/persistence/processing_migration.py +++ b/src/powercontext/builtin/persistence/processing_migration.py @@ -297,9 +297,6 @@ async def apply_processing_migration( # noqa: C901 - explicit durable maintenan # Names/definitions are fixed constants, never user SQL. await connection.exec_driver_sql(f"ALTER TABLE {STATES.name} ADD COLUMN {name} {definition}") return ProcessingMigrationProgress(phase="schema") - from powercontext.builtin.persistence.custom_artifacts import ensure_artifact_plugin_schema - - await ensure_artifact_plugin_schema(connection) problems = await _schema_issues(connection) if problems: raise ProcessingSchemaNotReadyError(reason="; ".join(problems)) diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 4c06951b4b..4ad91ba29a 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -33,7 +33,6 @@ from powercontext.builtin.persistence.artifact_handles import ArtifactHandleRepository from powercontext.builtin.persistence.artifacts import ArtifactRepository, with_artifact_content from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec -from powercontext.builtin.persistence.custom_artifacts import StoredArtifactContent, custom_artifact_repository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import ( RepositoryNotFoundError, @@ -60,14 +59,15 @@ ) from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.tables import ( - ARTIFACT_FAMILY_SCHEMAS_TABLE, ARTIFACT_HEADS_TABLE, + ARTIFACT_REVISION_COLUMNS, ARTIFACTS_TABLE, SOURCE_DEFINITION_MANIFESTS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ) from powercontext.builtin.persistence.tags import RelationalTagService, tag_predicate +from powercontext.builtin.plugins.families import PluginArtifactContent from powercontext.builtin.records import ( ArtifactCollectionItem, ArtifactCreated, @@ -478,7 +478,7 @@ async def create_atomic_memories( async def get_artifact( self, scope_id: str, family: str, artifact_id: str, /, *, execution_context=None ) -> ArtifactRecord: - repository = await self._read_repository(family) + repository = self._read_repository(family) if family == "atomic-memory" and execution_context is not None: return await self._get_authorized_atomic_artifact(scope_id, artifact_id, None, execution_context) async with self._database.transaction() as connection: @@ -504,7 +504,7 @@ async def get_artifact_revision( *, execution_context=None, ) -> ArtifactRecord: - repository = await self._read_repository(family) + repository = self._read_repository(family) if family == "atomic-memory" and execution_context is not None: return await self._get_authorized_atomic_artifact(scope_id, artifact_id, revision, execution_context) async with self._database.transaction() as connection: @@ -547,14 +547,14 @@ async def list_artifact_revisions( rows, _, next_cursor = await self.revision_rows( connection, scope_id, family, artifact_id, limit=limit, cursor=cursor ) - repository = await self._read_repository(family, connection=connection) + repository = self._read_repository(family) artifacts = await repository.decode_many(connection, scope_id, rows) items = tuple(_artifact_collection_item(scope_id, artifact) for artifact in artifacts) return ArtifactRevisionPage(items=items, next_cursor=next_cursor) async def revision_rows(self, connection, scope_id, family, artifact_id, *, limit, cursor, columns=()): """Read a bounded immutable revision page through a borrowed transaction.""" - await self._read_repository(family, connection=connection) + self._read_repository(family) _require_limit(limit) expected_cursor = { "version": 1, @@ -588,7 +588,7 @@ async def revision_rows(self, connection, scope_id, family, artifact_id, *, limi revisions = tuple( ( await connection.execute( - select(*(columns or ARTIFACTS_TABLE.c)) + select(*(columns or ARTIFACT_REVISION_COLUMNS)) .where( ARTIFACTS_TABLE.c.scope_id == scope_id, ARTIFACTS_TABLE.c.family == family, @@ -634,7 +634,7 @@ async def query_artifacts( cursor: str | None, tag_filter: TagFilter | None = None, ) -> ArtifactRecordPage: - repository = await self._read_repository(family) + repository = self._read_repository(family) _require_limit(limit) reader = self._topic_memory_list_reader if reader is not None and family == reader.family: @@ -837,22 +837,16 @@ def _require_content_source(self, source_type: str) -> None: if source_type != CONTENT_SOURCE_NAME: raise InvalidBaseAccessRequestError("source_type", "must be content") - async def _read_repository(self, family: str, *, connection: AsyncConnection | None = None) -> ArtifactRepository: + def _read_repository(self, family: str) -> ArtifactRepository: if family == "memory": raise BaseOperationNotSupportedError("artifact_family", family, "legacy Memory collection read") if family in self._artifacts.families: return self._artifacts - if connection is None: - async with self._database.transaction() as borrowed: - return await self._read_repository(family, connection=borrowed) - known = await connection.scalar( - select(ARTIFACT_FAMILY_SCHEMAS_TABLE.c.family) - .where(ARTIFACT_FAMILY_SCHEMAS_TABLE.c.family == family) - .limit(1) - ) - if known is None: - raise InvalidBaseAccessRequestError("family", "must be a registered Artifact family") - return custom_artifact_repository(family) + from powercontext.builtin.plugins.families import is_plugin_family, plugin_artifact_repository + + if is_plugin_family(family): + return plugin_artifact_repository(family, self._sources) + raise InvalidBaseAccessRequestError("family", "must be a registered Artifact family") async def _get_source( self, @@ -931,14 +925,11 @@ def _artifact_record( artifact: Artifact[Any], ) -> ArtifactRecord: content = cast(dict[str, JsonValue], artifact.content.model_dump(mode="json", by_alias=True)) - schema_version = None - if isinstance(artifact.content, StoredArtifactContent): - schema_version = artifact.content.schema_version + if isinstance(artifact.content, PluginArtifactContent): content = artifact.content.content return ArtifactRecord( scope_id=scope_id, family=cast(Any, artifact.family), - schema_version=schema_version, artifact_id=artifact.artifact_id, revision=artifact.revision, content=content, @@ -962,14 +953,11 @@ def _artifact_created(scope_id: str, artifact: Artifact[Any]) -> ArtifactCreated def _artifact_collection_item(scope_id: str, artifact: Artifact[Any]) -> ArtifactCollectionItem: content = cast(dict[str, JsonValue], artifact.content.model_dump(mode="json", by_alias=True)) - schema_version = None - if isinstance(artifact.content, StoredArtifactContent): - schema_version = artifact.content.schema_version + if isinstance(artifact.content, PluginArtifactContent): content = artifact.content.content return ArtifactCollectionItem( scope_id=scope_id, family=cast(Any, artifact.family), - schema_version=schema_version, artifact_id=artifact.artifact_id, revision=artifact.revision, sources=artifact.lineage.sources, diff --git a/src/powercontext/builtin/persistence/revision_metadata_schema.py b/src/powercontext/builtin/persistence/revision_metadata_schema.py index f56bde9274..d9bd19918d 100644 --- a/src/powercontext/builtin/persistence/revision_metadata_schema.py +++ b/src/powercontext/builtin/persistence/revision_metadata_schema.py @@ -22,7 +22,7 @@ from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE -_REVISION_METADATA_COLUMNS = ("created_at", "created_by", "restored_from_revision", "rollback_reason") +_REVISION_METADATA_COLUMNS = ("created_at", "created_by", "restored_from_revision", "rollback_reason", "metadata") async def ensure_artifact_revision_metadata_schema(connection: AsyncConnection, /) -> None: diff --git a/src/powercontext/builtin/persistence/schema.py b/src/powercontext/builtin/persistence/schema.py index c72fddc4b2..cd876f7776 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -16,7 +16,6 @@ from __future__ import annotations -import asyncio from collections.abc import Sequence from sqlalchemy import Connection, MetaData, Table, inspect, text @@ -29,19 +28,9 @@ from powercontext.builtin.persistence.migrations.atomic_memory_evidence import apply_atomic_memory_evidence_migration -async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) -> None: # noqa: C901 - Keep unmanaged backend creation and its derived structures together. - """Create selected tables only for unmanaged storage; managed schemas are read-only. +async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) -> None: + """Create selected tables only for unmanaged storage; managed schemas are read-only.""" - Evidence tables are applied only through their registered additive migration on - every dialect, so a MySQL-family database never gets the tables without the - registered marker row. - """ - - from powercontext.builtin.persistence.tables import ARTIFACT_VECTORS_TABLE - - tables = tuple( - table for table in tables if table is not ARTIFACT_VECTORS_TABLE or connection.dialect.name == "sqlite" - ) names = await connection.run_sync(lambda sync: set(inspect(sync).get_table_names())) if "pc_schema_revision" in names: if not {table.name for table in tables} <= names: @@ -55,66 +44,21 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) if any(table.name in evidence_names or table.name == "pc_sources" for table in tables): await apply_atomic_memory_evidence_migration(connection) if connection.dialect.name == "mysql": - selected = [table for table in tables if table.name not in evidence_names] - await connection.run_sync(lambda sync: _create_mysql_tables(sync, selected)) - else: - groups: dict[MetaData, list[Table]] = {} - for table in tables: - if table.name in evidence_names: - continue - groups.setdefault(table.metadata, []).append(table) - - for metadata, selected in groups.items(): - await connection.run_sync( - lambda sync_connection, current=metadata, owned=selected: current.create_all( - sync_connection, - tables=owned, - checkfirst=True, - ) - ) + await connection.run_sync(lambda sync: _create_mysql_tables(sync, tables)) + return - if any(table.name == "pc_artifact_search_text" for table in tables): - await _ensure_artifact_search_support(connection) - if connection.dialect.name != "sqlite" and any(table.name == "pc_artifact_vector_spaces" for table in tables): - from sqlalchemy import select - - from powercontext.builtin.persistence.artifact_indexes import SCHEMAS, register_definition, search_declaration - from powercontext.builtin.persistence.artifact_vector_space import provision_native_spaces - - rows = (await connection.execute(select(SCHEMAS))).mappings().all() - for row in rows: - declaration = search_declaration(row["declaration"]) - if declaration is not None: - for index in declaration.indexes: - await register_definition(connection, row["family"], row["schema_version"], index) - await connection.run_sync(provision_native_spaces) - - -async def _ensure_artifact_search_support(connection: AsyncConnection) -> None: - from powercontext.builtin.persistence.artifact_search_schema import ensure_search_support - - delays = (0.1, 0.2, 0.4, 0.8, 1.6) - for attempt in range(len(delays) + 1): - try: - await connection.run_sync(ensure_search_support) - except DBAPIError as error: - # OceanBase can reject index creation with a transient DDL lock. - # Recheck this derived index without replaying table/business writes - # or ending the caller's transaction, as for Topic Memory FTS. - if ( - connection.dialect.name != "mysql" - or mysql_error_code(error) != 1205 - or error.statement - != ( - "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts " - "ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" - ) - or attempt == len(delays) - ): - raise - await asyncio.sleep(delays[attempt]) - else: - return + groups: dict[MetaData, list[Table]] = {} + for table in tables: + groups.setdefault(table.metadata, []).append(table) + + for metadata, selected in groups.items(): + await connection.run_sync( + lambda sync_connection, current=metadata, owned=selected: current.create_all( + sync_connection, + tables=owned, + checkfirst=True, + ) + ) def _create_mysql_tables(connection: Connection, tables: Sequence[Table]) -> None: diff --git a/src/powercontext/builtin/persistence/scope_reads.py b/src/powercontext/builtin/persistence/scope_reads.py index 837aeebdd1..db8d198242 100644 --- a/src/powercontext/builtin/persistence/scope_reads.py +++ b/src/powercontext/builtin/persistence/scope_reads.py @@ -20,6 +20,7 @@ from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_REVISION_COLUMNS, ARTIFACTS_TABLE, SCOPES_TABLE, SOURCES_TABLE, @@ -224,7 +225,7 @@ async def revision_contents(connection, scope, family, artifact_id, revisions): return ( ( await connection.execute( - select(ARTIFACTS_TABLE).where( + select(*ARTIFACT_REVISION_COLUMNS).where( ARTIFACTS_TABLE.c.scope_id == scope, ARTIFACTS_TABLE.c.family == family, ARTIFACTS_TABLE.c.artifact_id == artifact_id, diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index 90a9fc9c53..9ee50a8158 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -51,7 +51,6 @@ from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository from powercontext.builtin.persistence.source_definitions import SourceDefinitionManifestRepository from powercontext.builtin.persistence.tables import ( - ARTIFACT_PROCESSING_BINDING_STATES_TABLE, SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ) @@ -279,7 +278,7 @@ async def add_many_with_status( if new_rows: intents = ArtifactProcessingIntentRepository() await intents.mark_dirty_many(connection, scope_id, SOURCE_PROCESSING_BINDINGS, increment=len(new_rows)) - await _mark_custom_bindings_dirty(connection, scope_id, intents, increment=len(new_rows)) + await _mark_plugin_bindings_dirty(connection, scope_id, intents, increment=len(new_rows)) return tuple(results) def _encode(self, source: Source, *, saved_at: datetime | None = None) -> tuple[SourceRef, bytes]: @@ -1284,22 +1283,13 @@ def _require_identity(field: str, value: object, maximum: int) -> None: raise InvalidRepositoryArgumentError(field, f"must not exceed {maximum} characters") -async def _mark_custom_bindings_dirty( +async def _mark_plugin_bindings_dirty( connection: AsyncConnection, scope_id: str, intents: ArtifactProcessingIntentRepository, *, increment: int, ) -> None: - plugins = ( - await connection.execute( - select( - ARTIFACT_PROCESSING_BINDING_STATES_TABLE.c.binding_name, - ARTIFACT_PROCESSING_BINDING_STATES_TABLE.c.plugin_spec[("manifest", "scope_ids")].label("scope_ids"), - ).where(ARTIFACT_PROCESSING_BINDING_STATES_TABLE.c.plugin_spec.is_not(None)) - ) - ).all() - bindings = tuple( - plugin.binding_name for plugin in plugins if plugin.scope_ids is None or scope_id in plugin.scope_ids - ) - await intents.mark_dirty_many(connection, scope_id, bindings, increment=increment) + from powercontext.builtin.plugins.families import source_bindings + + await intents.mark_dirty_many(connection, scope_id, source_bindings(scope_id), increment=increment) diff --git a/src/powercontext/builtin/persistence/sqlite/experience_index.py b/src/powercontext/builtin/persistence/sqlite/experience_index.py index e945ae820d..3c6cbb9d53 100644 --- a/src/powercontext/builtin/persistence/sqlite/experience_index.py +++ b/src/powercontext/builtin/persistence/sqlite/experience_index.py @@ -60,8 +60,7 @@ INSERT INTO pc_artifact_fts (scope_id, family, artifact_id, revision, searchable_text) SELECT h.scope_id, h.family, h.artifact_id, h.revision, h.searchable_text FROM pc_artifact_heads AS h -WHERE h.family IN ('experience', 'skill') - AND h.searchable_text IS NOT NULL +WHERE h.searchable_text IS NOT NULL """ _PROBE_FTS_SQL = "SELECT rowid FROM pc_artifact_fts WHERE pc_artifact_fts MATCH 'powercontext' LIMIT 1" _DELETE_FTS_SQL = text( diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 6068aae67a..b0f5883106 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -15,7 +15,6 @@ """Shared dialect-neutral relational tables.""" from sqlalchemy import ( - JSON, BigInteger, Boolean, CheckConstraint, @@ -35,7 +34,6 @@ ) from sqlalchemy.dialects.mysql import BINARY, DATETIME, MEDIUMBLOB, MEDIUMTEXT, VARCHAR -from powercontext.builtin.persistence.artifact_search_schema import artifact_search_tables from powercontext.limits import ( MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_HANDLE_KEY_LENGTH, @@ -203,7 +201,11 @@ def _entry_text_type(): Column("created_by", String(MAX_ARTIFACT_REVISION_ACTOR_LENGTH), nullable=True), Column("restored_from_revision", Integer, nullable=True), Column("rollback_reason", String(1024), nullable=True), + # Canonical JSON bytes that never take part in search or the content digest, such as the writing plugin. + Column("metadata", _canonical_payload_type(), nullable=True), ) +# Revision reads omit metadata: domain steps read revisions at earlier registered revisions that lack it. +ARTIFACT_REVISION_COLUMNS = tuple(column for column in ARTIFACTS_TABLE.c if column.name != "metadata") ARTIFACT_HANDLES_TABLE = Table( "pc_artifact_handles", @@ -593,24 +595,9 @@ def _entry_text_type(): Column("scan_generation", BigInteger, nullable=False, server_default="0"), Column("scan_in_progress", Boolean, nullable=False, server_default="0"), Column("scan_upper_pending_sequence", BigInteger), - Column("plugin_id", String(128)), - Column("plugin_family", String(128)), - Column("plugin_spec", JSON(none_as_null=True)), - Column("binding_generation", BigInteger, nullable=False, server_default="0"), - Column("desired_state", String(16)), - Column("phase", String(16)), - Column("last_error", String(1024)), CheckConstraint("scan_generation >= 0", name="ck_pc_processing_scan_generation"), ) -ARTIFACT_FAMILY_SCHEMAS_TABLE = Table( - "pc_artifact_family_schemas", - SHARED_METADATA, - Column("family", identity_string(128), primary_key=True), - Column("schema_version", identity_string(128), primary_key=True), - Column("declaration", JSON, nullable=False), -) - ARTIFACT_PROCESSING_SEQUENCES_TABLE = Table( "pc_artifact_processing_sequences", SHARED_METADATA, @@ -1105,7 +1092,6 @@ def _entry_text_type(): SHARED_TABLES = ( - ARTIFACT_FAMILY_SCHEMAS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ARTIFACTS_TABLE, @@ -1472,17 +1458,3 @@ def _entry_text_type(): LEXICAL_PROJECTION_VERSIONS_TABLE, ) ) - - -# Search builds are independent of generation bindings. - -( - ARTIFACT_VECTOR_SPACES_TABLE, - ARTIFACT_VECTOR_INDEXES_TABLE, - ARTIFACT_SEARCH_STATES_TABLE, - ARTIFACT_INDEX_COVERAGE_TABLE, - ARTIFACT_INDEX_CHANGES_TABLE, - ARTIFACT_VECTORS_TABLE, - ARTIFACT_SEARCH_TEXT_TABLE, -) = ARTIFACT_SEARCH_TABLES = artifact_search_tables(SHARED_METADATA, identity_string) -BUILTIN_TABLES += ARTIFACT_SEARCH_TABLES diff --git a/src/powercontext/builtin/persistence/tags.py b/src/powercontext/builtin/persistence/tags.py index bbd31798b0..e0a6eebf41 100644 --- a/src/powercontext/builtin/persistence/tags.py +++ b/src/powercontext/builtin/persistence/tags.py @@ -266,7 +266,7 @@ async def query(self, scope_id: str, query: TagQuery, *, caller: str = "runtime" head_key = (family, artifact_id) if head_key not in artifacts: ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=int(row[4])) - artifacts[head_key] = await self._artifacts.get(connection, scope_id, ref) + artifacts[head_key] = await self._repository(family).get(connection, scope_id, ref) reference = artifacts[head_key].as_ref() labels = await self._read(connection, scope_id, target) items.append(TaggedTarget(**labels.model_dump(), reference=reference)) @@ -283,9 +283,17 @@ async def _read( labels = await connection.scalars(statement) return tag_set(scope_id, target, tuple(labels)) + def _repository(self, family: str) -> Any: + from powercontext.builtin.plugins.families import is_plugin_family, plugin_artifact_repository + + # Plugin families are registered per process, not composed into the shared repository. + return plugin_artifact_repository(family) if is_plugin_family(family) else self._artifacts + async def _target_reference(self, connection: AsyncConnection, scope_id: str, target: TagTarget) -> ArtifactRef: try: - artifact = await self._artifacts.latest(connection, scope_id, target.family, target.artifact_id) + artifact = await self._repository(target.family).latest( + connection, scope_id, target.family, target.artifact_id + ) except RepositoryNotFoundError: raise BaseValueNotFoundError("artifact", target) from None return artifact.as_ref() diff --git a/examples/artifact_plugins/ticket_summary/plugin.yml b/src/powercontext/builtin/plugins/__init__.py similarity index 69% rename from examples/artifact_plugins/ticket_summary/plugin.yml rename to src/powercontext/builtin/plugins/__init__.py index 3bf122fc71..36dd1d28db 100644 --- a/examples/artifact_plugins/ticket_summary/plugin.yml +++ b/src/powercontext/builtin/plugins/__init__.py @@ -12,15 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -id: example-ticket-summary -version: "1" -entry: ./plugin.py:create_pipeline -binding_name: ticket-summaries -scope_ids: [team-b] -max_sources: 4 -processing: - interval_seconds: 5 - max_workers: 2 - timeout_seconds: 90 -config: - language: 简体中文 +"""Core execution of plugins: loading, the Artifact writer, and the Worker executor.""" diff --git a/src/powercontext/builtin/plugins/executor.py b/src/powercontext/builtin/plugins/executor.py new file mode 100644 index 0000000000..6b89d01a11 --- /dev/null +++ b/src/powercontext/builtin/plugins/executor.py @@ -0,0 +1,210 @@ +# 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. + +"""Run one Artifact plugin invocation inside a Supervisor Worker. + +The executor owns the window, the cursor and the acknowledgment. The plugin only +sees the window's Sources and the writer; whatever it writes commits together with +the cursor advance, and a failure leaves both unchanged. +""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from powercontext._logging import log_safely +from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.plugins.families import register_plugin_family, revision_metadata +from powercontext.builtin.plugins.manifest import PluginManifest, load_plugin +from powercontext.builtin.plugins.writer import PluginArtifactWriter, PluginWriteError +from powercontext.builtin.runtime.config import DatabaseConfig +from powercontext.builtin.runtime.processing_contracts import ( + ArtifactProcessingWorkAssignment, + ArtifactProcessingWorkerCompletion, +) +from powercontext.builtin.runtime.processing_execution import InvocationAlreadyHandled, ScopeInvocation +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY, SourceCursor +from powercontext.plugins import GenerateRequest, GenerationSource, PluginContext + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PluginWorkerSpec: + """Picklable Worker input; the plugin reads its own settings inside the Worker.""" + + database: DatabaseConfig + directory: str + manifest: dict[str, Any] + security: dict[str, Any] | None = None + inference: Any = None + storage_root: str | None = None + + +def run_plugin_worker( + spec: PluginWorkerSpec, assignment: ArtifactProcessingWorkAssignment, / +) -> ArtifactProcessingWorkerCompletion: + return asyncio.run(execute_plugin(spec, assignment)) + + +@asynccontextmanager +async def open_plugin_database(config: DatabaseConfig): + from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig, OceanBaseProfile + from powercontext.builtin.persistence.seekdb.profile import SeekDBProfile + from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile + + # The Server has prepared the schema; a Worker never creates tables. + if isinstance(config, SQLiteConfig): + opened = SQLiteProfile.open(config, tables=()) + elif isinstance(config, OceanBaseConfig): + opened = OceanBaseProfile.open(config, tables=()) + else: + opened = SeekDBProfile.open(config, tables=()) + async with opened as profile: + yield profile.database + + +async def execute_plugin( # noqa: C901 - one window from selection through acknowledgment + spec: PluginWorkerSpec, assignment: ArtifactProcessingWorkAssignment, /, *, database: Any = None +) -> ArtifactProcessingWorkerCompletion: + manifest = PluginManifest.model_validate(spec.manifest) + generate = manifest.spec + if assignment.binding_name != manifest.binding_name or assignment.artifact_family != generate.family: + raise ValueError("assignment does not match the plugin declaration") # noqa: TRY003 + register_plugin_family(generate.family) + scope_id = assignment.scope_id + binding = manifest.binding_name + sources = SourceRepository(BUILTIN_SOURCE_REGISTRY) + cursors = SourceCursorRepository() + async with AsyncExitStack() as resources: + if database is None: + database = await resources.enter_async_context(open_plugin_database(spec.database)) + from powercontext.server.processing_security import open_worker_security + + security = await resources.enter_async_context(open_worker_security(spec.security, database)) + + async def authorize(connection) -> None: + if generate.scopes is not None and scope_id not in generate.scopes: + raise PluginWriteError("the Scope is outside the plugin's scopes") # noqa: TRY003 + if security is not None: + await security.authorize_transaction(connection, scope_id) + + invocation = ScopeInvocation(assignment, authorize_transaction=authorize) + inputs = generate.inputs + try: + async with database.transaction() as connection: + await invocation.start(connection) + cursor = await cursors.load(connection, scope_id, binding) + after = 0 if cursor is None else cursor.cursor.sequence + through = after + rows = () + if inputs is not None: + high = await sources.journal_position(connection, scope_id) + through = min(high, after + inputs.max_sources) + rows = await GenerationSourceAccess(sources).list_window_for_generation( + connection, scope_id, after=after, through=through + ) + window = () + if inputs is not None: + accepted = frozenset(inputs.source_types) + # A matching Source without the declared Projection fails the window here. + window = tuple( + GenerationSource( + ref=row.ref, + position=row.journal_position, + projection=BUILTIN_SOURCE_REGISTRY.project( + row.value, inputs.projection_key(row.ref.source_type) + ), + ) + for row in rows + if row.ref.source_type in accepted + ) + + async def begin(connection) -> None: + await invocation.guard(connection) + + async def complete(connection) -> None: + await invocation.guard(connection) + if through > after: + await cursors.save( + connection, + scope_id, + binding, + SourceCursor(sequence=through), + expected_generation=None if cursor is None else cursor.generation, + ) + remaining = inputs is not None and await sources.journal_position(connection, scope_id) > through + await invocation.complete(connection, remaining_work=remaining) + + writer = PluginArtifactWriter( + database=database, + scope_id=scope_id, + family=generate.family, + metadata=revision_metadata(manifest.name, manifest.fingerprint), + window_sources=frozenset((item.ref.source_type, item.ref.source_id) for item in window), + sources=sources, + begin=begin, + complete=complete, + transaction_timeout_seconds=generate.transaction_timeout_seconds, + security=security, + ) + call = bool(window) or inputs is None + if call: + directory = Path(spec.directory) + loaded, declaration = load_plugin(directory) + if loaded != manifest: + raise ValueError("the plugin changed after the Server checked it; restart the Server") # noqa: TRY003 + async with AsyncExitStack() as plugin_resources: + from powercontext.builtin.plugins.storage import open_plugin_resources + + storage, models = await open_plugin_resources( + spec.database, spec.storage_root, manifest.name, spec.inference, plugin_resources + ) + context = PluginContext( + name=manifest.name, + scope_id=scope_id, + plugin_dir=directory, + logger=logging.getLogger(f"powercontext.plugin.{manifest.name}"), + resources=plugin_resources, + storage=storage, + models=models, + ) + request = GenerateRequest( + scope_id=scope_id, reason="sources" if window else "schedule", sources=window + ) + try: + plugin = await declaration.setup(context) + await plugin.generate(request, writer) + except Exception as error: + if not writer.committed: + raise + # Results are durable; do not consume or retry the window again. + log_safely( + logger, + logging.WARNING, + f"Plugin {manifest.name} raised after commit ({type(error).__name__})", + ) + if not writer.opened: + async with database.transaction() as connection: + await complete(connection) + except InvocationAlreadyHandled: + return ArtifactProcessingWorkerCompletion(noop=True) + return ArtifactProcessingWorkerCompletion(noop=not call) diff --git a/src/powercontext/builtin/plugins/families.py b/src/powercontext/builtin/plugins/families.py new file mode 100644 index 0000000000..e7dd5e9635 --- /dev/null +++ b/src/powercontext/builtin/plugins/families.py @@ -0,0 +1,124 @@ +# 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. + +"""Process-local registration and shared storage of plugin-owned Artifact families. + +Plugin families keep no durable declaration: the Server and each Worker register +them from the plugin directory at startup. Content is opaque JSON; core builds no +index for it, and plugins present and search their own families. +""" + +from __future__ import annotations + +import json +from functools import cache +from typing import Any + +from pydantic import BaseModel, ConfigDict, JsonValue, RootModel +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import Artifact +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.revision_meta import attach_revision_meta, revision_meta +from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE + +_PLUGIN_FAMILIES: set[str] = set() +# binding name -> (Scope allowlist or None, consumed Source types) +_SOURCE_BINDINGS: dict[str, tuple[frozenset[str] | None, frozenset[str]]] = {} + + +class PluginArtifactContent(BaseModel): + """Stored body of one plugin revision; core never interprets ``content``.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + content: dict[str, JsonValue] + + +class PluginContentView(RootModel[dict[str, JsonValue]]): + """The public shape of a plugin revision: the plugin's content only.""" + + +def register_plugin_family(family: str) -> None: + from powercontext.server.authz.profiles import register_plugin_artifact_family + + _PLUGIN_FAMILIES.add(family) + register_plugin_artifact_family(family) + + +def register_source_binding(binding_name: str, scopes: frozenset[str] | None, source_types: frozenset[str]) -> None: + """Let Source writes in this process mark the plugin's Scope invocation dirty.""" + + _SOURCE_BINDINGS[binding_name] = (scopes, source_types) + + +def source_bindings(scope_id: str) -> tuple[str, ...]: + return tuple(sorted(name for name, (scopes, _) in _SOURCE_BINDINGS.items() if scopes is None or scope_id in scopes)) + + +def is_plugin_family(family: str) -> bool: + return family in _PLUGIN_FAMILIES + + +def plugin_families() -> frozenset[str]: + return frozenset(_PLUGIN_FAMILIES) + + +@cache +def plugin_artifact_type(family: str) -> type[Artifact[Any]]: + return type("PluginArtifact", (Artifact[PluginArtifactContent],), {"family": family}) + + +@cache +def plugin_view_type(family: str) -> type[Artifact[Any]]: + return type("PluginArtifactView", (Artifact[PluginContentView],), {"family": family}) + + +def plugin_artifact_repository(family: str, sources: Any = None) -> ArtifactRepository: + return ArtifactRepository((plugin_artifact_type(family),), sources=sources) + + +def plugin_view(artifact: Artifact[Any]) -> Artifact[Any]: + """Present a stored revision as the plugin's content alone.""" + + view = plugin_view_type(artifact.family)( + artifact_id=artifact.artifact_id, + revision=artifact.revision, + content=PluginContentView(artifact.content.content), + lineage=artifact.lineage, + ) + attach_revision_meta(view, revision_meta(artifact)) + return view + + +def revision_metadata(plugin: str, version: str) -> bytes: + """Canonical JSON naming the plugin and code version that wrote a revision.""" + + return json.dumps({"plugin": plugin, "version": version}, separators=(",", ":"), sort_keys=True).encode() + + +async def record_revision_metadata( + connection: AsyncConnection, scope_id: str, family: str, artifact_id: str, revision: int, metadata: bytes +) -> None: + await connection.execute( + update(ARTIFACTS_TABLE) + .where( + ARTIFACTS_TABLE.c.scope_id == scope_id, + ARTIFACTS_TABLE.c.family == family, + ARTIFACTS_TABLE.c.artifact_id == artifact_id, + ARTIFACTS_TABLE.c.revision == revision, + ) + .values(metadata=metadata) + ) diff --git a/src/powercontext/builtin/plugins/host.py b/src/powercontext/builtin/plugins/host.py new file mode 100644 index 0000000000..b2a3efb5e0 --- /dev/null +++ b/src/powercontext/builtin/plugins/host.py @@ -0,0 +1,628 @@ +# 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 plugin host process: core runtime plus one plugin, serving synchronous calls. + +Like a Worker, the host builds its own database connection and authorization service +from configuration, so the Server only authenticates callers and forwards requests. +Authorization, version prechecks, plugin calls, and writes all happen here. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import logging +from contextlib import AsyncExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from pydantic import BaseModel, ValidationError + +from powercontext.artifacts import ArtifactRef +from powercontext.artifacts.search import ArtifactSearchExecutionContext +from powercontext.builtin.artifacts.merge_models import ArtifactMergeConflictError +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.plugins.executor import open_plugin_database +from powercontext.builtin.plugins.families import register_plugin_family, register_source_binding, revision_metadata +from powercontext.builtin.plugins.host_protocol import ( + HostFailed, + HostReady, + HostRequest, + HostResponse, + HostShutdown, + PluginHostSpec, +) +from powercontext.builtin.plugins.manifest import PluginConfigError, PluginManifest, load_plugin +from powercontext.builtin.plugins.storage import open_plugin_resources +from powercontext.builtin.plugins.writer import PluginArtifactWriter, PluginContractError, PluginWriteError, UserChange +from powercontext.builtin.records import ArtifactRevisionPreconditionError, BaseValueConflictError +from powercontext.builtin.runtime.artifact_merge import ArtifactMergeAccess +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY +from powercontext.errors import RevisionConflictError +from powercontext.plugins import ( + USER_CHANGE_METHODS, + AccessDenied, + ApiError, + ApiRequest, + BinaryResponse, + Caller, + CreateRequest, + ForgetRequest, + InvalidContent, + MergeInput, + MergeRequest, + ModelUnavailable, + PluginContext, + PluginScope, + Rendering, + RenderRequest, + RestoreRequest, + SearchRequest, + SearchResult, + UpdateRequest, +) +from powercontext.sources import SourceRef + +logger = logging.getLogger(__name__) + +_CHANGE_METHODS = { + "create_artifact": "create", + "update_artifact": "update", + "merge_artifacts": "merge", + "forget_artifact": "forget", + "restore_artifact": "restore", +} + + +@dataclass +class _Call: + request: HostRequest + context: ArtifactSearchExecutionContext | None + contribute_scopes: set[str] + + +_CURRENT: contextvars.ContextVar[_Call] = contextvars.ContextVar("powercontext_plugin_call") + + +def run_plugin_host(connection: Any, spec: PluginHostSpec) -> None: + """Spawn target: serve requests from the Server until the Pipe closes or a shutdown arrives.""" + + asyncio.run(_Host(connection, spec).serve()) + + +class _Host: + def __init__(self, connection: Any, spec: PluginHostSpec) -> None: + self.connection = connection + self.spec = spec + self.send_lock = asyncio.Lock() + self.manifest = PluginManifest.model_validate(spec.manifest) + self.family = self.manifest.spec.family + self.access: Any = None + self.deployment_id: str | None = None + self.plugin: Any = None + self.routes: dict[tuple[str, str], Any] = {} + self.definitions: Any = None + + async def _send(self, message: object) -> None: + async with self.send_lock: + await asyncio.to_thread(self.connection.send, message) + + async def serve(self) -> None: + async with AsyncExitStack() as resources: + try: + implemented = await self._start(resources) + except PluginConfigError as error: + await self._send(HostFailed(error=str(error), permanent=True)) + return + except Exception as error: + await self._send( + HostFailed(error=str(error) if isinstance(error, ValueError) else type(error).__name__) + ) + return + await self._send(HostReady(implemented=implemented)) + tasks: set[asyncio.Task[None]] = set() + while True: + try: + message = await asyncio.to_thread(self.connection.recv) + except (EOFError, OSError): + break + if isinstance(message, HostShutdown): + break + task = asyncio.create_task(self._handle(message)) + tasks.add(task) + task.add_done_callback(tasks.discard) + if tasks: + await asyncio.wait(tasks, timeout=10) + + async def _start(self, resources: AsyncExitStack) -> tuple[str, ...]: + spec = self.spec + directory = Path(spec.directory) + loaded, declaration = load_plugin(directory) + if loaded != self.manifest: + raise PluginConfigError("the plugin changed after the Server checked it; restart the Server") # noqa: TRY003 + for family in {*spec.plugin_families, self.family}: + register_plugin_family(family) + for binding, scopes, source_types in spec.source_bindings: + register_source_binding(binding, None if scopes is None else frozenset(scopes), frozenset(source_types)) + self.database = await resources.enter_async_context(open_plugin_database(spec.database)) + from powercontext.server.processing_security import open_worker_security + + security = await resources.enter_async_context(open_worker_security(spec.security, self.database)) + if security is not None: + self.access = security.access + if spec.security is not None: + self.deployment_id = spec.security.get("deployment_id") + storage, models = await open_plugin_resources( + spec.database, spec.storage_root, self.manifest.name, spec.inference, resources + ) + if declaration.definitions: + from powercontext.sources.definitions import SourceDefinitionRegistry + + self.definitions = SourceDefinitionRegistry(declaration.definitions) + if declaration.api is not None: + self.routes = {(route.method, route.path): route for route in declaration.api.routes} + context = PluginContext( + name=self.manifest.name, + scope_id=None, + plugin_dir=directory, + logger=logging.getLogger(f"powercontext.plugin.{self.manifest.name}"), + resources=resources, + storage=storage, + models=models, + host=self, + ) + self.plugin = await declaration.setup(context) + declared = self.manifest.spec + background = declared.schedule is not None or declared.on_sources + required = ("render_artifact", *(("generate",) if background else ())) + missing = [name for name in required if not callable(getattr(self.plugin, name, None))] + if missing: + raise PluginConfigError("setup returned an object without " + ", ".join(missing)) + return tuple(name for name in USER_CHANGE_METHODS if callable(getattr(self.plugin, name, None))) + + # Request handling + + async def _handle(self, request: HostRequest) -> None: # noqa: C901 - maps every plugin outcome to one response + context = None + if self.access is not None and request.caller is not None: + context = ArtifactSearchExecutionContext(principal=request.caller, access=self.access, audit=request.audit) + token = _CURRENT.set(_Call(request, context, set())) + try: + response = await self._dispatch(request) + except InvalidContent as error: + response = HostResponse(request.id, "invalid_content", message=error.message, field=error.field) + except ValidationError as error: + fields = ", ".join(".".join(str(part) for part in item["loc"]) or "body" for item in error.errors()) + response = HostResponse(request.id, "invalid_content", message=f"invalid fields: {fields}") + except ApiError as error: + response = HostResponse( + request.id, "api_error", status=error.status, field=error.code, message=error.message + ) + except PluginContractError as error: + response = HostResponse(request.id, "contract_violation", message=str(error)) + except ArtifactRevisionPreconditionError: + response = HostResponse(request.id, "precondition_failed") + except (ArtifactMergeConflictError, BaseValueConflictError, RevisionConflictError): + response = HostResponse(request.id, "conflict") + except RepositoryNotFoundError: + response = HostResponse(request.id, "not_found") + except (AccessDenied, PermissionError): + response = HostResponse(request.id, "forbidden") + except PluginWriteError as error: + response = HostResponse(request.id, "invalid_content", message=str(error)) + except ModelUnavailable: + response = HostResponse(request.id, "model_unavailable") + except Exception as error: + logger.warning("Plugin %s request failed (%s)", self.manifest.name, type(error).__name__) + response = HostResponse(request.id, "error", error_type=type(error).__name__) + finally: + _CURRENT.reset(token) + await self._send(response) + + async def _dispatch(self, request: HostRequest) -> HostResponse: + if request.method == "api": + return await self._api(request) + if request.method == "create_source": + return await self._create_source(request) + if request.method == "render_artifact": + return await self._render(request) + if request.method == "search_artifacts": + return await self._search(request) + return await self._change(request) + + async def _render(self, request: HostRequest) -> HostResponse: + scope_id = request.scope_id + if scope_id is None: + raise ValueError("rendering needs a Scope") # noqa: TRY003 + await self.require("scope.read", scope_id) + payload = request.payload + snapshot = await self._writer(scope_id).get(payload["artifact_id"], revision=payload.get("revision")) + if snapshot is None: + return HostResponse(request.id, "not_found") + rendering = await self.plugin.render_artifact( + RenderRequest(scope_id=scope_id, artifact=snapshot, caller=self.caller()) + ) + if not isinstance(rendering, Rendering): + raise PluginContractError("render_artifact must return a Rendering") # noqa: TRY003 + return HostResponse( + request.id, "ok", result={"ref": snapshot.ref.model_dump(mode="json"), **rendering.model_dump(mode="json")} + ) + + async def _search(self, request: HostRequest) -> HostResponse: + """Pass the whole search to the plugin, then keep only current, active, readable revisions.""" + + method = getattr(self.plugin, "search_artifacts", None) + if method is None: + return HostResponse(request.id, "not_implemented") + scope_id = request.scope_id + if scope_id is None: + raise ValueError("search needs a Scope") # noqa: TRY003 + await self.require("scope.read", scope_id) + payload = request.payload + result = await method( + SearchRequest( + scope_id=scope_id, search=payload["search"], max_bytes=payload.get("max_bytes"), caller=self.caller() + ) + ) + if not isinstance(result, SearchResult): + raise PluginContractError("search_artifacts must return a SearchResult") # noqa: TRY003 + reads = self._writer(scope_id) + hits = [] + for hit in result.hits: + if hit.ref.family != self.family: + raise PluginContractError("search_artifacts returned another family") # noqa: TRY003 + current = await reads.get(hit.ref.artifact_id) + if current is not None and current.ref == hit.ref and current.lifecycle_state == "active": + hits.append(hit.model_dump(mode="json")) + return HostResponse(request.id, "ok", result={"hits": hits}) + + async def _create_source(self, request: HostRequest) -> HostResponse: + """A generic Source write of one of this plugin's types: validate the input, then resolve and store it.""" + + scope_id = request.scope_id + if scope_id is None or self.definitions is None: + return HostResponse(request.id, "not_found") + await self.require("scope.contribute", scope_id) + selected = self.definitions.definition_for_name(request.payload["source_type"]) + value = selected.input_class.model_validate(request.payload["content"]) + ref = await self.store_source(scope_id, request.payload["source_type"], value) + return HostResponse(request.id, "ok", result=ref.model_dump(mode="json")) + + def _writer(self, scope_id: str, *, change: UserChange | None = None) -> PluginArtifactWriter: + spec = self.manifest.spec + return PluginArtifactWriter( + database=self.database, + scope_id=scope_id, + family=self.family, + sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), + metadata=revision_metadata(self.manifest.name, self.manifest.fingerprint), + transaction_timeout_seconds=spec.transaction_timeout_seconds, + context=_CURRENT.get().context, + change=change, + caller_scoped=True, + ) + + async def _authorize(self, scope_id: str, action: str, artifact_id: str | None) -> None: + call = _CURRENT.get() + ref = None if artifact_id is None else ArtifactRef(family=self.family, artifact_id=artifact_id, revision=1) + async with self.database.transaction() as connection: + await ArtifactMergeAccess(self.family).authorize(connection, scope_id, call.context, action, ref) + + async def _change(self, request: HostRequest) -> HostResponse: + method = getattr(self.plugin, request.method, None) + if method is None: + return HostResponse(request.id, "not_implemented") + scope_id = request.scope_id + payload = request.payload + operation = _CHANGE_METHODS[request.method] + caller = self.caller() + if scope_id is None: + raise ValueError("user changes need a Scope") # noqa: TRY003 + if operation == "create": + target = payload.get("artifact_id") or uuid4().hex + await self._authorize(scope_id, "create", None) + change = UserChange("create", target) + plugin_request: BaseModel = CreateRequest( + scope_id=scope_id, artifact_id=target, content=payload["content"], caller=caller + ) + elif operation == "merge": + target = payload.get("into") or uuid4().hex + inputs = tuple(MergeInput.model_validate(item) for item in payload["inputs"]) + await self._authorize(scope_id, "create", None) + for item in inputs: + await self._precheck(scope_id, item.artifact_id, item.revision, item.state_version, "write") + change = UserChange("merge", target, tuple(item.artifact_id for item in inputs)) + plugin_request = MergeRequest( + scope_id=scope_id, inputs=inputs, into=target, content=payload["content"], caller=caller + ) + else: + target = payload["artifact_id"] + await self._authorize(scope_id, "write", target) + if operation == "update": + await self._precheck(scope_id, target, payload["expected_revision"], None, "precondition") + change = UserChange("update", target) + plugin_request = UpdateRequest( + scope_id=scope_id, + artifact_id=target, + expected_revision=payload["expected_revision"], + content=payload["content"], + caller=caller, + ) + elif operation == "forget": + await self._precheck( + scope_id, target, payload["expected_revision"], payload["expected_state_version"], "write" + ) + change = UserChange("forget", target) + plugin_request = ForgetRequest( + scope_id=scope_id, + artifact_id=target, + expected_revision=payload["expected_revision"], + expected_state_version=payload["expected_state_version"], + caller=caller, + ) + else: + kind = payload.get("operation", "restore") + change = UserChange("undo_merge" if kind == "undo_merge" else "restore", target) + plugin_request = RestoreRequest( + scope_id=scope_id, + artifact_id=target, + operation=kind, + revision=payload.get("revision"), + caller=caller, + ) + writer = self._writer(scope_id, change=change) + await method(plugin_request, writer) + if not writer.committed: + raise PluginContractError(f"{request.method} returned without its agreed write") # noqa: TRY003 + snapshot = await self._writer(scope_id).get(target) + restored, retired, undone = writer.outcome + return HostResponse( + request.id, + "ok", + result={ + "artifact": None if snapshot is None else snapshot.model_dump(mode="json"), + "restored": [ref.model_dump(mode="json") for ref in restored], + "retired": [ref.model_dump(mode="json") for ref in retired], + "undo_merge_results": list(undone), + }, + ) + + async def _precheck( + self, scope_id: str, artifact_id: str, revision: int, state_version: int | None, kind: str + ) -> None: + current = await self._writer(scope_id).get(artifact_id) + if current is None: + raise RepositoryNotFoundError("artifact", (scope_id, self.family, artifact_id)) + if current.ref.revision != revision: + if kind == "precondition": + raise ArtifactRevisionPreconditionError(f'"{revision}"', f'"{current.ref.revision}"') + raise ArtifactMergeConflictError("the Artifact changed after it was read") # noqa: TRY003 + if state_version is not None and current.governance_generation != state_version: + raise ArtifactMergeConflictError("the Artifact governance changed after it was read") # noqa: TRY003 + + async def _api(self, request: HostRequest) -> HostResponse: + payload = request.payload + route = self.routes.get((payload["method"], payload["route"])) + if route is None: + return HostResponse(request.id, "not_found") + scope_id = None + if route.access != "authenticated": + scope_id = payload["path_params"][route.scope_param] + await self.require(route.access, scope_id) + body = request.body + if route.body == "json" and route.request_model is not None: + body = route.request_model.model_validate(body) + api_request = ApiRequest( + scope_id=scope_id, + route=route.path, + path_params=payload["path_params"], + query=payload["query"], + body=body, + caller=self.caller(), + ) + result = await getattr(self.plugin, route.handler)(api_request) + if result is None: + return HostResponse(request.id, "ok", result=("none", None)) + if isinstance(result, BinaryResponse): + return HostResponse( + request.id, "ok", result=("binary", result.content, result.content_type, result.filename) + ) + if isinstance(result, BaseModel): + return HostResponse(request.id, "ok", result=("json", result.model_dump(mode="json"))) + return HostResponse(request.id, "ok", result=("json", result)) + + # Request helpers behind PluginContext + + def caller(self) -> Caller: + principal = _CURRENT.get().request.caller + return Caller(principal_id="local" if principal is None else f"{principal.type}:{principal.id}") + + async def require(self, action: str, scope_id: str | None) -> None: + call = _CURRENT.get() + if action in {"scope.read", "scope.contribute"} and scope_id is None: + raise ValueError("Scope permissions need a scope_id") # noqa: TRY003 + if self.access is None or call.request.caller is None: + if action == "scope.contribute" and scope_id is not None: + call.contribute_scopes.add(scope_id) + return + from powercontext.server.authz import AccessAction, AccessDeniedError, ResourceRef + + if action == "server.admin": + resource = ResourceRef.server() if self.deployment_id is None else ResourceRef.server(self.deployment_id) + else: + assert scope_id is not None # noqa: S101 - checked above for Scope actions + resource = ResourceRef.scope(scope_id) + await self.access.bootstrap_static_scope(call.request.caller, scope_id, context=call.request.audit) + try: + await self.access.require(call.request.caller, AccessAction(action), resource, context=call.request.audit) + except AccessDeniedError: + raise AccessDenied(action) from None + if action == "scope.contribute" and scope_id is not None: + call.contribute_scopes.add(scope_id) + + def reads(self, scope_id: str) -> Any: + return _CheckedReads(self, scope_id) + + async def store_source(self, scope_id: str, definition: str, value: BaseModel) -> SourceRef: + """Resolve and project an input of one of this plugin's Source types, then store it like a remote observation.""" + + if self.definitions is None: + raise ValueError("the plugin declares no Source Definitions") # noqa: TRY003 + from powercontext.builtin.sources.observation_validation import validate_source_observation + from powercontext.sources.observations import SourceDefinitionManifest, project_source_for_transport + + selected = self.definitions.definition_for_name(definition) + if not isinstance(value, selected.input_class): + raise TypeError(f"{definition} expects {selected.input_class.__name__}") # noqa: TRY003 + observation = project_source_for_transport(self.definitions, await self.definitions.resolve(value)) + manifest = next( + SourceDefinitionManifest.model_validate(item) + for item in self.manifest.source_manifests + if item["name"] == definition + ) + validate_source_observation(observation, manifest) + repository = SourceRepository(BUILTIN_SOURCE_REGISTRY) + async with self.database.transaction() as connection: + stored = await repository.add(connection, scope_id, observation) + return stored.ref + + def writer(self, scope_id: str) -> PluginArtifactWriter: + if scope_id not in _CURRENT.get().contribute_scopes: + raise AccessDenied("call ctx.require('scope.contribute', scope_id) before writing") # noqa: TRY003 + return self._writer(scope_id) + + def sources(self) -> _Sources: + return _Sources(self) + + def scopes(self) -> _Scopes: + return _Scopes(self) + + +class _CheckedReads: + """Reads of one Scope after the caller's ``scope.read`` is confirmed.""" + + def __init__(self, host: _Host, scope_id: str) -> None: + self._host = host + self._scope_id = scope_id + self._ready = False + + def __getattr__(self, name: str) -> Any: + target = getattr(self._host._writer(self._scope_id), name) + if not callable(target): + return target + + async def checked(*args: Any, **kwargs: Any) -> Any: + if not self._ready: + await self._host.require("scope.read", self._scope_id) + self._ready = True + return await target(*args, **kwargs) + + return checked + + +class _Sources: + def __init__(self, host: _Host) -> None: + self._host = host + + async def submit(self, scope_id: str, definition: str, value: BaseModel, /) -> SourceRef: + if scope_id not in _CURRENT.get().contribute_scopes: + raise AccessDenied("call ctx.require('scope.contribute', scope_id) before submitting Sources") # noqa: TRY003 + return await self._host.store_source(scope_id, definition, value) + + +class _Scopes: + _KIND = "powercontext.plugin" + + def __init__(self, host: _Host) -> None: + self._host = host + + def _application(self) -> Any: + from powercontext.builtin.scope.application import ScopeApplication + + return ScopeApplication(self._host.database) + + def _own(self, descriptor: Any) -> bool: + return any( + reference.kind == self._KIND and reference.value == self._host.manifest.name + for reference in descriptor.external_references + ) + + @staticmethod + def _scope(descriptor: Any) -> PluginScope: + return PluginScope( + scope_id=descriptor.scope_id, + title=descriptor.title, + summary=descriptor.summary, + version=descriptor.version, + ) + + async def create(self, *, title: str, summary: str, idempotency_key: str) -> PluginScope: + from powercontext.builtin.scope.models import ScopeDraft, ScopeExternalReference + + host = self._host + await host.require("server.admin", None) + created = await self._application().create( + ScopeDraft( + title=title, + summary=summary, + idempotency_key=f"{host.manifest.name}:{idempotency_key}", + external_references=(ScopeExternalReference(kind=self._KIND, value=host.manifest.name),), + ) + ) + call = _CURRENT.get() + if host.access is not None and call.request.caller is not None: + await host.access.bootstrap_static_scope(call.request.caller, created.scope_id, context=call.request.audit) + return self._scope(created) + + async def update(self, scope_id: str, /, *, expected_version: int, title: str, summary: str) -> PluginScope: + from powercontext.builtin.scope.models import ScopeMutation + + host = self._host + application = self._application() + (current,) = await application.list(scope_ids=(scope_id,)) + if not self._own(current): + raise AccessDenied("the Scope was not created by this plugin") # noqa: TRY003 + await host.require("server.admin", None) + updated = await application.update( + scope_id, + ScopeMutation( + expected_version=expected_version, + title=title, + summary=summary, + parent_scope_id=current.parent_scope_id, + context_references=current.context_references, + external_references=current.external_references, + ), + ) + return self._scope(updated) + + async def list(self) -> tuple[PluginScope, ...]: + host = self._host + visible = [] + for descriptor in await self._application().list(): + if self._own(descriptor) and await _allowed(host, descriptor.scope_id): + visible.append(self._scope(descriptor)) + return tuple(visible) + + +async def _allowed(host: _Host, scope_id: str) -> bool: + try: + await host.require("scope.read", scope_id) + except AccessDenied: + return False + return True diff --git a/src/powercontext/builtin/plugins/host_protocol.py b/src/powercontext/builtin/plugins/host_protocol.py new file mode 100644 index 0000000000..3db39f7287 --- /dev/null +++ b/src/powercontext/builtin/plugins/host_protocol.py @@ -0,0 +1,100 @@ +# 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. + +"""Picklable messages between the Server and a plugin host process over a duplex Pipe.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +HostMethod = Literal[ + "create_artifact", + "update_artifact", + "merge_artifacts", + "forget_artifact", + "restore_artifact", + "create_source", + "render_artifact", + "search_artifacts", + "api", +] +HostOutcome = Literal[ + "ok", + "invalid_content", + "not_implemented", + "forbidden", + "not_found", + "precondition_failed", + "conflict", + "contract_violation", + "api_error", + "model_unavailable", + "error", +] + + +@dataclass(frozen=True) +class PluginHostSpec: + """Everything a fresh interpreter needs to become core runtime plus one plugin.""" + + database: Any + directory: str + manifest: dict[str, Any] + security: dict[str, Any] | None + inference: Any = None + # Every loaded plugin family and Source binding, so reads and Source writes behave as in the Server. + plugin_families: tuple[str, ...] = () + source_bindings: tuple[tuple[str, tuple[str, ...] | None, tuple[str, ...]], ...] = () + storage_root: str | None = None + + +@dataclass(frozen=True) +class HostReady: + implemented: tuple[str, ...] + + +@dataclass(frozen=True) +class HostFailed: + error: str + # The plugin breaks its contract, for example it lacks a required method; restarting cannot help. + permanent: bool = False + + +@dataclass(frozen=True) +class HostRequest: + id: str + method: HostMethod + scope_id: str | None + # The caller Server authenticated, and the audit context of the HTTP request; None without access control. + caller: Any + audit: Any + payload: dict[str, Any] = field(default_factory=dict) + body: Any = None + + +@dataclass(frozen=True) +class HostResponse: + id: str + outcome: HostOutcome + result: Any = None + message: str | None = None + field: str | None = None + status: int | None = None + error_type: str | None = None + + +@dataclass(frozen=True) +class HostShutdown: + pass diff --git a/src/powercontext/builtin/plugins/manifest.py b/src/powercontext/builtin/plugins/manifest.py new file mode 100644 index 0000000000..b6d3c777c2 --- /dev/null +++ b/src/powercontext/builtin/plugins/manifest.py @@ -0,0 +1,151 @@ +# 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. + +"""Plugin declarations and module loading shared by the Server check and Workers. + +A plugin is a directory named after the plugin that contains ``plugin.py``. Importing +it must assign an ``ArtifactPlugin`` to ``plugin``. The Server imports user code only +in a child process; the declaration crosses process boundaries as ``PluginManifest``. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import inspect +import re +import sys +from pathlib import Path +from types import ModuleType + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from powercontext.plugins import ArtifactPlugin, ArtifactPluginSpec + +ENTRY_FILE = "plugin.py" +_NAME = re.compile(r"^[a-z][a-z0-9-]{0,63}$") + + +class PluginConfigError(ValueError): + """A plugin directory or its declaration cannot be loaded.""" + + +class PluginManifest(BaseModel): + """The picklable, code-free declaration of one loaded plugin.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str = Field(pattern=_NAME.pattern) + spec: ArtifactPluginSpec + # Content digest of the plugin directory when it was checked; see ``code_fingerprint``. + fingerprint: str = "" + # Declarative manifests of the Source Definitions the plugin submits through its routes. + source_manifests: tuple[dict[str, JsonValue], ...] = () + + @property + def binding_name(self) -> str: + return f"plugin:{self.name}" + + +def code_fingerprint(directory: Path) -> str: + """SHA-256 over relative paths and contents, so any content change shows, whatever the timestamps.""" + + root = directory.resolve() + digest = hashlib.sha256() + files = sorted( + path for path in root.rglob("*") if path.is_file() and "__pycache__" not in path.relative_to(root).parts + ) + for path in files: + digest.update(str(path.relative_to(root)).encode()) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def load_module(directory: Path) -> ModuleType: + root = directory.resolve() + path = root / ENTRY_FILE + if not path.is_file(): + raise PluginConfigError(f"the plugin directory has no {ENTRY_FILE}") # noqa: TRY003 + module_name = f"powercontext_plugin_{root.name.replace('-', '_')}" + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise PluginConfigError("plugin entry is unavailable") # noqa: TRY003 + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + # Plugins may import sibling modules from their own directory. + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + spec.loader.exec_module(module) + return module + + +def load_plugin(directory: Path) -> tuple[PluginManifest, ArtifactPlugin]: + """Import ``plugin.py`` and validate its declaration without calling the factory.""" + + name = directory.name + if _NAME.fullmatch(name) is None: + raise PluginConfigError("the directory name is not a valid plugin name") # noqa: TRY003 + declaration = getattr(load_module(directory), "plugin", None) + if not isinstance(declaration, ArtifactPlugin): + raise PluginConfigError(f"{ENTRY_FILE} must assign an ArtifactPlugin to plugin") # noqa: TRY003 + if not inspect.iscoroutinefunction(declaration.setup): + raise PluginConfigError("setup must be an async function") # noqa: TRY003 + try: + spec = ArtifactPluginSpec.model_validate(declaration.model_dump(mode="json")) + except ValidationError as error: + fields = ", ".join(".".join(str(part) for part in item["loc"]) or "plugin" for item in error.errors()) + raise PluginConfigError(f"invalid declaration fields: {fields}") from error # noqa: TRY003 + if spec.pages is not None: + pages = (directory.resolve() / spec.pages.directory).resolve() + if not pages.is_relative_to(directory.resolve()) or not (pages / spec.pages.entry).is_file(): + raise PluginConfigError("pages.entry must be a file inside pages.directory") # noqa: TRY003 + from powercontext.sources.observations import manifest_for_definition + + try: + source_manifests = tuple( + manifest_for_definition(definition).model_dump(mode="json", by_alias=True) + for definition in declaration.definitions + ) + except Exception as error: + raise PluginConfigError("invalid Source Definitions") from error # noqa: TRY003 + return ( + PluginManifest( + name=name, spec=spec, fingerprint=code_fingerprint(directory), source_manifests=source_manifests + ), + declaration, + ) + + +def main() -> None: + """Check one plugin directory in a child process and report the outcome as JSON.""" + + import json + + try: + manifest, _ = load_plugin(Path(sys.argv[1])) + except Exception as error: + message = ( + str(error) + if isinstance(error, PluginConfigError) + else f"import or validation failed: {type(error).__name__}" + ) + print(json.dumps({"error": message})) + return + print(json.dumps({"manifest": manifest.model_dump(mode="json")})) + + +if __name__ == "__main__": + main() diff --git a/src/powercontext/builtin/plugins/storage.py b/src/powercontext/builtin/plugins/storage.py new file mode 100644 index 0000000000..d1efc2f154 --- /dev/null +++ b/src/powercontext/builtin/plugins/storage.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dedicated plugin storage and the models a plugin may call.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Sequence +from contextlib import AsyncExitStack +from pathlib import Path +from typing import Any, Literal, cast + +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from powercontext.plugins import ChatMessage, ModelUnavailable + + +class SQLitePluginStorage: + """One SQLite file per plugin; plugins own its tables and migrations.""" + + backend: Literal["sqlite"] = "sqlite" + + def __init__(self, path: Path) -> None: + self.path = path + self._engine: AsyncEngine | None = None + + def engine(self) -> AsyncEngine: + if self._engine is None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self._engine = create_async_engine(f"sqlite+aiosqlite:///{self.path}") + return self._engine + + async def aclose(self) -> None: + if self._engine is not None: + await self._engine.dispose() + + +def plugin_storage(database: Any, root: str | None, name: str) -> SQLitePluginStorage | None: + """Allocate the plugin's area on the deployment backend; other backends are not served yet.""" + + from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig + + if root is None or not isinstance(database, SQLiteConfig): + return None + return SQLitePluginStorage(Path(root) / f"{name}.sqlite3") + + +class ServerModels: + """Server's configured models; the plugin never sees their addresses or credentials.""" + + def __init__(self, embedding: Any, generation: Any, inference: Any) -> None: + self._embedding = embedding + self._generation = generation + self._inference = inference + + @property + def embedding_available(self) -> bool: + return self._embedding is not None + + @property + def embedding_dimension(self) -> int | None: + return None if self._embedding is None else self._embedding.profile.dimension + + @property + def generation_available(self) -> bool: + return self._generation is not None + + async def embed(self, texts: Sequence[str], /) -> tuple[tuple[float, ...], ...]: + if self._embedding is None: + raise ModelUnavailable("the Server has no embedding model") # noqa: TRY003 + result = await self._embedding.embed(tuple(texts)) + return tuple(tuple(float(value) for value in vector) for vector in result.vectors) + + async def generate(self, messages: Sequence[ChatMessage], /, *, max_tokens: int | None = None) -> str: + if self._generation is None: + raise ModelUnavailable("the Server has no generation model") # noqa: TRY003 + from pydantic_ai.direct import model_request + from pydantic_ai.messages import ( + ModelMessage, + ModelRequest, + ModelRequestPart, + ModelResponse, + SystemPromptPart, + TextPart, + UserPromptPart, + ) + + history: list[ModelMessage] = [] + pending: list[ModelRequestPart] = [] + for message in messages: + if message.role == "assistant": + if pending: + history.append(ModelRequest(parts=pending)) + pending = [] + history.append(ModelResponse(parts=[TextPart(message.content)])) + elif message.role == "system": + pending.append(SystemPromptPart(message.content)) + else: + pending.append(UserPromptPart(message.content)) + if pending: + history.append(ModelRequest(parts=pending)) + settings: dict[str, Any] = dict(self._inference.generation_model_settings) + if max_tokens is not None: + settings["max_tokens"] = max_tokens + async with asyncio.timeout(self._inference.generation_timeout_seconds): + response = await model_request(self._generation, history, model_settings=cast(Any, settings)) + return "".join(part.content for part in response.parts if isinstance(part, TextPart)) + + +async def plugin_models(inference: Any, resources: AsyncExitStack) -> ServerModels | None: + if inference is None or (inference.embedding_model is None and inference.generation_model is None): + return None + from powercontext.builtin.runtime.composition import _embedding_models, _open_pydantic_ai_model + + embedding = None + if inference.embedding_model is not None: + embedding, _ = await _embedding_models(inference, resources, None) + generation = None + if inference.generation_model is not None: + _, generation = await _open_pydantic_ai_model( + inference.generation_model, + base_url=inference.generation_base_url, + headers=inference.generation_headers, + resources=resources, + instrumentation=None, + streaming=inference.generation_streaming, + stream_idle_timeout_seconds=inference.generation_stream_idle_timeout_seconds, + ) + return ServerModels(embedding, generation, inference) + + +async def open_plugin_resources( + database: Any, root: str | None, name: str, inference: Any, resources: AsyncExitStack +) -> tuple[SQLitePluginStorage | None, ServerModels | None]: + storage = plugin_storage(database, root, name) + if storage is not None: + resources.push_async_callback(storage.aclose) + return storage, await plugin_models(inference, resources) diff --git a/src/powercontext/builtin/plugins/writer.py b/src/powercontext/builtin/plugins/writer.py new file mode 100644 index 0000000000..d3d71195df --- /dev/null +++ b/src/powercontext/builtin/plugins/writer.py @@ -0,0 +1,772 @@ +# 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 constrained Artifact writer handed to ``generate``, user changes, and plugin routes. + +Writes reuse the shared merge service so plugin families follow the same revision, +merge, restoration and governance rules as built-in families. In a Worker the single +write transaction commits together with the consumption cursor and acknowledgment; in +the host process it commits one request's writes as the caller. +""" + +from __future__ import annotations + +import asyncio +import hashlib +from collections.abc import Awaitable, Callable, Collection, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from functools import partial +from typing import Any, Literal, cast +from uuid import uuid4 + +from pydantic import JsonValue +from sqlalchemy import delete, func, insert, select +from sqlalchemy.ext.asyncio import AsyncConnection +from typing_extensions import override + +from powercontext.artifacts import ArtifactLineage, ArtifactRef +from powercontext.builtin.artifacts.merge import ArtifactMergeService +from powercontext.builtin.artifacts.merge_models import ArtifactMergeRecord +from powercontext.builtin.persistence.artifact_governance import ( + ArtifactGovernanceRepository, + ArtifactLifecycleState, +) +from powercontext.builtin.persistence.artifacts import RepositoryArtifactDraft +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.records import _canonical_source_text +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.tables import ( + ARTIFACT_HANDLES_TABLE, + ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_TAGS_TABLE, +) +from powercontext.builtin.plugins.families import ( + PluginArtifactContent, + plugin_artifact_repository, + record_revision_metadata, +) +from powercontext.builtin.runtime.artifact_merge import ArtifactMergeAccess, merge_artifact_tags +from powercontext.builtin.sources.content import ContentSource, ContentSourceInternal, ContentSourceTarget +from powercontext.builtin.tags import normalize_tags +from powercontext.plugins import ArtifactChange, ArtifactPage, ArtifactSnapshot, LifecycleState, UndoMergePreview +from powercontext.sources import SourceMaterialization, SourceRef + +MAX_WRITES_PER_TRANSACTION = 1000 +MAX_CONTENT_BYTES = 256 * 1024 +MAX_TRANSACTION_BYTES = 8 * 1024 * 1024 + + +class PluginWriteError(ValueError): + """A plugin write violated the writer's scope, evidence, or size constraints.""" + + +class PluginContractError(PluginWriteError): + """A user-change method returned without completing exactly its agreed write.""" + + +@dataclass(frozen=True) +class UserChange: + """The one write a user-change method must complete, on ``artifact_id`` (or ``into`` for merges).""" + + operation: Literal["create", "update", "merge", "forget", "restore", "undo_merge"] + artifact_id: str + inputs: tuple[str, ...] = () + + +_AGREED = { + "create": {"put"}, + "update": {"put"}, + "merge": {"merge"}, + "forget": {"deprecate"}, + "restore": {"revert", "activate"}, + "undo_merge": {"undo_merge"}, +} + + +class _Adapter: + """Plugin content is opaque; core records only which plugin version wrote each revision.""" + + def __init__(self, family: str, metadata: bytes | None) -> None: + self.family = family + self.metadata = metadata + + def draft(self, content, lineage, *, merge_inputs=(), historical=False): + return RepositoryArtifactDraft( + family=self.family, + content=PluginArtifactContent.model_validate(content.model_dump(mode="json")), + sources=lineage.sources, + artifacts=lineage.artifacts, + ) + + async def prepare(self, content): + return () + + def validate_prepared(self, prepared): + pass + + async def publish(self, connection, scope_id, record, prepared, execution_context): + if self.metadata is not None: + artifact = record.artifact + await record_revision_metadata( + connection, scope_id, self.family, artifact.artifact_id, artifact.revision, self.metadata + ) + + async def publish_many(self, connection, scope_id, records, execution_context): + for record, prepared in records: + await self.publish(connection, scope_id, record, prepared, execution_context) + + async def remove(self, connection, scope_id, artifact_id): + pass + + +class _Security: + """Worker security establishes owners; Scope authority is checked when the transaction opens.""" + + def __init__(self, family: str, security: Any) -> None: + self.family = family + self.security = security + + def subject(self, context): + return "plugin" if self.security is None else f"{self.security.principal.type}:{self.security.principal.id}" + + async def authorize(self, connection, scope_id, context, action, ref=None): + pass + + async def authorize_sources(self, connection, scope_id, context, sources): + pass + + async def authorize_creates(self, connection, scope_id, context, refs): + pass + + async def establish_owners(self, connection, scope_id, artifact_ids, context): + if self.security is None: + return + from powercontext.server.authz import ResourceRef + + bound = self.security.access.with_connection(connection) + for artifact_id in artifact_ids: + await bound.establish_artifact_owner( + ResourceRef.artifact(scope_id, family=self.family, artifact_id=artifact_id), + self.security.principal, + idempotency_key=f"plugin-owner:{scope_id}:{self.family}:{artifact_id}", + context=self.security.context, + ) + + +class _Reads: + """Reads over one connection; the writer and the transaction share this code.""" + + def __init__(self, owner: PluginArtifactWriter, connect: Callable[[], Any]) -> None: + self._owner = owner + self._connect = connect + + def _seen(self, snapshots: Sequence[ArtifactSnapshot]) -> None: + """Committed reads remember the revision each later write is decided against.""" + + async def _record(self, connection, artifact_id: str, revision: int | None = None) -> ArtifactMergeRecord | None: + owner = self._owner + try: + return await owner.service.get(connection, owner.scope_id, artifact_id, None, revision=revision) + except RepositoryNotFoundError: + return None + + async def get(self, artifact_id: str, /, *, revision: int | None = None) -> ArtifactSnapshot | None: + async with self._connect() as connection: + record = await self._record(connection, artifact_id, revision) + snapshot = None if record is None else _snapshot(record) + if snapshot is not None and revision is None: + self._seen((snapshot,)) + return snapshot + + async def revisions(self, artifact_id: str, /, *, cursor: str | None = None, limit: int = 50) -> ArtifactPage: + owner = self._owner + after = 0 if cursor is None else int(cursor) + async with self._connect() as connection: + state = await owner.states.get(connection, owner.scope_id, owner.family, artifact_id) + artifacts = await owner.artifacts.revisions( + connection, owner.scope_id, owner.family, artifact_id, since_revision=after, through_revision=None + ) + selected = artifacts[:limit] + items = tuple(_snapshot(ArtifactMergeRecord(artifact=item, state=state)) for item in selected) + return ArtifactPage(items=items, next_cursor=str(selected[-1].revision) if len(artifacts) > limit else None) + + async def list( + self, + *, + states: Collection[LifecycleState] = ("active",), + tags: Collection[str] = (), + cursor: str | None = None, + limit: int = 50, + ) -> ArtifactPage: + owner = self._owner + heads = ARTIFACT_HEADS_TABLE + statement = ( + select(heads.c.artifact_id) + .where( + heads.c.scope_id == owner.scope_id, + heads.c.family == owner.family, + heads.c.lifecycle_state.in_(tuple(states)), + ) + .order_by(heads.c.artifact_id) + .limit(limit + 1) + ) + if cursor is not None: + statement = statement.where(heads.c.artifact_id > cursor) + if tags: + keys = tuple(normalize_tags(tuple(tags), allow_empty=False)) + tagged = ( + select(ARTIFACT_TAGS_TABLE.c.artifact_id) + .where( + ARTIFACT_TAGS_TABLE.c.scope_id == owner.scope_id, + ARTIFACT_TAGS_TABLE.c.family == owner.family, + ARTIFACT_TAGS_TABLE.c.target_type == "artifact", + ARTIFACT_TAGS_TABLE.c.tag_key.in_(keys), + ) + .group_by(ARTIFACT_TAGS_TABLE.c.artifact_id) + .having(func.count() == len(keys)) + ) + statement = statement.where(heads.c.artifact_id.in_(tagged)) + async with self._connect() as connection: + identities = (await connection.execute(statement)).scalars().all() + records = [await self._record(connection, identity) for identity in identities[:limit]] + items = tuple(_snapshot(record) for record in records if record is not None) + self._seen(items) + return ArtifactPage(items=items, next_cursor=identities[limit - 1] if len(identities) > limit else None) + + async def by_source(self, ref: SourceRef, /) -> tuple[ArtifactSnapshot, ...]: + owner = self._owner + lineage = ARTIFACT_LINEAGE_SOURCES_TABLE + statement = ( + select(lineage.c.artifact_id) + .where( + lineage.c.scope_id == owner.scope_id, + lineage.c.family == owner.family, + lineage.c.source_type == ref.source_type, + lineage.c.source_id == ref.source_id, + ) + .distinct() + .order_by(lineage.c.artifact_id) + ) + async with self._connect() as connection: + identities = (await connection.execute(statement)).scalars().all() + records = [await self._record(connection, identity) for identity in identities] + found = tuple(_snapshot(record) for record in records if record is not None) + self._seen(found) + return found + + async def merge_inputs(self, artifact_id: str, /) -> tuple[ArtifactRef, ...]: + owner = self._owner + async with self._connect() as connection: + return await owner.artifacts.merge_inputs( + connection, owner.scope_id, ArtifactRef(family=owner.family, artifact_id=artifact_id, revision=1) + ) + + async def tags(self, artifact_id: str, /) -> tuple[str, ...]: + owner = self._owner + async with self._connect() as connection: + labels = await connection.scalars( + select(ARTIFACT_TAGS_TABLE.c.tag) + .where(*_tag_identity(owner.scope_id, owner.family, artifact_id)) + .order_by(ARTIFACT_TAGS_TABLE.c.tag_key) + ) + return tuple(labels) + + async def resolve_handle(self, handle_key: str, /) -> ArtifactRef | None: + owner = self._owner + handles = ARTIFACT_HANDLES_TABLE + async with self._connect() as connection: + row = ( + await connection.execute( + select(handles.c.artifact_id, handles.c.revision).where( + handles.c.scope_id == owner.scope_id, + handles.c.family == owner.family, + handles.c.handle_key == handle_key, + ) + ) + ).one_or_none() + if row is None: + return None + return ArtifactRef(family=owner.family, artifact_id=str(row.artifact_id), revision=int(row.revision)) + + +class PluginArtifactTransaction(_Reads): + """Writes run directly in the open transaction and return their exact revisions.""" + + def __init__(self, owner: PluginArtifactWriter, connection: AsyncConnection) -> None: + @asynccontextmanager + async def borrow(): + yield connection + + super().__init__(owner, borrow) + self._connection = connection + self._writes = 0 + self._bytes = 0 + self.closed = False + self.agreed = 0 + + def _agree(self, kind: str, artifact_id: str) -> None: + """In a user change, only the agreed write on its target (and its tags) may run.""" + + change = self._owner.change + if change is None: + return + if kind == "tags" and artifact_id == change.artifact_id: + return + if kind not in _AGREED[change.operation] or artifact_id != change.artifact_id or self.agreed: + raise PluginContractError(f"{change.operation} allows exactly one agreed write on its target") # noqa: TRY003 + self.agreed += 1 + + def _admit(self, content: dict[str, JsonValue] | None = None, sources: Sequence[SourceRef] = ()) -> None: + if self.closed: + raise PluginWriteError("the write transaction is closed") # noqa: TRY003 + self._writes += 1 + if self._writes > MAX_WRITES_PER_TRANSACTION: + raise PluginWriteError("the write transaction exceeds its operation limit") # noqa: TRY003 + if content is not None: + size = len(PluginArtifactContent(content=content).model_dump_json().encode()) + self._bytes += size + if size > MAX_CONTENT_BYTES or self._bytes > MAX_TRANSACTION_BYTES: + raise PluginWriteError("the write transaction exceeds its size limit") # noqa: TRY003 + if self._owner.change is None: + outside = {(ref.source_type, ref.source_id) for ref in sources} - self._owner.window_sources + if outside: + raise PluginWriteError("source_refs must reference Sources of this invocation window") # noqa: TRY003 + + async def _committed(self, plan, direct_source: SourceRef | None = None): + owner = self._owner + prepared = await owner.service.prepare_change(plan) + return await owner.service.commit(self._connection, prepared, owner.context, direct_source=direct_source) + + async def _commit(self, plan) -> ArtifactSnapshot: + return _snapshot((await self._committed(plan)).primary) + + async def _commit_change(self, plan, artifact_id: str) -> ArtifactChange: + result = await self._committed(plan) + change = ArtifactChange( + artifact=_snapshot(result.primary), + restored=tuple(ref for ref in result.restored if ref.artifact_id != artifact_id), + retired=tuple(ref for ref in result.retired if ref.artifact_id != artifact_id), + ) + self._owner.outcome = (result.restored, result.retired, result.undo_merge_results) + return change + + async def _require(self, artifact_id: str) -> ArtifactMergeRecord: + record = await self._record(self._connection, artifact_id) + if record is None: + raise RepositoryNotFoundError("artifact", (self._owner.scope_id, self._owner.family, artifact_id)) + return record + + async def put( + self, + artifact_id: str, + /, + *, + content: dict[str, JsonValue], + source_refs: Sequence[SourceRef] = (), + artifact_refs: Sequence[ArtifactRef] = (), + expected_revision: int | None = None, + ) -> ArtifactSnapshot: + self._admit(content, source_refs) + owner = self._owner + change = owner.change + self._agree("put", artifact_id) + body = owner.body(content) + current = await self._record(self._connection, artifact_id) + if expected_revision is None: + expected_revision = owner.observed.get(artifact_id) + if change is not None: + if (change.operation == "create") != (current is None): + raise owner.service.errors.conflict( + "create needs a new Artifact" if current is not None else "the Artifact no longer exists" + ) + allowed = ( + set() + if current is None + else {(ref.source_type, ref.source_id) for ref in current.artifact.lineage.sources} + ) + if {(ref.source_type, ref.source_id) for ref in source_refs} - allowed: + raise PluginWriteError("user changes may only keep Sources already in the Artifact lineage") # noqa: TRY003 + elif current is not None and current.artifact.content == body: + # As in Atomic Memory, unchanged content adds no revision and records no new evidence. + if expected_revision is not None and expected_revision != current.artifact.revision: + raise owner.service.errors.conflict("the Artifact changed after it was read") # noqa: TRY003 + return _snapshot(current) + if current is None and expected_revision not in {None, 0}: + raise owner.service.errors.conflict("the Artifact no longer exists") # noqa: TRY003 + plan = await owner.service.inspect_change( + self._connection, + owner.scope_id, + artifact_id, + body, + owner.context, + expected_revision=None + if current is None + else (current.artifact.revision if expected_revision is None else expected_revision), + lineage=ArtifactLineage(sources=tuple(source_refs), artifacts=tuple(artifact_refs)), + ) + direct = None + if change is not None: + # Like built-in edits, the submitted content is kept as a lineage-only Source of the new revision. + direct = await _lineage_source( + self._connection, + owner, + artifact_id, + content, + "artifact_create" if current is None else "artifact_replace", + 1 if current is None else current.artifact.revision + 1, + ) + return _snapshot((await self._committed(plan, direct)).primary) + + async def revert(self, artifact_id: str, /, *, to_revision: int) -> ArtifactChange: + self._admit() + self._agree("revert", artifact_id) + owner = self._owner + plan = await owner.service.inspect_restore( + self._connection, owner.scope_id, artifact_id, owner.context, operation="restore", revision=to_revision + ) + return await self._commit_change(plan, artifact_id) + + async def set_lifecycle( + self, artifact_id: str, state: LifecycleState, /, *, replacement_artifact_id: str | None = None + ) -> ArtifactChange: + self._admit() + owner = self._owner + requested = ArtifactLifecycleState(state) + self._agree( + "deprecate" + if requested is ArtifactLifecycleState.DEPRECATED and replacement_artifact_id is None + else "activate" + if requested is ArtifactLifecycleState.ACTIVE + else "transition", + artifact_id, + ) + current = await self._require(artifact_id) + if current.state.lifecycle_state is requested and current.state.replacement_artifact_id == ( + replacement_artifact_id + ): + return ArtifactChange(artifact=_snapshot(current)) + if requested is ArtifactLifecycleState.DEPRECATED and replacement_artifact_id is None: + plan = await owner.service.inspect_forget( + self._connection, + owner.scope_id, + artifact_id, + owner.context, + expected_revision=current.artifact.revision, + expected_state_version=current.state.governance_generation, + ) + return await self._commit_change(plan, artifact_id) + if requested is ArtifactLifecycleState.ACTIVE: + # Shared restoration also undoes the merges that froze an input, as one group. + plan = await owner.service.inspect_restore( + self._connection, owner.scope_id, artifact_id, owner.context, operation="restore" + ) + return await self._commit_change(plan, artifact_id) + await owner.states.transition( + self._connection, + owner.scope_id, + owner.family, + artifact_id, + current.state.governance_generation, + requested, + replacement_artifact_id, + ) + return ArtifactChange(artifact=_snapshot(await self._require(artifact_id))) + + async def merge( + self, + inputs: Sequence[str], + /, + *, + into: str, + content: dict[str, JsonValue], + source_refs: Sequence[SourceRef] = (), + artifact_refs: Sequence[ArtifactRef] = (), + ) -> ArtifactSnapshot: + self._admit(content, source_refs) + self._agree("merge", into) + owner = self._owner + change = owner.change + if change is not None and tuple(inputs) != change.inputs: + raise PluginContractError("merge_artifacts must merge exactly the requested inputs") # noqa: TRY003 + records = [await self._require(identity) for identity in inputs] + result = await self._record(self._connection, into) + if result is not None and all(record.state.merged_into_id == into for record in records): + return _snapshot(result) + reads = tuple(record.as_read() for record in records) + lineage = ArtifactLineage( + sources=tuple(source_refs), + artifacts=(*(read.ref for read in reads), *artifact_refs), + ) + plan = await owner.service.inspect_merge( + self._connection, + owner.scope_id, + into, + reads, + owner.body(content), + owner.context, + lineage=lineage, + ) + return await self._commit(plan) + + async def _undo_plan(self, artifact_id: str): + owner = self._owner + return await owner.service.inspect_restore( + self._connection, owner.scope_id, artifact_id, owner.context, operation="undo_merge" + ) + + async def preview_undo_merge(self, artifact_id: str, /) -> UndoMergePreview: + # Plugins act inside their own transaction, so the plan itself is the preview; no signed token. + plan = await self._undo_plan(artifact_id) + retire = {write.artifact_id for write in plan.writes if write.state is ArtifactLifecycleState.RETIRED} + return UndoMergePreview( + restore=tuple( + ArtifactRef(family=self._owner.family, artifact_id=item.artifact_id, revision=item.source_revision) + for item in plan.restore + ), + retire=tuple(read.ref for read in plan.reads if read.ref.artifact_id in retire), + undo_merge_results=plan.undo_merge_results, + ) + + async def undo_merge(self, artifact_id: str, /) -> UndoMergePreview: + self._admit() + self._agree("undo_merge", artifact_id) + owner = self._owner + prepared = await owner.service.prepare_change(await self._undo_plan(artifact_id)) + result = await owner.service.commit(self._connection, prepared, owner.context) + owner.outcome = (result.restored, result.retired, result.undo_merge_results) + return UndoMergePreview( + restore=result.restored, retire=result.retired, undo_merge_results=result.undo_merge_results + ) + + async def bind_handle(self, handle_key: str, ref: ArtifactRef, /) -> ArtifactRef: + self._admit() + self._agree("handle", ref.artifact_id) + owner = self._owner + if ref.family != owner.family: + raise PluginWriteError("handles must bind this plugin's family") # noqa: TRY003 + existing = await self.resolve_handle(handle_key) + if existing is not None: + if existing != ref: + raise owner.service.errors.conflict("the handle is already bound to another revision") # noqa: TRY003 + return existing + await owner.artifacts.get(self._connection, owner.scope_id, ref) + await self._connection.execute( + insert(ARTIFACT_HANDLES_TABLE).values( + scope_id=owner.scope_id, + family=owner.family, + handle_key=handle_key, + artifact_id=ref.artifact_id, + revision=ref.revision, + ) + ) + return ref + + async def set_tags(self, artifact_id: str, tags: Sequence[str], /) -> tuple[str, ...]: + self._admit() + self._agree("tags", artifact_id) + owner = self._owner + await owner.artifacts.lock_heads( + self._connection, + owner.scope_id, + ((await self._require(artifact_id)).artifact.as_ref(),), + ) + desired = normalize_tags(tuple(tags)) + await self._connection.execute( + delete(ARTIFACT_TAGS_TABLE).where(*_tag_identity(owner.scope_id, owner.family, artifact_id)) + ) + assigned_at = datetime.now(UTC) + for key, label in desired.items(): + await self._connection.execute( + insert(ARTIFACT_TAGS_TABLE).values( + scope_id=owner.scope_id, + family=owner.family, + artifact_id=artifact_id, + target_type="artifact", + target_id=artifact_id, + tag_key=key, + tag_key_hash=hashlib.sha256(key.encode("utf-8")).digest(), + tag=label, + assigned_at=assigned_at, + ) + ) + return tuple(desired.values()) + + +async def _nothing(connection: AsyncConnection) -> None: + return None + + +class PluginArtifactWriter(_Reads): + """Committed reads plus one write transaction. + + Inside an invocation the transaction also commits the cursor and acknowledgment. + """ + + def __init__( + self, + *, + database: Any, + scope_id: str, + family: str, + sources: SourceRepository, + metadata: bytes | None = None, + window_sources: frozenset[tuple[str, str]] = frozenset(), + begin: Callable[[AsyncConnection], Awaitable[None]] = _nothing, + complete: Callable[[AsyncConnection], Awaitable[None]] = _nothing, + transaction_timeout_seconds: float = 30, + security: Any = None, + context: Any = None, + change: UserChange | None = None, + caller_scoped: bool = False, + ) -> None: + """``caller_scoped`` writers act as ``context``'s caller under the shared Artifact access policy.""" + + self.database = database + self.scope_id = scope_id + self._family = family + self.window_sources = window_sources + self._begin = begin + self._complete = complete + self._timeout = transaction_timeout_seconds + self.context = context + self.change = change + self.service = plugin_merge_service( + family, + ArtifactMergeAccess(family) if caller_scoped or change is not None else _Security(family, security), + sources, + metadata=metadata, + ) + self.artifacts = self.service.artifacts + self.states = ArtifactGovernanceRepository() + self.sources = sources + self.observed: dict[str, int] = {} + # Restored and retired refs of the last lifecycle write, which user changes report back. + self.outcome: tuple[tuple[ArtifactRef, ...], tuple[ArtifactRef, ...], tuple[str, ...]] = ((), (), ()) + self.opened = False + self.committed = False + + @asynccontextmanager + async def committed_read(): + async with database.transaction() as connection: + yield connection + + super().__init__(self, committed_read) + + @override + def _seen(self, snapshots: Sequence[ArtifactSnapshot]) -> None: + for snapshot in snapshots: + self.observed.setdefault(snapshot.ref.artifact_id, snapshot.ref.revision) + + @property + def family(self) -> str: + return self._family + + def body(self, content: dict[str, JsonValue]) -> PluginArtifactContent: + return PluginArtifactContent(content=content) + + def stable_id(self, *parts: str) -> str: + digest = hashlib.sha256() + for part in (self._family, *parts): + digest.update(part.encode("utf-8")) + digest.update(b"\0") + return digest.hexdigest() + + @asynccontextmanager + async def transaction(self): + if self.opened: + raise PluginWriteError("each invocation opens at most one write transaction") # noqa: TRY003 + self.opened = True + async with asyncio.timeout(self._timeout), self.database.transaction() as connection: + await self._begin(connection) + transaction = PluginArtifactTransaction(self, connection) + try: + yield transaction + finally: + transaction.closed = True + if self.change is not None and transaction.agreed != 1: + raise PluginContractError(f"{self.change.operation} must complete its agreed write") # noqa: TRY003 + await self._complete(connection) + self.committed = True + + +def plugin_merge_service( + family: str, security: Any, sources: SourceRepository, *, metadata: bytes | None = None +) -> ArtifactMergeService: + """The shared governance engine for one plugin family; ``security`` decides who may change it. + + Plugin invocations use Worker security. The Server registers a second instance with + the shared Artifact access policy for public merge, forget and restoration. ``metadata`` + names the plugin version recorded on every revision it writes. + """ + + return ArtifactMergeService( + artifacts=plugin_artifact_repository(family, sources), + adapter=_Adapter(family, metadata), + security=security, + merge_tags=partial(merge_artifact_tags, family=family), + sources=sources, + ) + + +async def _lineage_source( + connection: AsyncConnection, + owner: PluginArtifactWriter, + artifact_id: str, + content: dict[str, JsonValue], + operation: Literal["artifact_create", "artifact_replace"], + revision: int, +) -> SourceRef: + """Keep submitted content as a lineage-only Source; it never enters a plugin's input window.""" + + source = ContentSource( + name=f"src_{uuid4().hex}", + materialization=SourceMaterialization.CAPTURED, + content=_canonical_source_text(cast(JsonValue, content)), + wire_content=content, + wire_content_present=True, + internal=ContentSourceInternal( + role="lineage_only", + operation=operation, + target=ContentSourceTarget( + scope_id=owner.scope_id, family=owner.family, artifact_id=artifact_id, revision=revision + ), + ), + ) + return (await owner.sources.add(connection, owner.scope_id, source)).ref + + +def _snapshot(record: ArtifactMergeRecord) -> ArtifactSnapshot: + artifact, state = record.artifact, record.state + return ArtifactSnapshot( + ref=artifact.as_ref(), + content=artifact.content.content, + lineage=artifact.lineage, + lifecycle_state=state.lifecycle_state.value, + merged_into_id=state.merged_into_id, + replacement_artifact_id=state.replacement_artifact_id, + governance_generation=state.governance_generation, + ) + + +def _tag_identity(scope_id: str, family: str, artifact_id: str): + tags = ARTIFACT_TAGS_TABLE + return ( + tags.c.scope_id == scope_id, + tags.c.family == family, + tags.c.artifact_id == artifact_id, + tags.c.target_type == "artifact", + ) diff --git a/src/powercontext/builtin/portability/bundle.py b/src/powercontext/builtin/portability/bundle.py index 2cd2a858d7..79bc26e1e1 100644 --- a/src/powercontext/builtin/portability/bundle.py +++ b/src/powercontext/builtin/portability/bundle.py @@ -38,6 +38,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass +from dataclasses import field as dataclass_field from datetime import UTC, date, datetime from importlib.metadata import PackageNotFoundError, version from pathlib import Path @@ -294,6 +295,8 @@ class _RecordSpec: payload: tuple[str, ...] scope_field: str | None = "scope_id" schema_version: int = 1 + # Payload fields of earlier schema versions still accepted on restore; fields they lack restore as null. + earlier_payloads: dict[int, tuple[str, ...]] = dataclass_field(default_factory=dict) _SPECS: dict[RecordType, _RecordSpec] = { @@ -341,8 +344,9 @@ class _RecordSpec: "artifact_revision": _RecordSpec( ARTIFACTS_TABLE, ("scope_id", "family", "artifact_id", "revision"), - ("content", "created_at", "created_by", "restored_from_revision", "rollback_reason"), - schema_version=2, + ("content", "created_at", "created_by", "restored_from_revision", "rollback_reason", "metadata"), + schema_version=3, + earlier_payloads={2: ("content", "created_at", "created_by", "restored_from_revision", "rollback_reason")}, ), "artifact_handle": _RecordSpec( ARTIFACT_HANDLES_TABLE, @@ -1420,7 +1424,10 @@ def _parse_record(value: object) -> _Record: not isinstance(record_type, str) or record_type not in _SPECS or type(value.get("schema_version")) is not int - or value["schema_version"] != _SPECS[cast(RecordType, record_type)].schema_version + or ( + value["schema_version"] != _SPECS[cast(RecordType, record_type)].schema_version + and value["schema_version"] not in _SPECS[cast(RecordType, record_type)].earlier_payloads + ) ): raise BundleFormatError("unsupported record type or schema") identity = value.get("identity") @@ -1432,21 +1439,24 @@ def _parse_record(value: object) -> _Record: payload = cast(dict[str, Any], payload) typed = cast(RecordType, record_type) spec = _SPECS[typed] + version = cast(int, value["schema_version"]) + fields = spec.payload if version == spec.schema_version else spec.earlier_payloads[version] if ( set(identity) != set(spec.identity) - or set(payload) != set(spec.payload) + or set(payload) != set(fields) or any(type(item) is not spec.table.c[field].type.python_type for field, item in identity.items()) or not _valid_digest(digest) ): raise BundleFormatError("record has invalid identity or digest") canonical = { "record_type": typed, - "schema_version": spec.schema_version, + "schema_version": version, "identity": identity, "payload": payload, } if _digest(canonical) != digest: raise BundleFormatError("record digest does not match content") + payload = {name: payload.get(name) for name in spec.payload} record = _Record(record_type=typed, identity=identity, payload=payload, digest=digest) _validate_record_values(record) _validate_record_safe(record) @@ -1569,6 +1579,7 @@ def bytes_values(*fields: str, nullable: tuple[str, ...] = ()) -> None: raise BundleFormatError("Skill package has invalid digest") elif kind == "artifact_revision": bytes_values("content") + bytes_values("metadata", nullable=("metadata",)) strings("created_at", "created_by", "rollback_reason", nullable=("created_at", "created_by", "rollback_reason")) if payload["created_at"] is not None: _validate_datetime_text(payload["created_at"]) diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index 9cb8106e37..6ddd890130 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -89,7 +89,6 @@ class ArtifactRecord(_RecordModel): scope_id: str family: ArtifactReadFamily - schema_version: str | None = None artifact_id: str revision: int content: dict[str, JsonValue] @@ -107,7 +106,6 @@ class ArtifactCollectionItem(_RecordModel): scope_id: str family: ArtifactReadFamily - schema_version: str | None = None artifact_id: str revision: int sources: tuple[SourceRef, ...] diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 989b58ac66..d34eda4b26 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -216,6 +216,7 @@ from powercontext.builtin.runtime.errors import InvalidRuntimeRequestError, TopicMemoryProcessingUnavailableError from powercontext.builtin.runtime.extraction_diagnostics import ExtractionDiagnostics from powercontext.builtin.runtime.models import ( + BUILTIN_CONTEXT_FAMILIES, ApproveCandidateRequest, BootstrapContext, BootstrapContextItem, @@ -1448,6 +1449,11 @@ async def _prepare_build( experience_candidates, profile_candidates, code, + plugins={ + section.family: bool(section.hits) + for section in (request.assembly.sections if request.assembly is not None else ()) + if section.family not in BUILTIN_CONTEXT_FAMILIES + }, ) started_build = perf_counter() if evidence is not None else None with self._runtime._stage( @@ -1582,10 +1588,13 @@ def _record_unretrieved( experience: Sequence[PreparedExperienceCandidates], profiles: Sequence[PreparedProfileCandidate], code: Sequence[PreparedCodeCandidate], + *, + plugins: Mapping[str, bool], ) -> None: if evidence is None: return retrieved = { + **plugins, MEMORY_FAMILY: any(group.hits for group in memory), TOPIC_MEMORY_FAMILY: bool(topic), EXPERIENCE_FAMILY: any(group.hits for group in experience), @@ -3968,6 +3977,17 @@ async def merge( inputs, content, artifact_id=artifact_id, lineage=lineage, context=self._context(context) ) + async def forget( + self, artifact_id: str, *, expected_revision: int, expected_state_version: int, context: Any = None + ) -> ArtifactMergeMutationResult: + async with self._runtime._scoped_operation(self.scope_id, embedding_purpose=ModelUsagePurpose.MEMORY_INDEXING): + return await self._scoped.forget( + artifact_id, + expected_revision=expected_revision, + expected_state_version=expected_state_version, + context=self._context(context), + ) + async def preview_restoration( self, artifact_id: str, @@ -4010,6 +4030,16 @@ def __init__(self, runtime: BuiltinRuntime, application: ArtifactMergeApplicatio def for_scope(self, scope_id: str, family: str, /) -> ScopedArtifactMergeApplication: return ScopedArtifactMergeApplication(self._runtime, self._application, scope_id, family) + def register(self, service: Any) -> None: + """Add a Family discovered after composition, such as a plugin family loaded at startup.""" + + self._application.register(service) + + def content_type(self, family: str) -> type[BaseModel]: + """The content model a registered Family's merge results validate against.""" + + return self._application.content_type(family) + class ScopedTopicMemoryApplication: """Search, exactly read, and request processing for Topic Memory in one scope.""" diff --git a/src/powercontext/builtin/runtime/artifact_merge.py b/src/powercontext/builtin/runtime/artifact_merge.py index f0a6a667c7..2622722d42 100644 --- a/src/powercontext/builtin/runtime/artifact_merge.py +++ b/src/powercontext/builtin/runtime/artifact_merge.py @@ -158,15 +158,22 @@ def register(self, service: ArtifactMergeService) -> None: raise ValueError(f"Artifact merge Family is already registered: {service.family}") # noqa: TRY003 self._services[service.family] = service - def for_scope(self, scope_id: str, family: str, /) -> ScopedArtifactMerge: - scope_id = validate_scope_id(scope_id) + def _service(self, family: str) -> ArtifactMergeService: try: - service = self._services[family] + return self._services[family] except KeyError: raise BaseOperationNotSupportedError( "artifact_family", family, "registered Artifact merge adapter" ) from None - return ScopedArtifactMerge(self, service, scope_id) + + def for_scope(self, scope_id: str, family: str, /) -> ScopedArtifactMerge: + scope_id = validate_scope_id(scope_id) + return ScopedArtifactMerge(self, self._service(family), scope_id) + + def content_type(self, family: str) -> type[BaseModel]: + """The content model a registered Family's merge results validate against.""" + + return self._service(family).artifacts.content_type(family) class ScopedArtifactMerge: @@ -223,6 +230,25 @@ async def merge( async with self.application.database.transaction() as connection: return await self.service.commit(connection, prepared, context) + async def forget( + self, artifact_id: str, *, expected_revision: int, expected_state_version: int, context: Any = None + ) -> ArtifactMergeMutationResult: + """Deprecate one exact current Artifact without a replacement or a new content revision.""" + + context = self._context(context) + async with self.application.database.transaction() as connection: + plan = await self.service.inspect_forget( + connection, + self.scope_id, + artifact_id, + context, + expected_revision=expected_revision, + expected_state_version=expected_state_version, + ) + prepared = await self.service.prepare_forget(plan) + async with self.application.database.transaction() as connection: + return await self.service.commit(connection, prepared, context) + async def restoration_outcome( self, artifact_id: str, *, revision: int, context: Any = None ) -> ArtifactRestorationOutcome | None: diff --git a/src/powercontext/builtin/runtime/artifact_plugins.py b/src/powercontext/builtin/runtime/artifact_plugins.py deleted file mode 100644 index c3dbea3a3e..0000000000 --- a/src/powercontext/builtin/runtime/artifact_plugins.py +++ /dev/null @@ -1,347 +0,0 @@ -# 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. - -"""Execute validated Artifact plugins in an owned generation Worker.""" - -from __future__ import annotations - -import asyncio -import json -import logging -from contextlib import AsyncExitStack -from dataclasses import dataclass -from typing import Any - -from jsonschema import Draft202012Validator -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext._logging import log_safely -from powercontext.artifact_plugins import ( - ArtifactDraft, - GenerationBatch, - GenerationContext, - GenerationSource, -) -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifact_plugin_validation import ( - _MAX_INPUT_BYTES, - _MAX_OUTPUT_BYTES, - _PluginManifest, - code_fingerprint, - describe_module, - load_plugin, -) -from powercontext.builtin.persistence.artifacts import RepositoryArtifactDraft -from powercontext.builtin.persistence.cursors import SourceCursorRepository -from powercontext.builtin.persistence.custom_artifacts import ( - StoredArtifactContent, - custom_artifact_repository, -) -from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess -from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.persistence.tables import ( - ARTIFACT_FAMILY_SCHEMAS_TABLE, - ARTIFACT_PROCESSING_BINDING_STATES_TABLE, -) -from powercontext.builtin.runtime.artifact_reads import ArtifactReadSession -from powercontext.builtin.runtime.config import BuiltinConfig, DatabaseConfig, InferenceConfig, RuntimeConfig -from powercontext.builtin.runtime.processing_contracts import ( - ArtifactProcessingWorkAssignment, - ArtifactProcessingWorkerCompletion, -) -from powercontext.builtin.runtime.processing_execution import InvocationAlreadyHandled, ScopeInvocation -from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY, SourceCursor -from powercontext.sources import SourceProjectionKey - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class ArtifactPluginWorkerSpec: - database: DatabaseConfig - binding_name: str - generation: int - definition: dict[str, Any] - security: dict[str, Any] | None = None - inference_config: Any = None - runtime_config: Any = None - - -async def require_current_binding(connection: AsyncConnection, spec: ArtifactPluginWorkerSpec, scope_id: str) -> None: - table = ARTIFACT_PROCESSING_BINDING_STATES_TABLE - locked = await connection.execute( - update(table) - .where( - table.c.binding_name == spec.binding_name, - table.c.binding_generation == spec.generation, - table.c.phase == "enabled", - ) - .values(binding_generation=table.c.binding_generation) - ) - scope_ids = spec.definition["manifest"]["scope_ids"] - if locked.rowcount != 1 or (scope_ids is not None and scope_id not in scope_ids): - raise ValueError("plugin invocation has been revoked") # noqa: TRY003 - - -def run_artifact_plugin_worker( - spec: ArtifactPluginWorkerSpec, - assignment: ArtifactProcessingWorkAssignment, -) -> ArtifactProcessingWorkerCompletion: - return asyncio.run(_process_window(spec, assignment)) - - -async def _restore_access_families(database) -> None: - from powercontext.builtin.authz.profiles import register_custom_artifact_access_family - - async with database.transaction() as connection: - families = (await connection.execute(select(ARTIFACT_FAMILY_SCHEMAS_TABLE.c.family).distinct())).scalars().all() - for family in families: - register_custom_artifact_access_family(family) - - -async def _process_window(spec: ArtifactPluginWorkerSpec, assignment: ArtifactProcessingWorkAssignment): # noqa: C901 - One authorized window with preparation outside its commit. - # Server authorization is reconstructed in this child using deployment identity. - from powercontext.builtin.authz.worker import open_worker_security - - definition = spec.definition - declaration = definition["declaration"] - if assignment.binding_name != spec.binding_name or assignment.artifact_family != declaration["family"]: - raise ValueError("assignment does not match the registered plugin") # noqa: TRY003 - sources = SourceRepository(BUILTIN_SOURCE_REGISTRY) - cursors = SourceCursorRepository() - async with AsyncExitStack() as resources: - from powercontext.builtin.persistence.artifact_indexes import ArtifactProjectionPublisher - from powercontext.builtin.runtime.composition import ( - _embedding_models, - compose_artifact_search, - open_builtin_contexts, - ) - - embedding = None - if spec.inference_config is not None: - embedding, _ = await _embedding_models(spec.inference_config, resources, None) - config = BuiltinConfig( - database=spec.database, - inference=spec.inference_config or InferenceConfig(), - runtime=spec.runtime_config or RuntimeConfig(), - ) - storage = await resources.enter_async_context( - open_builtin_contexts(config, embedding_model=embedding, _topic_memory_worker=True) - ) - search_service, _ = compose_artifact_search(storage, embedding) - publisher = ArtifactProjectionPublisher(storage.database, embedding) - await _restore_access_families(storage.database) - security = await resources.enter_async_context(open_worker_security(spec.security, storage.database)) - - async def authorize(connection): - await require_current_binding(connection, spec, assignment.scope_id) - if security is not None: - await security.authorize_transaction(connection, assignment.scope_id) - - invocation = ScopeInvocation(assignment, authorize_transaction=authorize) - try: - async with storage.database.transaction() as connection: - await invocation.start(connection) - cursor = await cursors.load(connection, assignment.scope_id, spec.binding_name) - after = 0 if cursor is None else cursor.cursor.sequence - high = await sources.journal_position(connection, assignment.scope_id) - through = min(high, after + definition["manifest"]["max_sources"]) - rows = await GenerationSourceAccess(sources).list_window_for_generation( - connection, - assignment.scope_id, - after=after, - through=through, - ) - # Desired index builds belong to maintenance. Generation prepares only the - # current serving projections, so an unavailable future index cannot block writes. - projections = { - name: SourceProjectionKey.model_validate(key) for name, key in declaration["projections"].items() - } - batch = GenerationBatch( - sources=tuple( - GenerationSource( - ref=row.ref, - projection=projections[row.ref.source_type], - value=BUILTIN_SOURCE_REGISTRY.project(row.value, projections[row.ref.source_type]), - ) - for row in rows - if row.ref.source_type in projections - ) - ) - if ( - len( - json.dumps( - [source.model_dump(mode="json") for source in batch.sources], - ensure_ascii=False, - separators=(",", ":"), - ).encode() - ) - > _MAX_INPUT_BYTES - ): - raise ValueError("input evidence exceeds the window budget") # noqa: TRY003 - source_evidence = frozenset((source.ref.source_type, source.ref.source_id) for source in batch.sources) - artifact_evidence = ArtifactReadSession( - storage.database, - assignment.scope_id, - {declaration["family"], *declaration.get("artifact_families", ())}, - access=None if security is None else security.access, - principal=None if security is None else security.principal, - audit=None if security is None else security.context, - guard=invocation.guard, - search_service=search_service, - ) - - drafts = [] - if batch.sources: - if code_fingerprint(definition["directory"]) != declaration["fingerprint"]: - raise ValueError("plugin files changed after registration") # noqa: TRY003 - manifest = _PluginManifest.model_validate(definition["manifest"]) - module = load_plugin(definition["directory"], manifest) - if describe_module(module, manifest, definition["directory"]) != { - "manifest": definition["manifest"], - "declaration": declaration, - }: - raise ValueError("Worker declaration differs from the registered definition") # noqa: TRY003 - ctx = GenerationContext( - assignment.scope_id, - resources, - artifact_evidence.read, - list_artifacts=artifact_evidence.list, - search_artifacts=artifact_evidence.search, - ) - pipeline = await module.create_pipeline( - ctx, module.Config.model_validate_json(json.dumps(manifest.config, ensure_ascii=False)) - ) - drafts = await pipeline.generate(batch) - validated = _validate_drafts(declaration, source_evidence, artifact_evidence.refs, drafts) - prepared = {} - for draft in validated: - prepared[draft.artifact_id] = await publisher.prepare( - assignment.scope_id, draft.family, draft.schema_version, draft.content - ) - await _commit_window( - storage.database, - spec, - assignment, - declaration, - invocation, - sources, - cursor, - through, - source_evidence, - artifact_evidence.refs, - drafts, - security, - prepared, - ) - except InvocationAlreadyHandled: - pass - return ArtifactProcessingWorkerCompletion() - - -def _validate_drafts(declaration, source_evidence, artifact_evidence, drafts) -> list[ArtifactDraft]: - if not isinstance(drafts, list): - raise TypeError("plugin must return a list of drafts") # noqa: TRY003 - validated = [ArtifactDraft.model_validate(draft) for draft in drafts] - if ( - len(json.dumps([draft.model_dump(mode="json") for draft in validated], ensure_ascii=False).encode()) - > _MAX_OUTPUT_BYTES - ): - raise ValueError("output exceeds the window budget") # noqa: TRY003 - validator = Draft202012Validator(declaration["content_schema"]) - identities = set() - for draft in validated: - if (draft.family, draft.schema_version) != (declaration["family"], declaration["schema_version"]): - raise ValueError("draft does not match the registered family/schema") # noqa: TRY003 - if draft.artifact_id in identities or not { - (ref.source_type, ref.source_id) for ref in draft.source_refs - }.issubset(source_evidence): - raise ValueError("duplicate artifact or Source evidence outside this invocation") # noqa: TRY003 - if not {(ref.family, ref.artifact_id, ref.revision) for ref in draft.artifact_refs}.issubset(artifact_evidence): - raise ValueError("Artifact evidence was not read through this invocation") # noqa: TRY003 - if ( - draft.expected_revision > 0 - and (draft.family, draft.artifact_id, draft.expected_revision) not in artifact_evidence - ): - raise ValueError("update revision must have been read through this invocation") # noqa: TRY003 - identities.add(draft.artifact_id) - validator.validate(draft.content) - return validated - - -async def _commit_window( - database, - spec, - assignment, - declaration, - invocation, - sources, - cursor, - through, - source_evidence, - artifact_evidence, - drafts, - security, - prepared_indexes=None, -): - from powercontext.builtin.authz import AccessAction, ResourceRef, bounded_idempotency_key - - validated = _validate_drafts(declaration, source_evidence, artifact_evidence, drafts) - repository = custom_artifact_repository(declaration["family"], sources, prepared=prepared_indexes) - async with database.transaction() as connection: - # The Worker guard rechecks Scope contribution, which includes evidence reads. - await invocation.guard(connection) - for draft in validated: - resource = ResourceRef.artifact(assignment.scope_id, family=draft.family, artifact_id=draft.artifact_id) - bound = None if security is None else security.access.with_connection(connection) - prepared = RepositoryArtifactDraft( - family=draft.family, - content=StoredArtifactContent(schema_version=draft.schema_version, content=draft.content), - sources=draft.source_refs, - artifacts=draft.artifact_refs, - ) - if draft.expected_revision == 0: - await repository.create(connection, assignment.scope_id, draft.artifact_id, prepared) - if bound is not None: - await bound.establish_artifact_owner( - resource, - security.principal, - idempotency_key=bounded_idempotency_key( - "background-custom-owner", assignment.scope_id, draft.family, draft.artifact_id - ), - context=security.context, - ) - else: - if bound is not None: - await bound.require( - security.principal, AccessAction.ARTIFACT_WRITE, resource, context=security.context - ) - previous = await repository.get( - connection, - assignment.scope_id, - ArtifactRef(family=draft.family, artifact_id=draft.artifact_id, revision=draft.expected_revision), - ) - await repository.revise(connection, assignment.scope_id, previous, prepared) - if through > (0 if cursor is None else cursor.cursor.sequence): - await SourceCursorRepository().save( - connection, - assignment.scope_id, - spec.binding_name, - SourceCursor(sequence=through), - expected_generation=None if cursor is None else cursor.generation, - ) - high = await sources.journal_position(connection, assignment.scope_id) - await invocation.complete(connection, remaining_work=high > through) - log_safely(logger, logging.DEBUG, "Artifact plugin window committed") diff --git a/src/powercontext/builtin/runtime/artifact_reads.py b/src/powercontext/builtin/runtime/artifact_reads.py deleted file mode 100644 index 60a33660cd..0000000000 --- a/src/powercontext/builtin/runtime/artifact_reads.py +++ /dev/null @@ -1,206 +0,0 @@ -# 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. - -"""Invocation-scoped Artifact reads and shared custom-family FTS adapters.""" - -from contextlib import AsyncExitStack, asynccontextmanager -from typing import get_args - -from pydantic import JsonValue, RootModel -from sqlalchemy import select - -from powercontext.artifact_plugins import ArtifactReferencePage, ArtifactSnapshot -from powercontext.artifacts import Artifact, ArtifactRef -from powercontext.artifacts.search import ( - ArtifactSearchExecutionContext, -) -from powercontext.builtin.authz import AccessAction, BuiltinAuthorizationProvider, ResourceRef -from powercontext.builtin.persistence.artifacts import ArtifactRepository -from powercontext.builtin.persistence.cursor_codec import SignedCursorCodec -from powercontext.builtin.persistence.custom_artifacts import StoredArtifactContent, custom_artifact_repository -from powercontext.builtin.persistence.errors import RepositoryNotFoundError -from powercontext.builtin.persistence.tables import ARTIFACT_FAMILY_SCHEMAS_TABLE, ARTIFACT_HEADS_TABLE -from powercontext.builtin.records import BaseArtifactFamily, BaseValueNotFoundError, InvalidBaseAccessRequestError -from powercontext.builtin.runtime.artifact_search import ArtifactSearchService -from powercontext.builtin.runtime.custom_artifact_search import CustomArtifactSearcher - - -class _StoredJsonContent(RootModel[dict[str, JsonValue]]): - pass - - -class ArtifactReadSession: - """Constrain reads to one Scope and record exact bodies eligible for generation lineage.""" - - def __init__( - self, - database, - scope_id, - families, - *, - access=None, - principal=None, - audit=None, - guard=None, - embedding_model=None, - search_service=None, - ): - self.database, self.scope_id = database, scope_id - self.families = frozenset(families) - self.access, self.principal, self.audit, self.guard = access, principal, audit, guard - # Only the exact built-in Provider guarantees Scope-to-Artifact read inheritance. - self._scope_covers_artifacts = access is None or type(access.provider) is BuiltinAuthorizationProvider - self.refs: set[tuple[str, str, int]] = set() - self.cursor = SignedCursorCodec() - self.search_service = search_service or ArtifactSearchService( - known_families=get_args(BaseArtifactFamily), - fallback=lambda family: CustomArtifactSearcher(database, family, embedding_model), - ) - - def _family(self, family): - if family not in self.families: - raise InvalidBaseAccessRequestError("family", "is outside the invocation's declared read families") - - @asynccontextmanager - async def _connection(self, family): - self._family(family) - async with AsyncExitStack() as resources: - if self.access is not None: - await resources.enter_async_context(self.access.defer_decision_audit()) - connection = await resources.enter_async_context(self.database.transaction()) - await connection.exec_driver_sql("SAVEPOINT pc_artifact_read") - if self.guard is not None: - await self.guard(connection) - # The Worker guard already checks current Scope contribution in this transaction. - if self.access is not None and (self.guard is None or not self._scope_covers_artifacts): - await self.access.for_read(connection).require( - self.principal, - AccessAction.SCOPE_READ, - ResourceRef.scope(self.scope_id), - context=self.audit, - ) - yield connection - - async def _repository(self, connection, family): - known = await connection.scalar( - select(ARTIFACT_FAMILY_SCHEMAS_TABLE.c.family) - .where( - ARTIFACT_FAMILY_SCHEMAS_TABLE.c.family == family, - ) - .limit(1) - ) - if known is not None: - return custom_artifact_repository(family) - if family in get_args(BaseArtifactFamily): - artifact_type = type("StoredArtifact", (Artifact[_StoredJsonContent],), {"family": family}) - return ArtifactRepository((artifact_type,)) - raise BaseValueNotFoundError("artifact_family", family) - - async def read(self, family, artifact_id, revision): - async with self._connection(family) as connection: - repository = await self._repository(connection, family) - try: - artifact = ( - await repository.latest(connection, self.scope_id, family, artifact_id) - if revision is None - else await repository.get( - connection, - self.scope_id, - ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), - ) - ) - except RepositoryNotFoundError: - if revision is None: - return None - raise BaseValueNotFoundError("artifact_revision", (family, artifact_id, revision)) from None - if self.access is not None and not self._scope_covers_artifacts and family != "topic-memory": - await self.access.for_read(connection).require( - self.principal, - AccessAction.ARTIFACT_READ, - ResourceRef.artifact(self.scope_id, family=family, artifact_id=artifact_id), - context=self.audit, - ) - ref = artifact.as_ref() - self.refs.add((ref.family, ref.artifact_id, ref.revision)) - custom = isinstance(artifact.content, StoredArtifactContent) - return ArtifactSnapshot( - ref=ref, - lineage=artifact.lineage, - schema_version=artifact.content.schema_version if custom else None, - content=artifact.content.content if custom else artifact.content.model_dump(mode="json", by_alias=True), - source_refs=artifact.lineage.sources, - artifact_refs=artifact.lineage.artifacts, - ) - - async def list(self, family, *, limit=50, cursor=None): - if type(limit) is not int or not 1 <= limit <= 100: - raise InvalidBaseAccessRequestError("limit", "must be between 1 and 100") - expected = {"scope": self.scope_id, "family": family, "limit": limit} - after = self.cursor.after_text(cursor, expected) - selected = [] - async with self._connection(family) as connection: - await self._repository(connection, family) - bound = None if self.access is None else self.access.for_read(connection) - while len(selected) <= limit: - rows = ( - await connection.execute( - select(ARTIFACT_HEADS_TABLE.c.artifact_id, ARTIFACT_HEADS_TABLE.c.revision) - .where( - ARTIFACT_HEADS_TABLE.c.scope_id == self.scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.lifecycle_state == "active", - ARTIFACT_HEADS_TABLE.c.artifact_id > after, - ) - .order_by(ARTIFACT_HEADS_TABLE.c.artifact_id) - .limit(100) - ) - ).all() - if not rows: - break - for artifact_id, revision in rows: - after = artifact_id - if bound is not None and not self._scope_covers_artifacts and family != "topic-memory": - decision = await bound.check( - self.principal, - AccessAction.ARTIFACT_READ, - ResourceRef.artifact(self.scope_id, family=family, artifact_id=artifact_id), - context=self.audit, - ) - if not decision.allowed: - continue - selected.append(ArtifactRef(family=family, artifact_id=artifact_id, revision=revision)) - if len(selected) > limit: - break - items = tuple(selected[:limit]) - return ArtifactReferencePage( - items=items, - next_cursor=self.cursor.encode(expected, items[-1].artifact_id) if len(selected) > limit else None, - ) - - async def search(self, family, query=None, **parameters): - self._family(family) - async with self._connection(family): - pass - payload = dict(parameters) - if query is not None: - payload["query"] = query - outcome = await self.search_service.search( - self.scope_id, - family, - payload, - execution_context=ArtifactSearchExecutionContext( - access=self.access, principal=self.principal, audit=self.audit, trusted_local=self.access is None - ), - ) - return outcome diff --git a/src/powercontext/builtin/runtime/artifact_search.py b/src/powercontext/builtin/runtime/artifact_search.py index ddc5554b3a..226a420e10 100644 --- a/src/powercontext/builtin/runtime/artifact_search.py +++ b/src/powercontext/builtin/runtime/artifact_search.py @@ -66,9 +66,8 @@ async def invoke( class ArtifactSearchService: """Dispatch repository-known Families without knowing their concrete models.""" - def __init__(self, *, known_families: Iterable[str], fallback=None) -> None: + def __init__(self, *, known_families: Iterable[str]) -> None: self._known_families = frozenset(known_families) - self._fallback = fallback self._bindings: dict[str, _SearcherBinding[Any, ArtifactSearchOutcome]] = {} def register( @@ -106,8 +105,6 @@ async def search( def _binding(self, family: str) -> _SearcherBinding[Any, ArtifactSearchOutcome]: if family not in self._known_families: - if self._fallback is not None: - return _SearcherBinding(self._fallback(family), None) raise ArtifactSearchFamilyNotFound(family) binding = self._bindings.get(family) if binding is None: diff --git a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py index 09cfe81ab1..004689ba0e 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py +++ b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py @@ -68,6 +68,7 @@ from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_REVISION_COLUMNS, ARTIFACTS_TABLE, ) from powercontext.sources import SourceRef @@ -148,7 +149,7 @@ async def _evidence_history( evidence = AtomicMemoryEvidenceRepository() after: tuple[str, str, int] | None = None while True: - statement = select(table).where(table.c.family == AtomicMemory.family) + statement = select(*ARTIFACT_REVISION_COLUMNS).where(table.c.family == AtomicMemory.family) if selected_scope is not None: statement = statement.where(table.c.scope_id == selected_scope) if after is not None: diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 227b901c9b..d477cc7985 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -122,7 +122,6 @@ ensure_supported_atomic_memory_schema, ) from powercontext.builtin.persistence.bootstrap_receipts import BootstrapReceiptRepository -from powercontext.builtin.persistence.custom_artifacts import ensure_artifact_plugin_schema from powercontext.builtin.persistence.experience_index import ensure_artifact_head_searchable_text from powercontext.builtin.persistence.lexical_projection import refresh_lexical_projections from powercontext.builtin.persistence.oceanbase.atomic_memory_index import OceanBaseAtomicMemoryIndex @@ -1186,12 +1185,8 @@ async def _open_artifact_processing_supervisor( def compose_artifact_search(contexts, embedding_model=None, *, observer=None): """Use the same Family-owned Searchers in Server, SDK and generation Workers.""" - from powercontext.builtin.runtime.custom_artifact_search import CustomArtifactSearcher - - artifact_search = ArtifactSearchService( - known_families=contexts.repositories.artifacts.families, - fallback=lambda family: CustomArtifactSearcher(contexts.database, family, embedding_model), - ) + # Plugin families are searched by their plugins through the Server, not here. + artifact_search = ArtifactSearchService(known_families=contexts.repositories.artifacts.families) artifact_search.register( ExperienceArtifactSearcher(contexts.database, contexts.repositories.artifacts, contexts.experience_index) ) @@ -1278,7 +1273,6 @@ async def open_builtin_contexts( # noqa: C901 -- Backend admission and resource validate_only=True, ) else: - await ensure_artifact_plugin_schema(connection) await bootstrap_processing_schema(connection, canonical_processing_manifest(config)) await assert_processing_schema_ready(connection, canonical_processing_manifest(config)) await ensure_skill_distribution_schema(connection) @@ -1369,7 +1363,6 @@ async def open_builtin_contexts( # noqa: C901 -- Backend admission and resource connection, topic_index, worker=_topic_memory_worker, recovery=_archive_recovery, validate_only=True ) else: - await ensure_artifact_plugin_schema(connection) await bootstrap_processing_schema(connection, canonical_processing_manifest(config)) await assert_processing_schema_ready(connection, canonical_processing_manifest(config)) await ensure_skill_distribution_schema(connection) diff --git a/src/powercontext/builtin/runtime/custom_artifact_search.py b/src/powercontext/builtin/runtime/custom_artifact_search.py deleted file mode 100644 index 775e5c52b7..0000000000 --- a/src/powercontext/builtin/runtime/custom_artifact_search.py +++ /dev/null @@ -1,463 +0,0 @@ -# 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. - -"""Declaration-driven text/vector/hybrid search returning complete exact Artifacts.""" - -from __future__ import annotations - -import math -import sqlite3 -import struct -from contextlib import AsyncExitStack -from dataclasses import dataclass -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, field_validator -from sqlalchemy import bindparam, select, text -from sqlalchemy.exc import SQLAlchemyError - -from powercontext.artifacts import Artifact, ArtifactRef -from powercontext.artifacts.fusion import ( - FusionCandidate, - FusionChannel, - FusionSelection, - RrfParameters, - fuse_rrf, - parse_fusion_parameters, -) -from powercontext.artifacts.indexes import VectorIndex -from powercontext.artifacts.search import ( - ArtifactSearchExecutionContext, - ArtifactSearchFamilyNotFound, - ArtifactSearchMatch, - ArtifactSearchQuery, - ArtifactSearchUnavailable, - ArtifactSearchUnsupported, - ChannelScore, -) -from powercontext.builtin.artifacts.memory.models import EmbeddingProfile -from powercontext.builtin.artifacts.search import analyze_fts_query, fts_match_query, lexical_search_score -from powercontext.builtin.authz import AccessAction, BuiltinAuthorizationProvider, ResourceRef -from powercontext.builtin.inference.protocols import embed_query -from powercontext.builtin.persistence.artifact_indexes import ( - DEFINITIONS, - SCHEMAS, - STATES, - entries, - key, - parse_index, - projection_table, - search_declaration, - vector_bytes, -) -from powercontext.builtin.persistence.custom_artifacts import custom_artifact_repository -from powercontext.builtin.persistence.sqlite.profile import ensure_sqlite_vector_extension -from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, ARTIFACT_PROCESSING_BINDING_STATES_TABLE - - -class ArtifactQueryVector(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) - profile: EmbeddingProfile - vector: tuple[float, ...] = Field(min_length=1) - - @field_validator("profile", mode="before") - @classmethod - def existing_profile_contract(cls, value): - if isinstance(value, dict) and set(value) - set(EmbeddingProfile.model_fields): - raise ValueError("query vector profile contains unsupported fields") # noqa: TRY003 - return value - - -class CustomArtifactSearchRequest(ArtifactSearchQuery): - query: str | None = Field(default=None, min_length=1, max_length=8192) - mode: Literal["text", "vector", "hybrid"] | None = None - query_vector: ArtifactQueryVector | None = None - indexes: dict[Literal["text", "vector"], tuple[str, ...]] | None = None - min_score: float = Field(default=0, ge=0, le=1, allow_inf_nan=False) - fusion: FusionSelection | None = None - - -@dataclass(frozen=True) -class CustomArtifactSearchOutcome: - matches: tuple[ArtifactSearchMatch, ...] - artifacts: tuple[Any, ...] - - -class CustomArtifactSearcher: - request_type = CustomArtifactSearchRequest - - def __init__(self, database, family, embedding_model=None): - self.database, self.family, self.embedding_model = database, family, embedding_model - self.artifact_type = type( - "CustomSearchArtifact", (Artifact[RootModel[dict[str, JsonValue]]],), {"family": family} - ) - - async def _configuration(self, connection, scope, request, *, config=None): # noqa: C901 - Validate one target or empty-scope snapshot. - durable = ( - ( - await connection.execute( - select(SCHEMAS.c.declaration) - .where(SCHEMAS.c.family == self.family) - .order_by(SCHEMAS.c.schema_version) - ) - ) - .scalars() - .all() - ) - if not durable: - raise ArtifactSearchFamilyNotFound(self.family) - declarations = [declaration for row in durable if (declaration := search_declaration(row)) is not None] - if not declarations: - raise ArtifactSearchUnsupported(self.family, field="family") - state = ( - (await connection.execute(select(STATES).where(key(STATES, scope, self.family)))).mappings().one_or_none() - ) - manifest = {} if state is None else state["manifest"] - if config is None and not manifest.get("config"): - binding = ARTIFACT_PROCESSING_BINDING_STATES_TABLE - definition = await connection.scalar( - select(binding.c.plugin_spec).where(binding.c.plugin_family == self.family) - ) - default = None if definition is None else search_declaration(definition["declaration"]) - config = default or declarations[0] - elif config is None: - # The manifest owns build identity/progress; query policy comes from the - # durable declaration in the same read snapshot, including during repair. - config = next( - (search_declaration(row) for row in durable if row["schema_version"] == manifest["default_schema"]), - None, - ) - if config is None: - raise ArtifactSearchUnavailable(self.family, "index declaration unavailable") - mode = request.mode or config.default_mode - self._validate_input(request, mode) - if mode not in config.modes: - raise ArtifactSearchUnsupported(self.family, field="mode") - try: - chosen = config.select_indexes(mode, request.indexes) - except ValueError as error: - raise ArtifactSearchUnsupported(self.family, field="indexes") from error - profiles = [index.profile for index in chosen if isinstance(index, VectorIndex)] - if profiles and any(profile != profiles[0] for profile in profiles): - raise ArtifactSearchUnsupported(self.family, field="indexes") - profile = profiles[0] if profiles else None - if request.query_vector is not None: - if request.query_vector.profile != profile: - raise ArtifactSearchUnsupported(self.family, field="query_vector.profile") - try: - vector_bytes(request.query_vector.vector, profile) - except ValueError as error: - raise ArtifactSearchUnsupported(self.family, field="query_vector.vector") from error - if request.fusion is not None: - try: - params = parse_fusion_parameters(request.fusion) - except ValueError as error: - raise ArtifactSearchUnsupported(self.family, field="fusion") from error - weights = {"text": config.text_weight, "vector": config.vector_weight} - if set(params.weights) - set(weights): - raise ArtifactSearchUnsupported(self.family, field="fusion.weights") - weights.update(params.weights) - if sum(weights.values()) <= 0: - raise ArtifactSearchUnsupported(self.family, field="fusion.weights") - if state is None: - has_content = await connection.scalar( - select(ARTIFACT_HEADS_TABLE.c.artifact_id) - .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope, ARTIFACT_HEADS_TABLE.c.family == self.family) - .limit(1) - ) - if mode != "text" or has_content is not None: - raise ArtifactSearchUnavailable(self.family, "index has not been built") - return None, config, mode, (), None - if state["state"] != "ready": - raise ArtifactSearchUnavailable(self.family, "index rebuild is incomplete") - selected = await self._selected_indexes(connection, state, chosen) - if any(isinstance(index, VectorIndex) and index.profile != profile for _, _, index in selected): - raise ArtifactSearchUnsupported(self.family, field="indexes") - return state, config, mode, selected, profile - - async def _selected_indexes(self, connection, state, chosen): - kinds = {index.name: index.kind for index in chosen} - selected = [] - for _, entry in entries(state["manifest"]): - definition = dict( - ( - await connection.execute( - select(DEFINITIONS).where(DEFINITIONS.c.index_def_id == entry["index_def_id"]) - ) - ) - .mappings() - .one() - ) - if definition["index_name"] not in kinds: - continue - await projection_table(connection, definition) - index = parse_index(definition) - if index.kind != kinds[index.name]: - raise ArtifactSearchUnsupported(self.family, field="indexes") - selected.append((state, definition, index)) - if not set(kinds).issubset({definition["index_name"] for _, definition, _ in selected}): - raise ArtifactSearchUnavailable(self.family, "configuration lacks a selected index") - return selected - - async def search(self, scope_id, request, /, *, execution_context=None): - try: - return await self._search(scope_id, request, execution_context=execution_context) - except SQLAlchemyError as error: - raise ArtifactSearchUnavailable(self.family, "search storage is unavailable") from error - - async def _search(self, scope_id, request, /, *, execution_context=None): - execution = execution_context or ArtifactSearchExecutionContext() - access = execution.access - # Pin query policy across encoding outside the transaction; recheck build and access below. - async with AsyncExitStack() as resources: - if access is not None: - await resources.enter_async_context(access.defer_decision_audit()) - async with self.database.transaction(consistent_snapshot=True) as connection: - if access is not None: - await access.for_read(connection).require( - execution.principal, - AccessAction.SCOPE_READ, - ResourceRef.scope(scope_id), - context=execution.audit, - ) - state, config, mode, selected, profile = await self._configuration(connection, scope_id, request) - if state is None: - return CustomArtifactSearchOutcome((), ()) - query_vector = await self._encode_query(request, profile) - async with self.database.transaction(consistent_snapshot=True) as connection: - current, config, mode, selected, checked_profile = await self._configuration( - connection, scope_id, request, config=config - ) - if current["build_id"] != state["build_id"] or checked_profile != profile: - raise ArtifactSearchUnavailable(self.family, "search build changed during query preparation; retry") - bound = None if access is None else access.for_read(connection) - if bound is not None: - await bound.require( - execution.principal, - AccessAction.SCOPE_READ, - ResourceRef.scope(scope_id), - context=execution.audit, - ) - channels = await self._candidates( - connection, scope_id, request, mode, selected, query_vector, profile, bound, execution - ) - native_text = connection.dialect.name != "sqlite" - text_metric = "oceanbase_match" if native_text else "sqlite_bm25" - scored = self._rank(channels, config, mode, request, native_text, text_metric) - return await self._outcome(connection, scope_id, request, channels, scored, native_text, text_metric) - - async def _encode_query(self, request, profile): - query_vector = None - if profile is not None: - if request.query_vector is not None: - try: - query_vector = vector_bytes(request.query_vector.vector, profile) - except ValueError as error: - raise ArtifactSearchUnsupported(self.family, field="query_vector.vector") from error - else: - model = self.embedding_model - if model is None or model.profile != profile: - raise ArtifactSearchUnavailable(self.family, "query encoder unavailable for declared profile") - try: - result = await embed_query(model, (request.query,)) - query_vector = vector_bytes(result.vectors[0], profile) - except Exception as error: - raise ArtifactSearchUnavailable(self.family, "query encoder failed for declared profile") from error - if len(result.vectors) != 1: - raise ArtifactSearchUnavailable(self.family, "query encoder returned an invalid vector count") - return query_vector - - async def _candidates(self, connection, scope_id, request, mode, selected, query_vector, profile, bound, execution): - visible = {} - channels = {name: {} for name in ({"text", "vector"} if mode == "hybrid" else {mode})} - for state, definition, index in selected: - if index.kind == "text": - rows = await self._text( - connection, scope_id, state["build_id"], definition["index_def_id"], request.query - ) - else: - rows = await self._vector(connection, scope_id, definition, state["build_id"], query_vector, profile) - for identity, revision, raw in rows: - if identity not in visible: - visible[identity] = ( - bound is None - # The same transaction already authorized this Scope's reads. - or type(bound.provider) is BuiltinAuthorizationProvider - or ( - await bound.check( - execution.principal, - AccessAction.ARTIFACT_READ, - ResourceRef.artifact(scope_id, family=self.family, artifact_id=identity), - context=execution.audit, - ) - ).allowed - ) - if not visible[identity]: - continue - if not math.isfinite(raw): - raise ArtifactSearchUnavailable(self.family, "index returned a nonfinite score") - address = (identity, revision) - previous = channels[index.kind].get(address) - if previous is None or raw < previous: - channels[index.kind][address] = raw - return channels - - def _rank(self, channels, config, mode, request, native_text, text_metric): - budget = max(request.limit, config.candidate_limit) - ordered = { - name: sorted(values, key=lambda address: (values[address], address))[:budget] - for name, values in channels.items() - } - if mode == "hybrid": - params = RrfParameters(rank_constant=config.rrf_k) - weights = {"text": config.text_weight, "vector": config.vector_weight} - if request.fusion is not None: - try: - params = parse_fusion_parameters(request.fusion) - except ValueError as error: - raise ArtifactSearchUnsupported(self.family, field="fusion") from error - if set(params.weights) - set(weights): - raise ArtifactSearchUnsupported(self.family, field="fusion.weights") - weights.update(params.weights) - if sum(weights.values()) <= 0: - raise ArtifactSearchUnsupported(self.family, field="fusion.weights") - fused = fuse_rrf( - tuple( - FusionChannel( - name, - weights[name], - tuple(FusionCandidate(identity, rank) for rank, identity in enumerate(identities, 1)), - ) - for name, identities in ordered.items() - ), - params, - tie_break=lambda identity: identity, - ) - scored = [(hit.key, hit.score) for hit in fused] - elif mode == "text": - scored = [ - (identity, lexical_search_score((-1 if native_text else 1) * channels[mode][identity], text_metric)[0]) - for identity in ordered[mode] - ] - else: - scored = [(identity, 1 / (1 + channels[mode][identity])) for identity in ordered[mode]] - return scored - - async def _outcome(self, connection, scope_id, request, channels, scored, native_text, text_metric): - repository = custom_artifact_repository(self.family) - matches = [] - final = [(identity, score) for identity, score in scored if score >= request.min_score][: request.limit] - artifacts = await repository.get_many( - connection, - scope_id, - tuple( - ArtifactRef(family=self.family, artifact_id=identity, revision=revision) - for (identity, revision), score in final - ), - ) - for ((identity, revision), score), artifact in zip(final, artifacts, strict=True): - raw_scores = { - name: ChannelScore( - (-1 if native_text and name == "text" else 1) * values[(identity, revision)], - text_metric if name == "text" else "l2_distance", - native_text and name == "text", - ) - for name, values in channels.items() - if (identity, revision) in values - } - matches.append( - ArtifactSearchMatch(artifact.as_ref(), score, raw_scores if request.include_scores else None) - ) - public_artifacts = tuple( - self.artifact_type( - artifact_id=artifact.artifact_id, - revision=artifact.revision, - content=artifact.content.content, - lineage=artifact.lineage, - ) - for artifact in artifacts - ) - return CustomArtifactSearchOutcome(tuple(matches), public_artifacts) - - def _validate_input(self, request, mode): - if mode in {"text", "hybrid"} and request.query is None: - raise ArtifactSearchUnsupported(self.family, field="query") - if mode == "text" and request.query_vector is not None: - raise ArtifactSearchUnsupported(self.family, field="query_vector") - if mode == "vector" and ((request.query is None) == (request.query_vector is None)): - raise ArtifactSearchUnsupported(self.family, field="query_vector") - if mode != "hybrid" and request.fusion is not None: - raise ArtifactSearchUnsupported(self.family, field="fusion") - - async def _text(self, connection, scope, build_id, index_def_id, query): - expression = fts_match_query(query) - if expression is None: - return [] - if connection.dialect.name != "sqlite": - result = await connection.execute( - text("""SELECT artifact_id, revision, - MATCH(searchable_text) AGAINST(:query) AS score FROM pc_artifact_search_text - WHERE scope_id=:scope AND family=:family AND build_id=:build_id AND index_def_id=:index_def_id - AND MATCH(searchable_text) AGAINST(:query) > 0 ORDER BY score DESC, artifact_id"""), - { - "query": analyze_fts_query(query), - "scope": scope, - "family": self.family, - "build_id": build_id, - "index_def_id": index_def_id, - }, - ) - return [(row.artifact_id, int(row.revision), -float(row.score)) for row in result] - result = await connection.execute( - text("""SELECT artifact_id, revision, bm25(pc_artifact_search_fts) AS score - FROM pc_artifact_search_fts WHERE pc_artifact_search_fts MATCH :query - AND scope_id=:scope AND family=:family AND build_id=:build_id AND index_def_id=:index_def_id - ORDER BY score, artifact_id"""), - { - "query": expression, - "scope": scope, - "family": self.family, - "build_id": build_id, - "index_def_id": index_def_id, - }, - ) - return [(row.artifact_id, int(row.revision), float(row.score)) for row in result] - - async def _vector(self, connection, scope, definition, build_id, vector, profile): - if connection.dialect.name == "sqlite": - try: - await ensure_sqlite_vector_extension(connection) - except (AttributeError, sqlite3.Error) as error: - raise ArtifactSearchUnavailable(self.family, "SQLite vector extension is unavailable") from error - table = await projection_table(connection, definition) - function = "vec_distance_l2" if connection.dialect.name == "sqlite" else "l2_distance" - # Table identity comes only from a validated server-owned space, never the request. - statement = text(f"""SELECT artifact_id, revision, MIN({function}(embedding, :vector)) AS distance - FROM {table.name} WHERE scope_id=:scope AND family=:family AND build_id=:build_id AND space_id=:space AND index_def_id=:index_def_id - GROUP BY artifact_id, revision ORDER BY distance, artifact_id""") # noqa: S608 - Function and table names come only from validated server-owned metadata. - if connection.dialect.name != "sqlite": - vector = list(struct.unpack(f"<{profile.dimension}f", vector)) - statement = statement.bindparams(bindparam("vector", type_=table.c.embedding.type)) - result = await connection.execute( - statement, - { - "vector": vector, - "scope": scope, - "family": self.family, - "build_id": build_id, - "space": definition["space_id"], - "index_def_id": definition["index_def_id"], - }, - ) - return [(row.artifact_id, int(row.revision), float(row.distance)) for row in result] diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 3d8f7c3bd5..17ec7f374c 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -294,16 +294,37 @@ class MemorySearchPage(BaseModel): rerank: MemoryRerankTrace | None = None +BUILTIN_CONTEXT_FAMILIES = frozenset({"memory", "experience", "profile", "topic-memory"}) + + +class ContextPluginHit(_PreparedContextModel): + """One passage a plugin's search returned for an exact revision of its family.""" + + artifact: ArtifactRef + text: str + + class ContextAssemblySection(_PreparedContextModel): - """One selected Artifact family and its maximum output count.""" + """One selected Artifact family and its maximum output count. + + A plugin family takes its entries from ``hits``, which the Server fills from the + plugin's ``search_artifacts`` before assembly; ``search`` carries the caller's + search fields for that plugin and is rejected for built-in families. + """ - family: Literal["memory", "experience", "profile", "topic-memory"] + family: Annotated[str, Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")] limit: Annotated[int, Field(ge=1, le=8)] + search: dict[str, JsonValue] | None = None + hits: tuple[ContextPluginHit, ...] = () @model_validator(mode="after") def validate_limit(self) -> ContextAssemblySection: if self.family == "experience" and self.limit > 2: raise ValueError("Experience sections cannot include more than two entries") # noqa: TRY003 + if self.family in BUILTIN_CONTEXT_FAMILIES and (self.search is not None or self.hits): + raise ValueError("search applies only to plugin families") # noqa: TRY003 + if any(hit.artifact.family != self.family for hit in self.hits): + raise ValueError("hits must belong to the section family") # noqa: TRY003 return self diff --git a/src/powercontext/builtin/runtime/prepared_context.py b/src/powercontext/builtin/runtime/prepared_context.py index 2308144a75..b960cb128d 100644 --- a/src/powercontext/builtin/runtime/prepared_context.py +++ b/src/powercontext/builtin/runtime/prepared_context.py @@ -34,7 +34,9 @@ from powercontext.builtin.runtime.atomic_memory import AtomicMemoryDependencySelection, AtomicMemorySearchHit from powercontext.builtin.runtime.errors import PreparedContextInvariantError from powercontext.builtin.runtime.models import ( + BUILTIN_CONTEXT_FAMILIES, ContextAssembly, + ContextAssemblySection, PrepareContextRequest, PreparedContext, PreparedContextReason, @@ -550,7 +552,7 @@ def _select_text( else min(section.limit, self._text_entry_limit - len(included)) ) entries = self._section_entries( - section.family, + section, current_scope_id, memory_candidates, experience_candidates, @@ -635,7 +637,7 @@ def _record_text_limit( def _section_entries( self, - family: str, + section: ContextAssemblySection, current_scope_id: str | None, memory_candidates: Sequence[PreparedMemoryCandidates], experience_candidates: Sequence[PreparedExperienceCandidates], @@ -643,6 +645,9 @@ def _section_entries( topic_memory_hits: Sequence[TopicMemorySearchHit], omitted: Counter[tuple[str, str]] | None, ) -> tuple[_PreparedContextEntry, ...]: + family = section.family + if family not in BUILTIN_CONTEXT_FAMILIES: + return self._plugin_entries(section, scope_id=current_scope_id) if family == "profile": return self._profile_entries(profile_candidates, omitted=omitted) if family == "topic-memory": @@ -660,6 +665,25 @@ def _section_entries( ) return tuple(chain.from_iterable(_interleave_groups(groups))) + def _plugin_entries( + self, section: ContextAssemblySection, *, scope_id: str | None + ) -> tuple[_PreparedContextEntry, ...]: + if scope_id is None: + raise PreparedContextInvariantError("plugin-scope-missing") + entries = [] + for hit in section.hits: + origin = ArtifactAddress(scope_id=scope_id, artifact=hit.artifact) + entries.append( + _PreparedContextEntry( + origin=origin, + kind=section.family, + citation={"artifact": origin.model_dump(mode="json")}, + content=hit.text, + truncated=False, + ) + ) + return tuple(entries) + def _profile_entries( self, candidates: Sequence[PreparedProfileCandidate], *, omitted: Counter[tuple[str, str]] | None = None ) -> tuple[_PreparedContextEntry, ...]: diff --git a/src/powercontext/builtin/runtime/prepared_text.py b/src/powercontext/builtin/runtime/prepared_text.py index e7ebb39911..88da9097f0 100644 --- a/src/powercontext/builtin/runtime/prepared_text.py +++ b/src/powercontext/builtin/runtime/prepared_text.py @@ -47,6 +47,10 @@ class ContextTextItem: truncated: bool = False +# Plugin families are titled with their family name. +_TITLES = {"memory": "Memory", "experience": "Experience", "profile": "Profile", "topic-memory": "Topic Memory"} + + def render_context_text(items: Sequence[ContextTextItem], assembly: ContextAssembly) -> str: """Render already selected entries without interpreting historical Markdown.""" @@ -56,12 +60,7 @@ def render_context_text(items: Sequence[ContextTextItem], assembly: ContextAssem included = [item for item in items if item.artifact.artifact.family in selected_families] if not included: continue - title = { - "memory": "Memory", - "experience": "Experience", - "profile": "Profile", - "topic-memory": "Topic Memory", - }[section.family] + title = _TITLES.get(section.family, section.family) parts.append(f"## {title}") for number, item in enumerate(included, start=1): parts.extend(_item_parts(title, number, item, assembly)) @@ -71,12 +70,8 @@ def render_context_text(items: Sequence[ContextTextItem], assembly: ContextAssem def rendered_text_fragment_bytes(item: ContextTextItem, assembly: ContextAssembly, number: int) -> int: """Measure the rendered item, excluding the separators between items.""" - title = { - "atomic-memory": "Memory", - "experience": "Experience", - "profile": "Profile", - "topic-memory": "Topic Memory", - }[item.artifact.artifact.family] + family = item.artifact.artifact.family + title = _TITLES.get("memory" if family == "atomic-memory" else family, family) return len("\n\n".join(_item_parts(title, number, item, assembly)).encode("utf-8")) diff --git a/src/powercontext/builtin/runtime/receipt.py b/src/powercontext/builtin/runtime/receipt.py index 22a9ae1fd8..407190553c 100644 --- a/src/powercontext/builtin/runtime/receipt.py +++ b/src/powercontext/builtin/runtime/receipt.py @@ -23,7 +23,8 @@ from powercontext.artifacts import ArtifactAddress RECEIPT_POLICY = "powercontext.prepared-context-receipt.v1" -ReceiptFamily = Literal["atomic-memory", "topic-memory", "experience", "profile", "code"] +# atomic-memory, topic-memory, experience, profile, code, or a plugin family. +ReceiptFamily = Annotated[str, Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")] OmissionReason = Literal[ "duplicate", "blank", diff --git a/src/powercontext/builtin/tags.py b/src/powercontext/builtin/tags.py index bb51c0b6af..846b198c05 100644 --- a/src/powercontext/builtin/tags.py +++ b/src/powercontext/builtin/tags.py @@ -24,12 +24,28 @@ from typing import Literal, get_args import rfc8785 -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator from powercontext.artifacts import ArtifactRef from powercontext.builtin.records import BaseAccessError, BaseArtifactFamily, InvalidBaseAccessRequestError -TaggableArtifactFamily = BaseArtifactFamily +TaggableArtifactFamily = str + + +def _taggable_families() -> tuple[str, ...]: + from powercontext.builtin.plugins.families import plugin_families + + return (*get_args(BaseArtifactFamily), *sorted(plugin_families())) + + +def _require_taggable(family: str) -> str: + """Accept built-in families and the plugin families loaded in this process.""" + + if family not in _taggable_families(): + raise InvalidBaseAccessRequestError("family", "is not a taggable Artifact family") + return family + + TagMatch = Literal["all", "any"] TagTargetType = Literal["artifact"] MAX_TARGET_TAGS = 32 @@ -46,6 +62,11 @@ class ArtifactTagTarget(_TagModel): family: TaggableArtifactFamily artifact_id: str = Field(min_length=1, max_length=128) + @field_validator("family") + @classmethod + def validate_family(cls, value: str) -> str: + return _require_taggable(value) + TagTarget = ArtifactTagTarget @@ -120,7 +141,7 @@ class TagPreconditionError(BaseAccessError): class TagQuery(TagFilter): """A bounded exact query within one authorized Scope.""" - families: tuple[TaggableArtifactFamily, ...] = get_args(TaggableArtifactFamily) + families: tuple[TaggableArtifactFamily, ...] = Field(default_factory=_taggable_families) target_types: tuple[TagTargetType, ...] = ("artifact",) include_inactive: bool = False limit: int = Field(default=50, ge=1, le=100) @@ -128,9 +149,12 @@ class TagQuery(TagFilter): @field_validator("families", "target_types") @classmethod - def validate_selection(cls, value: tuple[str, ...]) -> tuple[str, ...]: + def validate_selection(cls, value: tuple[str, ...], info: ValidationInfo) -> tuple[str, ...]: if not value or len(set(value)) != len(value): raise InvalidBaseAccessRequestError("filters", "must contain distinct nonempty selections") + if info.field_name == "families": + for family in value: + _require_taggable(family) return value diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 51c5aed6a0..fd360e1213 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -801,7 +801,8 @@ async def replace_memory_entry_tags( async def query_artifact_tags(self, scope_id: str, request: QueryArtifactTagsRequest) -> ArtifactTagPage: """Find visible targets by exact tags within a Scope.""" - if "memory" in (request.families or ()) or "memory_entry" in (request.target_types or ()): + families = {family.root for family in request.families or ()} + if "memory" in families or "memory_entry" in (request.target_types or ()): raise ValueError("legacy Memory tag queries are unsupported; use atomic-memory Artifact tags") # noqa: TRY003 return await self._request(QUERY_ARTIFACT_TAGS, request, path_parameters={"scope_id": scope_id}) @@ -1446,25 +1447,8 @@ def _prepare_request( if not isinstance(payload, dict): message = "Request must serialize to an object." raise TypeError(message) - if ( - operation is PREPARE_CONTEXT - and isinstance(request, PrepareContextRequest) - and "assembly" not in request.model_fields_set - ): - payload.pop("assembly", None) - if ( - operation is PREPARE_CONTEXT - and isinstance(request, PrepareContextRequest) - and not payload.get("include_receipt", False) - ): - # ``PREPARE_CONTEXT`` serializes its defaults because an older Server needs the fields - # it already knows about. ``include_receipt`` is not one of them: an older Server - # rejects the key with ``422 invalid_request``, so a client upgraded ahead of its - # Server must not volunteer a flag whose default means "say nothing extra". ``false`` - # and absence are the same request. - payload.pop("include_receipt", None) - if operation is PREPARE_CONTEXT and not payload.get("include_reuse", False): - payload.pop("include_reuse", None) + if operation is PREPARE_CONTEXT: + _trim_prepare_context_payload(request, payload) if operation.request_location == "query": request_query.update({key: value for key, value in payload.items() if value is not None}) else: @@ -1475,6 +1459,27 @@ def _prepare_request( return path, json_payload, request_query or None +def _trim_prepare_context_payload(request: object, payload: dict[str, Any]) -> None: + """Send only the Prepared Context fields an older Server already accepts, unless they are used.""" + + if isinstance(request, PrepareContextRequest) and "assembly" not in request.model_fields_set: + payload.pop("assembly", None) + if isinstance(payload.get("assembly"), dict): + # Plugin search fields are new; omit them when unused so an older Server accepts the request. + for section in payload["assembly"].get("sections", []): + if section.get("search") is None: + section.pop("search", None) + if isinstance(request, PrepareContextRequest) and not payload.get("include_receipt", False): + # ``PREPARE_CONTEXT`` serializes its defaults because an older Server needs the fields + # it already knows about. ``include_receipt`` is not one of them: an older Server + # rejects the key with ``422 invalid_request``, so a client upgraded ahead of its + # Server must not volunteer a flag whose default means "say nothing extra". ``false`` + # and absence are the same request. + payload.pop("include_receipt", None) + if not payload.get("include_reuse", False): + payload.pop("include_reuse", None) + + def _bind_operation_path( operation: Operation[Any, Any], path_parameters: Mapping[str, str | int] | None, diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 6c3f4f79b2..01a78630d8 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -58,14 +58,19 @@ ArtifactCreated, ArtifactDreamingOperationCapability, ArtifactFamilyAccessCapability, + ArtifactGovernanceInput, + ArtifactGovernanceRecord, + ArtifactGovernanceResponse, ArtifactHandleBinding, ArtifactInventoryStatistics, + ArtifactLifecycleState, ArtifactPage, - ArtifactPluginPage, - ArtifactPluginState, ArtifactPublication, ArtifactReadFamily, ArtifactReference, + ArtifactRendering, + ArtifactRestorationOperation, + ArtifactRestorationResult, ArtifactRevision, ArtifactRevisionPage, ArtifactSearchFusion, @@ -122,7 +127,6 @@ CaptureWorkingNoteRequest, CatalogChangeProposal, CatalogTagResult, - ChangeArtifactPluginStateRequest, CheckContextBeforeRelease, CheckContextBeforeSend, CheckContextRequest, @@ -141,7 +145,6 @@ ConnectorBinding, ConnectorCheckpointState, ContextAssembly, - ContextAssemblyFamily, ContextAssemblyFormat, ContextAssemblyMetadata, ContextAssemblySection, @@ -156,12 +159,14 @@ CreateAccessBindingRequest, CreateArtifactRequest, CreateAtomicMemoryArtifactRequest, + CreateBuiltinArtifactRequest, CreateDreamRunRequest, CreateExperienceArtifactRequest, CreateHandoffArtifactRequest, CreateMemoryArtifactContent, CreateMemoryArtifactEntry, CreateMemoryArtifactRequest, + CreatePluginArtifactRequest, CreatePromptArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, @@ -172,7 +177,6 @@ CreateTopicMemoryArtifactRequest, CreateWorkContractRequest, CurrentWorkHandoff, - CustomArtifactFamilySchema, DownloadRemoteSkillPackageRequest, DreamBudget, DreamCandidateRef, @@ -190,8 +194,6 @@ DreamSourceReference, DreamStatus, DreamUsage, - EnableAllArtifactPluginsResponse, - EnableArtifactPluginResult, EnrollRemoteSkillTargetRequest, EntryChange, EntryChangeOperation, @@ -223,6 +225,7 @@ FlushStatus, FlushTopicMemoryRequest, FlushTopicMemoryResponse, + ForgetArtifactRequest, GatewayLifecycleDecision, GatewayLifecycleError, GatewayModelSend, @@ -231,6 +234,7 @@ GenerateExperienceRequest, GeneratePromptDemonstrationsRequest, GenerateSkillRequest, + GetArtifactRenderingRequest, GetCandidateRequest, GetConnectorCheckpointRequest, GetExperienceRequest, @@ -295,6 +299,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListPluginScopesRequest, ListRemoteSkillTargetsRequest, ListRemoteSkillTargetsResponse, ListScopesRequest, @@ -313,6 +318,7 @@ MemoryRevisionChanges, MemorySearchMode, MemoryUsedSearchMode, + MergeArtifactsRequest, MergeAtomicMemoryRequest, ModelJsonPayload, ModelJsonTextLocation, @@ -323,6 +329,10 @@ Omission, OpenQuestion, PlanAccessOwnershipRequest, + PluginPage, + PluginScopePage, + PluginScopeState, + PluginState, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -374,7 +384,6 @@ RecurrenceStreak, RegisterSourceDefinitionRequest, RejectCandidateRequest, - ReloadArtifactPluginRequest, RemainingWorkItem, RememberMemoryRequest, RemoteAgentKind, @@ -403,6 +412,7 @@ ReplaceMemoryArtifactContent, ReplaceMemoryArtifactEntry, ReplaceMemoryArtifactRequest, + ReplacePluginArtifactRequest, ReplacePromptArtifactRequest, ReplaceSkillArtifactRequest, ReplaceTopicMemoryArtifactRequest, @@ -411,6 +421,7 @@ ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, + RestoreArtifactRequest, RetireMemoryEntryRequest, RetryTopicMemoryProcessingRequest, RetryTopicMemoryProcessingResponse, @@ -560,14 +571,19 @@ "ArtifactCreated", "ArtifactDreamingOperationCapability", "ArtifactFamilyAccessCapability", + "ArtifactGovernanceInput", + "ArtifactGovernanceRecord", + "ArtifactGovernanceResponse", "ArtifactHandleBinding", "ArtifactInventoryStatistics", + "ArtifactLifecycleState", "ArtifactPage", - "ArtifactPluginPage", - "ArtifactPluginState", "ArtifactPublication", "ArtifactReadFamily", "ArtifactReference", + "ArtifactRendering", + "ArtifactRestorationOperation", + "ArtifactRestorationResult", "ArtifactRevision", "ArtifactRevisionPage", "ArtifactSearchFusion", @@ -624,7 +640,6 @@ "CaptureWorkingNoteRequest", "CatalogChangeProposal", "CatalogTagResult", - "ChangeArtifactPluginStateRequest", "CheckContextBeforeRelease", "CheckContextBeforeSend", "CheckContextRequest", @@ -643,7 +658,6 @@ "ConnectorBinding", "ConnectorCheckpointState", "ContextAssembly", - "ContextAssemblyFamily", "ContextAssemblyFormat", "ContextAssemblyMetadata", "ContextAssemblySection", @@ -658,12 +672,14 @@ "CreateAccessBindingRequest", "CreateArtifactRequest", "CreateAtomicMemoryArtifactRequest", + "CreateBuiltinArtifactRequest", "CreateDreamRunRequest", "CreateExperienceArtifactRequest", "CreateHandoffArtifactRequest", "CreateMemoryArtifactContent", "CreateMemoryArtifactEntry", "CreateMemoryArtifactRequest", + "CreatePluginArtifactRequest", "CreatePromptArtifactRequest", "CreateRemoteSkillTargetRequest", "CreateScopeRequest", @@ -674,7 +690,6 @@ "CreateTopicMemoryArtifactRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", - "CustomArtifactFamilySchema", "DownloadRemoteSkillPackageRequest", "DreamBudget", "DreamCandidateRef", @@ -692,8 +707,6 @@ "DreamSourceReference", "DreamStatus", "DreamUsage", - "EnableAllArtifactPluginsResponse", - "EnableArtifactPluginResult", "EnrollRemoteSkillTargetRequest", "EntryChange", "EntryChangeOperation", @@ -725,6 +738,7 @@ "FlushStatus", "FlushTopicMemoryRequest", "FlushTopicMemoryResponse", + "ForgetArtifactRequest", "GatewayLifecycleDecision", "GatewayLifecycleError", "GatewayModelSend", @@ -733,6 +747,7 @@ "GenerateSkillRequest", "GeneratedCandidateResponse", "GeneratedCandidateStatus", + "GetArtifactRenderingRequest", "GetCandidateRequest", "GetConnectorCheckpointRequest", "GetExperienceRequest", @@ -797,6 +812,7 @@ "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", + "ListPluginScopesRequest", "ListRemoteSkillTargetsRequest", "ListRemoteSkillTargetsResponse", "ListScopesRequest", @@ -815,6 +831,7 @@ "MemoryRevisionChanges", "MemorySearchMode", "MemoryUsedSearchMode", + "MergeArtifactsRequest", "MergeAtomicMemoryRequest", "ModelJsonPayload", "ModelJsonTextLocation", @@ -825,6 +842,10 @@ "Omission", "OpenQuestion", "PlanAccessOwnershipRequest", + "PluginPage", + "PluginScopePage", + "PluginScopeState", + "PluginState", "PrepareContextRequest", "PrepareContextResult", "PrepareHandoffHintRequest", @@ -876,7 +897,6 @@ "RecurrenceStreak", "RegisterSourceDefinitionRequest", "RejectCandidateRequest", - "ReloadArtifactPluginRequest", "RemainingWorkItem", "RememberMemoryRequest", "RemoteAgentKind", @@ -905,6 +925,7 @@ "ReplaceMemoryArtifactContent", "ReplaceMemoryArtifactEntry", "ReplaceMemoryArtifactRequest", + "ReplacePluginArtifactRequest", "ReplacePromptArtifactRequest", "ReplaceSkillArtifactRequest", "ReplaceTopicMemoryArtifactRequest", @@ -913,6 +934,7 @@ "ResolveScopeBindingRequest", "ResolveScopeSelectionRequest", "ResolvedUsagePeriod", + "RestoreArtifactRequest", "RetireMemoryEntryRequest", "RetryTopicMemoryProcessingRequest", "RetryTopicMemoryProcessingResponse", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 30d09ef30b..7cfee7202b 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -12,6 +12,7 @@ AwareDatetime, BaseModel, ConfigDict, + Discriminator, Field, RootModel, StrictBool, @@ -21,6 +22,9 @@ field_validator, model_validator, ) +from pydantic import ( + Tag as UnionTag, +) class GatewayLifecycleDecision(StrEnum): @@ -677,107 +681,164 @@ class ArtifactDreamingOperationCapability(BaseModel): effect: Effect -class CustomArtifactFamilySchema(BaseModel): +class PluginKind(StrEnum): + ARTIFACT = "artifact" + + +class PluginLoadStatus(StrEnum): + LOADED = "loaded" + FAILED = "failed" + + +class PluginTrigger(StrEnum): + SCHEDULE = "schedule" + SOURCES = "sources" + + +class PluginState(BaseModel): model_config = ConfigDict( extra="forbid", ) - family: StrictStr - schema_version: StrictStr - content_schema: dict[str, Any] - search_fields: Annotated[ - list[StrictStr], Field(description="Legacy persisted text fields; new plugins use search.") + name: StrictStr + kind: PluginKind + status: PluginLoadStatus + error: Annotated[StrictStr | None, Field(description="Safe failure category from the startup check.")] + family: Annotated[StrictStr | None, Field(...)] + binding_name: Annotated[StrictStr | None, Field(...)] + scopes: Annotated[list[StrictStr] | None, Field(...)] + source_types: Annotated[ + list[StrictStr] | None, + Field(description="Source types delivered to the plugin; null when it consumes no Sources."), ] - search: Annotated[ - dict[str, Any] | None, Field(description="Persistent declarative indexes and supported search modes.") - ] = None + triggers: list[PluginTrigger] -class ReloadArtifactPluginRequest(BaseModel): +class PluginPage(BaseModel): model_config = ConfigDict( extra="forbid", ) - expected_generation: Annotated[StrictInt, Field(ge=0)] + items: list[PluginState] -class ChangeArtifactPluginStateRequest(BaseModel): +class PluginScopeState(BaseModel): model_config = ConfigDict( extra="forbid", ) - expected_generation: Annotated[StrictInt, Field(ge=1)] + scope_id: StrictStr + pending: Annotated[ + StrictBool, Field(description="New Sources or a schedule request are waiting for an invocation.") + ] + consumed_position: Annotated[ + StrictInt | None, + Field( + description="Source journal position the plugin has consumed through; null when it consumes no Sources.", + ge=0, + ), + ] + source_position: Annotated[ + StrictInt | None, Field(description="Latest Source journal position in the Scope, of any Source type.", ge=0) + ] -class DesiredState(StrEnum): - ENABLED = "enabled" - DISABLED = "disabled" - UNLOADED = "unloaded" +class ListPluginScopesRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + cursor: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None + limit: Annotated[StrictInt, Field(ge=1, le=200)] = 50 -class Phase(StrEnum): - ACTIVATING = "activating" - ENABLED = "enabled" - STOPPING = "stopping" - DISABLED = "disabled" - UNLOADING = "unloading" - UNLOADED = "unloaded" - FAILED = "failed" +class PluginScopePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[PluginScopeState] + next_cursor: Annotated[StrictStr | None, Field(...)] + +class ArtifactLifecycleState(StrEnum): + ACTIVE = "active" + DEPRECATED = "deprecated" + RETIRED = "retired" -class ArtifactPluginState(BaseModel): + +class ArtifactGovernanceInput(BaseModel): model_config = ConfigDict( extra="forbid", ) - binding_name: StrictStr - plugin_id: StrictStr - family: StrictStr - binding_generation: Annotated[StrictInt, Field(ge=1)] - desired_state: DesiredState - phase: Phase - last_error: Annotated[StrictStr | None, Field(...)] + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + revision: Annotated[StrictInt, Field(ge=1)] + state_version: Annotated[StrictInt, Field(ge=0)] -class ArtifactPluginPage(BaseModel): +class ForgetArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - items: list[ArtifactPluginState] + revision: Annotated[StrictInt, Field(ge=1)] + state_version: Annotated[StrictInt, Field(ge=0)] -class Outcome(StrEnum): - ACCEPTED = "accepted" - SKIPPED = "skipped" - FAILED = "failed" +class ArtifactRestorationOperation(StrEnum): + RESTORE = "restore" + UNDO_MERGE = "undo_merge" -class ErrorEnum(StrEnum): - CONFLICT = "conflict" - INVALID_PLUGIN = "invalid_plugin" - UNAVAILABLE = "unavailable" +class RestoreArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + operation: ArtifactRestorationOperation = ArtifactRestorationOperation.RESTORE + revision: Annotated[ + StrictInt | None, + Field( + description="Content revision to restore; omitted restores the current one. Not accepted with undo_merge.", + ge=1, + ), + ] = None -class Error(RootModel[ErrorEnum | None]): - root: Annotated[ - ErrorEnum | None, Field(description="Safe per-plugin failure code; null for accepted or skipped entries.") - ] = None +class ArtifactGovernanceRecord(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactReference + lifecycle_state: ArtifactLifecycleState + state_version: Annotated[StrictInt, Field(ge=0)] + merged_into_id: Annotated[StrictStr | None, Field(...)] -class EnableArtifactPluginResult(BaseModel): +class ArtifactGovernanceResponse(BaseModel): model_config = ConfigDict( extra="forbid", ) - binding_name: StrictStr - outcome: Outcome - status_url: Annotated[StrictStr, Field(description="Registration status URL for the asynchronous result.")] - state: ArtifactPluginState - error: Annotated[ - Error | None, Field(description="Safe per-plugin failure code; null for accepted or skipped entries.") - ] + changed: StrictBool + records: list[ArtifactGovernanceRecord] + + +class ArtifactRestorationResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + changed: StrictBool + restored: list[ArtifactReference] + retired: list[ArtifactReference] + undo_merge_results: list[StrictStr] + + +class CreatePluginArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[a-z][a-z0-9-]*$")] + content: dict[str, Any] -class EnableAllArtifactPluginsResponse(BaseModel): +class ReplacePluginArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - items: list[EnableArtifactPluginResult] + content: dict[str, Any] class Configuration(StrEnum): @@ -1401,7 +1462,7 @@ class PolicyId(StrEnum): POWERCONTEXT_PREPARED_CONTEXT_RECEIPT_V1 = "powercontext.prepared-context-receipt.v1" -class Outcome1(StrEnum): +class Outcome(StrEnum): COMPLETED = "completed" EXPANSION_FAILED = "expansion_failed" @@ -1411,7 +1472,7 @@ class PreparedContextRecallRound(BaseModel): extra="forbid", ) round: Annotated[StrictInt, Field(ge=0, le=2)] - outcome: Outcome1 + outcome: Outcome retained: StrictBool @@ -1424,7 +1485,7 @@ class Mode3(StrEnum): NONE = "none" -class Outcome2(StrEnum): +class Outcome1(StrEnum): COMPLETED = "completed" NOT_RUN = "not_run" FAILED = "failed" @@ -1437,6 +1498,26 @@ class FallbackReason(StrEnum): REUSED_FTS_FALLBACK = "reused_fts_fallback" +class PreparedContextSearchGroup(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + round: Annotated[StrictInt, Field(ge=0, le=2)] + family: Annotated[ + StrictStr, + Field( + description="atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family.", + max_length=128, + min_length=1, + pattern="^[a-z][a-z0-9-]*$", + ), + ] + mode: Mode3 + outcome: Outcome1 + fallback_reason: FallbackReason + count: Annotated[StrictInt, Field(ge=1)] + + class PreparedContextPromptArtifactAddress(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1445,7 +1526,7 @@ class PreparedContextPromptArtifactAddress(BaseModel): artifact: ArtifactReference -class Outcome3(StrEnum): +class Outcome2(StrEnum): SELECTED = "selected" FALLBACK = "fallback" FAILED = "failed" @@ -1465,19 +1546,11 @@ class PreparedContextRerankGroup(BaseModel): ) round: Annotated[StrictInt, Field(ge=0, le=2)] config_id: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] - outcome: Outcome3 + outcome: Outcome2 fallback_reason: FallbackReason1 count: Annotated[StrictInt, Field(ge=1)] -class PreparedContextFamily(StrEnum): - ATOMIC_MEMORY = "atomic-memory" - TOPIC_MEMORY = "topic-memory" - EXPERIENCE = "experience" - PROFILE = "profile" - CODE = "code" - - class PreparedContextCodeEvidence(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1495,7 +1568,15 @@ class PreparedContextSelectedItem(BaseModel): model_config = ConfigDict( extra="forbid", ) - kind: PreparedContextFamily + kind: Annotated[ + StrictStr, + Field( + description="atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family.", + max_length=128, + min_length=1, + pattern="^[a-z][a-z0-9-]*$", + ), + ] artifact_address: ArtifactAddress | None = None code_evidence: PreparedContextCodeEvidence | None = None rendered_bytes: Annotated[StrictInt, Field(ge=0)] @@ -1517,7 +1598,15 @@ class PreparedContextOmittedGroup(BaseModel): model_config = ConfigDict( extra="forbid", ) - family: PreparedContextFamily + family: Annotated[ + StrictStr, + Field( + description="atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family.", + max_length=128, + min_length=1, + pattern="^[a-z][a-z0-9-]*$", + ), + ] reason: Reason count: Annotated[StrictInt, Field(ge=1)] @@ -1609,7 +1698,7 @@ class PreparedContextLocation(BaseModel): location: ModelJsonTextLocation -class Phase1(StrEnum): +class Phase(StrEnum): BEFORE_SEND = "before_send" @@ -1624,7 +1713,7 @@ class CheckContextBeforeSend(BaseModel): prepared_contexts: Annotated[list[PreparedContextLocation], Field(max_length=16)] -class Phase2(StrEnum): +class Phase1(StrEnum): BEFORE_RELEASE = "before_release" @@ -1664,7 +1753,7 @@ class Schema9(StrEnum): POWERCONTEXT_CONTEXT_CHECK_V1 = "powercontext.context-check.v1" -class Phase3(StrEnum): +class Phase2(StrEnum): BEFORE_SEND = "before_send" BEFORE_RELEASE = "before_release" @@ -1693,7 +1782,7 @@ class ContextCheckResult(BaseModel): extra="forbid", ) schema_: Annotated[Schema9, Field(alias="schema")] - phase: Phase3 + phase: Phase2 decision: Decision reason_codes: Annotated[list[ReasonCode], Field(max_length=32)] coverage: ContextCheckCoverage @@ -2167,7 +2256,7 @@ class Validation(StrEnum): UNKNOWN = "unknown" -class Outcome4(StrEnum): +class Outcome3(StrEnum): SUCCESS = "success" FAILURE = "failure" UNKNOWN = "unknown" @@ -2659,7 +2748,7 @@ class CodeQueryResponse(RootModel[CodeQueryResult | CodeStatus]): root: CodeQueryResult | CodeStatus -class Outcome5(StrEnum): +class Outcome4(StrEnum): INJECTED = "injected" FAILED = "failed" @@ -2670,14 +2759,36 @@ class RecordBootstrapDeliveryRequest(BaseModel): ) scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] receipt_id: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[\\x21-\\x7E]+$")] - outcome: Outcome5 + outcome: Outcome4 -class ContextAssemblyFamily(StrEnum): - MEMORY = "memory" - EXPERIENCE = "experience" - PROFILE = "profile" - TOPIC_MEMORY = "topic-memory" +class ContextAssemblySection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Annotated[ + StrictStr, + Field( + description="A built-in family (memory, experience, profile, topic-memory) or a loaded plugin family that implements search. A plugin family's entries come from the plugin's search with the request query and this limit; a family that is neither returns HTTP 422.", + max_length=128, + min_length=1, + pattern="^[a-z][a-z0-9-]*$", + ), + ] + search: Annotated[ + dict[str, Any] | None, + Field( + description="Additional generic search fields passed to the plugin's search unchanged, such as mode, filters, fusion, or rerank. Only plugin families accept it; the query and limit come from the request and this section." + ), + ] = None + limit: Annotated[ + StrictInt, + Field( + description="Maximum included entries. Each Profile entry is one Scope snapshot. Experience is limited to two. All section limits together must not exceed the Server's runtime.context_assembly_max_entries policy (default 8); exceeding it returns HTTP 422 before recall. The byte budget may reduce the actual output count.", + ge=1, + le=8, + ), + ] class ContextAssemblyFormat(StrEnum): @@ -2953,6 +3064,22 @@ def _reject_invalid_search_value(cls, value: Any) -> Any: return value +class Format(StrEnum): + MARKDOWN = "markdown" + TEXT = "text" + + +class ArtifactRendering(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Annotated[StrictStr, Field(min_length=1)] + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + revision: Annotated[StrictInt, Field(ge=1)] + format: Format + text: StrictStr + + class ArtifactSearchItem(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3094,24 +3221,33 @@ class CreateSourceRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - source_type: SourceType = SourceType.CONTENT + source_type: Annotated[ + StrictStr, + Field( + description="The built-in content type, or a Source type that a loaded plugin defines. A plugin type's content is that type's input; the plugin resolves it into the stored Source.", + max_length=128, + min_length=1, + pattern="^[a-z][a-z0-9._-]*$", + ), + ] = "content" content: Annotated[ Any, Field( - description="JSON value persisted by the built-in content Source adapter. Server-reserved payload schemas, including handoff receipts, are rejected and must be created through their dedicated workflow. String values that are empty or whitespace-only are rejected." + description="JSON value persisted by the built-in content Source adapter, or the input of a plugin Source type. Server-reserved payload schemas, including handoff receipts, are rejected and must be created through their dedicated workflow. String values that are empty or whitespace-only are rejected." ), ] -class TaggableArtifactFamily(StrEnum): - MEMORY = "memory" - ATOMIC_MEMORY = "atomic-memory" - EXPERIENCE = "experience" - SKILL = "skill" - HANDOFF = "handoff" - PROFILE = "profile" - PROMPT = "prompt" - TOPIC_MEMORY = "topic-memory" +class TaggableArtifactFamily(RootModel[StrictStr]): + root: Annotated[ + StrictStr, + Field( + description="A built-in Family or a loaded plugin family; every readable Family supports logical tags on persisted Artifacts. The collection-level memory Family tags entries instead.", + max_length=128, + min_length=1, + pattern="^[a-z][a-z0-9-]*$", + ), + ] class TagMatch(StrEnum): @@ -3253,6 +3389,13 @@ class ListArtifactsRequest(BaseModel): cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None +class GetArtifactRenderingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + revision: Annotated[StrictInt | None, Field(ge=1)] = None + + class ListArtifactRevisionsRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -4271,7 +4414,7 @@ class SourceInstancesResponse(BaseModel): management: Management | None = None -class Outcome6(StrEnum): +class Outcome5(StrEnum): SAVED = "saved" UNCHANGED = "unchanged" @@ -4280,7 +4423,7 @@ class SourceCollectionResult(BaseModel): model_config = ConfigDict( extra="forbid", ) - outcome: Outcome6 + outcome: Outcome5 source_refs: list[SourceTypeReference] @@ -4831,9 +4974,6 @@ class ArtifactCollectionItem(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[a-z][a-z0-9-]*$")] artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - schema_version: Annotated[ - StrictStr | None, Field(description="Persisted schema version for a custom Artifact family.") - ] = None revision: Annotated[StrictInt, Field(ge=1)] sources: list[SourceTypeReference] artifacts: list[ArtifactReference] @@ -4854,7 +4994,7 @@ class ArtifactCollectionItem(BaseModel): class ArtifactCreated(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - family: BaseArtifactFamily + family: ArtifactReadFamily artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] revision: Annotated[StrictInt, Field(ge=1)] sources: list[SourceTypeReference] @@ -4878,9 +5018,6 @@ class ArtifactRevision(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[a-z][a-z0-9-]*$")] artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - schema_version: Annotated[ - StrictStr | None, Field(description="Persisted schema version for a custom Artifact family.") - ] = None revision: Annotated[StrictInt, Field(ge=1)] content: dict[str, Any] sources: list[SourceTypeReference] @@ -4950,6 +5087,18 @@ class DreamRootEvidenceGroup(BaseModel): independence: DreamEvidenceIndependence = DreamEvidenceIndependence.UNKNOWN +class MergeArtifactsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + inputs: Annotated[list[ArtifactGovernanceInput], Field(min_length=2)] + content: Annotated[ + dict[str, Any], Field(description="Complete content of the result in the Family's content shape.") + ] + source_refs: list[SourceReference] | None = None + artifact_refs: list[ArtifactReference] | None = None + + class ExtractionObservation(BaseModel): model_config = ConfigDict( extra="forbid", @@ -5167,18 +5316,6 @@ class HandoffGenerationMetadata(BaseModel): edit_status: EditStatus -class PreparedContextSearchGroup(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - round: Annotated[StrictInt, Field(ge=0, le=2)] - family: PreparedContextFamily - mode: Mode3 - outcome: Outcome2 - fallback_reason: FallbackReason - count: Annotated[StrictInt, Field(ge=1)] - - class PreparedContextPromptIdentity(BaseModel): model_config = ConfigDict( extra="forbid", @@ -5314,7 +5451,7 @@ class RecordSkillUsageRequest(BaseModel): selected: StrictBool invoked: Invoked validation: Validation - outcome: Outcome4 + outcome: Outcome3 task_source: SourceReference | None = None environment_fingerprint: Annotated[StrictStr | None, Field(pattern="^sha256:[0-9a-f]{64}$")] = None @@ -5515,21 +5652,6 @@ class BootstrapContextRequest(BaseModel): ] = None -class ContextAssemblySection(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - family: ContextAssemblyFamily - limit: Annotated[ - StrictInt, - Field( - description="Maximum included entries. Each Profile entry is one Scope snapshot. Experience is limited to two. All section limits together must not exceed the Server's runtime.context_assembly_max_entries policy (default 8); exceeding it returns HTTP 422 before recall. The byte budget may reduce the actual output count.", - ge=1, - le=8, - ), - ] - - class ContextAssembly(BaseModel): model_config = ConfigDict( extra="forbid", @@ -6117,7 +6239,6 @@ class Capabilities(BaseModel): model_config = ConfigDict( extra="forbid", ) - custom_artifact_schemas: Annotated[list[CustomArtifactFamilySchema], Field(validate_default=True)] = [] prompts: Annotated[dict[str, PromptCapability], Field(validate_default=True)] = {} artifact_dreaming: Annotated[ StrictBool, Field(description="Whether asynchronous Artifact Dream execution is configured.") @@ -6606,7 +6727,7 @@ class GeneratedCandidateResponse(BaseModel): candidate: Annotated[Candidate | None, Field(...)] -class CreateArtifactRequest( +class CreateBuiltinArtifactRequest( RootModel[ CreateAtomicMemoryArtifactRequest | CreateTopicMemoryArtifactRequest @@ -6641,6 +6762,7 @@ class ReplaceArtifactRequest( | ReplaceHandoffArtifactRequest | ReplacePromptArtifactRequest | ReplaceProfileArtifactRequest + | ReplacePluginArtifactRequest ] ): root: ( @@ -6652,6 +6774,7 @@ class ReplaceArtifactRequest( | ReplaceHandoffArtifactRequest | ReplacePromptArtifactRequest | ReplaceProfileArtifactRequest + | ReplacePluginArtifactRequest ) @@ -6684,5 +6807,35 @@ class PreparedContextWithReuse(BaseModel): reuse: ContextReuse +_CREATE_ARTIFACT_REQUEST_BUILTIN_FAMILIES = frozenset([ + "atomic-memory", + "experience", + "handoff", + "memory", + "profile", + "prompt", + "skill", + "topic-memory", +]) + + +def _create_artifact_request_branch(value: Any) -> str: + family = value.get("family") if isinstance(value, dict) else getattr(value, "family", None) + return ( + "plugin" if isinstance(family, str) and family not in _CREATE_ARTIFACT_REQUEST_BUILTIN_FAMILIES else "builtin" + ) + + +class CreateArtifactRequest(RootModel[CreateBuiltinArtifactRequest | CreatePluginArtifactRequest]): + root: Annotated[ + Annotated[CreateBuiltinArtifactRequest, UnionTag("builtin")] + | Annotated[CreatePluginArtifactRequest, UnionTag("plugin")], + Discriminator(_create_artifact_request_branch), + Field( + description="A built-in family's creation request, or the complete content of an Artifact in a loaded plugin's family. The family name selects the branch: built-in names select the built-in branch." + ), + ] + + class PrepareContextResult(RootModel[PreparedContext | PreparedContextWithReuse]): root: Annotated[PreparedContext | PreparedContextWithReuse, Field(discriminator="schema_")] diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 1383195e37..0d24932312 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -23,11 +23,13 @@ ApplyAccessOwnershipRequest, ApproveCandidateRequest, ArtifactCreated, + ArtifactGovernanceRecord, + ArtifactGovernanceResponse, ArtifactHandleBinding, ArtifactPage, - ArtifactPluginPage, - ArtifactPluginState, ArtifactPublication, + ArtifactRendering, + ArtifactRestorationResult, ArtifactRevision, ArtifactRevisionPage, ArtifactTagPage, @@ -50,7 +52,6 @@ CaptureContentSourceRequest, CaptureContentSourceResponse, CaptureWorkingNoteRequest, - ChangeArtifactPluginStateRequest, CheckContextRequest, ClearScopeBindingRequest, ClearScopeBindingResponse, @@ -75,7 +76,6 @@ DownloadRemoteSkillPackageRequest, DreamRun, DreamRunPage, - EnableAllArtifactPluginsResponse, EnrollRemoteSkillTargetRequest, ExperienceArtifact, ExternalSkillResolution, @@ -86,10 +86,12 @@ FlushProfileResponse, FlushTopicMemoryRequest, FlushTopicMemoryResponse, + ForgetArtifactRequest, GeneratedCandidateResponse, GenerateExperienceRequest, GeneratePromptDemonstrationsRequest, GenerateSkillRequest, + GetArtifactRenderingRequest, GetCandidateRequest, GetConnectorCheckpointRequest, GetExperienceRequest, @@ -128,14 +130,19 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListPluginScopesRequest, ListRemoteSkillTargetsRequest, ListRemoteSkillTargetsResponse, ListScopesRequest, ListSourcesRequest, MemoryCapacity, MemoryMutationResponse, + MergeArtifactsRequest, MergeAtomicMemoryRequest, PlanAccessOwnershipRequest, + PluginPage, + PluginScopePage, + PluginState, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -164,7 +171,6 @@ RecordTaskOutcomeRequest, RegisterSourceDefinitionRequest, RejectCandidateRequest, - ReloadArtifactPluginRequest, RememberMemoryRequest, RemoteSkillPublication, RemoteSkillReceiptResponse, @@ -178,6 +184,7 @@ ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, + RestoreArtifactRequest, RetireMemoryEntryRequest, RetryTopicMemoryProcessingRequest, RetryTopicMemoryProcessingResponse, @@ -682,181 +689,6 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="server.observe", resource="server", scope_id_field=None, resolver="static"), ) -LIST_ARTIFACT_PLUGINS = Operation[None, ArtifactPluginPage]( - method="GET", - path="/v1/admin/artifact-plugins", - operation_id="list_artifact_plugins", - request_type=None, - request_location=None, - path_parameters=(), - response_type=ArtifactPluginPage, - success_status=200, - summary="List Artifact plugin registrations", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 200: {"description": "Durable registration states."}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 403: {"$ref": "#/components/responses/Forbidden"}, - 503: {"$ref": "#/components/responses/Unavailable"}, - }, - access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), -) - -GET_ARTIFACT_PLUGIN = Operation[None, ArtifactPluginState]( - method="GET", - path="/v1/admin/artifact-plugins/{binding_name}", - operation_id="get_artifact_plugin", - request_type=None, - request_location=None, - path_parameters=("binding_name",), - response_type=ArtifactPluginState, - success_status=200, - summary="Get an Artifact plugin registration", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 200: {"description": "Durable registration state without plugin configuration secrets."}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 403: {"$ref": "#/components/responses/Forbidden"}, - 404: {"$ref": "#/components/responses/NotFound"}, - 503: {"$ref": "#/components/responses/Unavailable"}, - }, - access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), -) - -ENABLE_ALL_ARTIFACT_PLUGINS = Operation[None, EnableAllArtifactPluginsResponse]( - method="POST", - path="/v1/admin/artifact-plugins/enable-all", - operation_id="enable_all_artifact_plugins", - request_type=None, - request_location=None, - path_parameters=(), - response_type=EnableAllArtifactPluginsResponse, - success_status=200, - summary="Enable the currently disabled Artifact plugins", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 200: {"description": "Per-plugin acceptance, skip or failure results."}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 403: {"$ref": "#/components/responses/Forbidden"}, - 503: {"$ref": "#/components/responses/Unavailable"}, - }, - access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), -) - -RELOAD_ARTIFACT_PLUGIN = Operation[ReloadArtifactPluginRequest, ArtifactPluginState]( - method="POST", - path="/v1/admin/artifact-plugins/{binding_name}/reload", - operation_id="reload_artifact_plugin", - request_type=ReloadArtifactPluginRequest, - request_location="body", - path_parameters=("binding_name",), - response_type=ArtifactPluginState, - success_status=202, - summary="Load or update a Worker-loaded Artifact plugin", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 202: { - "description": "Definition saved; poll its registration state until transition completes.", - "headers": {"Location": {"description": "Registration status URL.", "schema": {"type": "string"}}}, - }, - 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"), -) - -ENABLE_ARTIFACT_PLUGIN = Operation[ChangeArtifactPluginStateRequest, ArtifactPluginState]( - method="POST", - path="/v1/admin/artifact-plugins/{binding_name}/enable", - operation_id="enable_artifact_plugin", - request_type=ChangeArtifactPluginStateRequest, - request_location="body", - path_parameters=("binding_name",), - response_type=ArtifactPluginState, - success_status=202, - summary="Enable an Artifact plugin", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 202: { - "description": "Target saved; poll its registration state until transition completes.", - "headers": {"Location": {"description": "Registration status URL.", "schema": {"type": "string"}}}, - }, - 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"), -) - -DISABLE_ARTIFACT_PLUGIN = Operation[ChangeArtifactPluginStateRequest, ArtifactPluginState]( - method="POST", - path="/v1/admin/artifact-plugins/{binding_name}/disable", - operation_id="disable_artifact_plugin", - request_type=ChangeArtifactPluginStateRequest, - request_location="body", - path_parameters=("binding_name",), - response_type=ArtifactPluginState, - success_status=202, - summary="Disable an Artifact plugin", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 202: { - "description": "Target saved; poll its registration state until transition completes.", - "headers": {"Location": {"description": "Registration status URL.", "schema": {"type": "string"}}}, - }, - 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"), -) - -UNLOAD_ARTIFACT_PLUGIN = Operation[ChangeArtifactPluginStateRequest, ArtifactPluginState]( - method="POST", - path="/v1/admin/artifact-plugins/{binding_name}/unload", - operation_id="unload_artifact_plugin", - request_type=ChangeArtifactPluginStateRequest, - request_location="body", - path_parameters=("binding_name",), - response_type=ArtifactPluginState, - success_status=202, - summary="Unload an Artifact plugin", - tags=("artifact-plugins",), - scope_mode="none", - responses={ - 202: { - "description": "Target saved; poll its registration state until transition completes.", - "headers": {"Location": {"description": "Registration status URL.", "schema": {"type": "string"}}}, - }, - 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"), -) - GET_CAPABILITIES = Operation[None, Capabilities]( method="GET", path="/v1/capabilities", @@ -1742,6 +1574,166 @@ class AccessRequirement(BaseModel): ), ) +LIST_PLUGINS = Operation[None, PluginPage]( + method="GET", + path="/v1/plugins", + operation_id="list_plugins", + request_type=None, + request_location=None, + path_parameters=(), + response_type=PluginPage, + success_status=200, + summary="List loaded plugins", + tags=("plugins",), + scope_mode="none", + responses={ + 200: {"description": "Plugins found in the plugin directory at startup."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +GET_PLUGIN = Operation[None, PluginState]( + method="GET", + path="/v1/plugins/{name}", + operation_id="get_plugin", + request_type=None, + request_location=None, + path_parameters=("name",), + response_type=PluginState, + success_status=200, + summary="Get one loaded plugin", + tags=("plugins",), + scope_mode="none", + responses={ + 200: {"description": "The plugin's startup check result and declaration."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +LIST_PLUGIN_SCOPES = Operation[ListPluginScopesRequest, PluginScopePage]( + method="GET", + path="/v1/plugins/{name}/scopes", + operation_id="list_plugin_scopes", + request_type=ListPluginScopesRequest, + request_location="query", + path_parameters=("name",), + response_type=PluginScopePage, + success_status=200, + summary="List a plugin's progress per Scope", + tags=("plugins",), + scope_mode="none", + responses={ + 200: {"description": "Scopes the plugin has been asked to process, ordered by Scope ID."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +MERGE_ARTIFACTS = Operation[MergeArtifactsRequest, ArtifactGovernanceResponse]( + method="POST", + path="/v1/scopes/{scope_id}/artifacts/{family}/merges", + operation_id="merge_artifacts", + request_type=MergeArtifactsRequest, + request_location="body", + path_parameters=("scope_id", "family"), + response_type=ArtifactGovernanceResponse, + success_status=200, + summary="Merge exact current Artifacts into a new Artifact", + tags=("artifacts",), + scope_mode="current", + responses={ + 200: {"description": "The merge result and the frozen inputs."}, + 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="artifact_merge_domain_access"), +) + +GET_ARTIFACT_GOVERNANCE = Operation[None, ArtifactGovernanceRecord]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", + operation_id="get_artifact_governance", + request_type=None, + request_location=None, + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactGovernanceRecord, + success_status=200, + summary="Read an Artifact's current governance state", + tags=("artifacts",), + scope_mode="current", + responses={ + 200: {"description": "The current governance state."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="artifact_merge_domain_access"), +) + +FORGET_ARTIFACT = Operation[ForgetArtifactRequest, ArtifactGovernanceResponse]( + method="POST", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", + operation_id="forget_artifact", + request_type=ForgetArtifactRequest, + request_location="body", + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactGovernanceResponse, + success_status=200, + summary="Forget one Artifact without changing its content revision", + tags=("artifacts",), + scope_mode="current", + responses={ + 200: {"description": "The Artifact's governance state after forgetting."}, + 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="artifact_merge_domain_access"), +) + +RESTORE_ARTIFACT = Operation[RestoreArtifactRequest, ArtifactRestorationResult]( + method="POST", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", + operation_id="restore_artifact", + request_type=RestoreArtifactRequest, + request_location="body", + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactRestorationResult, + success_status=200, + summary="Restore an Artifact or undo the merge that produced it", + tags=("artifacts",), + scope_mode="current", + responses={ + 200: {"description": "The revisions restored and retired by the operation."}, + 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="artifact_merge_domain_access"), +) + SEARCH_ARTIFACTS = Operation[SearchArtifactsRequest, SearchArtifactsResponse]( method="POST", path="/v1/scopes/{scope_id}/artifacts/{family}/search", @@ -3604,6 +3596,31 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="path_artifact_read_access"), ) +GET_ARTIFACT_RENDERING = Operation[GetArtifactRenderingRequest, ArtifactRendering]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", + operation_id="get_artifact_rendering", + request_type=GetArtifactRenderingRequest, + request_location="query", + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactRendering, + success_status=200, + summary="Present a plugin Artifact", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: {"description": "The rendering of the exact revision."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="path_artifact_read_access"), +) + LIST_ARTIFACT_REVISIONS = Operation[ListArtifactRevisionsRequest, ArtifactRevisionPage]( method="GET", path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 4b33314c21..3749fed6b0 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -499,319 +499,6 @@ "x-powercontext-access": {"action": "server.observe", "resource": {"type": "server"}}, } }, - "/v1/admin/artifact-plugins": { - "get": { - "tags": ["artifact-plugins"], - "summary": "List Artifact plugin registrations", - "operationId": "list_artifact_plugins", - "responses": { - "200": { - "description": "Durable registration states.", - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPluginPage"}} - }, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "403": {"$ref": "#/components/responses/Forbidden"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - }, - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - } - }, - "/v1/admin/artifact-plugins/{binding_name}": { - "get": { - "tags": ["artifact-plugins"], - "summary": "Get an Artifact plugin registration", - "operationId": "get_artifact_plugin", - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - "parameters": [ - { - "name": "binding_name", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9-]*$"}, - } - ], - "responses": { - "200": { - "description": "Durable registration state without plugin configuration secrets.", - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPluginState"}} - }, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "403": {"$ref": "#/components/responses/Forbidden"}, - "404": {"$ref": "#/components/responses/NotFound"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - }, - } - }, - "/v1/admin/artifact-plugins/enable-all": { - "post": { - "tags": ["artifact-plugins"], - "summary": "Enable the currently disabled Artifact plugins", - "description": "Captures " - "registrations and " - "generations at " - "request start, then " - "accepts each " - "eligible enable " - "independently. " - "Enabled, unloaded, " - "failed and " - "transitioning " - "plugins are skipped. " - "Concurrent changes " - "and validation " - "failures are " - "reported per plugin " - "without rolling back " - "successes. Does not " - "discover or reload " - "plugins; poll each " - "status URL for the " - "asynchronous " - "outcome.", - "operationId": "enable_all_artifact_plugins", - "responses": { - "200": { - "description": "Per-plugin acceptance, skip or failure results.", - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/EnableAllArtifactPluginsResponse"} - } - }, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "403": {"$ref": "#/components/responses/Forbidden"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - }, - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - } - }, - "/v1/admin/artifact-plugins/{binding_name}/reload": { - "post": { - "tags": ["artifact-plugins"], - "summary": "Load or update a Worker-loaded Artifact plugin", - "description": "Reads the " - "binding's " - "direct " - "child " - "entry " - "under " - "POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR " - "and fixes " - "its " - "resolved " - "immutable " - "directory. " - "The " - "manifest " - "binding_name " - "must " - "match the " - "directory " - "entry and " - "request " - "path. " - "Saves " - "validated " - "configuration " - "and " - "declarations " - "without " - "creating " - "a " - "pipeline. " - "Existing " - "enabled " - "or " - "disabled " - "plugins " - "keep " - "their " - "state; " - "initial, " - "unloaded " - "or failed " - "registrations " - "become " - "disabled. " - "This " - "initial " - "path " - "supports " - "the " - "global " - "SQLite " - "Supervisor. " - "Plugin " - "code is " - "loaded " - "only in " - "child " - "processes.", - "operationId": "reload_artifact_plugin", - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - "parameters": [ - { - "name": "binding_name", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9-]*$"}, - } - ], - "requestBody": { - "required": True, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ReloadArtifactPluginRequest"}} - }, - }, - "responses": { - "202": { - "description": "Definition saved; poll its registration state until transition completes.", - "headers": { - "Location": {"schema": {"type": "string"}, "description": "Registration status URL."} - }, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPluginState"}} - }, - }, - "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/admin/artifact-plugins/{binding_name}/enable": { - "post": { - "tags": ["artifact-plugins"], - "summary": "Enable an Artifact plugin", - "operationId": "enable_artifact_plugin", - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - "parameters": [ - { - "name": "binding_name", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9-]*$"}, - } - ], - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ChangeArtifactPluginStateRequest"} - } - }, - }, - "responses": { - "202": { - "description": "Target saved; poll its registration state until transition completes.", - "headers": { - "Location": {"schema": {"type": "string"}, "description": "Registration status URL."} - }, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPluginState"}} - }, - }, - "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/admin/artifact-plugins/{binding_name}/disable": { - "post": { - "tags": ["artifact-plugins"], - "summary": "Disable an Artifact plugin", - "operationId": "disable_artifact_plugin", - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - "parameters": [ - { - "name": "binding_name", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9-]*$"}, - } - ], - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ChangeArtifactPluginStateRequest"} - } - }, - }, - "responses": { - "202": { - "description": "Target saved; poll its registration state until transition completes.", - "headers": { - "Location": {"schema": {"type": "string"}, "description": "Registration status URL."} - }, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPluginState"}} - }, - }, - "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/admin/artifact-plugins/{binding_name}/unload": { - "post": { - "tags": ["artifact-plugins"], - "summary": "Unload an Artifact plugin", - "operationId": "unload_artifact_plugin", - "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, - "parameters": [ - { - "name": "binding_name", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9-]*$"}, - } - ], - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ChangeArtifactPluginStateRequest"} - } - }, - }, - "responses": { - "202": { - "description": "Target saved; poll its registration state until transition completes.", - "headers": { - "Location": {"schema": {"type": "string"}, "description": "Registration status URL."} - }, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPluginState"}} - }, - }, - "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/capabilities": { "get": { "tags": ["capabilities"], @@ -2231,66 +1918,460 @@ }, "responses": { "200": { - "description": "Bounded orientation " - "text, or empty when " - "no complete " - "supported hint can " - "be delivered.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreparedContext"}}}, + "description": "Bounded orientation " + "text, or empty when " + "no complete " + "supported hint can " + "be delivered.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PreparedContext"}}}, + }, + "404": {"$ref": "#/components/responses/NotFound"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": {"resolver": "continue_handoff_access"}, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/topic-memory/flush": { + "post": { + "tags": ["topic-memory"], + "summary": "Request asynchronous Topic Memory processing", + "description": "Persist a flush generation without " + "waiting for background processing " + "to complete. Request pending Topic " + "Memory processing when the user " + "explicitly asks for that " + "processing. Processing depends on " + "configured capabilities and can " + "yield no changes. Do not use it as " + "an explicit Memory save or infer " + "success from Source acceptance " + "alone.", + "operationId": "flush_topic_memory", + "requestBody": { + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/FlushTopicMemoryRequest"}} + }, + "required": True, + }, + "responses": { + "200": { + "description": "The durable request was accepted, or the source cursor was already current.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/FlushTopicMemoryResponse"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "x-powercontext-scope-mode": "current", + } + }, + "/v1/plugins": { + "get": { + "tags": ["plugins"], + "summary": "List loaded plugins", + "description": "Lists the plugins checked at Server startup, " + "whether each loaded or failed, and what each " + "one declares. Plugins have no persisted " + "lifecycle state; change the plugin directory " + "and restart Server.", + "operationId": "list_plugins", + "responses": { + "200": { + "description": "Plugins found in the plugin directory at startup.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PluginPage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + } + }, + "/v1/plugins/{name}": { + "get": { + "tags": ["plugins"], + "summary": "Get one loaded plugin", + "operationId": "get_plugin", + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z][a-z0-9-]*$"}, + } + ], + "responses": { + "200": { + "description": "The plugin's startup check result and declaration.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PluginState"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/plugins/{name}/scopes": { + "get": { + "tags": ["plugins"], + "summary": "List a plugin's progress per Scope", + "description": "Reads the plugin's pending work " + "and consumption cursor in each " + "Scope from the database, so every " + "Server role returns the same " + "answer. Call outcomes are " + "reported through logs and " + "metrics, not here.", + "operationId": "list_plugin_scopes", + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z][a-z0-9-]*$"}, + }, + { + "name": "cursor", + "in": "query", + "required": False, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + { + "name": "limit", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, + }, + ], + "responses": { + "200": { + "description": "Scopes the plugin has been asked to process, ordered by Scope ID.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PluginScopePage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/scopes/{scope_id}/artifacts/{family}/merges": { + "post": { + "tags": ["artifacts"], + "summary": "Merge exact current Artifacts into a new Artifact", + "description": "Freezes " + "every " + "input into " + "a new " + "result " + "Artifact " + "in one " + "transaction. " + "Available " + "for every " + "Family " + "registered " + "with the " + "shared " + "merge " + "service, " + "including " + "plugin " + "families; " + "other " + "families " + "return " + "422. The " + "caller " + "needs " + "write " + "access to " + "each " + "input.", + "operationId": "merge_artifacts", + "x-powercontext-scope-mode": "current", + "x-powercontext-access": {"resolver": "artifact_merge_domain_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/ArtifactReadFamily"}, + }, + ], + "requestBody": { + "required": True, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MergeArtifactsRequest"}}}, + }, + "responses": { + "200": { + "description": "The merge result and the frozen inputs.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactGovernanceResponse"}} + }, + }, + "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/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance": { + "get": { + "tags": ["artifacts"], + "summary": "Read an Artifact's current governance state", + "description": "Returns " + "the " + "lifecycle " + "state, " + "state " + "version " + "and " + "merge " + "target " + "that " + "forget, " + "merge " + "and " + "restoration " + "check. " + "Available " + "for " + "every " + "Family " + "registered " + "with " + "the " + "shared " + "merge " + "service.", + "operationId": "get_artifact_governance", + "x-powercontext-scope-mode": "current", + "x-powercontext-access": {"resolver": "artifact_merge_domain_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/ArtifactReadFamily"}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + ], + "responses": { + "200": { + "description": "The current governance state.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactGovernanceRecord"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget": { + "post": { + "tags": ["artifacts"], + "summary": "Forget one Artifact without changing its content revision", + "description": "Deprecates " + "the " + "exact " + "current " + "Artifact " + "without " + "a " + "replacement " + "so " + "it " + "leaves " + "search; " + "restoration " + "brings " + "it " + "back. " + "Available " + "for " + "every " + "Family " + "registered " + "with " + "the " + "shared " + "merge " + "service.", + "operationId": "forget_artifact", + "x-powercontext-scope-mode": "current", + "x-powercontext-access": {"resolver": "artifact_merge_domain_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/ArtifactReadFamily"}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + ], + "requestBody": { + "required": True, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForgetArtifactRequest"}}}, + }, + "responses": { + "200": { + "description": "The Artifact's governance state after forgetting.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactGovernanceResponse"}} + }, }, - "404": {"$ref": "#/components/responses/NotFound"}, "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"}, - "500": {"$ref": "#/components/responses/InternalError"}, }, - "x-powercontext-access": {"resolver": "continue_handoff_access"}, - "x-powercontext-scope-mode": "current", } }, - "/v1/topic-memory/flush": { + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations": { "post": { - "tags": ["topic-memory"], - "summary": "Request asynchronous Topic Memory processing", - "description": "Persist a flush generation without " - "waiting for background processing " - "to complete. Request pending Topic " - "Memory processing when the user " - "explicitly asks for that " - "processing. Processing depends on " - "configured capabilities and can " - "yield no changes. Do not use it as " - "an explicit Memory save or infer " - "success from Source acceptance " - "alone.", - "operationId": "flush_topic_memory", + "tags": ["artifacts"], + "summary": "Restore an Artifact or undo the merge that produced it", + "description": "`restore` " + "reactivates " + "the " + "Artifact, " + "optionally " + "at " + "an " + "earlier " + "content " + "revision, " + "and " + "undoes " + "the " + "merges " + "that " + "froze " + "it. " + "`undo_merge` " + "restores " + "a " + "merge " + "result's " + "inputs " + "and " + "retires " + "the " + "result. " + "Each " + "runs " + "as " + "one " + "group. " + "Available " + "for " + "every " + "Family " + "registered " + "with " + "the " + "shared " + "merge " + "service.", + "operationId": "restore_artifact", + "x-powercontext-scope-mode": "current", + "x-powercontext-access": {"resolver": "artifact_merge_domain_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/ArtifactReadFamily"}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + ], "requestBody": { + "required": True, "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/FlushTopicMemoryRequest"}} + "application/json": {"schema": {"$ref": "#/components/schemas/RestoreArtifactRequest"}} }, - "required": True, }, "responses": { "200": { - "description": "The durable request was accepted, or the source cursor was already current.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "description": "The revisions restored and retired by the operation.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/FlushTopicMemoryResponse"}} + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactRestorationResult"}} }, }, "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"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - "x-powercontext-access": { - "action": "scope.contribute", - "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, - "x-powercontext-scope-mode": "current", } }, "/v1/scopes/{scope_id}/artifacts/{family}/search": { @@ -4424,7 +4505,12 @@ "description": "Persist one Source without " "synchronously deriving " "Artifacts. The Server " - "generates source_id.", + "generates source_id. A " + "Source type defined by a " + "loaded plugin is resolved by " + "that plugin from the " + "submitted input; unknown " + "types return 404.", "operationId": "create_source", "x-powercontext-access": { "action": "scope.contribute", @@ -4530,7 +4616,14 @@ "head, chunks, and " "configured retrieval " "indexes are committed " - "atomically.", + "atomically. A family " + "provided by a loaded " + "plugin takes the complete " + "content object; core " + "allocates the Artifact ID " + "and the plugin's " + "create_artifact method " + "writes revision one.", "operationId": "create_artifact", "x-powercontext-access": {"resolver": "create_artifact_access"}, "parameters": [ @@ -4976,7 +5069,22 @@ "through " "its " "restoration " - "flow.", + "flow. " + "Plugin " + "families " + "accept " + "their " + "own " + "content " + "unchanged; " + "the " + "plugin's " + "next " + "invocation " + "sees " + "the " + "new " + "revision.", "operationId": "replace_artifact", "x-powercontext-scope-mode": "current", "x-powercontext-access": {"resolver": "path_artifact_write_access"}, @@ -4991,19 +5099,7 @@ "name": "family", "in": "path", "required": True, - "schema": { - "type": "string", - "enum": [ - "memory", - "atomic-memory", - "experience", - "skill", - "handoff", - "prompt", - "topic-memory", - "profile", - ], - }, + "schema": {"$ref": "#/components/schemas/ArtifactReadFamily"}, }, { "name": "artifact_id", @@ -5435,6 +5531,92 @@ }, } }, + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering": { + "get": { + "tags": ["artifacts"], + "summary": "Present a plugin Artifact", + "description": "Returns " + "the " + "default " + "presentation " + "of " + "one " + "revision " + "of " + "a " + "plugin " + "family " + "as " + "the " + "plugin " + "renders " + "it, " + "in " + "Markdown " + "or " + "plain " + "text, " + "for " + "people " + "and " + "for " + "agent " + "context. " + "The " + "current " + "revision " + "is " + "used " + "unless " + "revision " + "is " + "given. " + "Built-in " + "families " + "have " + "no " + "rendering " + "and " + "return " + "422.", + "operationId": "get_artifact_rendering", + "x-powercontext-access": {"resolver": "path_artifact_read_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/ArtifactReadFamily"}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + {"name": "revision", "in": "query", "required": False, "schema": {"type": "integer", "minimum": 1}}, + ], + "responses": { + "200": { + "description": "The rendering of the exact revision.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactRendering"}}}, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions": { "get": { "tags": ["artifacts"], @@ -7385,11 +7567,6 @@ "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "family": {"$ref": "#/components/schemas/ArtifactReadFamily"}, "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "schema_version": { - "type": "string", - "description": "Persisted schema version for a custom Artifact family.", - "nullable": True, - }, "revision": {"type": "integer", "minimum": 1.0}, "sources": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, "artifacts": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, @@ -7427,7 +7604,7 @@ "ArtifactCreated": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "family": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + "family": {"$ref": "#/components/schemas/ArtifactReadFamily"}, "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, "revision": {"type": "integer", "minimum": 1.0}, "sources": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, @@ -7458,11 +7635,6 @@ "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "family": {"$ref": "#/components/schemas/ArtifactReadFamily"}, "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "schema_version": { - "type": "string", - "description": "Persisted schema version for a custom Artifact family.", - "nullable": True, - }, "revision": {"type": "integer", "minimum": 1.0}, "content": {"additionalProperties": True, "type": "object"}, "sources": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, @@ -8133,169 +8305,295 @@ "of 32 " "references.", }, - "target": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, - "reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "result_artifact": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, - "decision_reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, - "candidate_kind": {"type": "string", "enum": ["artifact", "tag"]}, - "result": {"$ref": "#/components/schemas/CatalogTagResult", "nullable": True}, - "operation": {"type": "string", "nullable": True}, - "origin": {"type": "string", "enum": ["dream", "manual"], "nullable": True}, - "dream_run_id": {"type": "string", "nullable": True}, + "target": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + "result_artifact": {"$ref": "#/components/schemas/ArtifactReference", "nullable": True}, + "decision_reason": {"type": "string", "maxLength": 2000, "minLength": 1, "nullable": True}, + "candidate_kind": {"type": "string", "enum": ["artifact", "tag"]}, + "result": {"$ref": "#/components/schemas/CatalogTagResult", "nullable": True}, + "operation": {"type": "string", "nullable": True}, + "origin": {"type": "string", "enum": ["dream", "manual"], "nullable": True}, + "dream_run_id": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "candidate_kind", + "candidate_id", + "version", + "family", + "status", + "proposal", + "source_refs", + "artifact_refs", + "target", + "reason", + "result_artifact", + "decision_reason", + ], + }, + "CandidatePage": { + "properties": { + "candidates": {"items": {"$ref": "#/components/schemas/Candidate"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["candidates", "next_cursor"], + }, + "ApproveCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "expected_version": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id", "expected_version"], + }, + "ArtifactDreamingOperationCapability": { + "properties": { + "operation": {"$ref": "#/components/schemas/DreamOperation"}, + "output_kind": {"type": "string", "enum": ["candidate", "tag_candidate"]}, + "effect": { + "type": "string", + "enum": [ + "review_then_publish", + "review_then_publish_configuration", + "review_then_replace_tags", + ], + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["operation", "output_kind", "effect"], + }, + "PluginKind": {"type": "string", "enum": ["artifact"]}, + "PluginLoadStatus": {"type": "string", "enum": ["loaded", "failed"]}, + "PluginTrigger": {"type": "string", "enum": ["schedule", "sources"]}, + "PluginState": { + "properties": { + "name": {"type": "string"}, + "kind": {"$ref": "#/components/schemas/PluginKind"}, + "status": {"$ref": "#/components/schemas/PluginLoadStatus"}, + "error": { + "type": "string", + "description": "Safe failure category from the startup check.", + "nullable": True, + }, + "family": {"type": "string", "nullable": True}, + "binding_name": {"type": "string", "nullable": True}, + "scopes": {"items": {"type": "string"}, "type": "array", "nullable": True}, + "source_types": { + "items": {"type": "string"}, + "type": "array", + "description": "Source types delivered to the plugin; null when it consumes no Sources.", + "nullable": True, + }, + "triggers": {"items": {"$ref": "#/components/schemas/PluginTrigger"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "name", + "kind", + "status", + "error", + "family", + "binding_name", + "scopes", + "source_types", + "triggers", + ], + }, + "PluginPage": { + "properties": {"items": {"items": {"$ref": "#/components/schemas/PluginState"}, "type": "array"}}, + "additionalProperties": False, + "type": "object", + "required": ["items"], + }, + "PluginScopeState": { + "properties": { + "scope_id": {"type": "string"}, + "pending": { + "type": "boolean", + "description": "New Sources or a schedule request are waiting for an invocation.", + }, + "consumed_position": { + "type": "integer", + "minimum": 0.0, + "description": "Source " + "journal " + "position " + "the " + "plugin " + "has " + "consumed " + "through; " + "null " + "when " + "it " + "consumes " + "no " + "Sources.", + "nullable": True, + }, + "source_position": { + "type": "integer", + "minimum": 0.0, + "description": "Latest Source journal position in the Scope, of any Source type.", + "nullable": True, + }, }, "additionalProperties": False, "type": "object", - "required": [ - "candidate_kind", - "candidate_id", - "version", - "family", - "status", - "proposal", - "source_refs", - "artifact_refs", - "target", - "reason", - "result_artifact", - "decision_reason", - ], + "required": ["scope_id", "pending", "consumed_position", "source_position"], }, - "CandidatePage": { + "ListPluginScopesRequest": { "properties": { - "candidates": {"items": {"$ref": "#/components/schemas/Candidate"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, + "cursor": {"type": "string", "maxLength": 256, "minLength": 1}, + "limit": {"type": "integer", "maximum": 200.0, "minimum": 1.0, "default": 50}, }, "additionalProperties": False, "type": "object", - "required": ["candidates", "next_cursor"], }, - "ApproveCandidateRequest": { + "PluginScopePage": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "expected_version": {"type": "integer", "minimum": 1.0}, + "items": {"items": {"$ref": "#/components/schemas/PluginScopeState"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "candidate_id", "expected_version"], + "required": ["items", "next_cursor"], }, - "ArtifactDreamingOperationCapability": { + "ArtifactLifecycleState": {"type": "string", "enum": ["active", "deprecated", "retired"]}, + "ArtifactGovernanceInput": { "properties": { - "operation": {"$ref": "#/components/schemas/DreamOperation"}, - "output_kind": {"type": "string", "enum": ["candidate", "tag_candidate"]}, - "effect": { - "type": "string", - "enum": [ - "review_then_publish", - "review_then_publish_configuration", - "review_then_replace_tags", - ], - }, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1}, + "revision": {"type": "integer", "minimum": 1.0}, + "state_version": {"type": "integer", "minimum": 0.0}, }, "additionalProperties": False, "type": "object", - "required": ["operation", "output_kind", "effect"], + "required": ["artifact_id", "revision", "state_version"], + "description": "An exact current Artifact the caller has read; a later change rejects the request.", }, - "CustomArtifactFamilySchema": { + "MergeArtifactsRequest": { "properties": { - "family": {"type": "string"}, - "schema_version": {"type": "string"}, - "content_schema": {"additionalProperties": True, "type": "object"}, - "search_fields": { - "items": {"type": "string"}, + "inputs": { + "items": {"$ref": "#/components/schemas/ArtifactGovernanceInput"}, "type": "array", - "description": "Legacy persisted text fields; new plugins use search.", + "minItems": 2, }, - "search": { + "content": { "additionalProperties": True, "type": "object", - "description": "Persistent declarative indexes and supported search modes.", + "description": "Complete content of the result in the Family's content shape.", }, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": ["family", "schema_version", "content_schema", "search_fields"], + "required": ["inputs", "content"], }, - "ReloadArtifactPluginRequest": { - "properties": {"expected_generation": {"type": "integer", "minimum": 0.0}}, + "ForgetArtifactRequest": { + "properties": { + "revision": {"type": "integer", "minimum": 1.0}, + "state_version": {"type": "integer", "minimum": 0.0}, + }, "additionalProperties": False, "type": "object", - "required": ["expected_generation"], + "required": ["revision", "state_version"], }, - "ChangeArtifactPluginStateRequest": { - "properties": {"expected_generation": {"type": "integer", "minimum": 1.0}}, + "ArtifactRestorationOperation": {"type": "string", "enum": ["restore", "undo_merge"]}, + "RestoreArtifactRequest": { + "properties": { + "operation": {"$ref": "#/components/schemas/ArtifactRestorationOperation", "default": "restore"}, + "revision": { + "type": "integer", + "minimum": 1.0, + "description": "Content " + "revision " + "to " + "restore; " + "omitted " + "restores " + "the " + "current " + "one. " + "Not " + "accepted " + "with " + "undo_merge.", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", - "required": ["expected_generation"], }, - "ArtifactPluginState": { + "ArtifactGovernanceRecord": { "properties": { - "binding_name": {"type": "string"}, - "plugin_id": {"type": "string"}, - "family": {"type": "string"}, - "binding_generation": {"type": "integer", "minimum": 1.0}, - "desired_state": {"type": "string", "enum": ["enabled", "disabled", "unloaded"]}, - "phase": { - "type": "string", - "enum": ["activating", "enabled", "stopping", "disabled", "unloading", "unloaded", "failed"], - }, - "last_error": {"type": "string", "nullable": True}, + "artifact": {"$ref": "#/components/schemas/ArtifactReference"}, + "lifecycle_state": {"$ref": "#/components/schemas/ArtifactLifecycleState"}, + "state_version": {"type": "integer", "minimum": 0.0}, + "merged_into_id": {"type": "string", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": [ - "binding_name", - "plugin_id", - "family", - "binding_generation", - "desired_state", - "phase", - "last_error", - ], + "required": ["artifact", "lifecycle_state", "state_version", "merged_into_id"], }, - "ArtifactPluginPage": { + "ArtifactGovernanceResponse": { "properties": { - "items": {"items": {"$ref": "#/components/schemas/ArtifactPluginState"}, "type": "array"} + "changed": {"type": "boolean"}, + "records": {"items": {"$ref": "#/components/schemas/ArtifactGovernanceRecord"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": ["items"], + "required": ["changed", "records"], }, - "EnableArtifactPluginResult": { + "ArtifactRestorationResult": { "properties": { - "binding_name": {"type": "string"}, - "outcome": {"type": "string", "enum": ["accepted", "skipped", "failed"]}, - "status_url": { - "type": "string", - "description": "Registration status URL for the asynchronous result.", - }, - "state": {"$ref": "#/components/schemas/ArtifactPluginState"}, - "error": { - "type": "string", - "enum": ["conflict", "invalid_plugin", "unavailable", None], - "description": "Safe per-plugin failure code; null for accepted or skipped entries.", - "nullable": True, - }, + "changed": {"type": "boolean"}, + "restored": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "retired": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "undo_merge_results": {"items": {"type": "string"}, "type": "array"}, }, "additionalProperties": False, "type": "object", - "required": ["binding_name", "outcome", "status_url", "state", "error"], + "required": ["changed", "restored", "retired", "undo_merge_results"], }, - "EnableAllArtifactPluginsResponse": { + "CreatePluginArtifactRequest": { "properties": { - "items": {"items": {"$ref": "#/components/schemas/EnableArtifactPluginResult"}, "type": "array"} + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[a-z][a-z0-9-]*$"}, + "content": {"additionalProperties": True, "type": "object"}, }, "additionalProperties": False, "type": "object", - "required": ["items"], + "required": ["family", "content"], + "description": "Create an Artifact of " + "a family a loaded " + "plugin provides. The " + "family must not be a " + "built-in family name; " + "core allocates the " + "Artifact ID and stores " + "the content the plugin " + "writes.", + }, + "ReplacePluginArtifactRequest": { + "properties": {"content": {"additionalProperties": True, "type": "object"}}, + "additionalProperties": False, + "type": "object", + "required": ["content"], + "description": "Complete content of a " + "plugin Artifact; core " + "stores it unchanged " + "and indexes the " + "declared fields.", }, "Capabilities": { "properties": { - "custom_artifact_schemas": { - "items": {"$ref": "#/components/schemas/CustomArtifactFamilySchema"}, - "type": "array", - "default": [], - }, "prompts": { "additionalProperties": {"$ref": "#/components/schemas/PromptCapability"}, "type": "object", @@ -10349,7 +10647,22 @@ "PreparedContextSearchGroup": { "properties": { "round": {"type": "integer", "maximum": 2.0, "minimum": 0.0}, - "family": {"$ref": "#/components/schemas/PreparedContextFamily"}, + "family": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "atomic-memory, " + "topic-memory, " + "experience, " + "profile, " + "code, " + "or " + "a " + "loaded " + "plugin " + "family.", + }, "mode": {"type": "string", "enum": ["fts", "vector", "hybrid", "snapshot", "code", "none"]}, "outcome": {"type": "string", "enum": ["completed", "not_run", "failed"]}, "fallback_reason": { @@ -10449,10 +10762,6 @@ "type": "object", "required": ["round", "config_id", "outcome", "fallback_reason", "count"], }, - "PreparedContextFamily": { - "type": "string", - "enum": ["atomic-memory", "topic-memory", "experience", "profile", "code"], - }, "PreparedContextCodeEvidence": { "properties": { "scope_id": {"type": "string"}, @@ -10477,7 +10786,22 @@ }, "PreparedContextSelectedItem": { "properties": { - "kind": {"$ref": "#/components/schemas/PreparedContextFamily"}, + "kind": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "atomic-memory, " + "topic-memory, " + "experience, " + "profile, " + "code, " + "or " + "a " + "loaded " + "plugin " + "family.", + }, "artifact_address": {"$ref": "#/components/schemas/ArtifactAddress"}, "code_evidence": {"$ref": "#/components/schemas/PreparedContextCodeEvidence"}, "rendered_bytes": {"type": "integer", "minimum": 0.0}, @@ -10489,7 +10813,22 @@ }, "PreparedContextOmittedGroup": { "properties": { - "family": {"$ref": "#/components/schemas/PreparedContextFamily"}, + "family": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "atomic-memory, " + "topic-memory, " + "experience, " + "profile, " + "code, " + "or " + "a " + "loaded " + "plugin " + "family.", + }, "reason": { "type": "string", "enum": [ @@ -12620,7 +12959,88 @@ }, "ContextAssemblySection": { "properties": { - "family": {"$ref": "#/components/schemas/ContextAssemblyFamily"}, + "family": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "A " + "built-in " + "family " + "(memory, " + "experience, " + "profile, " + "topic-memory) " + "or " + "a " + "loaded " + "plugin " + "family " + "that " + "implements " + "search. " + "A " + "plugin " + "family's " + "entries " + "come " + "from " + "the " + "plugin's " + "search " + "with " + "the " + "request " + "query " + "and " + "this " + "limit; " + "a " + "family " + "that " + "is " + "neither " + "returns " + "HTTP " + "422.", + }, + "search": { + "additionalProperties": True, + "type": "object", + "description": "Additional " + "generic " + "search " + "fields " + "passed " + "to " + "the " + "plugin's " + "search " + "unchanged, " + "such " + "as " + "mode, " + "filters, " + "fusion, " + "or " + "rerank. " + "Only " + "plugin " + "families " + "accept " + "it; " + "the " + "query " + "and " + "limit " + "come " + "from " + "the " + "request " + "and " + "this " + "section.", + }, "limit": { "type": "integer", "maximum": 8.0, @@ -12759,7 +13179,6 @@ "preserve the existing context " "format; null is invalid.", }, - "ContextAssemblyFamily": {"type": "string", "enum": ["memory", "experience", "profile", "topic-memory"]}, "ContextAssemblyFormat": {"type": "string", "enum": ["markdown"]}, "ContextAssemblyMetadata": {"type": "string", "enum": ["confidence", "recall_rank"]}, "ProposeExperienceRequest": { @@ -13312,6 +13731,18 @@ "required": ["retrieval", "channels"], "x-powercontext-artifact-search-validation": "score", }, + "ArtifactRendering": { + "properties": { + "family": {"type": "string", "minLength": 1}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1}, + "revision": {"type": "integer", "minimum": 1.0}, + "format": {"type": "string", "enum": ["markdown", "text"]}, + "text": {"type": "string"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "artifact_id", "revision", "format", "text"], + }, "ArtifactSearchItem": { "properties": { "family": {"type": "string", "minLength": 1}, @@ -13429,6 +13860,23 @@ "required": ["artifact", "title", "summary", "detail", "source_refs"], }, "CreateArtifactRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/CreateBuiltinArtifactRequest"}, + {"$ref": "#/components/schemas/CreatePluginArtifactRequest"}, + ], + "description": "A built-in family's creation " + "request, or the complete " + "content of an Artifact in a " + "loaded plugin's family. The " + "family name selects the " + "branch: built-in names " + "select the built-in branch.", + "x-powercontext-family-dispatch": { + "builtin": "CreateBuiltinArtifactRequest", + "plugin": "CreatePluginArtifactRequest", + }, + }, + "CreateBuiltinArtifactRequest": { "oneOf": [ {"$ref": "#/components/schemas/CreateAtomicMemoryArtifactRequest"}, {"$ref": "#/components/schemas/CreateTopicMemoryArtifactRequest"}, @@ -13607,9 +14055,49 @@ "type": "object", "required": ["kind", "text"], }, + "SourceType": { + "type": "string", + "enum": ["content"], + "description": "The built-in Source type that create_source accepts.", + }, "CreateSourceRequest": { "properties": { - "source_type": {"type": "string", "enum": ["content"], "default": "content"}, + "source_type": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9._-]*$", + "description": "The " + "built-in " + "content " + "type, " + "or " + "a " + "Source " + "type " + "that " + "a " + "loaded " + "plugin " + "defines. " + "A " + "plugin " + "type's " + "content " + "is " + "that " + "type's " + "input; " + "the " + "plugin " + "resolves " + "it " + "into " + "the " + "stored " + "Source.", + "default": "content", + }, "content": { "description": "JSON " "value " @@ -13619,7 +14107,15 @@ "built-in " "content " "Source " - "adapter. " + "adapter, " + "or " + "the " + "input " + "of " + "a " + "plugin " + "Source " + "type. " "Server-reserved " "payload " "schemas, " @@ -13653,17 +14149,17 @@ }, "TaggableArtifactFamily": { "type": "string", - "enum": [ - "memory", - "atomic-memory", - "experience", - "skill", - "handoff", - "profile", - "prompt", - "topic-memory", - ], - "description": "All readable Artifact families support logical tags on persisted Artifacts.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "A built-in Family or a " + "loaded plugin family; every " + "readable Family supports " + "logical tags on persisted " + "Artifacts. The " + "collection-level memory " + "Family tags entries " + "instead.", }, "TagMatch": {"type": "string", "enum": ["all", "any"]}, "TagTargetType": {"type": "string", "enum": ["artifact", "memory_entry"]}, @@ -13862,6 +14358,7 @@ {"$ref": "#/components/schemas/ReplaceHandoffArtifactRequest"}, {"$ref": "#/components/schemas/ReplacePromptArtifactRequest"}, {"$ref": "#/components/schemas/ReplaceProfileArtifactRequest"}, + {"$ref": "#/components/schemas/ReplacePluginArtifactRequest"}, ] }, "ReplacePromptArtifactRequest": { @@ -13880,6 +14377,11 @@ "type": "object", "required": ["content"], }, + "GetArtifactRenderingRequest": { + "properties": {"revision": {"type": "integer", "minimum": 1.0, "nullable": True}}, + "additionalProperties": False, + "type": "object", + }, "ListArtifactRevisionsRequest": { "properties": { "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, diff --git a/src/powercontext/plugins.py b/src/powercontext/plugins.py new file mode 100644 index 0000000000..b7a71fe370 --- /dev/null +++ b/src/powercontext/plugins.py @@ -0,0 +1,788 @@ +# 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. + +"""Interfaces that plugins implement and that PC Server calls when it needs them. + +A plugin directory holds ``plugin.py``, which assigns an ``ArtifactPlugin`` declaration +to ``plugin``. The declaration tells core which Sources to deliver and when; its +``setup`` factory returns an object with ``render_artifact(request)``, ``generate(request, +writer)`` when the declaration has a schedule or ``on_sources``, and, optionally, +``search_artifacts(request)``, the user-change methods, and the handlers of the plugin's +own HTTP routes. +Slow work such as model calls runs outside the writer; Artifact writes run inside one +short ``async with writer.transaction()`` block, which core commits together with the +consumption cursor and the invocation acknowledgment. + +Callers reach plugins through the existing Source and Artifact APIs, and through the +routes a plugin declares under ``/v1/plugins/{name}/api/``. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Collection, Sequence +from contextlib import AbstractAsyncContextManager, AsyncExitStack +from datetime import timedelta +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator + +from powercontext.artifacts import ArtifactLineage, ArtifactRef +from powercontext.sources import SourceProjectionKey, SourceRef + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + +ResourceT = TypeVar("ResourceT") +LifecycleState = Literal["active", "deprecated", "retired"] +BUILTIN_FAMILIES = frozenset({ + "memory", + "atomic-memory", + "experience", + "skill", + "handoff", + "profile", + "prompt", + "topic-memory", +}) + + +class Interval(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + seconds: float = Field(gt=0) + + @field_validator("seconds") + @classmethod + def representable(cls, value: float) -> float: + if timedelta(seconds=value).total_seconds() <= 0: + raise ValueError("seconds must be at least one microsecond") # noqa: TRY003 + return value + + +class Cron(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + expression: str = Field(min_length=1) + timezone: str = "Asia/Shanghai" + + +_PROJECTION = r"^[^@\s]+@[^@\s]+$" + + +class Inputs(BaseModel): + """The Sources delivered to ``generate`` and the Projection each one arrives in. + + ``projection`` applies to every Source type; ``projections`` names a different one for + particular types. Every listed type needs one or the other. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + source_types: tuple[Annotated[str, Field(min_length=1)], ...] = Field(min_length=1) + projection: str | None = Field(default=None, pattern=_PROJECTION) + projections: dict[str, Annotated[str, Field(pattern=_PROJECTION)]] = Field(default_factory=dict) + max_sources: int = Field(default=50, ge=1, le=1000) + + @model_validator(mode="after") + def every_type_has_a_projection(self) -> Inputs: + if set(self.projections) - set(self.source_types): + raise ValueError("projections may only name listed source_types") # noqa: TRY003 + if self.projection is None and set(self.source_types) - set(self.projections): + raise ValueError("every source type needs a projection") # noqa: TRY003 + return self + + def projection_key(self, source_type: str) -> SourceProjectionKey: + projection = self.projections.get(source_type, self.projection) + if projection is None: + raise KeyError(source_type) + name, version = projection.rsplit("@", 1) + return SourceProjectionKey(name=name, version=version) + + +class RouteSpec(BaseModel): + """The data part of one plugin route: what core needs to mount and authorize it.""" + + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] + path: str = Field(pattern=r"^/[A-Za-z0-9._~{}/-]*$", max_length=512) + handler: str = Field(pattern=r"^[a-z_][a-z0-9_]*$") + access: Literal["authenticated", "scope.read", "scope.contribute"] + scope_param: str | None = None + body: Literal["none", "json", "binary"] = "none" + # The route's own body limit; defaults to 1 MiB for JSON and to the deployment's ceiling for binary + # bodies. The deployment's POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES always caps it. + max_body_bytes: int | None = Field(default=None, ge=1) + timeout_seconds: float = Field(default=30, gt=0) + description: str | None = None + mcp: bool = False + request_schema: dict[str, JsonValue] | None = None + response_schema: dict[str, JsonValue] | None = None + + @model_validator(mode="after") + def consistent(self) -> RouteSpec: + parameters = set(_path_parameters(self.path)) + if self.access == "authenticated": + if self.scope_param is not None: + raise ValueError("authenticated routes do not take scope_param") # noqa: TRY003 + elif self.scope_param not in parameters: + raise ValueError("Scope routes must name a path parameter as scope_param") # noqa: TRY003 + if self.max_body_bytes is not None and self.body == "none": + raise ValueError("max_body_bytes needs a JSON or binary body") # noqa: TRY003 + if self.mcp and (self.body == "binary" or not self.description): + raise ValueError("MCP routes need a description and a JSON or empty body") # noqa: TRY003 + return self + + +class PluginApiSpec(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + routes: tuple[RouteSpec, ...] = Field(min_length=1, max_length=200) + + @model_validator(mode="after") + def unique_routes(self) -> PluginApiSpec: + keys = [(route.method, route.path) for route in self.routes] + if len(set(keys)) != len(keys): + raise ValueError("routes must be unique by method and path") # noqa: TRY003 + return self + + +class Route(RouteSpec): + """One HTTP route a plugin declares; its handler is a method of the ``setup`` result.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + request_model: type[BaseModel] | None = Field(default=None, exclude=True) + response_model: type[BaseModel] | None = Field(default=None, exclude=True) + + @model_validator(mode="after") + def describe_models(self) -> Route: + # Schemas cross process boundaries; the classes themselves stay in the plugin. + if self.request_model is not None and self.body != "json": + raise ValueError("request_model requires a JSON body") # noqa: TRY003 + object.__setattr__( + self, "request_schema", None if self.request_model is None else self.request_model.model_json_schema() + ) + object.__setattr__( + self, "response_schema", None if self.response_model is None else self.response_model.model_json_schema() + ) + return self + + +class PluginApi(PluginApiSpec): + model_config = ConfigDict(arbitrary_types_allowed=True) + + routes: tuple[Route, ...] = Field(min_length=1, max_length=200) + + +class PluginPages(BaseModel): + """Static pages the Dashboard shows in a sandboxed frame; they reach only this plugin's routes.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + directory: str = Field(default="ui", pattern=r"^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$") + entry: str = Field(default="index.html", pattern=r"^[A-Za-z0-9._-]+\.html$") + # Navigation title by language; English is required as the fallback. + title: dict[str, Annotated[str, Field(min_length=1, max_length=64)]] + + @field_validator("directory") + @classmethod + def inside_plugin(cls, value: str) -> str: + if ".." in value.split("/"): + raise ValueError("directory must stay inside the plugin directory") # noqa: TRY003 + return value + + @field_validator("title") + @classmethod + def has_english(cls, value: dict[str, str]) -> dict[str, str]: + if "en" not in value: + raise ValueError("title needs an en entry") # noqa: TRY003 + return value + + +class ArtifactPluginSpec(BaseModel): + """The data part of a declaration: what core needs before it runs plugin code.""" + + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + family: str = Field(pattern=r"^[a-z][a-z0-9-]{0,127}$") + inputs: Inputs | None = None + schedule: Interval | Cron | None = None + on_sources: bool = False + scopes: tuple[Annotated[str, Field(min_length=1, max_length=256)], ...] | None = Field( + default=None, min_length=1, max_length=1000 + ) + max_workers: int = Field(default=1, ge=1) + timeout_seconds: float = Field(default=300, gt=0) + transaction_timeout_seconds: float = Field(default=30, gt=0) + # Time limit of one user change or plugin API call in the host process. + change_timeout_seconds: float = Field(default=30, gt=0) + api: PluginApiSpec | None = None + pages: PluginPages | None = None + + @field_validator("family") + @classmethod + def not_builtin(cls, value: str) -> str: + if value in BUILTIN_FAMILIES: + raise ValueError("family must not reuse a built-in Artifact family") # noqa: TRY003 + return value + + @field_validator("scopes") + @classmethod + def unique_scopes(cls, value: tuple[str, ...] | None) -> tuple[str, ...] | None: + if value is not None and len(set(value)) != len(value): + raise ValueError("Scope IDs must be unique") # noqa: TRY003 + return None if value is None else tuple(sorted(value)) + + @model_validator(mode="after") + def consistent_triggers(self) -> ArtifactPluginSpec: + # Without a schedule or on_sources the plugin has no background generation. + if self.inputs is not None and self.schedule is None and not self.on_sources: + raise ValueError("inputs require a schedule, on_sources, or both") # noqa: TRY003 + if self.on_sources and self.inputs is None: + raise ValueError("on_sources requires inputs") # noqa: TRY003 + if isinstance(self.schedule, Cron) and (self.on_sources or self.inputs is None): + # The Supervisor admits one interval or one cron per binding, and cron plugins + # without inputs have no dirty work to admit. + raise ValueError("cron schedules require inputs and cannot be combined with on_sources") # noqa: TRY003 + if self.schedule is not None and self.inputs is None and self.scopes is None: + raise ValueError("scheduled plugins without inputs must list scopes explicitly") # noqa: TRY003 + if self.transaction_timeout_seconds > self.timeout_seconds: + raise ValueError("transaction_timeout_seconds must not exceed timeout_seconds") # noqa: TRY003 + if self.pages is not None and self.api is None: + raise ValueError("pages call the plugin's own routes; declare api as well") # noqa: TRY003 + return self + + +class GenerationSource(BaseModel): + """One Source of the invocation window with the manifest's input Projection.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ref: SourceRef + position: int = Field(ge=1) + projection: JsonValue + + +class GenerateRequest(BaseModel): + """Inputs of one Scope invocation; the cursor itself is never exposed.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + reason: Literal["schedule", "sources"] + sources: tuple[GenerationSource, ...] = () + + +class ArtifactSnapshot(BaseModel): + """One exact revision with its current governance state and direct lineage.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + ref: ArtifactRef + content: dict[str, JsonValue] + lineage: ArtifactLineage + lifecycle_state: LifecycleState + merged_into_id: str | None = None + replacement_artifact_id: str | None = None + governance_generation: int = Field(ge=0) + + +class ArtifactPage(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + items: tuple[ArtifactSnapshot, ...] + next_cursor: str | None = None + + +class UndoMergePreview(BaseModel): + """The group an undo would restore and retire, computed from current state.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + restore: tuple[ArtifactRef, ...] + retire: tuple[ArtifactRef, ...] + undo_merge_results: tuple[str, ...] + + +class ArtifactChange(BaseModel): + """A lifecycle change and the other Artifacts the shared flow restored or retired with it.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + artifact: ArtifactSnapshot + restored: tuple[ArtifactRef, ...] = () + retired: tuple[ArtifactRef, ...] = () + + +class PluginStorage(Protocol): + """The plugin's dedicated area on the deployment's database backend, outside core's transactions.""" + + @property + def backend(self) -> Literal["sqlite", "oceanbase", "seekdb"]: ... + + def engine(self) -> AsyncEngine: ... + + +class ChatMessage(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + role: Literal["system", "user", "assistant"] + content: str = Field(min_length=1) + + +class ModelUnavailable(Exception): + """The Server has no model of the kind the plugin called.""" + + +class PluginModels(Protocol): + """Server's configured models; addresses and credentials stay with core. + + ``ctx.models`` is None when the Server configures neither an embedding nor a generation + model; a call to a kind that is not configured raises ``ModelUnavailable``. + """ + + @property + def embedding_available(self) -> bool: ... + + @property + def embedding_dimension(self) -> int | None: ... + + @property + def generation_available(self) -> bool: ... + + async def embed(self, texts: Sequence[str], /) -> tuple[tuple[float, ...], ...]: ... + + async def generate(self, messages: Sequence[ChatMessage], /, *, max_tokens: int | None = None) -> str: ... + + +class Caller(BaseModel): + """The authenticated caller of a user change or plugin route; carries no credentials.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + principal_id: str + + +class AccessDenied(Exception): + """``ctx.require`` found the caller without the permission; the route returns 403.""" + + +class PluginContext: + """Context of one Worker invocation, or of a host process for every Scope it serves. + + In a Worker ``scope_id`` is the invocation Scope. In the host process it is None, + each request carries its Scope, and the request helpers below act as that + request's caller. Resources registered here close when the call or process ends. + """ + + def __init__( + self, + *, + name: str, + scope_id: str | None, + plugin_dir: Path, + logger: logging.Logger, + resources: AsyncExitStack, + storage: PluginStorage | None = None, + models: PluginModels | None = None, + host: Any = None, + ) -> None: + self.name = name + self.scope_id = scope_id + self.plugin_dir = plugin_dir + self.logger = logger + self.models = models + self._storage = storage + self._resources = resources + self._host = host + + @property + def storage(self) -> PluginStorage: + if self._storage is None: + raise RuntimeError("dedicated plugin storage is unavailable") # noqa: TRY003 + return self._storage + + async def enter_async_context(self, manager: AbstractAsyncContextManager[ResourceT]) -> ResourceT: + return await self._resources.enter_async_context(manager) + + def _request_host(self) -> Any: + if self._host is None: + raise RuntimeError("request helpers are available only while the host process serves a request") # noqa: TRY003 + return self._host + + @property + def caller(self) -> Caller: + return self._request_host().caller() + + async def require( + self, action: Literal["scope.read", "scope.contribute", "server.admin"], scope_id: str | None = None + ) -> None: + await self._request_host().require(action, scope_id) + + async def allowed( + self, action: Literal["scope.read", "scope.contribute", "server.admin"], scope_id: str | None = None + ) -> bool: + try: + await self.require(action, scope_id) + except AccessDenied: + return False + return True + + def reads(self, scope_id: str) -> ArtifactReads: + return self._request_host().reads(scope_id) + + def writer(self, scope_id: str) -> ArtifactWriter: + return self._request_host().writer(scope_id) + + @property + def sources(self) -> PluginSources: + return self._request_host().sources() + + @property + def scopes(self) -> PluginScopes: + return self._request_host().scopes() + + +class PluginSources(Protocol): + async def submit(self, scope_id: str, definition: str, value: BaseModel, /) -> SourceRef: ... + + +class PluginScope(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + title: str + summary: str + version: int + + +class PluginScopes(Protocol): + async def create(self, *, title: str, summary: str, idempotency_key: str) -> PluginScope: ... + + async def update(self, scope_id: str, /, *, expected_version: int, title: str, summary: str) -> PluginScope: ... + + async def list(self) -> tuple[PluginScope, ...]: ... + + +class ArtifactReads(Protocol): + """Reads limited to the invocation Scope and the plugin's own Family.""" + + async def get(self, artifact_id: str, /, *, revision: int | None = None) -> ArtifactSnapshot | None: ... + + async def revisions(self, artifact_id: str, /, *, cursor: str | None = None, limit: int = 50) -> ArtifactPage: ... + + async def list( + self, + *, + states: Collection[LifecycleState] = ("active",), + tags: Collection[str] = (), + cursor: str | None = None, + limit: int = 50, + ) -> ArtifactPage: ... + + async def by_source(self, ref: SourceRef, /) -> tuple[ArtifactSnapshot, ...]: ... + + async def merge_inputs(self, artifact_id: str, /) -> tuple[ArtifactRef, ...]: ... + + async def tags(self, artifact_id: str, /) -> tuple[str, ...]: ... + + async def resolve_handle(self, handle_key: str, /) -> ArtifactRef | None: ... + + +class ArtifactTransaction(ArtifactReads, Protocol): + """Writes execute immediately in the open transaction and return exact revisions.""" + + async def put( + self, + artifact_id: str, + /, + *, + content: dict[str, JsonValue], + source_refs: Sequence[SourceRef] = (), + artifact_refs: Sequence[ArtifactRef] = (), + expected_revision: int | None = None, + ) -> ArtifactSnapshot: ... + + async def revert(self, artifact_id: str, /, *, to_revision: int) -> ArtifactSnapshot: ... + + async def set_lifecycle( + self, artifact_id: str, state: LifecycleState, /, *, replacement_artifact_id: str | None = None + ) -> ArtifactSnapshot: ... + + async def merge( + self, + inputs: Sequence[str], + /, + *, + into: str, + content: dict[str, JsonValue], + source_refs: Sequence[SourceRef] = (), + artifact_refs: Sequence[ArtifactRef] = (), + ) -> ArtifactSnapshot: ... + + async def preview_undo_merge(self, artifact_id: str, /) -> UndoMergePreview: ... + + async def undo_merge(self, artifact_id: str, /) -> UndoMergePreview: ... + + async def bind_handle(self, handle_key: str, ref: ArtifactRef, /) -> ArtifactRef: ... + + async def set_tags(self, artifact_id: str, tags: Sequence[str], /) -> tuple[str, ...]: ... + + +class ArtifactWriter(ArtifactReads, Protocol): + """Committed reads outside the transaction, plus the single write transaction.""" + + @property + def family(self) -> str: ... + + def stable_id(self, *parts: str) -> str: ... + + def transaction(self) -> AbstractAsyncContextManager[ArtifactTransaction]: ... + + +class RenderRequest(BaseModel): + """Present one exact revision; the result may be cached by family, ID, revision, and plugin version.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + artifact: ArtifactSnapshot + caller: Caller | None = None + + +class Rendering(BaseModel): + """The default presentation of one revision, shown to people and placed into agent context.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + format: Literal["markdown", "text"] = "markdown" + text: str + + +class SearchRequest(BaseModel): + """A generic search of the plugin's family, passed on with every field the caller sent.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + # The complete body of POST .../artifacts/{family}/search as JSON; Prepared Context sends its + # query, the section's limit, and the section's own search fields. Reject what you do not + # support with InvalidContent. + search: dict[str, JsonValue] + # Bytes still available to this family when the search comes from Prepared Context. + max_bytes: int | None = Field(default=None, ge=0) + caller: Caller | None = None + + +class SearchHit(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + ref: ArtifactRef + # Text for agent context: a passage of the revision or its rendering. + text: str + score: float | None = None + locator: dict[str, JsonValue] | None = None + + +class SearchResult(BaseModel): + """Hits in the plugin's order; core drops any the caller cannot read or that are not current.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + hits: tuple[SearchHit, ...] = () + + +class ArtifactGenerator(Protocol): + """What ``setup`` returns; core checks the required methods after calling it.""" + + async def render_artifact(self, request: RenderRequest, /) -> Rendering: ... + + # Required when the declaration has a schedule or on_sources. + async def generate(self, request: GenerateRequest, writer: ArtifactWriter, /) -> None: ... + + +class CreateRequest(BaseModel): + """A user creates an Artifact; core allocated ``artifact_id``.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + artifact_id: str + content: dict[str, JsonValue] + caller: Caller | None = None + + +class UpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + artifact_id: str + expected_revision: int + content: dict[str, JsonValue] + caller: Caller | None = None + + +class MergeInput(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + artifact_id: str + revision: int + state_version: int + + +class MergeRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + inputs: tuple[MergeInput, ...] + into: str + content: dict[str, JsonValue] + caller: Caller | None = None + + +class ForgetRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + artifact_id: str + expected_revision: int + expected_state_version: int + caller: Caller | None = None + + +class RestoreRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + artifact_id: str + operation: Literal["restore", "undo_merge"] = "restore" + revision: int | None = None + caller: Caller | None = None + + +class InvalidContent(Exception): + """The plugin rejects content a user submitted; the API returns 422 with this message.""" + + def __init__(self, message: str, *, field: str | None = None) -> None: + super().__init__(message) + self.message = message + self.field = field + + +class ApiRequest(BaseModel): + """One call to a plugin route, after core authenticated the caller and read the body.""" + + model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True) + + scope_id: str | None + route: str + path_params: dict[str, str] + query: dict[str, list[str]] + body: Any = None + caller: Caller + + +class BinaryResponse(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + content: bytes + content_type: str + filename: str | None = None + + +class ApiError(Exception): + """A handler's 4xx answer; core decides 401 and 403 itself.""" + + def __init__(self, status: int, code: str, message: str) -> None: + if not 400 <= status <= 499 or status in {401, 403}: + raise ValueError("ApiError status must be a 4xx other than 401 and 403") # noqa: TRY003 + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +USER_CHANGE_METHODS = ("create_artifact", "update_artifact", "merge_artifacts", "forget_artifact", "restore_artifact") + + +class ArtifactPlugin(ArtifactPluginSpec): + """The declaration ``plugin.py`` assigns to ``plugin``; the plugin name is its directory name.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + # Called once per Worker invocation, and once per host process; read deployment settings from the environment here. + setup: Callable[[PluginContext], Awaitable[ArtifactGenerator]] = Field(exclude=True) + # Source Definitions this plugin's own routes submit; their manifests register when the plugin loads. + definitions: tuple[Any, ...] = Field(default=(), exclude=True) + api: PluginApi | None = None + + +def _path_parameters(path: str) -> tuple[str, ...]: + import re + + return tuple(re.findall(r"{([A-Za-z_][A-Za-z0-9_]*)}", path)) + + +__all__ = [ + "USER_CHANGE_METHODS", + "AccessDenied", + "ApiError", + "ApiRequest", + "ArtifactChange", + "ArtifactGenerator", + "ArtifactPage", + "ArtifactPlugin", + "ArtifactPluginSpec", + "ArtifactReads", + "ArtifactSnapshot", + "ArtifactTransaction", + "ArtifactWriter", + "BinaryResponse", + "Caller", + "ChatMessage", + "CreateRequest", + "Cron", + "ForgetRequest", + "GenerateRequest", + "GenerationSource", + "Inputs", + "Interval", + "InvalidContent", + "LifecycleState", + "MergeInput", + "MergeRequest", + "ModelUnavailable", + "PluginApi", + "PluginApiSpec", + "PluginContext", + "PluginModels", + "PluginPages", + "PluginScope", + "PluginScopes", + "PluginSources", + "PluginStorage", + "RenderRequest", + "Rendering", + "RestoreRequest", + "Route", + "RouteSpec", + "SearchHit", + "SearchRequest", + "SearchResult", + "UndoMergePreview", + "UpdateRequest", +] diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index e568fe36d1..d40aa040d6 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -31,6 +31,7 @@ from time import perf_counter from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeVar, cast from urllib.parse import quote, unquote +from uuid import uuid4 from fastapi import Depends, FastAPI, Header, Path, Query, Request, Response, status from fastapi import Path as PathParameter @@ -47,7 +48,7 @@ from typing_extensions import override from powercontext._logging import error_category, log_context, log_safely -from powercontext.artifacts import ArtifactAddress, ArtifactRef, StableHandle +from powercontext.artifacts import ArtifactAddress, ArtifactLineage, ArtifactRef, StableHandle from powercontext.artifacts.search import ( ArtifactSearchExecutionContext, ArtifactSearchFamilyNotFound, @@ -80,6 +81,14 @@ MemoryEntryNotFoundError, MemoryWriteRejectedError, ) +from powercontext.builtin.artifacts.merge_models import ( + ArtifactMergeConflictError, + ArtifactMergePreviewExpiredError, + ArtifactMergeRead, + ArtifactMergeRelationError, + InvalidArtifactMergePreviewError, + InvalidArtifactMergeStateError, +) from powercontext.builtin.artifacts.prompt import GeneratePromptDemonstrations, PromptError from powercontext.builtin.artifacts.skill import ( AgentKind, @@ -384,6 +393,7 @@ from powercontext.builtin.runtime.context_check import ( ContextCheckResult as RuntimeContextCheckResult, ) +from powercontext.builtin.runtime.models import BUILTIN_CONTEXT_FAMILIES, ContextPluginHit from powercontext.builtin.runtime.skill_search import search_skill_library from powercontext.builtin.scope import ( ScopeApplication, @@ -431,9 +441,6 @@ from powercontext.builtin.tags import ( TagFilter as RuntimeTagFilter, ) -from powercontext.builtin.tags import ( - TaggableArtifactFamily as RuntimeTaggableArtifactFamily, -) from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -486,12 +493,15 @@ ArtifactCollectionItem, ArtifactCreated, ArtifactFamilyAccessCapability, + ArtifactGovernanceRecord, + ArtifactGovernanceResponse, ArtifactHandleBinding, ArtifactPage, - ArtifactPluginPage, - ArtifactPluginState, ArtifactReadFamily, ArtifactReference, + ArtifactRendering, + ArtifactRestorationOperation, + ArtifactRestorationResult, ArtifactRevision, ArtifactRevisionPage, BaseArtifactFamily, @@ -507,7 +517,6 @@ CaptureContentSourceRequest, CaptureContentSourceResponse, CaptureWorkingNoteRequest, - ChangeArtifactPluginStateRequest, CheckContextRequest, ClearScopeBindingRequest, ClearScopeBindingResponse, @@ -523,7 +532,9 @@ ContinueHandoffRequest, CreateAccessBindingRequest, CreateArtifactRequest, + CreateBuiltinArtifactRequest, CreateDreamRunRequest, + CreatePluginArtifactRequest, CreatePromptArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, @@ -534,7 +545,6 @@ DownloadRemoteSkillPackageRequest, DreamRun, DreamRunPage, - EnableAllArtifactPluginsResponse, EnrollRemoteSkillTargetRequest, ErrorDetail, ErrorResponse, @@ -547,6 +557,7 @@ FlushProfileResponse, FlushTopicMemoryRequest, FlushTopicMemoryResponse, + ForgetArtifactRequest, GeneratedCandidateResponse, GenerateExperienceRequest, GeneratePromptDemonstrationsRequest, @@ -584,6 +595,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListPluginScopesRequest, ListRemoteSkillTargetsRequest, ListRemoteSkillTargetsResponse, ListScopesRequest, @@ -591,7 +603,11 @@ MemoryCapacity, MemoryEntryAccessSelector, MemoryMutationResponse, + MergeArtifactsRequest, PlanAccessOwnershipRequest, + PluginPage, + PluginScopePage, + PluginState, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -618,7 +634,6 @@ RecordTaskOutcomeRequest, RegisterSourceDefinitionRequest, RejectCandidateRequest, - ReloadArtifactPluginRequest, RememberMemoryRequest, RemoteSkillAction, RemoteSkillPublication, @@ -630,9 +645,11 @@ RenameRemoteSkillTargetRequest, ReplaceAccessBindingRequest, ReplaceArtifactRequest, + ReplacePluginArtifactRequest, ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, + RestoreArtifactRequest, RetireMemoryEntryRequest, ReviseCandidateRequest, ReviseMemoryEntryRequest, @@ -649,6 +666,7 @@ ScopeQueryField, ScopeSelection, SearchArtifactsRequest, + SearchArtifactsResponse, SearchMemoryRequest, SearchMemoryResponse, SearchTopicMemoryRequest, @@ -666,6 +684,7 @@ SourceObservationReceipt, SourcePage, SourceRecord, + SourceType, SubmitSourceObservationRequest, TopicMemoryArtifact, UnpublishRemoteSkillRequest, @@ -709,6 +728,9 @@ from powercontext.http import ( AccessSubject as TransportAccessSubject, ) +from powercontext.http import ( + ArtifactLifecycleState as TransportArtifactLifecycleState, +) from powercontext.http import ( ArtifactPublication as TransportArtifactPublication, ) @@ -747,7 +769,6 @@ ReplaceArtifactTagsRequest, RetryTopicMemoryProcessingRequest, RetryTopicMemoryProcessingResponse, - TaggableArtifactFamily, TagMatch, TagTargetType, TopicMemoryProcessingBlock, @@ -789,22 +810,21 @@ CREATE_SOURCE, CREATE_SUBJECT_SOURCE, CREATE_WORK_CONTRACT, - DISABLE_ARTIFACT_PLUGIN, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, - ENABLE_ALL_ARTIFACT_PLUGINS, - ENABLE_ARTIFACT_PLUGIN, ENROLL_REMOTE_SKILL_TARGET, FINALIZE_HANDOFF, FLUSH_MEMORY, FLUSH_PROFILE, FLUSH_TOPIC_MEMORY, + FORGET_ARTIFACT, GENERATE_EXPERIENCE, GENERATE_PROMPT_DEMONSTRATIONS, GENERATE_SKILL, GET_ACCESS_PRINCIPAL, GET_ARTIFACT, - GET_ARTIFACT_PLUGIN, + GET_ARTIFACT_GOVERNANCE, + GET_ARTIFACT_RENDERING, GET_ARTIFACT_REVISION, GET_ARTIFACT_TAGS, GET_CANDIDATE, @@ -820,6 +840,7 @@ GET_MEMORY_CAPACITY, GET_MEMORY_ENTRY, GET_MEMORY_ENTRY_TAGS, + GET_PLUGIN, GET_PROFILE_POLICY, GET_PROMPT_CONFIGURATION, GET_READINESS, @@ -838,7 +859,6 @@ LIST_ACCESS_BINDINGS, LIST_ACCESS_RESOURCES, LIST_ACCESS_ROLES, - LIST_ARTIFACT_PLUGINS, LIST_ARTIFACT_REVISIONS, LIST_ARTIFACTS, LIST_CANDIDATES, @@ -847,9 +867,12 @@ LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_PLUGIN_SCOPES, + LIST_PLUGINS, LIST_REMOTE_SKILL_TARGETS, LIST_SCOPES, LIST_SOURCES, + MERGE_ARTIFACTS, OPENAPI_VERSION, PLAN_ACCESS_OWNERSHIP, PREPARE_BOOTSTRAP_CONTEXT, @@ -871,7 +894,6 @@ RECORD_TASK_OUTCOME, REGISTER_SOURCE_DEFINITION, REJECT_CANDIDATE, - RELOAD_ARTIFACT_PLUGIN, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, REPLACE_ACCESS_BINDING, @@ -882,6 +904,7 @@ RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, + RESTORE_ARTIFACT, RETIRE_MEMORY_ENTRY, RETRY_TOPIC_MEMORY_PROCESSING, REVISE_CANDIDATE, @@ -895,7 +918,6 @@ SET_DEFAULT_SCOPE, SET_SCOPE_BINDING, SUBMIT_SOURCE_OBSERVATION, - UNLOAD_ARTIFACT_PLUGIN, UNPUBLISH_REMOTE_SKILL, UPDATE_SCOPE, UPDATE_SKILL_LIFECYCLE, @@ -904,7 +926,6 @@ ) from powercontext.http._generated.schema import OPENAPI_SCHEMA from powercontext.server import mapping -from powercontext.server.artifact_plugins import ArtifactPluginManager from powercontext.server.artifact_read_observation import observe_artifact_read from powercontext.server.atomic_memory import add_atomic_memory_routes from powercontext.server.atomic_memory import record_response as atomic_record_response @@ -928,11 +949,13 @@ apply_ownership_migration, plan_ownership_migration, ) +from powercontext.server.plugins import PluginManager from powercontext.server.request_id import REQUEST_ID_HEADER, HttpRequestIdMiddleware from powercontext.server.retrieval_observation import observe_retrieval from powercontext.server.tracing import request_id_from_span from powercontext.sources import ConnectorBinding as RuntimeConnectorBinding from powercontext.sources import SourceDefinitionManifest as RuntimeSourceDefinitionManifest +from powercontext.sources import SourceRef if TYPE_CHECKING: from powercontext.server.metrics import ServerMetrics @@ -1464,9 +1487,8 @@ def create_app( middleware=list(middleware), ) app.openapi_version = OPENAPI_VERSION - app.state.artifact_plugin_manager = None - app.state.artifact_declaration_provider = None - app.state.artifact_access_refresher = None + app.state.plugin_manager = None + app.state.plugin_api_max_body_bytes = 32 * 1024 * 1024 app.state.application = application app.state.capability_provider = capability_provider app.state.readiness_probe = readiness_probe @@ -1528,6 +1550,7 @@ async def invalid_request( @app.exception_handler(BaseAccessError) @app.exception_handler(PromptError) @app.exception_handler(SourceNotEligibleError) + @app.exception_handler(PluginCallError) @app.exception_handler(PowerContextError) @app.exception_handler(PersistenceError) async def application_error(request: Request, error: Exception) -> JSONResponse: @@ -1559,13 +1582,20 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_READINESS, get_readiness) _add_route(app, GET_SERVER_INFO, get_server_info) _add_route(app, GET_CAPABILITIES, get_capabilities) - _add_route(app, LIST_ARTIFACT_PLUGINS, list_artifact_plugins) - _add_route(app, ENABLE_ALL_ARTIFACT_PLUGINS, enable_all_artifact_plugins) - _add_route(app, GET_ARTIFACT_PLUGIN, get_artifact_plugin) - _add_route(app, RELOAD_ARTIFACT_PLUGIN, reload_artifact_plugin) - _add_route(app, ENABLE_ARTIFACT_PLUGIN, enable_artifact_plugin) - _add_route(app, DISABLE_ARTIFACT_PLUGIN, disable_artifact_plugin) - _add_route(app, UNLOAD_ARTIFACT_PLUGIN, unload_artifact_plugin) + _add_route(app, LIST_PLUGINS, list_plugins) + _add_route(app, GET_PLUGIN, get_plugin) + # Plugin routes are outside the main contract; core authenticates, the plugin host authorizes. + app.router.add_api_route( + "/v1/plugins/{name}/api/{path:path}", + call_plugin_api, + methods=["GET", "POST", "PUT", "PATCH", "DELETE"], + include_in_schema=False, + ) + _add_route(app, LIST_PLUGIN_SCOPES, list_plugin_scopes) + _add_route(app, GET_ARTIFACT_GOVERNANCE, get_artifact_governance) + _add_route(app, MERGE_ARTIFACTS, merge_artifacts) + _add_route(app, FORGET_ARTIFACT, forget_artifact) + _add_route(app, RESTORE_ARTIFACT, restore_artifact) from powercontext.source_management.gateway import mount_source_routes mount_source_routes(app) @@ -1615,6 +1645,7 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_ARTIFACT, get_artifact) _add_route(app, LIST_ARTIFACTS, list_artifacts) _add_route(app, LIST_ARTIFACT_REVISIONS, list_artifact_revisions) + _add_route(app, GET_ARTIFACT_RENDERING, get_artifact_rendering) _add_route(app, GENERATE_PROMPT_DEMONSTRATIONS, generate_prompt_demonstrations) _add_route(app, GET_PROMPT_CONFIGURATION, get_prompt_configuration) _add_route(app, REPLACE_ARTIFACT, replace_artifact) @@ -1791,95 +1822,239 @@ async def access_readiness_checks(state: Any) -> dict[str, str]: } -_PluginBindingPath = Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")] - - -def _require_artifact_plugin_manager(request: Request) -> ArtifactPluginManager: - manager = request.app.state.artifact_plugin_manager +def _require_plugin_manager(request: Request) -> PluginManager: + manager = request.app.state.plugin_manager if manager is None: raise _RuntimeNotReadyError return manager -async def list_artifact_plugins(http_request: Request) -> ArtifactPluginPage: - return ArtifactPluginPage(items=await _require_artifact_plugin_manager(http_request).list_registrations()) +async def list_plugins(http_request: Request) -> PluginPage: + return _require_plugin_manager(http_request).page() -async def enable_all_artifact_plugins(http_request: Request) -> EnableAllArtifactPluginsResponse: - return await _require_artifact_plugin_manager(http_request).enable_all() +async def get_plugin( + name: Annotated[str, Path(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9-]*$")], http_request: Request +) -> PluginState: + return _require_plugin_manager(http_request).get(name) -async def get_artifact_plugin(binding_name: _PluginBindingPath, http_request: Request) -> ArtifactPluginState: - return await _require_artifact_plugin_manager(http_request).get(binding_name) +def _list_plugin_scopes_query( + http_request: Request, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=256)] = None, +) -> ListPluginScopesRequest: + _require_strict_query(http_request, {"limit", "cursor"}) + return ListPluginScopesRequest(limit=limit, cursor=cursor) -async def reload_artifact_plugin( - binding_name: _PluginBindingPath, - request: ReloadArtifactPluginRequest, +async def list_plugin_scopes( + name: Annotated[str, Path(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9-]*$")], + request: Annotated[ListPluginScopesRequest, Depends(_list_plugin_scopes_query)], http_request: Request, - response: Response, -) -> ArtifactPluginState: - result = await _require_artifact_plugin_manager(http_request).reload(binding_name, request) - response.headers["Location"] = f"/v1/admin/artifact-plugins/{binding_name}" - return result +) -> PluginScopePage: + return await _require_plugin_manager(http_request).scopes(name, cursor=request.cursor, limit=request.limit or 50) + + +def _merge_registry(http_request: Request, family: str) -> Any: + merge = getattr(_require_application(http_request), "artifact_merge", None) + if merge is None: + raise BaseOperationNotSupportedError("artifact_family", family, "registered Artifact merge adapter") + return merge + + +def _merge_scope(http_request: Request, scope_id: str, family: str) -> Any: + return _merge_registry(http_request, family).for_scope(scope_id, family) + + +def _merge_execution_context(http_request: Request, operation: str) -> ArtifactSearchExecutionContext | None: + # The shared merge service authorizes every input and result itself. + access = access_control_for_mode(http_request.app.state.access_control, mode=http_request.app.state.access_mode) + if access is None: + return None + return ArtifactSearchExecutionContext( + principal=_require_principal(), access=access, audit=_access_audit_context(operation) + ) + + +def _merge_content(http_request: Request, family: str, content: dict[str, Any]) -> Any: + from powercontext.builtin.plugins.families import PluginArtifactContent + + manager = http_request.app.state.plugin_manager + manifest = None if manager is None else manager.plugin_for_family(family) + if manifest is not None: + return PluginArtifactContent(content=content) + content_type = _merge_registry(http_request, family).content_type(family) + try: + return content_type.model_validate(content) + except PydanticValidationError as error: + raise InvalidBaseAccessRequestError("content", "does not match the Family's content shape") from error + +def _artifact_reference(ref: Any) -> ArtifactReference: + return ArtifactReference(family=ref.family, artifact_id=ref.artifact_id, revision=ref.revision) -async def enable_artifact_plugin( - binding_name: _PluginBindingPath, - request: ChangeArtifactPluginStateRequest, + +def _governance_record(record: Any) -> ArtifactGovernanceRecord: + return ArtifactGovernanceRecord( + artifact=_artifact_reference(record.ref), + lifecycle_state=TransportArtifactLifecycleState(record.state.lifecycle_state.value), + state_version=record.state.governance_generation, + merged_into_id=record.state.merged_into_id, + ) + + +def _governance_response(result: Any) -> ArtifactGovernanceResponse: + return ArtifactGovernanceResponse( + changed=result.changed, records=[_governance_record(record) for record in result.records] + ) + + +async def get_artifact_governance( + scope_id: _ScopePathId, + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], http_request: Request, - response: Response, -) -> ArtifactPluginState: - result = await _require_artifact_plugin_manager(http_request).change( - binding_name, request.expected_generation, "enabled" +) -> ArtifactGovernanceRecord: + record = await _merge_scope(http_request, scope_id, family).get( + artifact_id, context=_merge_execution_context(http_request, "get_artifact_governance") ) - response.headers["Location"] = f"/v1/admin/artifact-plugins/{binding_name}" - return result + return _governance_record(record) -async def disable_artifact_plugin( - binding_name: _PluginBindingPath, - request: ChangeArtifactPluginStateRequest, +async def merge_artifacts( + scope_id: _ScopePathId, + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], + request: MergeArtifactsRequest, http_request: Request, - response: Response, -) -> ArtifactPluginState: - result = await _require_artifact_plugin_manager(http_request).change( - binding_name, request.expected_generation, "disabled" +) -> ArtifactGovernanceResponse: + found = _plugin_family_host(http_request, family) + if found is not None: + manifest, host = found + result = await _call_plugin_host( + http_request, + host, + "merge_artifacts", + scope_id=scope_id, + payload={ + "inputs": [item.model_dump(mode="json") for item in request.inputs], + "content": request.content, + }, + timeout=manifest.spec.change_timeout_seconds, + operation="merge_artifacts", + ) + inputs = [item.artifact_id for item in request.inputs] + merged = result["artifact"]["ref"]["artifact_id"] + return await _plugin_governance(http_request, scope_id, family, [merged, *inputs]) + scoped = _merge_scope(http_request, scope_id, family) + content = _merge_content(http_request, family, request.content) + inputs = tuple( + ArtifactMergeRead( + ref=ArtifactRef(family=family, artifact_id=item.artifact_id, revision=item.revision), + lifecycle_state=ArtifactLifecycleState.ACTIVE, + state_version=item.state_version, + ) + for item in request.inputs + ) + references = ( + *(item.ref for item in inputs), + *(ArtifactRef.model_validate(item.model_dump(mode="json")) for item in request.artifact_refs or ()), + ) + lineage = ArtifactLineage( + sources=tuple(SourceRef.model_validate(item.model_dump(mode="json")) for item in request.source_refs or ()), + artifacts=tuple({(ref.family, ref.artifact_id, ref.revision): ref for ref in references}.values()), + ) + result = await scoped.merge( + inputs, content, lineage=lineage, context=_merge_execution_context(http_request, "merge_artifacts") ) - response.headers["Location"] = f"/v1/admin/artifact-plugins/{binding_name}" - return result + return _governance_response(result) -async def unload_artifact_plugin( - binding_name: _PluginBindingPath, - request: ChangeArtifactPluginStateRequest, +async def forget_artifact( + scope_id: _ScopePathId, + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + request: ForgetArtifactRequest, http_request: Request, - response: Response, -) -> ArtifactPluginState: - result = await _require_artifact_plugin_manager(http_request).change( - binding_name, request.expected_generation, "unloaded" +) -> ArtifactGovernanceResponse: + found = _plugin_family_host(http_request, family) + if found is not None: + manifest, host = found + await _call_plugin_host( + http_request, + host, + "forget_artifact", + scope_id=scope_id, + payload={ + "artifact_id": artifact_id, + "expected_revision": request.revision, + "expected_state_version": request.state_version, + }, + timeout=manifest.spec.change_timeout_seconds, + operation="forget_artifact", + ) + return await _plugin_governance(http_request, scope_id, family, [artifact_id]) + result = await _merge_scope(http_request, scope_id, family).forget( + artifact_id, + expected_revision=request.revision, + expected_state_version=request.state_version, + context=_merge_execution_context(http_request, "forget_artifact"), + ) + return _governance_response(result) + + +async def restore_artifact( + scope_id: _ScopePathId, + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + request: RestoreArtifactRequest, + http_request: Request, +) -> ArtifactRestorationResult: + found = _plugin_family_host(http_request, family) + if found is not None: + manifest, host = found + result = await _call_plugin_host( + http_request, + host, + "restore_artifact", + scope_id=scope_id, + payload={ + "artifact_id": artifact_id, + "operation": (request.operation or ArtifactRestorationOperation.RESTORE).value, + "revision": request.revision, + }, + timeout=manifest.spec.change_timeout_seconds, + operation="restore_artifact", + ) + return ArtifactRestorationResult( + changed=True, + restored=[_artifact_reference(ArtifactRef.model_validate(item)) for item in result["restored"]], + retired=[_artifact_reference(ArtifactRef.model_validate(item)) for item in result["retired"]], + undo_merge_results=result["undo_merge_results"], + ) + result = await _merge_scope(http_request, scope_id, family).restore( + artifact_id, + operation=(request.operation or ArtifactRestorationOperation.RESTORE).value, + revision=request.revision, + context=_merge_execution_context(http_request, "restore_artifact"), + ) + return ArtifactRestorationResult( + changed=result.changed, + restored=[_artifact_reference(ref) for ref in result.restored], + retired=[_artifact_reference(ref) for ref in result.retired], + undo_merge_results=list(result.undo_merge_results), ) - response.headers["Location"] = f"/v1/admin/artifact-plugins/{binding_name}" - return result async def get_capabilities(request: Request) -> Capabilities: capability_provider: CapabilityProvider | None = request.app.state.capability_provider value = capability_provider() if capability_provider is not None else request.app.state.capabilities - declaration_provider: Callable[[], Awaitable[list[dict[str, Any]]]] | None = ( - request.app.state.artifact_declaration_provider - ) - if declaration_provider is None: - return value - declarations = await declaration_provider() - for declaration in declarations: - register_custom_artifact_access_family(declaration["family"]) - return Capabilities.model_validate({ - **value.model_dump(mode="json"), - "artifact_families": value.artifact_families - + sorted({item["family"] for item in declarations} - set(value.artifact_families)), - "custom_artifact_schemas": declarations, - }) + # Only the families this Server's plugins provide; the process-wide family registry may hold others. + manager: PluginManager | None = request.app.state.plugin_manager + added = [] if manager is None else sorted(manager.families() - set(value.artifact_families)) + if added: + value = value.model_copy(update={"artifact_families": [*value.artifact_families, *added]}) + return value async def get_server_info(request: Request) -> ServerInfo: @@ -1943,7 +2118,6 @@ async def check_access(payload: AccessCheckRequest, request: Request) -> AccessC ) for requirement in payload.requirements ]) - await _refresh_artifact_access(request.app.state, {resource.family for _, resource in requirements}) decisions = await access.check_batch( _require_principal(), requirements, @@ -1963,8 +2137,6 @@ async def check_access(payload: AccessCheckRequest, request: Request) -> AccessC async def list_access_resources(payload: ListAccessResourcesRequest, request: Request) -> AccessResourcePage: access = _require_access_control(request) resource_type = AccessResourceType(payload.resource_type.value) - if resource_type is AccessResourceType.ARTIFACT: - await _refresh_artifact_access(request.app.state, {payload.family}) page = await access.list_resources( _require_principal(), action=AccessAction(payload.action.value), @@ -2083,7 +2255,6 @@ async def list_access_roles(payload: ListAccessRolesRequest, request: Request) - if payload.family is not None: if resource_type not in {None, AccessResourceType.ARTIFACT}: raise AccessInvalidRequestError("action-resource") - await _refresh_artifact_access(request.app.state, {payload.family}) selected_profile = ARTIFACT_FAMILY_PROFILES.get(payload.family) if selected_profile is None: raise AccessInvalidRequestError("artifact-family") @@ -2138,7 +2309,6 @@ async def list_access_roles(payload: ListAccessRolesRequest, request: Request) - async def list_access_bindings(payload: ListAccessBindingsRequest, request: Request) -> AccessBindingPage: access = _require_access_control(request) resource = _supported_access_resource(_access_resource(payload.management_resource)) - await _refresh_artifact_access(request.app.state, {resource.family}) page = await access.list_bindings( _require_principal(), BindingSearchRequest( @@ -2160,7 +2330,6 @@ async def list_access_bindings(payload: ListAccessBindingsRequest, request: Requ async def create_access_binding(payload: CreateAccessBindingRequest, request: Request) -> TransportAccessBinding: access = _require_access_control(request) resource = _supported_access_resource(_access_resource(payload.resource)) - await _refresh_artifact_access(request.app.state, {resource.family}) binding = await access.create_binding( _require_principal(), CreateBinding( @@ -2185,7 +2354,6 @@ async def revoke_access_binding(payload: RevokeAccessBindingRequest, request: Re expected_version=payload.expected_version, idempotency_key=payload.idempotency_key, context=_access_audit_context(REVOKE_ACCESS_BINDING.operation_id), - prepare_resource=lambda resource: _refresh_artifact_access(request.app.state, {resource.family}), ) return _access_binding_response(binding) @@ -2206,7 +2374,6 @@ async def replace_access_binding( expires_at=payload.replacement.expires_at, ), context=_access_audit_context(REPLACE_ACCESS_BINDING.operation_id), - prepare_resource=lambda resource: _refresh_artifact_access(request.app.state, {resource.family}), ) return TransportAccessBindingReplacement( previous=_access_binding_response(result.previous), @@ -2715,17 +2882,46 @@ async def create_source( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], request: CreateSourceRequest, response: Response, + http_request: Request, application: Annotated[ServerApplication, Depends(_require_application)], ) -> SourceRecord: + if request.source_type != SourceType.CONTENT: + return await _create_plugin_source(scope_id, request, response, http_request, application) _reject_reserved_handoff_receipt_content(request.content) result = await application.records.for_scope(scope_id).create_source( - request.source_type.value, + request.source_type, request.content, ) response.headers["Location"] = _source_location(result) return _source_record_response(result) +async def _create_plugin_source( + scope_id: str, + request: CreateSourceRequest, + response: Response, + http_request: Request, + application: ServerApplication, +) -> SourceRecord: + manager = http_request.app.state.plugin_manager + found = None if manager is None else manager.host_for_source_type(request.source_type) + if found is None: + raise BaseValueNotFoundError("source_type", request.source_type) + manifest, host = found + ref = await _call_plugin_host( + http_request, + host, + "create_source", + scope_id=scope_id, + payload={"source_type": request.source_type, "content": request.content}, + timeout=manifest.spec.change_timeout_seconds, + operation=CREATE_SOURCE.operation_id, + ) + result = await application.records.for_scope(scope_id).get_source(ref["source_type"], ref["source_id"]) + response.headers["Location"] = _source_location(result) + return _source_record_response(result) + + async def create_subject_source( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], request: CreateSubjectSourceRequest, @@ -2880,15 +3076,18 @@ async def create_artifact( http_request: Request, application: Annotated[ServerApplication, Depends(_require_application)], ) -> ArtifactCreated: + if isinstance(request.root, CreatePluginArtifactRequest): + return await _create_plugin_artifact(scope_id, request.root, response, http_request, application) + builtin = request.root result = await application.records.for_scope(scope_id).create_artifact( - request.root.family, - _artifact_write(request), + builtin.root.family, + _artifact_write(builtin), **( {"execution_context": _atomic_execution_context(http_request, CREATE_ARTIFACT.operation_id)} - if request.root.family == "atomic-memory" + if builtin.root.family == "atomic-memory" else {} ), - **_artifact_owner_commit(http_request, scope_id, request.root.family), + **_artifact_owner_commit(http_request, scope_id, builtin.root.family), ) await _establish_base_artifact_owners( http_request, @@ -3047,12 +3246,29 @@ async def search_artifacts( application: Annotated[ServerApplication, Depends(_require_application)], http_request: Request, ) -> Response: + found = _plugin_family_host(http_request, family) + if found is not None: + # Plugins search their own families and interpret the whole request; core alone presents scores. + manifest, host = found + search = request.model_dump(mode="json", exclude_unset=True, exclude={"include_scores"}) + result = await _call_plugin_host( + http_request, + host, + "search_artifacts", + scope_id=scope_id, + payload={"search": search, "max_bytes": None}, + timeout=manifest.spec.change_timeout_seconds, + operation=SEARCH_ARTIFACTS.operation_id, + ) + return JSONResponse( + content=await _plugin_search_response( + application, scope_id, family, result["hits"], include_scores=request.include_scores + ) + ) artifacts = getattr(application, "artifacts", None) if artifacts is None: raise ArtifactSearchUnsupported(family, field="family") access = access_control_for_mode(http_request.app.state.access_control, mode=http_request.app.state.access_mode) - if access is not None: - await _refresh_artifact_access(http_request.app.state, {family}) execution_context = ArtifactSearchExecutionContext( principal=_require_principal() if access is not None else None, access=access, @@ -3076,6 +3292,68 @@ async def get_topic_memory( return mapping.topic_memory_response(result) +async def _plugin_search_response( + application: ServerApplication, scope_id: str, family: str, hits: list[dict[str, Any]], *, include_scores: bool +) -> dict[str, Any]: + """Present plugin hits in the generic search shape, reading each exact revision from core.""" + + records = application.records.for_scope(scope_id) + results = [] + for hit in hits: + ref = hit["ref"] + record = await records.get_artifact_revision(family, ref["artifact_id"], ref["revision"]) + item: dict[str, Any] = { + "family": family, + "artifact_id": record.artifact_id, + "revision": record.revision, + "content": record.content, + "lineage": { + "sources": [source.model_dump(mode="json") for source in record.sources], + "artifacts": [artifact.model_dump(mode="json") for artifact in record.artifacts], + }, + } + if include_scores: + score = hit.get("score") + item["scores"] = { + "retrieval": 0.0 if score is None else min(max(float(score), 0.0), 1.0), + "channels": {} + if score is None + else {"plugin": {"raw": float(score), "metric": "plugin", "higher_is_better": True}}, + } + results.append(item) + return SearchArtifactsResponse.model_validate({"results": results}).model_dump(mode="json", exclude_unset=True) + + +async def get_artifact_rendering( + scope_id: _ScopePathId, + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + http_request: Request, + revision: Annotated[int | None, Query(ge=1)] = None, +) -> ArtifactRendering: + _require_strict_query(http_request, {"revision"}) + found = _plugin_family_host(http_request, family) + if found is None: + raise InvalidBaseAccessRequestError("family", "must be a loaded plugin family") + manifest, host = found + result = await _call_plugin_host( + http_request, + host, + "render_artifact", + scope_id=scope_id, + payload={"artifact_id": artifact_id, "revision": revision}, + timeout=manifest.spec.change_timeout_seconds, + operation=GET_ARTIFACT_RENDERING.operation_id, + ) + return ArtifactRendering( + family=family, + artifact_id=result["ref"]["artifact_id"], + revision=result["ref"]["revision"], + format=result["format"], + text=result["text"], + ) + + def _list_artifact_revisions_query( http_request: Request, limit: Annotated[int, Query(ge=1, le=100)] = 50, @@ -3139,7 +3417,7 @@ async def generate_prompt_demonstrations( async def get_artifact_tags( scope_id: _ScopePathId, - family: Annotated[TaggableArtifactFamily, Path()], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], artifact_id: Annotated[str, Path(min_length=1, max_length=128)], response: Response, application: Annotated[ServerApplication, Depends(_require_application)], @@ -3152,7 +3430,7 @@ async def get_artifact_tags( async def replace_artifact_tags( scope_id: _ScopePathId, - family: Annotated[TaggableArtifactFamily, Path()], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], artifact_id: Annotated[str, Path(min_length=1, max_length=128)], request: ReplaceArtifactTagsRequest, response: Response, @@ -3161,7 +3439,7 @@ async def replace_artifact_tags( if_match: Annotated[str | None, Header(alias="If-Match")] = None, ) -> ArtifactTagSet: target = ArtifactTagTarget(family=_tag_family(family), artifact_id=artifact_id) - if family.value == "atomic-memory": + if target.family == "atomic-memory": await _authorize_atomic_tag_write(http_request, scope_id, artifact_id, "replace_artifact_tags") result = await application.records.for_scope(scope_id).replace_tags( target, @@ -3175,10 +3453,10 @@ async def replace_artifact_tags( return ArtifactTagSet.model_validate(result.model_dump(mode="json")) -def _tag_family(family: TaggableArtifactFamily) -> RuntimeTaggableArtifactFamily: - if family is TaggableArtifactFamily.MEMORY: +def _tag_family(family: str) -> str: + if family == BaseArtifactFamily.MEMORY.value: raise BaseOperationNotSupportedError("artifact_family", "memory", "collection tags") - return family.value + return family async def get_memory_entry_tags( @@ -3237,9 +3515,9 @@ async def query_artifact_tags( if principal is not None else sha256(http_request.headers.get("authorization", "anonymous").encode()).hexdigest() ) - if TaggableArtifactFamily.MEMORY in (request.families or ()) or TagTargetType.MEMORY_ENTRY in ( - request.target_types or () - ): + if any( + family.root == BaseArtifactFamily.MEMORY.value for family in request.families or () + ) or TagTargetType.MEMORY_ENTRY in (request.target_types or ()): raise BaseOperationNotSupportedError("artifact_family", "memory", "tag query") query = TagQuery.model_validate_json(request.model_dump_json(exclude_none=True)) result = await application.records.for_scope(scope_id).query_tags(query, caller=caller) @@ -3276,7 +3554,7 @@ async def get_artifact( async def replace_artifact( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[BaseArtifactFamily, Path()], + family_name: Annotated[str, Path(alias="family", min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], request: ReplaceArtifactRequest, response: Response, @@ -3284,6 +3562,16 @@ async def replace_artifact( application: Annotated[ServerApplication, Depends(_require_application)], if_match: Annotated[str | None, Header(alias="If-Match")] = None, ) -> ArtifactRevision: + from powercontext.builtin.plugins.families import is_plugin_family + + if is_plugin_family(family_name): + return await _replace_plugin_artifact( + scope_id, family_name, artifact_id, response, http_request, application, if_match + ) + try: + family = BaseArtifactFamily(family_name) + except ValueError: + raise BaseValueNotFoundError("artifact_family", family_name) from None if family.value == "memory": raise BaseOperationNotSupportedError("artifact_family", "memory", "collection writes") expected_etag = _require_artifact_etag(if_match) @@ -3311,6 +3599,210 @@ async def replace_artifact( return _artifact_revision_response(result) +PLUGIN_API_JSON_BODY_BYTES = 1024 * 1024 + + +class PluginCallError(Exception): + """A plugin host outcome that becomes the shared HTTP error envelope.""" + + def __init__(self, status_code: int, code: str, message: str, details: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.status = status_code + self.code = code + self.message = message + self.details = details + + +_PLUGIN_OUTCOMES = { + "not_implemented": (422, "plugin_operation_not_supported", "The plugin does not support this operation."), + "forbidden": (403, "access_denied", "The caller is not allowed to perform this operation."), + "not_found": (404, "not_found", "The requested resource was not found."), + "precondition_failed": (412, "precondition_failed", "The Artifact changed after it was read."), + "conflict": (409, "conflict", "The Artifact changed after it was read."), + "contract_violation": (502, "plugin_contract_violation", "The plugin did not complete its agreed write."), + "model_unavailable": (503, "model_unavailable", "The Server has no model of the kind the plugin needs."), + "error": (500, "plugin_error", "The plugin failed."), +} + + +async def _call_plugin_host( + http_request: Request, + host: Any, + method: str, + *, + scope_id: str | None, + payload: dict[str, Any], + timeout: float, # noqa: ASYNC109 - forwarded to the host as its deadline + operation: str, + body: Any = None, +) -> Any: + """Forward an authenticated request to the plugin host and translate its outcome.""" + + from powercontext.server.plugin_hosts import PluginHostUnavailable + + access = access_control_for_mode(http_request.app.state.access_control, mode=http_request.app.state.access_mode) + caller = None if access is None else _require_principal() + audit = None if access is None else _access_audit_context(operation) + try: + response = await host.call( + method, scope_id=scope_id, caller=caller, audit=audit, payload=payload, body=body, timeout=timeout + ) + except PluginHostUnavailable: + raise PluginCallError(503, "plugin_unavailable", "The plugin is temporarily unavailable.") from None + if response.outcome == "ok": + return response.result + if response.outcome == "invalid_content": + details = None if response.field is None else {"field": response.field} + raise PluginCallError(422, "invalid_content", response.message or "The plugin rejected the content.", details) + if response.outcome == "api_error": + raise PluginCallError(response.status or 400, response.field or "plugin_error", response.message or "") + status_code, code, message = _PLUGIN_OUTCOMES[response.outcome] + raise PluginCallError(status_code, code, message) + + +def _plugin_family_host(http_request: Request, family: str) -> tuple[Any, Any] | None: + manager = http_request.app.state.plugin_manager + return None if manager is None else manager.host_for_family(family) + + +async def _plugin_governance(http_request: Request, scope_id: str, family: str, ids: Sequence[str]): + scoped = _merge_scope(http_request, scope_id, family) + context = _merge_execution_context(http_request, "get_artifact_governance") + records = [await scoped.get(artifact_id, context=context) for artifact_id in ids] + return ArtifactGovernanceResponse(changed=True, records=[_governance_record(record) for record in records]) + + +async def _replace_plugin_artifact( + scope_id: str, + family: str, + artifact_id: str, + response: Response, + http_request: Request, + application: ServerApplication, + if_match: str | None, +) -> ArtifactRevision: + expected_etag = _require_artifact_etag(if_match) + # Plugin content is opaque: forward the submitted JSON, not a built-in model's re-serialization. + body = await http_request.json() + content = body.get("content") if isinstance(body, dict) else None + if not isinstance(content, dict) or set(body) != {"content"}: + raise InvalidBaseAccessRequestError("content", "must be the complete plugin content object") + prefix, _, number = expected_etag.strip('"').partition(":") + if prefix != "revision" or not number.isdigit(): + raise ArtifactRevisionPreconditionError(expected_etag, expected_etag) + expected_revision = int(number) + found = _plugin_family_host(http_request, family) + if found is None: + raise BaseValueNotFoundError("artifact_family", family) + manifest, host = found + await _call_plugin_host( + http_request, + host, + "update_artifact", + scope_id=scope_id, + payload={"artifact_id": artifact_id, "expected_revision": expected_revision, "content": content}, + timeout=manifest.spec.change_timeout_seconds, + operation=REPLACE_ARTIFACT.operation_id, + ) + result = await application.records.for_scope(scope_id).get_artifact(family, artifact_id) + response.headers["ETag"] = _artifact_etag(result.revision) + return _artifact_revision_response(result) + + +async def _create_plugin_artifact( + scope_id: str, + request: CreatePluginArtifactRequest, + response: Response, + http_request: Request, + application: ServerApplication, +) -> ArtifactCreated: + found = _plugin_family_host(http_request, request.family) + if found is None: + # Like the other generic routes, a family no loaded plugin provides is an invalid request. + raise InvalidBaseAccessRequestError("family", "must be a built-in or loaded plugin family") + manifest, host = found + result = await _call_plugin_host( + http_request, + host, + "create_artifact", + scope_id=scope_id, + payload={"artifact_id": uuid4().hex, "content": request.content}, + timeout=manifest.spec.change_timeout_seconds, + operation=CREATE_ARTIFACT.operation_id, + ) + created = await application.records.for_scope(scope_id).get_artifact( + request.family, result["artifact"]["ref"]["artifact_id"] + ) + response.headers["Location"] = _artifact_location(created) + response.headers["ETag"] = _artifact_etag(created.revision) + return _artifact_created_response(created) + + +async def call_plugin_api(name: str, path: str, http_request: Request) -> Response: # noqa: C901 - one route from match to response + """Mount every plugin route under ``/v1/plugins/{name}/api``; the host authorizes and handles it.""" + + from starlette.routing import compile_path + + manager = _require_plugin_manager(http_request) + manifest = manager.loaded_manifest(name) + if manifest is None or manifest.spec.api is None: + raise BaseValueNotFoundError("plugin", name) + target = "/" + path + routes = sorted(manifest.spec.api.routes, key=lambda route: "{" in route.path) + for route in routes: + if route.method != http_request.method: + continue + pattern, _, _ = compile_path(route.path) + matched = pattern.match(target) + if matched is not None: + break + else: + raise BaseValueNotFoundError("plugin_route", f"{http_request.method} {target}") + body: Any = None + ceiling = http_request.app.state.plugin_api_max_body_bytes + if route.body != "none": + default = PLUGIN_API_JSON_BODY_BYTES if route.body == "json" else ceiling + limit = min(route.max_body_bytes or default, ceiling) + declared = http_request.headers.get("content-length", "") + if declared.isdecimal() and int(declared) > limit: + raise PluginCallError(413, "request_too_large", "The request body is too large.") + raw = await http_request.body() + if len(raw) > limit: + raise PluginCallError(413, "request_too_large", "The request body is too large.") + if route.body == "json": + try: + body = json.loads(raw or b"null") + except ValueError: + raise PluginCallError(422, "invalid_json", "The request body is not JSON.") from None + else: + body = raw + result = await _call_plugin_host( + http_request, + manager.host(name), + "api", + scope_id=None, + payload={ + "method": route.method, + "route": route.path, + "path_params": matched.groupdict(), + "query": {key: http_request.query_params.getlist(key) for key in http_request.query_params}, + }, + body=body, + timeout=route.timeout_seconds, + operation=f"plugin:{name}:{route.handler}", + ) + kind = result[0] + if kind == "none": + return Response(status_code=status.HTTP_204_NO_CONTENT) + if kind == "binary": + _, content, content_type, filename = result + if len(content) > ceiling: + raise PluginCallError(500, "plugin_error", "The plugin response is too large.") + headers = {} if filename is None else {"Content-Disposition": f'attachment; filename="{filename}"'} + return Response(content=content, media_type=content_type, headers=headers) + return JSONResponse(content=result[1]) + + async def get_artifact_revision( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")], @@ -3353,7 +3845,6 @@ def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevisio return ArtifactRevision( scope_id=value.scope_id, family=value.family, - schema_version=value.schema_version, artifact_id=value.artifact_id, revision=value.revision, content=value.content, @@ -3367,10 +3858,10 @@ def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevisio ) -def _artifact_created_response(value: RuntimeArtifactCreated) -> ArtifactCreated: +def _artifact_created_response(value: RuntimeArtifactCreated | RuntimeArtifactRecord) -> ArtifactCreated: return ArtifactCreated( scope_id=value.scope_id, - family=BaseArtifactFamily(value.family), + family=ArtifactReadFamily(value.family), artifact_id=value.artifact_id, revision=value.revision, sources=[mapping.source_type_reference(ref) for ref in value.sources], @@ -3382,7 +3873,6 @@ def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) -> return ArtifactCollectionItem( scope_id=value.scope_id, family=value.family, - schema_version=value.schema_version, artifact_id=value.artifact_id, revision=value.revision, sources=[mapping.source_type_reference(ref) for ref in value.sources], @@ -3399,8 +3889,11 @@ def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) -> ) -def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: - content = value.root.content +def _artifact_write(value: CreateBuiltinArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: + root = value.root + if isinstance(root, ReplacePluginArtifactRequest): + raise InvalidBaseAccessRequestError("content", "does not match a built-in Artifact contract") + content = root.content if isinstance(content, TransportHandoffContent): content = mapping.runtime_handoff_content(content) return RuntimeArtifactWrite( @@ -3423,7 +3916,7 @@ def _source_location(value: RuntimeSourceRecord) -> str: return f"/v1/scopes/{scope_id}/sources/{source_type}/{source_id}" -def _artifact_location(value: RuntimeArtifactCreated) -> str: +def _artifact_location(value: RuntimeArtifactCreated | RuntimeArtifactRecord) -> str: artifact_id = quote(value.artifact_id, safe="") scope_id = quote(value.scope_id, safe="") family = quote(value.family, safe="") @@ -3560,7 +4053,9 @@ async def prepare_context( application: Annotated[ServerApplication, Depends(_require_application)], http_request: Request, ) -> PrepareContextResult: - prepared_request = mapping.prepare_context_request(request) + prepared_request = await _with_plugin_sections( + http_request, request.scope_id, mapping.prepare_context_request(request) + ) scoped = application.context.for_scope(request.scope_id) reuse_arguments = {"binding_identity": _context_binding_identity()} if request.include_reuse else {} access = access_control_for_mode( @@ -3587,6 +4082,41 @@ async def authorize_scopes(scope_ids: tuple[str, ...]) -> None: return mapping.prepare_context_result_response(result) +async def _with_plugin_sections(http_request: Request, scope_id: str, request: Any) -> Any: + """Fill each plugin section with the plugin's search hits before the Runtime assembles context.""" + + assembly = request.assembly + if assembly is None or all(section.family in BUILTIN_CONTEXT_FAMILIES for section in assembly.sections): + return request + sections = [] + for section in assembly.sections: + if section.family in BUILTIN_CONTEXT_FAMILIES: + sections.append(section) + continue + found = _plugin_family_host(http_request, section.family) + if found is None: + raise InvalidBaseAccessRequestError( + "assembly.sections.family", "must be a built-in or loaded plugin family" + ) + manifest, host = found + search = {**(section.search or {}), "query": request.query, "limit": section.limit} + result = await _call_plugin_host( + http_request, + host, + "search_artifacts", + scope_id=scope_id, + payload={"search": search, "max_bytes": request.max_bytes}, + timeout=manifest.spec.change_timeout_seconds, + operation=PREPARE_CONTEXT.operation_id, + ) + hits = tuple( + ContextPluginHit(artifact=ArtifactRef.model_validate(hit["ref"]), text=hit["text"]) + for hit in result["hits"][: section.limit] + ) + sections.append(section.model_copy(update={"hits": hits})) + return request.model_copy(update={"assembly": assembly.model_copy(update={"sections": tuple(sections)})}) + + async def check_context( request: CheckContextRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -3968,6 +4498,11 @@ def _atomic_memory_domain_access(payload: Mapping[str, Any], _deployment_id: str return () +def _artifact_merge_domain_access(payload: Mapping[str, Any], _deployment_id: str): + # Every input, result and restored group is authorized by the shared merge service. + return () + + def _atomic_memory_state_access(payload: Mapping[str, Any], _deployment_id: str): return ( ( @@ -5299,15 +5834,6 @@ async def authorize(request: Request) -> None: return authorize -async def _refresh_artifact_access(state: Any, families: set[str | None]) -> None: - refresh = getattr(state, "artifact_access_refresher", None) - if refresh is None: - return - missing = {family for family in families if family is not None and family not in ARTIFACT_FAMILY_PROFILES} - if missing: - await refresh(missing) - - async def _authorize_checks( request: Request, access: AccessControlService, @@ -5315,7 +5841,6 @@ async def _authorize_checks( *, operation: str, ) -> None: - await _refresh_artifact_access(request.app.state, {resource.family for _, resource in checks}) context = _access_audit_context(operation) principal = current_principal() decisions = ( @@ -5644,6 +6169,19 @@ def _path_artifact_write_access( payload: Mapping[str, Any], _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: + from powercontext.builtin.plugins.families import is_plugin_family + + if is_plugin_family(family := _path_artifact_read_family(payload)): + return ( + ( + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact( + _nested_request_value(payload, "scope_id"), + family=family, + artifact_id=_nested_request_value(payload, "artifact_id"), + ), + ), + ) family = _path_artifact_family(payload) if family in {BaseArtifactFamily.PROMPT.value, "topic-memory"}: # Scope-owned configuration and knowledge cannot use retained Artifact ownership. @@ -5657,7 +6195,8 @@ def _path_artifact_tags_write_access( payload: Mapping[str, Any], _deployment_id: str, ) -> tuple[tuple[AccessAction, ResourceRef], ...]: - if _path_artifact_family(payload) in { + family = _path_artifact_read_family(payload) + if family in { BaseArtifactFamily.MEMORY.value, BaseArtifactFamily.PROMPT.value, "topic-memory", @@ -5665,7 +6204,16 @@ def _path_artifact_tags_write_access( # Scope-owned knowledge and configuration metadata require current # Scope administration, even if an Artifact owner binding remains. return _path_scope_access(payload, action=AccessAction.SCOPE_ADMIN) - return _path_artifact_access(payload, action=AccessAction.ARTIFACT_WRITE) + return ( + ( + AccessAction.ARTIFACT_WRITE, + ResourceRef.artifact( + _nested_request_value(payload, "scope_id"), + family=family, + artifact_id=_nested_request_value(payload, "artifact_id"), + ), + ), + ) def _path_memory_entry_access( @@ -5796,6 +6344,7 @@ def _acknowledge_handoff_resolver( Callable[[Mapping[str, Any], str], tuple[tuple[AccessAction, ResourceRef], ...]], ] = { "atomic_memory_domain_access": _atomic_memory_domain_access, + "artifact_merge_domain_access": _artifact_merge_domain_access, "atomic_memory_state_access": _atomic_memory_state_access, "acknowledge_handoff_access": _acknowledge_handoff_resolver, "continue_handoff_access": _continue_handoff_resolver, @@ -6197,6 +6746,8 @@ def _set_error_headers(response: Response, error: Exception) -> None: def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: + if isinstance(error, PluginCallError): + return error.status, error.code, error.message, error.details if isinstance(error, DreamError) and error.code == "client_upgrade_required": return 426, error.code, "Upgrade to a client supporting Dream contract 2.", {"required_dream_contract": 2} if isinstance(error, ArtifactSearchFamilyNotFound): @@ -6633,11 +7184,9 @@ def _map_domain_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | "The Topic Memory block no longer owns this frontier.", None, ) - if isinstance(error, AtomicMemoryIndexError): - return 503, error.code, str(error), None - if isinstance(error, AtomicMemoryError): - code = 422 if isinstance(error, InvalidAtomicMemoryPreviewError) else 409 - return code, error.code, str(error), None + artifact_merge = _map_artifact_merge_error(error) + if artifact_merge is not None: + return artifact_merge source_ingestion = _map_source_ingestion_error(error) if source_ingestion is not None: return source_ingestion @@ -6682,6 +7231,27 @@ def _map_context_inference_error(error: Exception) -> tuple[int, str, str, dict[ return None +def _map_artifact_merge_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, AtomicMemoryIndexError): + return 503, error.code, str(error), None + if isinstance(error, AtomicMemoryError): + code = 422 if isinstance(error, InvalidAtomicMemoryPreviewError) else 409 + return code, error.code, str(error), None + if isinstance( + error, + ( + ArtifactMergeConflictError, + ArtifactMergeRelationError, + InvalidArtifactMergeStateError, + ArtifactMergePreviewExpiredError, + ), + ): + return status.HTTP_409_CONFLICT, error.code, str(error), None + if isinstance(error, InvalidArtifactMergePreviewError): + return status.HTTP_422_UNPROCESSABLE_CONTENT, error.code, str(error), None + return None + + def _map_memory_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, ArtifactNotFoundError): return status.HTTP_404_NOT_FOUND, "artifact_not_found", "The requested Artifact was not found.", None diff --git a/src/powercontext/server/artifact_plugins.py b/src/powercontext/server/artifact_plugins.py deleted file mode 100644 index 2e90df2629..0000000000 --- a/src/powercontext/server/artifact_plugins.py +++ /dev/null @@ -1,661 +0,0 @@ -# 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 hot registration and retained reads for custom Artifact plugins.""" - -from __future__ import annotations - -import asyncio -import logging -import re -from collections.abc import Callable -from contextlib import AsyncExitStack -from datetime import timedelta -from functools import partial -from pathlib import Path -from typing import Any - -from sqlalchemy import insert, select, union, update -from sqlalchemy.exc import IntegrityError, SQLAlchemyError - -from powercontext._logging import log_safely -from powercontext.builtin.artifact_plugin_validation import validate_plugin -from powercontext.builtin.authz.profiles import ARTIFACT_FAMILY_PROFILES, register_custom_artifact_access_family -from powercontext.builtin.dream.bindings import DREAM_BINDINGS -from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder -from powercontext.builtin.persistence.database import AsyncDatabase -from powercontext.builtin.persistence.oceanbase import OceanBaseConfig -from powercontext.builtin.persistence.sources import SOURCE_PROCESSING_BINDINGS -from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.persistence.tables import ( - ARTIFACT_FAMILY_SCHEMAS_TABLE, - ARTIFACT_HEADS_TABLE, - ARTIFACT_PROCESSING_BINDING_STATES_TABLE, - ARTIFACT_SEARCH_STATES_TABLE, - ARTIFACT_VECTOR_INDEXES_TABLE, - ARTIFACT_VECTOR_SPACES_TABLE, - SCOPES_TABLE, -) -from powercontext.builtin.records import BaseValueConflictError, BaseValueNotFoundError, InvalidBaseAccessRequestError -from powercontext.builtin.runtime.artifact_plugins import ( - ArtifactPluginWorkerSpec, - run_artifact_plugin_worker, -) -from powercontext.builtin.runtime.artifact_processing import ( - ArtifactProcessingBinding, - ArtifactProcessingSupervisor, - ArtifactProcessingSupervisors, - SpawnArtifactProcessingWorkerLauncher, -) -from powercontext.builtin.runtime.config import DatabaseConfig -from powercontext.builtin.runtime.processing_discovery import SourceProcessingPendingProvider -from powercontext.http._generated.models import ( - ArtifactPluginState, - EnableAllArtifactPluginsResponse, - EnableArtifactPluginResult, - ReloadArtifactPluginRequest, -) - -_STABLE_PHASES = frozenset({"enabled", "disabled", "unloaded", "failed"}) -_STATES = ARTIFACT_PROCESSING_BINDING_STATES_TABLE -_STATE_COLUMNS = ( - _STATES.c.binding_name, - _STATES.c.plugin_id, - _STATES.c.plugin_family, - _STATES.c.binding_generation, - _STATES.c.desired_state, - _STATES.c.phase, - _STATES.c.last_error, -) -_SCHEMAS = ARTIFACT_FAMILY_SCHEMAS_TABLE -logger = logging.getLogger(__name__) - - -async def read_artifact_declarations(database: AsyncDatabase) -> list[dict[str, Any]]: - async with database.transaction() as connection: - return list( - ( - await connection.execute( - select(_SCHEMAS.c.declaration).order_by(_SCHEMAS.c.family, _SCHEMAS.c.schema_version) - ) - ).scalars() - ) - - -async def refresh_artifact_access_families(database: AsyncDatabase, families: set[str]) -> None: - families = families - ARTIFACT_FAMILY_PROFILES.keys() - if not families: - return - statement = select(_SCHEMAS.c.family).distinct().where(_SCHEMAS.c.family.in_(families)) - async with database.transaction() as connection: - registered = (await connection.execute(statement)).scalars().all() - for family in registered: - register_custom_artifact_access_family(family) - - -class ArtifactPluginManager: - """Own registrations and transitions; user code stays outside the Server process.""" - - def __init__( - self, - database: AsyncDatabase, - config: DatabaseConfig, - supervisor: ArtifactProcessingSupervisors | None, - security_builder: Callable[[], dict[str, Any] | None], - on_supervisor: Callable[[ArtifactProcessingSupervisors], None], - plugins_dir: Path | None = None, - embedding_model=None, - inference_config=None, - runtime_config=None, - ) -> None: - self.database = database - self.index_builder = ArtifactIndexBuilder(database, embedding_model) - self.inference_config = inference_config - self.runtime_config = runtime_config - self.config = config - self.supervisor = supervisor - self.security_builder = security_builder - self.on_supervisor = on_supervisor - self.plugins_dir = None if plugins_dir is None else plugins_dir.expanduser().resolve() - self._resources = AsyncExitStack() - self._supervisor_lock = asyncio.Lock() - self._locks: dict[str, asyncio.Lock] = {} - self._tasks: set[asyncio.Task[None]] = set() - self._index_locks: dict[str, asyncio.Lock] = {} - self._index_task: asyncio.Task[None] | None = None - self._index_failures: set[tuple[str, str]] = set() - self._index_refresh_families: set[str] = set() - self._space_failures: set[str] = set() - - async def __aenter__(self): - async with self.database.transaction() as connection: - families = (await connection.execute(select(_SCHEMAS.c.family).distinct())).scalars().all() - rows = (await connection.execute(select(_STATES).where(_STATES.c.plugin_id.is_not(None)))).mappings().all() - restored = [] - for persisted in rows: - if persisted["phase"] in {"failed", "disabled", "unloaded"}: - continue - row = dict(persisted) - row["binding_generation"] += 1 - row["phase"] = {"enabled": "activating", "disabled": "stopping", "unloaded": "unloading"}[ - row["desired_state"] - ] - await connection.execute( - update(_STATES) - .where(_STATES.c.binding_name == row["binding_name"]) - .values(binding_generation=row["binding_generation"], phase=row["phase"]) - ) - restored.append(row) - self._index_refresh_families.update(families) - for row in restored: - await self._transition(row) - await self._discover() - # Retained indexes recover in the background; encoding existing content - # must not delay Server startup or availability of unrelated families. - self._index_task = asyncio.create_task(self._maintain_search(), name="artifact-index-maintenance") - return self - - async def __aexit__(self, *_): - if self._index_task is not None: - self._index_task.cancel() - await asyncio.gather(self._index_task, return_exceptions=True) - try: - if self._tasks: - await asyncio.gather(*self._tasks, return_exceptions=True) - finally: - await self._resources.aclose() - - async def _ensure_supervisor(self) -> ArtifactProcessingSupervisors: - async with self._supervisor_lock: - if self.supervisor is None: - controller = await self._resources.enter_async_context( - ArtifactProcessingSupervisor( - database=self.database, - bindings=(), - lease_mode="oceanbase" if isinstance(self.config, OceanBaseConfig) else "single-process", - ) - ) - self.supervisor = ArtifactProcessingSupervisors((controller,)) - self.on_supervisor(self.supervisor) - return self.supervisor - - async def _row(self, name: str, *, include_definition: bool = False) -> dict[str, Any]: - columns = (*_STATE_COLUMNS, _STATES.c.plugin_spec) if include_definition else _STATE_COLUMNS - async with self.database.transaction() as connection: - row = ( - ( - await connection.execute( - select(*columns).where(_STATES.c.binding_name == name, _STATES.c.plugin_id.is_not(None)) - ) - ) - .mappings() - .first() - ) - if row is None: - raise BaseValueNotFoundError("artifact_plugin", name) - return dict(row) - - @staticmethod - def _state(row: dict[str, Any]) -> ArtifactPluginState: - return ArtifactPluginState.model_validate({ - "binding_name": row["binding_name"], - "plugin_id": row["plugin_id"], - "family": row["plugin_family"], - "binding_generation": row["binding_generation"], - "desired_state": row["desired_state"], - "phase": row["phase"], - "last_error": row["last_error"], - }) - - async def get(self, name: str) -> ArtifactPluginState: - return self._state(await self._row(name)) - - async def list_registrations(self) -> list[ArtifactPluginState]: - async with self.database.transaction() as connection: - rows = ( - ( - await connection.execute( - select(*_STATE_COLUMNS).where(_STATES.c.plugin_id.is_not(None)).order_by(_STATES.c.binding_name) - ) - ) - .mappings() - .all() - ) - return [self._state(dict(row)) for row in rows] - - async def _discover(self) -> None: - directory = self.plugins_dir - if directory is None: - return - try: - entries = await asyncio.to_thread(lambda: sorted(directory.iterdir())) - except OSError as error: - log_safely( - logger, logging.WARNING, f"Artifact plugin discovery directory unavailable ({type(error).__name__})" - ) - return - known = {state.binding_name for state in await self.list_registrations()} - for entry in entries: - if entry.name in known or not await asyncio.to_thread(entry.is_dir): - continue - try: - await self.reload(entry.name, ReloadArtifactPluginRequest(expected_generation=0)) - except Exception as error: - log_safely( - logger, - logging.WARNING, - f"Artifact plugin discovery failed: {entry.name!r} ({type(error).__name__})", - ) - - async def reload(self, name: str, request: ReloadArtifactPluginRequest) -> ArtifactPluginState: - if re.fullmatch(r"[a-z][a-z0-9-]{0,127}", name) is None: - raise InvalidBaseAccessRequestError("binding_name", "must be a canonical binding name") - if name in {*SOURCE_PROCESSING_BINDINGS, *DREAM_BINDINGS.values()}: - raise InvalidBaseAccessRequestError("binding_name", "is reserved for a built-in processor") - directory = self.plugins_dir - if directory is None: - raise InvalidBaseAccessRequestError("artifact_plugins_dir", "must be configured on the Server") - try: - path = await asyncio.to_thread(lambda: (directory / name).resolve(strict=True)) - is_directory = await asyncio.to_thread(path.is_dir) - except (OSError, RuntimeError) as error: - raise InvalidBaseAccessRequestError( - "binding_name", "must identify a deployed plugin directory entry" - ) from error - if not is_directory: - raise InvalidBaseAccessRequestError("binding_name", "must identify a deployed plugin directory entry") - try: - descriptor = await validate_plugin(str(path)) - except (ValueError, OSError, TimeoutError) as error: - raise InvalidBaseAccessRequestError("plugin", "declaration or configuration validation failed") from error - if descriptor["manifest"]["binding_name"] != name: - raise InvalidBaseAccessRequestError( - "binding_name", "must match plugin.yml, directory entry and request path" - ) - definition = {"directory": str(path), **descriptor} - return await self._accept(name, request.expected_generation, None, definition) - - async def enable_all(self) -> EnableAllArtifactPluginsResponse: - # Freeze eligibility and generations before any per-plugin validation can yield. - snapshot = await self.list_registrations() - results = [] - for state in snapshot: - outcome, error = "skipped", None - current = state - if state.phase == "disabled": - try: - current = await self.change( - state.binding_name, state.binding_generation, "enabled", require_disabled=True - ) - outcome = "accepted" - except Exception as failure: - outcome = "failed" - error = ( - "conflict" - if isinstance(failure, BaseValueConflictError) - else "invalid_plugin" - if isinstance(failure, InvalidBaseAccessRequestError) - else "unavailable" - ) - current = await self.get(state.binding_name) - results.append( - EnableArtifactPluginResult.model_validate({ - "binding_name": state.binding_name, - "outcome": outcome, - "status_url": f"/v1/admin/artifact-plugins/{state.binding_name}", - "state": current, - "error": error, - }) - ) - return EnableAllArtifactPluginsResponse(items=results) - - async def change( - self, name: str, expected: int, target: str, *, require_disabled: bool = False - ) -> ArtifactPluginState: - row = await self._row(name, include_definition=True) - if ( - row["binding_generation"] != expected - or row["phase"] not in _STABLE_PHASES - or (require_disabled and row["phase"] != "disabled") - ): - raise BaseValueConflictError("artifact_plugin", name) - definition = row["plugin_spec"] - if target == "enabled": - if definition is None or row["phase"] == "failed": - raise InvalidBaseAccessRequestError( - "plugin", "an unloaded or failed plugin requires reload before enabling" - ) - if isinstance(self.config, SQLiteConfig) and self.config.is_in_memory: - raise InvalidBaseAccessRequestError( - "database", "Artifact plugin generation requires persistent storage" - ) - try: - descriptor = await validate_plugin(definition["directory"], definition["manifest"]) - except (ValueError, OSError, TimeoutError) as error: - raise InvalidBaseAccessRequestError("plugin", "saved plugin validation failed") from error - if descriptor != {"manifest": definition["manifest"], "declaration": definition["declaration"]}: - raise InvalidBaseAccessRequestError("plugin", "changed code must be registered with reload") - return await self._accept(name, expected, target, definition, require_disabled=require_disabled) - - async def _validate_identity(self, connection, name, old, definition): # noqa: C901 - Validate durable ownership and immutable declarations together. - declaration = None if definition is None else definition["declaration"] - if declaration is None: - if old is None: - raise BaseValueNotFoundError("artifact_plugin", name) - plugin_id, family = old["plugin_id"], old["plugin_family"] - else: - plugin_id, family = declaration["plugin_id"], declaration["family"] - if old is not None and (old["plugin_id"], old["plugin_family"]) != (plugin_id, family): - raise BaseValueConflictError("artifact_plugin_identity", name) - owner = await connection.scalar( - select(_STATES.c.binding_name) - .where(_STATES.c.plugin_family == family, _STATES.c.binding_name != name) - .limit(1) - ) - if owner is not None: - raise BaseValueConflictError("artifact_family", family) - if declaration is not None: - scope_ids = definition["manifest"]["scope_ids"] - if scope_ids is not None: - scopes = set( - ( - await connection.execute( - select(SCOPES_TABLE.c.scope_id).where(SCOPES_TABLE.c.scope_id.in_(scope_ids)) - ) - ).scalars() - ) - if scopes != set(scope_ids): - raise InvalidBaseAccessRequestError("scope_ids", "must identify existing Scopes") - durable = {key: declaration[key] for key in ("family", "schema_version", "content_schema", "search_fields")} - if "search" in declaration: - durable["search"] = declaration["search"] - from powercontext.builtin.persistence.artifact_indexes import register_definition, search_declaration - - search = search_declaration(durable) - saved = ( - (await connection.execute(select(_SCHEMAS.c.declaration).where(_SCHEMAS.c.family == family))) - .scalars() - .all() - ) - # Only query policy may change; persisted index identity survives unload and restart. - query_policy = { - "modes", - "default_mode", - "defaults", - "candidate_limit", - "rrf_k", - "text_weight", - "vector_weight", - } - fixed = None if search is None else search.model_dump(exclude=query_policy) - for item in saved: - previous_search = search_declaration(item) - previous_fixed = None if previous_search is None else previous_search.model_dump(exclude=query_policy) - if previous_fixed != fixed: - raise BaseValueConflictError("artifact_search_declaration", family) - if ( - saved - and search is not None - and all(item["schema_version"] != durable["schema_version"] for item in saved) - ): - raise BaseValueConflictError("artifact_search_declaration", family) - identity = (_SCHEMAS.c.family == family) & (_SCHEMAS.c.schema_version == durable["schema_version"]) - previous = await connection.scalar(select(_SCHEMAS.c.declaration).where(identity)) - if previous is None: - await connection.execute( - insert(_SCHEMAS).values( - family=family, schema_version=durable["schema_version"], declaration=durable - ) - ) - elif previous["content_schema"] != durable["content_schema"]: - raise BaseValueConflictError("artifact_schema", (family, durable["schema_version"])) - elif previous != durable: - await connection.execute(update(_SCHEMAS).where(identity).values(declaration=durable)) - if search is not None: - for index in search.indexes: - try: - await register_definition(connection, family, durable["schema_version"], index) - except ValueError as error: - raise BaseValueConflictError( - "artifact_search_definition", (family, index.name, index.projection_version) - ) from error - return plugin_id, family - - async def _accept( # noqa: C901 - Registration CAS and lifecycle acceptance share one transaction. - self, - name: str, - expected: int, - target: str | None, - definition: dict[str, Any] | None, - *, - require_disabled: bool = False, - ) -> ArtifactPluginState: - lock = self._locks.setdefault(name, asyncio.Lock()) - if lock.locked(): - raise BaseValueConflictError("artifact_plugin", name) - async with lock: - try: - async with self.database.transaction() as connection: - # This is also the per-registration serialization point used by Worker commit guards. - await connection.execute( - update(_STATES) - .where(_STATES.c.binding_name == name) - .values(binding_generation=_STATES.c.binding_generation) - ) - old = ( - (await connection.execute(select(_STATES).where(_STATES.c.binding_name == name))) - .mappings() - .first() - ) - generation = 0 if old is None else int(old["binding_generation"]) - if ( - generation != expected - or (old is not None and (old["plugin_id"] is None or old["phase"] not in _STABLE_PHASES)) - or (require_disabled and (old is None or old["phase"] != "disabled")) - ): - raise BaseValueConflictError("artifact_plugin", name) - if target is None: - target = "enabled" if old is not None and old["phase"] == "enabled" else "disabled" - if target == "enabled": - self.security_builder() - if ( - old is not None - and old["plugin_spec"] == definition - and old["desired_state"] == target - and old["phase"] == target - ): - return self._state(dict(old)) - plugin_id, family = await self._validate_identity(connection, name, old, definition) - values = { - "plugin_id": plugin_id, - "plugin_family": family, - "plugin_spec": definition, - "binding_generation": generation + 1, - "desired_state": target, - "phase": {"enabled": "activating", "disabled": "stopping", "unloaded": "unloading"}[target], - "last_error": None, - } - if old is None and target == "disabled": - values["phase"] = "disabled" - if old is None: - await connection.execute(insert(_STATES).values(binding_name=name, **values)) - else: - await connection.execute(update(_STATES).where(_STATES.c.binding_name == name).values(**values)) - except IntegrityError as error: - raise BaseValueConflictError("artifact_plugin", name) from error - register_custom_artifact_access_family(family) - if old is None: - self._index_refresh_families.add(family) - row = {"binding_name": name, **values} - if row["phase"] == target: - return self._state(row) - task = asyncio.create_task(self._transition(row), name=f"artifact-plugin-transition-{name}") - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - return self._state(row) - - async def _transition(self, row: dict[str, Any]) -> None: - name, generation = row["binding_name"], row["binding_generation"] - try: - definition = row["plugin_spec"] - target = row["desired_state"] - supervisor = await self._ensure_supervisor() if target == "enabled" else self.supervisor - if supervisor is not None: - await supervisor.replace_binding(name, None) - if target == "enabled" and supervisor is not None: - descriptor = await validate_plugin(definition["directory"], definition["manifest"]) - if descriptor != {"manifest": definition["manifest"], "declaration": definition["declaration"]}: - raise ValueError("plugin code differs from the accepted definition") # noqa: TRY003, TRY301 - manifest, declaration = descriptor["manifest"], descriptor["declaration"] - spec = ArtifactPluginWorkerSpec( - self.config, - name, - generation, - definition, - self.security_builder(), - self.inference_config, - self.runtime_config, - ) - - async def scopes(connection, candidates): - phase = await connection.scalar( - select(_STATES.c.phase).where( - _STATES.c.binding_name == name, _STATES.c.binding_generation == generation - ) - ) - if phase != "enabled": - return frozenset() - scope_ids = manifest["scope_ids"] - return frozenset(candidates if scope_ids is None else set(candidates).intersection(scope_ids)) - - await supervisor.replace_binding( - name, - ArtifactProcessingBinding( - binding_name=name, - artifact_family=declaration["family"], - launcher=SpawnArtifactProcessingWorkerLauncher(partial(run_artifact_plugin_worker, spec)), - max_workers=manifest["processing"]["max_workers"], - worker_timeout_seconds=manifest["processing"]["timeout_seconds"], - automatic_processing_interval=timedelta(seconds=manifest["processing"]["interval_seconds"]), - pending_provider=SourceProcessingPendingProvider(self.database, name, declaration["family"]), - automatic_scope_filter=scopes, - invocation_scope_filter=scopes, - ), - ) - async with self.database.transaction() as connection: - await connection.execute( - update(_STATES) - .where(_STATES.c.binding_name == name, _STATES.c.binding_generation == generation) - .values(phase=target, plugin_spec=None if target == "unloaded" else definition, last_error=None) - ) - if supervisor is not None: - supervisor.wake(name) - except Exception as error: - # No plugin configuration, credentials or exception input values in management status. - async with self.database.transaction() as connection: - await connection.execute( - update(_STATES) - .where(_STATES.c.binding_name == name, _STATES.c.binding_generation == generation) - .values(phase="failed", last_error=type(error).__name__) - ) - - async def _maintain_search(self): - states = ARTIFACT_SEARCH_STATES_TABLE - while True: - try: - # Persisted unfinished declarations also cover registration by - # another Server, including families with no content or Scope. - async with self.database.transaction() as connection: - spaces = ARTIFACT_VECTOR_SPACES_TABLE - definitions = ARTIFACT_VECTOR_INDEXES_TABLE - unfinished = await connection.scalars( - select(definitions.c.family) - .join(spaces, definitions.c.space_id == spaces.c.space_id) - .where(spaces.c.provision_state == "declared") - .distinct() - ) - self._index_refresh_families.update(unfinished) - # Startup and initial registration discover related pairs needing initialization. - for family in tuple(self._index_refresh_families): - self._index_refresh_families.discard(family) - try: - from powercontext.builtin.persistence.artifact_vector_space import prepare_family_spaces - - await prepare_family_spaces(self.database, family) - self._space_failures.discard(family) - complete = await self._refresh_search(family) - except Exception: - self._index_refresh_families.add(family) - if family not in self._space_failures: - self._space_failures.add(family) - log_safely(logger, logging.WARNING, "Artifact search maintenance unavailable; retrying") - continue - if not complete: - self._index_refresh_families.add(family) - async with self.database.transaction(consistent_snapshot=True) as connection: - pairs = ( - await connection.execute( - select(states.c.scope_id, states.c.family).where(states.c.state != "ready") - ) - ).all() - for scope, family in pairs: - async with self._index_locks.setdefault(family, asyncio.Lock()): - complete = False - try: - from powercontext.builtin.persistence.artifact_vector_space import prepare_family_spaces - - await prepare_family_spaces(self.database, family) - await self.index_builder.refresh(scope, family) - complete = await self.index_builder.build(scope, family) - except Exception: - self._index_failure(scope, family) - if complete: - self._index_failures.discard((scope, family)) - except SQLAlchemyError: - log_safely(logger, logging.WARNING, "Artifact search maintenance unavailable; retrying") - await asyncio.sleep(2) - - def _index_failure(self, scope, family): - if (scope, family) not in self._index_failures: - self._index_failures.add((scope, family)) - log_safely( - logger, logging.WARNING, "Artifact search rebuild incomplete; retrying while search is unavailable" - ) - - async def _refresh_search(self, family): - """Initialize missing targets on startup or first registration, without encoding.""" - async with self._index_locks.setdefault(family, asyncio.Lock()): - async with self.database.transaction(consistent_snapshot=True) as connection: - scopes = ( - ( - await connection.execute( - union( - select(ARTIFACT_HEADS_TABLE.c.scope_id).where(ARTIFACT_HEADS_TABLE.c.family == family), - select(ARTIFACT_SEARCH_STATES_TABLE.c.scope_id).where( - ARTIFACT_SEARCH_STATES_TABLE.c.family == family - ), - ) - ) - ) - .scalars() - .all() - ) - complete = True - for scope in scopes: - try: - await self.index_builder.refresh(scope, family) - except Exception: - complete = False - self._index_failure(scope, family) - return complete diff --git a/src/powercontext/server/dashboard/__init__.py b/src/powercontext/server/dashboard/__init__.py index ee98688a66..393bcd15ff 100644 --- a/src/powercontext/server/dashboard/__init__.py +++ b/src/powercontext/server/dashboard/__init__.py @@ -22,6 +22,7 @@ from starlette.types import Scope from typing_extensions import override +from powercontext.server.dashboard.plugin_pages import plugin_asset, plugin_client, plugin_forward, plugin_page from powercontext.server.dashboard.session import save_session @@ -44,5 +45,15 @@ def mount_dashboard(app: FastAPI) -> None: app.add_api_route( "/dashboard/session", save_session, methods=["POST"], include_in_schema=False, response_model=None ) + app.add_api_route("/dashboard/plugins/_client.js", plugin_client, include_in_schema=False) + app.add_api_route("/dashboard/plugins/{name}/ui/{path:path}", plugin_asset, include_in_schema=False) + app.add_api_route( + "/dashboard/plugins/{name}/api/{path:path}", + plugin_forward, + methods=["GET", "POST", "PUT", "PATCH", "DELETE"], + include_in_schema=False, + response_model=None, + ) + app.add_api_route("/dashboard/plugins/{name}", plugin_page, include_in_schema=False, response_model=None) app.include_router(router, prefix="/dashboard", include_in_schema=False) app.add_api_route("/", lambda: RedirectResponse("/dashboard/home"), include_in_schema=False) diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json index d17bb8a337..566a352a6a 100644 --- a/src/powercontext/server/dashboard/labels.en.json +++ b/src/powercontext/server/dashboard/labels.en.json @@ -647,5 +647,7 @@ "processing_dream_no_output": "This run recorded no new output.", "processing_dream_more_inputs": "{count} additional inputs are listed under references.", "processing_dream_source_inputs": "This run recorded {count} source references. Expand references to inspect them.", - "observability": "Cloud-native monitoring" + "observability": "Cloud-native monitoring", + "plugin": "Plugin", + "plugin_page_stopped": "The plugin page navigated away from its start page, so requests are no longer forwarded for it. Reopen the page to continue." } diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json index 8044a65320..17889e8d4c 100644 --- a/src/powercontext/server/dashboard/labels.json +++ b/src/powercontext/server/dashboard/labels.json @@ -647,5 +647,7 @@ "processing_dream_no_output": "本次任务未记录新的产物。", "processing_dream_more_inputs": "另有 {count} 项输入,可展开引用查看。", "processing_dream_source_inputs": "本次任务记录了 {count} 项资料引用,可展开查看。", - "observability": "云原生监控" + "observability": "云原生监控", + "plugin": "插件", + "plugin_page_stopped": "插件页面已离开起始页,已停止为它转发请求。重新打开页面后继续使用。" } diff --git a/src/powercontext/server/dashboard/plugin_pages.py b/src/powercontext/server/dashboard/plugin_pages.py new file mode 100644 index 0000000000..bfbd0a7f4c --- /dev/null +++ b/src/powercontext/server/dashboard/plugin_pages.py @@ -0,0 +1,182 @@ +# 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. + +"""Plugin pages: static files in a sandboxed frame that reach their plugin's routes through the Dashboard. + +The page runs in an opaque origin, so it cannot read the Dashboard session cookie, and +its own requests to the Server carry no credentials. The host page forwards the page's +request messages to ``/dashboard/plugins/{name}/api/...`` with the session, only for +this plugin's declared routes, and stops forwarding once the frame navigates away. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fastapi import Request +from fastapi.responses import FileResponse, HTMLResponse, Response + +from powercontext.server.dashboard.session import login_response + +ASSET_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".webp": "image/webp", + ".woff2": "font/woff2", +} +MAX_ASSET_BYTES = 5 * 1024 * 1024 +CLIENT_SCRIPT = Path(__file__).parent / "static" / "plugin-client.js" +# The frame may open external sites and submit forms; it never gets same-origin access. +FRAME_SANDBOX = "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads" + + +def _loaded(request: Request, name: str) -> tuple[Any, Any] | None: + manager = getattr(request.app.state, "plugin_manager", None) + plugin = None if manager is None else manager.plugins.get(name) + manifest = None if manager is None else manager.loaded_manifest(name) + if plugin is None or manifest is None or manifest.spec.pages is None: + return None + return plugin, manifest + + +def plugin_pages(request: Request, language: str) -> list[dict[str, str]]: + """Navigation entries for every loaded plugin that declares pages.""" + + manager = getattr(request.app.state, "plugin_manager", None) + if manager is None: + return [] + entries = [] + for name in sorted(manager.plugins): + manifest = manager.loaded_manifest(name) + if manifest is not None and manifest.spec.pages is not None: + title = manifest.spec.pages.title + entries.append({ + "name": name, + "title": title.get(language, title["en"]), + "path": f"/dashboard/plugins/{name}", + }) + return entries + + +def _asset_headers(request: Request, name: str) -> dict[str, str]: + origin = f"{request.url.scheme}://{request.url.netloc}" + own = f"{origin}/dashboard/plugins/{name}/ui/" + client = f"{origin}/dashboard/plugins/_client.js" + policy = "; ".join(( + f"sandbox {FRAME_SANDBOX}", + "default-src 'none'", + f"script-src {own} {client}", + f"style-src {own} 'unsafe-inline'", + f"img-src {own} data: https:", + f"font-src {own}", + # Requests to external services are allowed; they never carry the PowerContext session. + "connect-src https:", + "form-action https:", + "base-uri 'none'", + "frame-ancestors 'self'", + )) + return { + "Content-Security-Policy": policy, + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-cache", + } + + +async def plugin_asset(request: Request, name: str, path: str) -> Response: + """Serve one static file of a plugin's pages; plugin code and assets only, never user data.""" + + loaded = _loaded(request, name) + suffix = Path(path).suffix.lower() + if loaded is None or suffix not in ASSET_TYPES or not path or ".." in path.split("/"): + return Response(status_code=404) + plugin, manifest = loaded + root = (plugin.directory / manifest.spec.pages.directory).resolve() + target = (root / path).resolve() + # Symbolic links may not lead outside the pages directory. + if not target.is_relative_to(root) or not target.is_file() or target.stat().st_size > MAX_ASSET_BYTES: + return Response(status_code=404) + return FileResponse(target, media_type=ASSET_TYPES[suffix], headers=_asset_headers(request, name)) + + +async def plugin_client(request: Request) -> Response: + return FileResponse( + CLIENT_SCRIPT, + media_type=ASSET_TYPES[".js"], + headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"}, + ) + + +async def plugin_page(request: Request, name: str) -> HTMLResponse: + """The logged-in Dashboard page that frames a plugin's entry page and forwards its requests.""" + + from powercontext.server.dashboard.api import DashboardAPI, ReadError + from powercontext.server.dashboard.routes import default_scope, initial_context, render, scope_context + + ctx = initial_context(request, "plugin") + loaded = _loaded(request, name) + if loaded is None: + ctx.update(status=404, page_error=ReadError(404, "not_found", None)) + return render(request, ctx) + _, manifest = loaded + pages = manifest.spec.pages + entry = next(item for item in ctx["plugin_pages"] if item["name"] == name) + api = DashboardAPI(request) + try: + descriptor = await default_scope(api, ctx) + if ctx["scope"]: + await scope_context(api, ctx, descriptor) + except ReadError as error: + if error.status == 401: + return login_response(request=request) + # The plugin page does not depend on a Scope; it receives none. + ctx["scope"] = None + finally: + await api.client.aclose() + ctx["page_title"] = entry["title"] + ctx["plugin_page"] = { + **entry, + "src": f"/dashboard/plugins/{name}/ui/{pages.entry}", + "api": f"/dashboard/plugins/{name}/api", + "sandbox": FRAME_SANDBOX, + "routes": [{"method": route.method, "path": route.path} for route in manifest.spec.api.routes], + } + return render(request, ctx) + + +def _same_origin(request: Request) -> bool: + site = request.headers.get("sec-fetch-site") + if site is not None: + return site == "same-origin" + origin = request.headers.get("origin") + return origin is not None and origin == f"{request.url.scheme}://{request.url.netloc}" + + +async def plugin_forward(name: str, path: str, request: Request) -> Response: + """Call a plugin route as the Dashboard session's principal, for the host page only.""" + + from powercontext.server.app import call_plugin_api + + # A page in the sandboxed frame sends Origin: null and a cross-site fetch; neither is forwarded. + if not _same_origin(request) or request.headers.get("origin") == "null": + return Response(status_code=403) + if _loaded(request, name) is None: + return Response(status_code=404) + return await call_plugin_api(name, path, request) diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py index ed6ba8753c..f9c1db6c50 100644 --- a/src/powercontext/server/dashboard/routes.py +++ b/src/powercontext/server/dashboard/routes.py @@ -40,6 +40,7 @@ ) from powercontext.server.dashboard.page import load_builtin_page from powercontext.server.dashboard.pagination import PAGE_SIZE +from powercontext.server.dashboard.plugin_pages import plugin_pages from powercontext.server.dashboard.preferences import CATALOGS, presentation, remember_language from powercontext.server.dashboard.presenters import set_scope, source_view from powercontext.server.dashboard.review import load_review, load_review_inspection, submit_review @@ -370,10 +371,13 @@ def initial_context(request: Request, page: str) -> dict[str, Any]: page=return_page, ), "reading_return": request_reading_return(request), + "page_title": None, + "plugin_page": None, } if ctx["return_to"]: ctx["return_to"] = with_language(ctx["return_to"], ctx["language"]) ctx["link"] = links(request, ctx) + ctx["plugin_pages"] = plugin_pages(request, ctx["language"]) return ctx diff --git a/src/powercontext/server/dashboard/static/plugin-client.js b/src/powercontext/server/dashboard/static/plugin-client.js new file mode 100644 index 0000000000..4c9a37bf2d --- /dev/null +++ b/src/powercontext/server/dashboard/static/plugin-client.js @@ -0,0 +1,73 @@ +/* +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 client a plugin page uses to call its plugin's routes. The page runs in a sandboxed +// frame without the Dashboard session; the Dashboard host page forwards each request. +(function () { + "use strict"; + let nextId = 0; + const pending = new Map(); + let resolveContext; + const context = new Promise(resolve => { resolveContext = resolve; }); + + window.addEventListener("message", event => { + if (event.source !== window.parent) return; + const data = event.data || {}; + if (data.type === "powercontext.context") { + resolveContext(data.context); + } else if (data.type === "powercontext.response" && pending.has(data.id)) { + const settle = pending.get(data.id); + pending.delete(data.id); + settle(data); + } + }); + window.parent.postMessage({ type: "powercontext.ready" }, "*"); + + function request(method, path, options = {}) { + const id = ++nextId; + const message = { + type: "powercontext.request", + id, + method, + path, + query: options.query || {}, + json: options.json, + body: options.body, + contentType: options.contentType, + }; + return new Promise((resolve, reject) => { + pending.set(id, reply => { + if (reply.error) { + reject(Object.assign(new Error(reply.error), { status: reply.status })); + return; + } + resolve({ + status: reply.status, + ok: reply.status >= 200 && reply.status < 300, + contentType: reply.contentType || "", + filename: reply.filename || null, + json: reply.json, + blob: reply.blob, + }); + }); + window.parent.postMessage(message, "*"); + }); + } + + window.powercontextPluginClient = function () { + return { context: () => context, request }; + }; +})(); diff --git a/src/powercontext/server/dashboard/templates/components/navigation.html b/src/powercontext/server/dashboard/templates/components/navigation.html index 93ae1f910d..fc0bbb7183 100644 --- a/src/powercontext/server/dashboard/templates/components/navigation.html +++ b/src/powercontext/server/dashboard/templates/components/navigation.html @@ -24,8 +24,9 @@ {% include 'components/brand.html' %}
diff --git a/src/powercontext/server/dashboard/templates/macros.html b/src/powercontext/server/dashboard/templates/macros.html index 04bd6fe432..f6dc76ef41 100644 --- a/src/powercontext/server/dashboard/templates/macros.html +++ b/src/powercontext/server/dashboard/templates/macros.html @@ -16,7 +16,7 @@ {% macro icon(name) %} {% endmacro %} diff --git a/src/powercontext/server/dashboard/templates/plugin.html b/src/powercontext/server/dashboard/templates/plugin.html new file mode 100644 index 0000000000..2502ba5de3 --- /dev/null +++ b/src/powercontext/server/dashboard/templates/plugin.html @@ -0,0 +1,98 @@ + + +
+ + + + + +
diff --git a/src/powercontext/server/dashboard/templates/workspace.html b/src/powercontext/server/dashboard/templates/workspace.html index 898d81ac6e..585dcd1981 100644 --- a/src/powercontext/server/dashboard/templates/workspace.html +++ b/src/powercontext/server/dashboard/templates/workspace.html @@ -31,6 +31,8 @@ {% from 'components/read-error.html' import read_error with context %} {% if page_error %} {{ read_error(page_error, t.method_generation_failed if page == 'methods' and request.method == 'POST' else '') }} + {% elif plugin_page %} + {% include 'plugin.html' %} {% elif (not scope_descriptor and not record_only and page not in ['runtime','observability']) or page == 'entry' %} {% include 'entry.html' %} {% else %} diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index e578fe16c8..638d1c6506 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -22,12 +22,10 @@ import warnings from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import AsyncExitStack, asynccontextmanager, nullcontext, suppress -from functools import partial from pathlib import Path from fastapi import FastAPI, Request, Response from prometheus_client.exposition import choose_encoder -from sqlalchemy import select from starlette.middleware import Middleware from powercontext._logging import log_safely @@ -59,7 +57,6 @@ from powercontext.builtin.inference import EmbeddingModel from powercontext.builtin.persistence.records import RelationalRecordService from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.persistence.tables import ARTIFACT_FAMILY_SCHEMAS_TABLE from powercontext.builtin.runtime import ( BuiltinRuntime, ) @@ -86,13 +83,9 @@ ReadinessResponse, ReadinessStatus, ) +from powercontext.paths import powercontext_data_dir from powercontext.server.access import HttpAccessLogMiddleware from powercontext.server.app import _bind_evidence_access, access_readiness_checks, create_app -from powercontext.server.artifact_plugins import ( - ArtifactPluginManager, - read_artifact_declarations, - refresh_artifact_access_families, -) from powercontext.server.authentication import ( AuthenticationProvider, MultiBearerAuthenticationProvider, @@ -109,6 +102,7 @@ from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import HttpMetricsMiddleware, ServerMetrics, http_operations from powercontext.server.middleware import AuthenticationMiddleware +from powercontext.server.plugins import PluginManager from powercontext.server.processing_security import build_worker_security from powercontext.server.request_id import HttpRequestIdMiddleware from powercontext.server.settings import MissingAuthenticationProviderError, ServerSettings @@ -337,36 +331,50 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: C901 - shared app.state.owner_recovery = recovery await resources.enter_async_context(recovery.monitor()) server_id = await _initialize_server_identity(runtime, cursor_secret) - records = await _restore_artifact_access_profiles(runtime) - app.state.artifact_access_refresher = partial(refresh_artifact_access_families, records.database) + records = runtime._records() + if not isinstance(records, RelationalRecordService): + raise TypeError("Plugins require relational record storage") # noqa: TRY003 _bind_evidence_access(runtime, active_access_control, resolved.access.mode) - if ( + dispatch_plugins = ( config.runtime.artifact_processing_supervisor_mode == "global" and config.runtime.artifact_processing_role != "api" - ): + ) - def bind_plugin_supervisor(supervisors) -> None: - runtime.artifact_processing_supervisor = supervisors + def bind_supervisor(supervisors) -> None: + runtime.artifact_processing_supervisor = supervisors - app.state.artifact_plugin_manager = await resources.enter_async_context( - ArtifactPluginManager( - records.database, - config.database, - runtime.artifact_processing_supervisor, - lambda: build_worker_security( + app.state.plugin_manager = await resources.enter_async_context( + PluginManager( + records.database, + config.database, + runtime.artifact_processing_supervisor, + lambda: ( + build_worker_security( resolved, active_access_control, legacy_static_principal=static_principal if legacy_static_admin else None, enabled=True, injected=configured_access_control is not None, - ), - on_supervisor=bind_plugin_supervisor, - plugins_dir=resolved.artifact_plugins_dir, - embedding_model=runtime._topic_memory_embedding_model, - inference_config=config.inference, - runtime_config=config.runtime, - ) + ) + if dispatch_plugins + else None + ), + on_supervisor=bind_supervisor, + plugins_dir=resolved.plugins_dir, + register_merge=None if runtime.artifact_merge is None else runtime.artifact_merge.register, + dispatch=dispatch_plugins, + inference_config=config.inference, + storage_root=powercontext_data_dir() / "plugins", + # Host processes authorize callers in every role that serves requests. + host_security_builder=lambda: build_worker_security( + resolved, + active_access_control, + legacy_static_principal=static_principal if legacy_static_admin else None, + enabled=True, + injected=configured_access_control is not None, + ), ) + ) if active_access_control is not None: migrated, unresolved = await runtime._records().migrate_handoff_receipts( active_access_control.committed_receipt_identity, @@ -380,6 +388,7 @@ def bind_plugin_supervisor(supervisors) -> None: gateway = await _source_gateway(runtime, resolved) await resources.enter_async_context(gateway) app.state.source_gateway = gateway + app.state.plugin_api_max_body_bytes = resolved.plugin_api_max_body_bytes app.state.application = runtime app.state.dashboard_runtime = runtime app.state.access_control = active_access_control @@ -397,7 +406,6 @@ def current_capabilities() -> Capabilities: } ) - app.state.artifact_declaration_provider = partial(read_artifact_declarations, records.database) app.state.capabilities = capabilities app.state.capability_provider = current_capabilities app.state.server_info = server_info(server_id) @@ -417,9 +425,7 @@ def current_capabilities() -> Capabilities: await asyncio.gather(deployment_task, return_exceptions=True) _log_lifecycle("server.stopping", "PowerContext Server is stopping") readiness_probe.unbind() - app.state.artifact_plugin_manager = None - app.state.artifact_declaration_provider = None - app.state.artifact_access_refresher = None + app.state.plugin_manager = None app.state.application = None app.state.source_gateway = None app.state.dashboard_runtime = None @@ -443,7 +449,12 @@ def current_capabilities() -> Capabilities: configured_middleware = list(middleware) configured_middleware.insert( - 0, Middleware(HttpRequestBodyLimitMiddleware, max_request_body_bytes=resolved.http.max_request_body_bytes) + 0, + Middleware( + HttpRequestBodyLimitMiddleware, + max_request_body_bytes=resolved.http.max_request_body_bytes, + plugin_api_max_body_bytes=resolved.plugin_api_max_body_bytes, + ), ) if configured_authentication is not None: configured_middleware.insert( @@ -525,18 +536,6 @@ async def _initialize_server_identity(runtime: BuiltinRuntime, cursor_secret: by return server_id -async def _restore_artifact_access_profiles(runtime: BuiltinRuntime) -> RelationalRecordService: - """Restore retained family access even when this process runs no plugin manager.""" - records = runtime._records() - if not isinstance(records, RelationalRecordService): - raise TypeError("Artifact plugins require relational record storage") # noqa: TRY003 - async with records.database.transaction() as connection: - families = (await connection.execute(select(ARTIFACT_FAMILY_SCHEMAS_TABLE.c.family).distinct())).scalars().all() - for family in families: - register_custom_artifact_access_family(family) - return records - - async def _source_gateway(runtime: BuiltinRuntime, settings: ServerSettings): from powercontext.source_management.gateway import SourceGateway from powercontext.source_management.write import SourceWritePlugins diff --git a/src/powercontext/server/http_limits.py b/src/powercontext/server/http_limits.py index cd9a20ea6c..4dc00ab434 100644 --- a/src/powercontext/server/http_limits.py +++ b/src/powercontext/server/http_limits.py @@ -16,12 +16,16 @@ from __future__ import annotations +import re + from starlette.responses import JSONResponse from starlette.types import ASGIApp, Message, Receive, Scope, Send from powercontext.http import ErrorDetail, ErrorResponse from powercontext.server.context import is_internal_bridge +_PLUGIN_API = re.compile(r"^/(?:v1|dashboard)/plugins/[^/]+/api/") + class HttpAdmissionMiddleware: """Bound concurrent external response lifetimes.""" @@ -83,9 +87,16 @@ class _RequestBodyDisconnected(Exception): class HttpRequestBodyLimitMiddleware: """Bound external request bodies before downstream parsing.""" - def __init__(self, app: ASGIApp, *, max_request_body_bytes: int) -> None: + def __init__( + self, app: ASGIApp, *, max_request_body_bytes: int, plugin_api_max_body_bytes: int | None = None + ) -> None: self.app = app self._max_body = max_request_body_bytes + # Plugin routes declare their own limits under a separate deployment ceiling. + self._plugin_max_body = plugin_api_max_body_bytes or max_request_body_bytes + + def _limit(self, scope: Scope) -> int: + return self._plugin_max_body if _PLUGIN_API.match(scope.get("path", "")) else self._max_body async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http" or is_internal_bridge(): @@ -97,7 +108,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, _replay_body(body, receive), send) async def _receive_body(self, scope: Scope, receive: Receive, send: Send) -> bytearray | None: - if self._declared_too_large(scope): + limit = self._limit(scope) + if self._declared_too_large(scope, limit): await _reject_body(scope, receive, send) return None body = bytearray() @@ -108,18 +120,19 @@ async def _receive_body(self, scope: Scope, receive: Receive, send: Send) -> byt # is sent, so admission catches this outside them and ends the request. raise _RequestBodyDisconnected chunk = message.get("body", b"") - if len(chunk) > self._max_body - len(body): + if len(chunk) > limit - len(body): await _reject_body(scope, receive, send) return None body.extend(chunk) if not message.get("more_body", False): return body - def _declared_too_large(self, scope: Scope) -> bool: + @staticmethod + def _declared_too_large(scope: Scope, limit: int) -> bool: for name, value in scope.get("headers", []): if name == b"content-length": try: - if int(value) > self._max_body: + if int(value) > limit: return True except ValueError: continue diff --git a/src/powercontext/server/middleware.py b/src/powercontext/server/middleware.py index a9d659acfd..a5e4350683 100644 --- a/src/powercontext/server/middleware.py +++ b/src/powercontext/server/middleware.py @@ -16,6 +16,8 @@ from __future__ import annotations +import re + from starlette.requests import Request from starlette.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send @@ -44,6 +46,9 @@ }) +_PLUGIN_ASSET = re.compile(r"^/dashboard/plugins/[a-z][a-z0-9-]*/ui/") + + class AuthenticationMiddleware: """Authenticate every protected external HTTP request through one Provider.""" @@ -118,6 +123,9 @@ def _is_public(scope: Scope) -> bool: or scope["path"] in _PUBLIC_PATHS or scope["path"] == "/dashboard/session" or scope["path"].startswith("/dashboard/static/") + # Plugin page files are plugin code and assets, never user data. + or scope["path"] == "/dashboard/plugins/_client.js" + or _PLUGIN_ASSET.match(scope["path"]) is not None ) diff --git a/src/powercontext/server/ownership.py b/src/powercontext/server/ownership.py index 0292c36a1d..a40f2865e0 100644 --- a/src/powercontext/server/ownership.py +++ b/src/powercontext/server/ownership.py @@ -156,7 +156,7 @@ async def plan_ownership_migration( raise AccessUnavailableError("relationship_management_unavailable") scopes: list[ScopeOwnershipPlan] = [] selected = tuple(dict.fromkeys(scope_ids)) - families = await _owned_artifact_families(application) + families = frozenset(OWNED_ARTIFACT_FAMILIES) for scope_id in selected: scopes.append( ScopeOwnershipPlan( @@ -232,7 +232,7 @@ async def apply_ownership_migration( context = migration_context(MIGRATION_OPERATION, transport=transport, context=context) items: list[ArtifactOwnershipGap | CandidateOwnershipGap] = [] conflicts: list[OwnershipDisposition] = [] - families = await _owned_artifact_families(application) + families = frozenset(OWNED_ARTIFACT_FAMILIES) for scope_id in dict.fromkeys(scope_ids): inventory = ( *await _artifact_gaps(application, None, scope_id, families), @@ -402,18 +402,6 @@ def _validate_migration_owner(owner: PrincipalRef) -> None: raise AccessInvalidRequestError("principal") -async def _owned_artifact_families(application: Application) -> frozenset[str]: - """Families whose Artifact lifecycle Access enforces, including saved plugins.""" - - from powercontext.server.artifact_plugins import read_artifact_declarations - - families = set(OWNED_ARTIFACT_FAMILIES) - database = getattr(application, "primary_database", None) - if database is not None: - families.update(declaration["family"] for declaration in await read_artifact_declarations(database)) - return frozenset(families) - - async def _artifact_gaps( application: Application, access: AccessControlService | None, diff --git a/src/powercontext/server/plugin_hosts.py b/src/powercontext/server/plugin_hosts.py new file mode 100644 index 0000000000..5bd275bb99 --- /dev/null +++ b/src/powercontext/server/plugin_hosts.py @@ -0,0 +1,221 @@ +# 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-side management of plugin host processes. + +Each Server process keeps at most one host process per plugin. It is a spawned, +fresh interpreter connected by a duplex Pipe; the Server forwards authenticated +requests to it, terminates it when a request outlives its time limit, and restarts +it after a backoff. +""" + +from __future__ import annotations + +import asyncio +import logging +import multiprocessing +import time +from contextlib import suppress +from typing import Any +from uuid import uuid4 + +from powercontext._logging import log_safely +from powercontext.builtin.plugins.host import run_plugin_host +from powercontext.builtin.plugins.host_protocol import ( + HostFailed, + HostMethod, + HostReady, + HostRequest, + HostResponse, + HostShutdown, + PluginHostSpec, +) + +logger = logging.getLogger(__name__) + +READY_TIMEOUT_SECONDS = 60.0 +HOST_CONCURRENCY = 8 +_BACKOFF_START = 1.0 +_BACKOFF_LIMIT = 60.0 +_HEALTHY_RESET_SECONDS = 300.0 + + +class PluginHostUnavailable(RuntimeError): + """The host process is starting, in restart backoff, or was terminated during the call.""" + + +class PluginHost: + def __init__(self, name: str, spec: PluginHostSpec) -> None: + self.name = name + self.spec = spec + self.implemented: tuple[str, ...] = () + # Why the plugin cannot run at all, such as a missing required method; it is not restarted. + self.failure: str | None = None + self._process: Any = None + self._connection: Any = None + self._ready: asyncio.Future[None] | None = None + self._reader: asyncio.Task[None] | None = None + self._pending: dict[str, asyncio.Future[HostResponse]] = {} + self._slots = asyncio.Semaphore(HOST_CONCURRENCY) + self._send_lock = asyncio.Lock() + self._start_lock = asyncio.Lock() + self._backoff = 0.0 + self._not_before = 0.0 + self._started_at = 0.0 + self._closing = False + + async def start(self) -> None: + """Start in the background; callers wait for readiness within their own time limit.""" + + async with self._start_lock: + if self._ready is not None or self._closing: + return + if self.failure is not None: + raise PluginHostUnavailable(self.name) + if time.monotonic() < self._not_before: + raise PluginHostUnavailable(self.name) + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe(duplex=True) + process = context.Process( + target=run_plugin_host, args=(child, self.spec), name=f"powercontext-plugin-host-{self.name}" + ) + await asyncio.to_thread(process.start) + child.close() + self._process, self._connection = process, parent + self._ready = asyncio.get_running_loop().create_future() + self._started_at = time.monotonic() + self._reader = asyncio.create_task(self._read(parent), name=f"plugin-host-{self.name}") + log_safely(logger, logging.INFO, f"Plugin host {self.name} started") + + async def _read(self, connection: Any) -> None: + ready = self._ready + try: + while True: + message = await asyncio.to_thread(connection.recv) + if isinstance(message, HostReady): + self.implemented = message.implemented + if ready is not None and not ready.done(): + ready.set_result(None) + elif isinstance(message, HostFailed): + log_safely(logger, logging.WARNING, f"Plugin host {self.name} failed to start ({message.error})") + if message.permanent: + self.failure = message.error + break + elif isinstance(message, HostResponse): + future = self._pending.pop(message.id, None) + if future is not None and not future.done(): + future.set_result(message) + except (EOFError, OSError): + pass + except asyncio.CancelledError: + raise + await self._terminate("crash") + + async def _terminate(self, reason: str) -> None: + process, connection, ready = self._process, self._connection, self._ready + if process is None: + return + self._process = self._connection = self._ready = None + for future in self._pending.values(): + if not future.done(): + future.set_exception(PluginHostUnavailable(self.name)) + self._pending.clear() + if ready is not None and not ready.done(): + ready.set_exception(PluginHostUnavailable(self.name)) + ready.exception() + if connection is not None: + connection.close() + await asyncio.to_thread(_stop_process, process) + if not self._closing and self.failure is None: + healthy = time.monotonic() - self._started_at >= _HEALTHY_RESET_SECONDS + self._backoff = _BACKOFF_START if healthy or not self._backoff else min(self._backoff * 2, _BACKOFF_LIMIT) + self._not_before = time.monotonic() + self._backoff + log_safely(logger, logging.WARNING, f"Plugin host {self.name} stopped ({reason}); restarting later") + asyncio.get_running_loop().call_later(self._backoff, self._restart) + + def _restart(self) -> None: + if not self._closing: + task = asyncio.ensure_future(self._restart_quietly()) + task.add_done_callback(lambda done: done.exception()) + + async def _restart_quietly(self) -> None: + with suppress(PluginHostUnavailable): + await self.start() + + async def call( + self, + method: HostMethod, + *, + scope_id: str | None, + caller: Any, + audit: Any, + payload: dict[str, Any], + body: Any = None, + timeout: float, # noqa: ASYNC109 - the deadline also decides when to replace the process + ) -> HostResponse: + deadline = time.monotonic() + timeout + if self._ready is None: + await self.start() + ready = self._ready + if ready is None: + raise PluginHostUnavailable(self.name) + try: + await asyncio.wait_for(asyncio.shield(ready), max(0.0, deadline - time.monotonic())) + except TimeoutError: + raise PluginHostUnavailable(self.name) from None + async with self._slots: + connection = self._connection + if connection is None: + raise PluginHostUnavailable(self.name) + request = HostRequest( + id=uuid4().hex, + method=method, + scope_id=scope_id, + caller=caller, + audit=audit, + payload=payload, + body=body, + ) + future: asyncio.Future[HostResponse] = asyncio.get_running_loop().create_future() + self._pending[request.id] = future + async with self._send_lock: + await asyncio.to_thread(connection.send, request) + try: + return await asyncio.wait_for(asyncio.shield(future), max(0.0, deadline - time.monotonic())) + except TimeoutError: + # A blocked plugin cannot be interrupted reliably; replace the whole process. + await self._terminate("timeout") + raise PluginHostUnavailable(self.name) from None + + async def close(self) -> None: + self._closing = True + connection = self._connection + if connection is not None: + with suppress(OSError): + connection.send(HostShutdown()) + process = self._process + if process is not None: + await asyncio.to_thread(process.join, 10) + await self._terminate("server_stop") + if self._reader is not None: + self._reader.cancel() + + +def _stop_process(process: Any) -> None: + if process.is_alive(): + process.terminate() + process.join(5) + if process.is_alive(): + process.kill() + process.join() diff --git a/src/powercontext/server/plugins.py b/src/powercontext/server/plugins.py new file mode 100644 index 0000000000..7cf40ef3ad --- /dev/null +++ b/src/powercontext/server/plugins.py @@ -0,0 +1,453 @@ +# 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 plugins at startup and hand their calls to the Artifact processing Supervisor. + +Nothing about a plugin is persisted: the directory as it is at startup decides which +plugins exist. Plugin code runs only in check, Worker, and host child processes. User +changes to plugin Artifacts and the plugin's own routes go to its host process, which +authorizes, prechecks, and writes as the caller; this process only authenticates and +forwards. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +from collections.abc import Callable +from contextlib import AsyncExitStack +from dataclasses import dataclass, field +from datetime import timedelta +from functools import partial +from pathlib import Path +from typing import Any + +from sqlalchemy import select + +from powercontext._logging import log_safely +from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig +from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.tables import ARTIFACT_PROCESSING_INTENTS_TABLE +from powercontext.builtin.plugins.executor import PluginWorkerSpec, run_plugin_worker +from powercontext.builtin.plugins.families import register_plugin_family, register_source_binding, revision_metadata +from powercontext.builtin.plugins.host_protocol import PluginHostSpec +from powercontext.builtin.plugins.manifest import PluginManifest +from powercontext.builtin.plugins.writer import plugin_merge_service +from powercontext.builtin.records import BaseValueNotFoundError +from powercontext.builtin.runtime.artifact_merge import ArtifactMergeAccess +from powercontext.builtin.runtime.artifact_processing import ( + ArtifactProcessingBinding, + ArtifactProcessingSupervisor, + ArtifactProcessingSupervisors, + SpawnArtifactProcessingWorkerLauncher, +) +from powercontext.builtin.runtime.config import DatabaseConfig +from powercontext.builtin.runtime.processing_discovery import SourceProcessingPendingProvider +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY +from powercontext.http._generated.models import PluginPage, PluginScopePage, PluginScopeState, PluginState +from powercontext.plugins import Cron, Interval +from powercontext.server.plugin_hosts import PluginHost + +logger = logging.getLogger(__name__) +CHECK_TIMEOUT_SECONDS = 60 +# How soon a dirty Scope is admitted when the plugin asks to run on new Sources. +ON_SOURCES_INTERVAL_SECONDS = 2.0 + + +@dataclass +class _Plugin: + name: str + directory: Path + manifest: PluginManifest | None = None + error: str | None = None + tasks: list[asyncio.Task[None]] = field(default_factory=list) + host: PluginHost | None = None + + @property + def failure(self) -> str | None: + return self.error if self.error is not None or self.host is None else self.host.failure + + def state(self) -> PluginState: + spec = None if self.manifest is None else self.manifest.spec + triggers = [] + if spec is not None: + triggers = [ + name + for name, enabled in (("schedule", spec.schedule is not None), ("sources", spec.on_sources)) + if enabled + ] + return PluginState.model_validate({ + "name": self.name, + "kind": "artifact", + "status": "failed" if self.failure is not None else "loaded", + "error": self.failure, + "family": None if spec is None else spec.family, + "binding_name": None if self.manifest is None else self.manifest.binding_name, + "scopes": None if spec is None or spec.scopes is None else list(spec.scopes), + "source_types": None if spec is None or spec.inputs is None else list(spec.inputs.source_types), + "triggers": triggers, + }) + + +async def check_plugin_directory(directory: Path) -> PluginManifest: + """Import user code only in a time-limited child process.""" + + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "powercontext.builtin.plugins.manifest", + str(directory), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + output, _ = await asyncio.wait_for(process.communicate(), CHECK_TIMEOUT_SECONDS) + except TimeoutError: + process.kill() + await process.wait() + raise ValueError("plugin check timed out") from None # noqa: TRY003 + lines = output.decode("utf-8", "replace").strip().splitlines() + try: + result = json.loads(lines[-1]) + except (IndexError, ValueError): + raise ValueError("plugin check produced no result") from None # noqa: TRY003 + if "error" in result: + raise ValueError(result["error"]) + return PluginManifest.model_validate(result["manifest"]) + + +class PluginManager: + def __init__( + self, + database: AsyncDatabase, + config: DatabaseConfig, + supervisor: ArtifactProcessingSupervisors | None, + security_builder: Callable[[], dict[str, Any] | None], + on_supervisor: Callable[[ArtifactProcessingSupervisors], None], + plugins_dir: Path | None, + *, + register_merge: Callable[[Any], None] | None = None, + dispatch: bool = True, + inference_config: Any = None, + storage_root: Path | None = None, + host_security_builder: Callable[[], dict[str, Any] | None] | None = None, + serve_requests: bool = True, + ) -> None: + self.database = database + self.register_merge = register_merge + # API-only instances register families for reads but never dispatch plugin Workers. + self.dispatch = dispatch + self.config = config + self.supervisor = supervisor + self.security_builder = security_builder + self.on_supervisor = on_supervisor + self.plugins_dir = None if plugins_dir is None else plugins_dir.expanduser().resolve() + self.plugins: dict[str, _Plugin] = {} + self._resources = AsyncExitStack() + self.inference_config = inference_config + self.storage_root = None if storage_root is None else str(storage_root) + self.host_security_builder = host_security_builder or security_builder + # Instances that receive no requests (the background role) start no host processes. + self.serve_requests = serve_requests + + async def __aenter__(self) -> PluginManager: + directory = self.plugins_dir + if directory is None: + return self + try: + entries = await asyncio.to_thread(lambda: sorted(path for path in directory.iterdir() if path.is_dir())) + except OSError as error: + log_safely(logger, logging.WARNING, f"Plugin directory unavailable ({type(error).__name__})") + return self + families: set[str] = set() + for entry in entries: + plugin = _Plugin(name=entry.name, directory=entry.resolve()) + self.plugins[entry.name] = plugin + try: + manifest = await check_plugin_directory(plugin.directory) + if manifest.spec.family in families: + raise ValueError("family is already provided by another plugin") # noqa: TRY003, TRY301 + plugin.manifest = manifest + families.add(manifest.spec.family) + await self._register(plugin) + except Exception as error: + plugin.error = str(error) if isinstance(error, ValueError) else type(error).__name__ + log_safely(logger, logging.WARNING, f"Plugin {entry.name!r} failed to load") + if self.serve_requests: + self._prepare_hosts() + return self + + def _prepare_hosts(self) -> None: + loaded = [ + (plugin, plugin.manifest) + for plugin in self.plugins.values() + if plugin.error is None and plugin.manifest is not None + ] + families = tuple(sorted(manifest.spec.family for _, manifest in loaded)) + bindings = tuple( + (manifest.binding_name, manifest.spec.scopes, manifest.spec.inputs.source_types) + for _, manifest in loaded + if manifest.spec.inputs is not None + ) + security = self.host_security_builder() + for plugin, manifest in loaded: + plugin.host = PluginHost( + plugin.name, + PluginHostSpec( + database=self.config, + directory=str(plugin.directory), + manifest=manifest.model_dump(mode="json"), + security=security, + inference=self.inference_config, + plugin_families=families, + source_bindings=bindings, + storage_root=self.storage_root, + ), + ) + # Every loaded plugin's host starts now, so the first request does not wait for a spawn. + task = asyncio.create_task(self._start_host(plugin.host), name=f"plugin-host-start-{plugin.name}") + plugin.tasks.append(task) + + @staticmethod + async def _start_host(host: PluginHost) -> None: + try: + await host.start() + except Exception as error: + log_safely(logger, logging.WARNING, f"Plugin host {host.name} did not start ({type(error).__name__})") + + async def __aexit__(self, *_: object) -> None: + for plugin in self.plugins.values(): + for task in plugin.tasks: + task.cancel() + await asyncio.gather(*plugin.tasks, return_exceptions=True) + await asyncio.gather( + *(plugin.host.close() for plugin in self.plugins.values() if plugin.host is not None), + return_exceptions=True, + ) + await self._resources.aclose() + + async def _ensure_supervisor(self) -> ArtifactProcessingSupervisors: + if self.supervisor is None: + controller = await self._resources.enter_async_context( + ArtifactProcessingSupervisor( + database=self.database, + bindings=(), + lease_mode="oceanbase" if isinstance(self.config, OceanBaseConfig) else "single-process", + ) + ) + self.supervisor = ArtifactProcessingSupervisors((controller,)) + self.on_supervisor(self.supervisor) + return self.supervisor + + async def _register(self, plugin: _Plugin) -> None: + manifest = plugin.manifest + if manifest is None: + raise ValueError("plugin manifest is unavailable") # noqa: TRY003 + generate = manifest.spec + await self._register_source_definitions(manifest) + register_plugin_family(generate.family) + if self.register_merge is not None: + self.register_merge( + plugin_merge_service( + generate.family, + ArtifactMergeAccess(generate.family), + SourceRepository(BUILTIN_SOURCE_REGISTRY), + metadata=revision_metadata(manifest.name, manifest.fingerprint), + ) + ) + scopes = None if generate.scopes is None else frozenset(generate.scopes) + inputs = generate.inputs + if inputs is not None: + register_source_binding(manifest.binding_name, scopes, frozenset(inputs.source_types)) + interval = None + cron = None + if inputs is not None: + if isinstance(generate.schedule, Cron): + cron = generate.schedule + seconds = [ + *([generate.schedule.seconds] if isinstance(generate.schedule, Interval) else []), + *([ON_SOURCES_INTERVAL_SECONDS] if generate.on_sources else []), + ] + interval = timedelta(seconds=min(seconds)) if seconds else None + + async def in_scope(connection, candidates): + return frozenset(candidates if scopes is None else set(candidates) & scopes) + + # Without a schedule or on_sources the plugin has no background generation and no binding. + if not self.dispatch or (generate.schedule is None and not generate.on_sources): + return + spec = PluginWorkerSpec( + database=self.config, + directory=str(plugin.directory), + manifest=manifest.model_dump(mode="json"), + security=self.security_builder(), + inference=self.inference_config, + storage_root=self.storage_root, + ) + supervisor = await self._ensure_supervisor() + await supervisor.replace_binding( + manifest.binding_name, + ArtifactProcessingBinding( + binding_name=manifest.binding_name, + artifact_family=generate.family, + launcher=SpawnArtifactProcessingWorkerLauncher(partial(run_plugin_worker, spec)), + max_workers=generate.max_workers, + worker_timeout_seconds=generate.timeout_seconds, + automatic_processing_interval=interval, + cron=None if cron is None else cron.expression, + timezone="Asia/Shanghai" if cron is None else cron.timezone, + pending_provider=None + if inputs is None + else SourceProcessingPendingProvider(self.database, manifest.binding_name, generate.family), + automatic_scope_filter=in_scope, + invocation_scope_filter=in_scope, + ), + ) + supervisor.wake(manifest.binding_name) + if inputs is None and isinstance(generate.schedule, Interval): + # Without inputs there is no dirty Source work; request each listed Scope when due. + plugin.tasks.append( + asyncio.create_task(self._tick(manifest, generate.schedule.seconds), name=f"plugin-{plugin.name}") + ) + + async def _register_source_definitions(self, manifest: PluginManifest) -> None: + """Register the manifests of the plugin's Source Definitions; identical manifests are reused.""" + + from powercontext.builtin.persistence.source_definitions import SourceDefinitionManifestRepository + from powercontext.errors import SourceDefinitionNotFoundError + from powercontext.sources.observations import SourceDefinitionManifest + + repository = SourceDefinitionManifestRepository() + for item in manifest.source_manifests: + definition = SourceDefinitionManifest.model_validate(item) + try: + BUILTIN_SOURCE_REGISTRY.definition_for_name(definition.name) + except SourceDefinitionNotFoundError: + pass + else: + raise ValueError(f"Source type {definition.name} is a built-in Source type") # noqa: TRY003 + async with self.database.transaction() as connection: + await repository.register(connection, definition) + + async def _tick(self, manifest: PluginManifest, seconds: float) -> None: + intents = ArtifactProcessingIntentRepository() + while True: + await asyncio.sleep(seconds) + try: + async with self.database.transaction() as connection: + for scope_id in manifest.spec.scopes or (): + await intents.request(connection, scope_id, manifest.binding_name) + if self.supervisor is not None: + self.supervisor.wake(manifest.binding_name) + except Exception as error: + log_safely(logger, logging.WARNING, f"Plugin schedule request failed ({type(error).__name__})") + + def page(self) -> PluginPage: + return PluginPage(items=[plugin.state() for _, plugin in sorted(self.plugins.items())]) + + def _plugin(self, name: str) -> _Plugin: + plugin = self.plugins.get(name) + if plugin is None: + raise BaseValueNotFoundError("plugin", name) + return plugin + + def get(self, name: str) -> PluginState: + return self._plugin(name).state() + + async def scopes(self, name: str, *, cursor: str | None, limit: int) -> PluginScopePage: + """Per-Scope progress, read from the shared intent and cursor tables by any Server role.""" + + manifest = self._plugin(name).manifest + if manifest is None: + return PluginScopePage(items=[], next_cursor=None) + intents = ARTIFACT_PROCESSING_INTENTS_TABLE + statement = ( + select(intents) + .where(intents.c.binding_name == manifest.binding_name) + .order_by(intents.c.scope_id) + .limit(limit + 1) + ) + if cursor is not None: + statement = statement.where(intents.c.scope_id > cursor) + sources = SourceRepository(BUILTIN_SOURCE_REGISTRY) + consumes = manifest.spec.inputs is not None + items = [] + async with self.database.transaction() as connection: + rows = (await connection.execute(statement)).mappings().all() + for row in rows[:limit]: + scope_id = row["scope_id"] + consumed = source_position = None + if consumes: + saved = await SourceCursorRepository().load(connection, scope_id, manifest.binding_name) + consumed = 0 if saved is None else saved.cursor.sequence + source_position = await sources.journal_position(connection, scope_id) + items.append( + PluginScopeState( + scope_id=scope_id, + pending=row["dirty_generation"] > row["clean_generation"] + or row["requested_generation"] > row["handled_generation"], + consumed_position=consumed, + source_position=source_position, + ) + ) + return PluginScopePage(items=items, next_cursor=rows[limit - 1]["scope_id"] if len(rows) > limit else None) + + def families(self) -> frozenset[str]: + """The families of the plugins this manager loaded.""" + + return frozenset( + plugin.manifest.spec.family + for plugin in self.plugins.values() + if plugin.error is None and plugin.manifest is not None + ) + + def plugin_for_family(self, family: str) -> PluginManifest | None: + for plugin in self.plugins.values(): + if plugin.error is None and plugin.manifest is not None and plugin.manifest.spec.family == family: + return plugin.manifest + return None + + def loaded_manifest(self, name: str) -> PluginManifest | None: + plugin = self.plugins.get(name) + return None if plugin is None or plugin.error is not None else plugin.manifest + + def host(self, name: str) -> PluginHost: + plugin = self._plugin(name) + if plugin.host is None: + raise BaseValueNotFoundError("plugin", name) + return plugin.host + + def host_for_source_type(self, source_type: str) -> tuple[PluginManifest, PluginHost] | None: + for plugin in self.plugins.values(): + manifest = plugin.manifest + if ( + plugin.error is None + and manifest is not None + and plugin.host is not None + and any(item["name"] == source_type for item in manifest.source_manifests) + ): + return manifest, plugin.host + return None + + def host_for_family(self, family: str) -> tuple[PluginManifest, PluginHost] | None: + for plugin in self.plugins.values(): + manifest = plugin.manifest + if plugin.error is None and manifest is not None and manifest.spec.family == family: + return (manifest, plugin.host) if plugin.host is not None else None + return None diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 7ab9f94d87..86cf77af53 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -266,7 +266,9 @@ class ServerSettings(BaseSettings): handoff_generation_verification_secrets: tuple[SecretStr, ...] = Field(default=(), max_length=8, repr=False) code: CodeConfig = Field(default_factory=CodeConfig) runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) - artifact_plugins_dir: Path | None = None + plugins_dir: Path | None = None + # Ceiling on plugin route request and response bodies; a route may declare a lower limit. + plugin_api_max_body_bytes: int = Field(default=32 * 1024 * 1024, ge=1024) source_plugins_dir: Path | None = None database: DatabaseConfig = Field(default_factory=_default_database, discriminator="kind") handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig) diff --git a/tests/builtin/persistence/test_artifact_indexes.py b/tests/builtin/persistence/test_artifact_indexes.py deleted file mode 100644 index 86ff50a771..0000000000 --- a/tests/builtin/persistence/test_artifact_indexes.py +++ /dev/null @@ -1,834 +0,0 @@ -# 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. - -"""Actual SQLite search, exact evidence and atomic projection publication.""" - -import asyncio -from contextlib import asynccontextmanager - -import pytest -from sqlalchemy import insert, select, update - -from powercontext.artifacts.indexes import ArtifactSearchDeclaration, FragmentProjection, TextIndex, VectorIndex -from powercontext.artifacts.search import ArtifactSearchUnavailable, ArtifactSearchUnsupported -from powercontext.builtin.artifacts.memory.models import EmbeddingProfile -from powercontext.builtin.inference.models import EmbeddingResult -from powercontext.builtin.persistence.artifact_governance import ArtifactGovernanceRepository, ArtifactLifecycleState -from powercontext.builtin.persistence.artifact_indexes import ( - SCHEMAS, - STATES, - ArtifactIndexBuilder, - ArtifactProjectionPublisher, -) -from powercontext.builtin.persistence.artifacts import RepositoryArtifactDraft -from powercontext.builtin.persistence.custom_artifacts import StoredArtifactContent, custom_artifact_repository -from powercontext.builtin.persistence.errors import RepositoryNotFoundError -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import BUILTIN_TABLES -from powercontext.builtin.runtime.artifact_search import ArtifactSearchService -from powercontext.builtin.runtime.custom_artifact_search import CustomArtifactSearcher - -PROFILE = EmbeddingProfile(profile_id="features-v1", model="fixture", dimension=2, normalization="none") - - -def declaration(*, version="1", vector_field="vector", profile=PROFILE): - return ArtifactSearchDeclaration( - version=version, - indexes=( - TextIndex(name="lexical", fields=("text",), projection_version="1"), - VectorIndex( - name="visual", - projection_version=version, - source="content", - profile=profile, - vector_field=vector_field, - profile_field="profile", - fragments=FragmentProjection(array_field="fragments", id_field="id"), - ), - ), - modes=("text", "vector", "hybrid"), - default_mode="text", - ) - - -@asynccontextmanager -async def store(config=None, model=None): - async with SQLiteProfile.open(config or SQLiteConfig(), tables=BUILTIN_TABLES) as storage: - yield ( - storage.database, - ArtifactIndexBuilder(storage.database, model), - ArtifactProjectionPublisher(storage.database, model), - ) - - -async def register(db, family, search): - durable = {"family": family, "schema_version": "1", "search_fields": [], "search": search.model_dump(mode="json")} - async with db.transaction() as connection: - existing = await connection.scalar(select(SCHEMAS.c.family).where(SCHEMAS.c.family == family)) - if existing is None: - await connection.execute(insert(SCHEMAS).values(family=family, schema_version="1", declaration=durable)) - else: - await connection.execute(update(SCHEMAS).where(SCHEMAS.c.family == family).values(declaration=durable)) - - -async def write(db, publisher, scope, family, identity, content): - prepared = await publisher.prepare(scope, family, "1", content) - repository = custom_artifact_repository(family, prepared={identity: prepared}) - async with db.transaction() as connection: - draft = RepositoryArtifactDraft( - family=family, content=StoredArtifactContent(schema_version="1", content=content) - ) - try: - previous = await repository.latest(connection, scope, family, identity) - except RepositoryNotFoundError: - return await repository.create(connection, scope, identity, draft) - return await repository.revise(connection, scope, previous, draft) - - -def body(text, *vectors, profile=PROFILE): - return { - "text": text, - "fragments": [ - {"id": f"f{i}", "vector": vector, "profile": profile.model_dump(mode="json")} - for i, vector in enumerate(vectors) - ], - } - - -def service(db, family, model=None): - result = ArtifactSearchService(known_families=(family,)) - result.register(CustomArtifactSearcher(db, family, model)) - return result - - -def test_missing_vector_extension_keeps_text_search_and_content_writes_available(monkeypatch, tmp_path): - import sqlite_vec - - async def run(): - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'vectors.db'}") - async with store(config) as (db, builder, publisher): - await register(db, "reports", declaration()) - await write(db, publisher, "scope", "reports", "a", body("数据库", [1, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - - monkeypatch.setattr(sqlite_vec, "loadable_path", lambda: str(tmp_path / "missing-sqlite-vec")) - async with store(config) as (db, _, publisher): - search = service(db, "reports") - with pytest.raises(ArtifactSearchUnavailable): - await search.search( - "scope", - "reports", - { - "mode": "vector", - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}, - }, - ) - result = await search.search("scope", "reports", {"mode": "text", "query": "数据库"}) - assert [artifact.artifact_id for artifact in result.artifacts] == ["a"] - revised = await write(db, publisher, "scope", "reports", "a", body("数据库 restored", [0, 1])) - assert revised.revision == 2 - - asyncio.run(run()) - - -def test_exact_vectors_collapse_fragments_before_limit_and_preserve_scope_history(): - async def run(): - async with store() as (db, builder, publisher): - for family in ("reports", "other"): - await register(db, family, declaration()) - await write(db, publisher, "scope", "reports", "a", body("数据库 timeout", *([[0, 0]] * 8))) - await write(db, publisher, "scope", "reports", "b", body("数据库 normal", [1, 0])) - await write(db, publisher, "foreign", "reports", "a", body("数据库 hidden", [0, 0])) - await write(db, publisher, "scope", "other", "a", body("数据库 hidden", [0, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports") - request = { - "mode": "vector", - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [0, 0]}, - "limit": 2, - "include_scores": True, - } - result = await search.search("scope", "reports", request) - assert [artifact.artifact_id for artifact in result.artifacts] == ["a", "b"] - assert result.matches[0].channel_scores["vector"].raw == 0 - old = result.artifacts[0].as_ref() - hybrid = await search.search("scope", "reports", {**request, "mode": "hybrid", "query": "数据库"}) - assert {artifact.artifact_id for artifact in hybrid.artifacts} == {"a", "b"} - assert all(0 <= hit.retrieval_score <= 1 for hit in hybrid.matches) - await write(db, publisher, "scope", "reports", "a", body("recovered")) - current = await search.search("scope", "reports", request) - assert [artifact.artifact_id for artifact in current.artifacts] == ["b"] - async with db.transaction() as connection: - historical = await custom_artifact_repository("reports").get(connection, "scope", old) - assert len(historical.content.content["fragments"]) == 8 - assert not (await search.search("scope", "reports", {"query": "timeout"})).matches - - asyncio.run(run()) - - -def test_initial_build_catches_insert_before_scan_cursor_and_publishes_atomically(): - async def run(): - async with store() as (db, builder, publisher): - - class ConcurrentEncoder: - profile = PROFILE - inserted = False - - async def embed(self, texts): - if not self.inserted: - self.inserted = True - # The text generation has finished its scan. This new identity is - # before its cursor and must be picked up by invalidation replay. - await write(db, publisher, "scope", "reports", "a", {"text": "original", "title": "new-index"}) - return EmbeddingResult(vectors=tuple((1.0, 0.0) for _ in texts)) - - builder = ArtifactIndexBuilder(db, ConcurrentEncoder()) - initial = ArtifactSearchDeclaration( - version="1", - indexes=( - TextIndex(name="lexical", fields=("title",), projection_version="1"), - VectorIndex( - name="semantic", projection_version="1", source="text", profile=PROFILE, fields=("title",) - ), - ), - modes=("text",), - default_mode="text", - ) - await register(db, "reports", initial) - await write(db, publisher, "scope", "reports", "z", {"text": "original", "title": "new-index"}) - await builder.refresh("scope", "reports") - search = service(db, "reports") - with pytest.raises(ArtifactSearchUnavailable): - await search.search("scope", "reports", {"query": "original"}) - async with db.transaction() as connection: - await connection.exec_driver_sql( - "CREATE TRIGGER fail_release BEFORE UPDATE OF state ON pc_artifact_search_states WHEN new.state='ready' BEGIN SELECT RAISE(ABORT, 'release failure'); END" - ) - with pytest.raises(Exception, match="release failure"): - await builder.build("scope", "reports") - async with db.transaction() as connection: - assert ( - await connection.scalar( - select(STATES.c.state).where(STATES.c.scope_id == "scope", STATES.c.family == "reports") - ) - == "building" - ) - await connection.exec_driver_sql("DROP TRIGGER fail_release") - with pytest.raises(ArtifactSearchUnavailable): - await search.search("scope", "reports", {"query": "original"}) - assert await builder.build("scope", "reports") - assert not (await search.search("scope", "reports", {"query": "original"})).matches - assert len((await search.search("scope", "reports", {"query": "new-index"})).matches) == 2 - - asyncio.run(run()) - - -def test_text_vectors_use_declared_encoder_and_unready_profile_never_falls_back(): - class Encoder: - profile = PROFILE - - async def embed(self, texts): - return EmbeddingResult(vectors=tuple((float(len(text)), 1.0) for text in texts)) - - async def run(): - model = Encoder() - async with store(model=model) as (db, builder, publisher): - search_config = ArtifactSearchDeclaration( - version="1", - indexes=( - VectorIndex( - name="semantic", projection_version="1", source="text", profile=PROFILE, fields=("text",) - ), - ), - modes=("vector",), - default_mode="vector", - ) - await register(db, "reports", search_config) - await write(db, publisher, "scope", "reports", "a", {"text": "数据库"}) - search = service(db, "reports", model) - with pytest.raises(ArtifactSearchUnavailable): - await search.search("scope", "reports", {"query": "数据库"}) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - assert (await search.search("scope", "reports", {"query": "数据库"})).artifacts[0].artifact_id == "a" - with pytest.raises(ArtifactSearchUnavailable): - await service(db, "reports").search("scope", "reports", {"query": "数据库"}) - assert ( - await service(db, "reports").search( - "scope", "reports", {"query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [3, 1]}} - ) - ).matches - - asyncio.run(run()) - - -def test_query_errors_are_unsupported_and_declared_indexes_are_unavailable_during_repair(): - async def run(): - async with store() as (db, builder, publisher): - await register(db, "reports", declaration()) - await write(db, publisher, "scope", "reports", "a", body("数据库", [0, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports") - vector = {"profile": PROFILE.model_dump(mode="json"), "vector": [0, 0]} - invalid = ( - {"query": "数据库", "mode": "text", "indexes": {"vector": ["visual"]}}, - {"query": "数据库", "indexes": {"text": []}}, - { - "query": "数据库", - "mode": "hybrid", - "query_vector": vector, - "fusion": {"method": "rrf", "params": {"weights": {"text": 0, "vector": 0}}}, - }, - {"mode": "vector", "query_vector": {**vector, "vector": [0]}}, - {"mode": "vector", "query": "数据库", "query_vector": vector}, - ) - for request in invalid: - with pytest.raises(ArtifactSearchUnsupported): - await search.search("scope", "reports", request) - await builder.refresh("scope", "reports", rebuild=True) - with pytest.raises(ArtifactSearchUnavailable): - await search.search("scope", "reports", {"query": "数据库", "indexes": {"text": ["lexical"]}}) - with pytest.raises(ArtifactSearchUnavailable): - await search.search("scope", "reports", {"query": "数据库"}) - - asyncio.run(run()) - - -def test_governance_removal_restore_and_rebuild_preserve_exact_history(): - async def run(): - async with store() as (db, builder, publisher): - await register(db, "reports", declaration()) - original = await write(db, publisher, "scope", "reports", "a", body("数据库", [0, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports") - async with db.transaction() as connection: - state = await ArtifactGovernanceRepository().transition( - connection, "scope", "reports", "a", 0, ArtifactLifecycleState.DEPRECATED, None - ) - assert not (await search.search("scope", "reports", {"query": "数据库"})).matches - # Restore requires all serving projections to be prepared outside its transaction. - with pytest.raises(ValueError, match="prepared before"): - async with db.transaction() as connection: - await ArtifactGovernanceRepository().transition( - connection, - "scope", - "reports", - "a", - state.governance_generation, - ArtifactLifecycleState.ACTIVE, - None, - ) - prepared = await publisher.prepare("scope", "reports", "1", original.content.content) - async with db.transaction() as connection: - governance = ArtifactGovernanceRepository(prepared_indexes={("scope", "reports", "a"): prepared}) - state = await governance.transition( - connection, - "scope", - "reports", - "a", - state.governance_generation, - ArtifactLifecycleState.ACTIVE, - None, - ) - assert (await search.search("scope", "reports", {"query": "数据库"})).artifacts[0].revision == 1 - await builder.refresh("scope", "reports", rebuild=True) - async with db.transaction() as connection: - await ArtifactGovernanceRepository().transition( - connection, - "scope", - "reports", - "a", - state.governance_generation, - ArtifactLifecycleState.RETIRED, - None, - ) - assert await builder.build("scope", "reports") - assert not (await search.search("scope", "reports", {"query": "数据库"})).matches - async with db.transaction() as connection: - assert ( - await custom_artifact_repository("reports").get(connection, "scope", original.as_ref()) - ).content == original.content - - asyncio.run(run()) - - -def test_incompatible_content_vector_write_preserves_search_and_history(): - async def run(): - async with store() as (db, builder, publisher): - await register(db, "reports", declaration()) - first = await write(db, publisher, "scope", "reports", "a", body("数据库", [1, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - next_profile = PROFILE.model_copy(update={"profile_id": "features-v2", "model": "second"}) - with pytest.raises(ValueError, match="profile differs"): - await write(db, publisher, "scope", "reports", "a", body("数据库", [0, 1], profile=next_profile)) - search = service(db, "reports") - request = { - "mode": "vector", - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}, - } - assert (await search.search("scope", "reports", request)).artifacts[0].revision == 1 - async with db.transaction() as connection: - assert ( - await custom_artifact_repository("reports").get(connection, "scope", first.as_ref()) - ).content == first.content - - asyncio.run(run()) - - -def test_initial_content_vector_validation_rejects_bad_record_without_requiring_index_resources(): - async def run(): - async with store() as (db, _, publisher): - base = declaration() - search_config = ArtifactSearchDeclaration( - version="1", - indexes=( - *base.indexes, - VectorIndex( - name="semantic", fields=("text",), source="text", profile=PROFILE, projection_version="1" - ), - ), - modes=base.modes, - default_mode="text", - defaults={ - "text": {"text": ("lexical",)}, - "vector": {"vector": ("visual",)}, - "hybrid": {"text": ("lexical",), "vector": ("visual",)}, - }, - ) - await register(db, "reports", search_config) - wrong = PROFILE.model_copy(update={"profile_id": "features-v2", "model": "second"}) - with pytest.raises(ValueError, match="profile differs"): - await write(db, publisher, "scope", "reports", "a", body("invalid", [1, 0], profile=wrong)) - async with db.transaction() as connection: - with pytest.raises(RepositoryNotFoundError): - await custom_artifact_repository("reports").latest(connection, "scope", "reports", "a") - - # The family is still unready: neither the text encoder nor physical vector spaces are needed for this write. - stored = await write(db, publisher, "scope", "reports", "a", body("valid", [1, 0])) - assert stored.revision == 1 - - asyncio.run(run()) - - -def test_repair_content_vector_validation_rejects_bad_revision_across_ready_transition(): - async def run(): - async with store() as (db, builder, publisher): - await register(db, "reports", declaration()) - original = await write(db, publisher, "scope", "reports", "a", body("original", [1, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - await builder.refresh("scope", "reports", rebuild=True) - - with pytest.raises(ValueError, match="dimension"): - await write(db, publisher, "scope", "reports", "a", body("invalid dimension", [1])) - async with db.transaction() as connection: - latest = await custom_artifact_repository("reports").latest(connection, "scope", "reports", "a") - assert latest.revision == original.revision - assert latest.content == original.content - - duplicate = body("duplicate fragments", [1, 0], [0, 1]) - duplicate["fragments"][1]["id"] = duplicate["fragments"][0]["id"] - with pytest.raises(ValueError, match="identities must be unique"): - await write(db, publisher, "scope", "reports", "a", duplicate) - - changed = body("changed while repairing", [0, 1]) - prepared = await publisher.prepare("scope", "reports", "1", changed) - assert await builder.build("scope", "reports") - repository = custom_artifact_repository("reports", prepared={"a": prepared}) - draft = RepositoryArtifactDraft( - family="reports", content=StoredArtifactContent(schema_version="1", content=changed) - ) - with pytest.raises(ValueError): - async with db.transaction() as connection: - await repository.revise(connection, "scope", original, draft) - async with db.transaction() as connection: - latest = await repository.latest(connection, "scope", "reports", "a") - assert latest.revision == original.revision - assert latest.content == original.content - - asyncio.run(run()) - - -def test_vector_profile_is_validated_against_requested_index(): - async def run(): - async with store() as (db, builder, _): - alternate = PROFILE.model_copy(update={"profile_id": "alternate"}) - initial = declaration() - declared = ArtifactSearchDeclaration( - version="1", - indexes=( - *initial.indexes, - initial.indexes[1].model_copy(update={"name": "alternate", "profile": alternate}), - ), - modes=initial.modes, - default_mode="text", - defaults={"vector": {"vector": ("visual",)}, "hybrid": {"text": ("lexical",), "vector": ("visual",)}}, - ) - await register(db, "reports", declared) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports") - for selected, profile in (("visual", PROFILE), ("alternate", alternate)): - request = { - "mode": "vector", - "indexes": {"vector": [selected]}, - "query_vector": {"profile": profile.model_dump(mode="json"), "vector": [0, 0]}, - } - assert not (await search.search("scope", "reports", request)).matches - other_profile = alternate if selected == "visual" else PROFILE - request["query_vector"]["profile"] = other_profile.model_dump(mode="json") - with pytest.raises(ArtifactSearchUnsupported): - await search.search("scope", "reports", request) - - asyncio.run(run()) - - -def test_named_indexes_share_build_and_space_without_cross_matching(): - async def run(): - async with store() as (db, builder, publisher): - search_config = ArtifactSearchDeclaration( - version="1", - indexes=( - TextIndex(name="title", fields=("title",), projection_version="1"), - TextIndex(name="body", fields=("text",), projection_version="1"), - VectorIndex( - name="near", - source="content", - vector_field="v1", - profile_field="profile", - profile=PROFILE, - projection_version="1", - ), - VectorIndex( - name="far", - source="content", - vector_field="v2", - profile_field="profile", - profile=PROFILE, - projection_version="1", - ), - ), - modes=("text", "vector"), - default_mode="text", - defaults={"text": {"text": ("title",)}, "vector": {"vector": ("near",)}}, - ) - await register(db, "reports", search_config) - await write( - db, - publisher, - "scope", - "reports", - "a", - { - "title": "标题", - "text": "正文", - "v1": [0, 0], - "v2": [10, 0], - "profile": PROFILE.model_dump(mode="json"), - }, - ) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports") - for name, query, expected in (("title", "正文", False), ("body", "正文", True)): - assert ( - bool( - (await search.search("scope", "reports", {"query": query, "indexes": {"text": [name]}})).matches - ) - is expected - ) - for name, distance in (("near", 0), ("far", 10)): - found = await search.search( - "scope", - "reports", - { - "mode": "vector", - "indexes": {"vector": [name]}, - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [0, 0]}, - "include_scores": True, - }, - ) - assert found.matches[0].channel_scores["vector"].raw == distance - - asyncio.run(run()) - - -def test_inflight_content_preparation_obeys_current_build_state(): - async def run(): - async with store() as (db, builder, publisher): - await register(db, "reports", declaration()) - original = await write(db, publisher, "scope", "reports", "a", body("before", [0, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - content = body("recovered", [1, 0]) - prepared = await publisher.prepare("scope", "reports", "1", content) - await builder.refresh("scope", "reports", rebuild=True) - # Preparation started when ready; commit now uses only the change log. - repository = custom_artifact_repository("reports", prepared={"a": prepared}) - draft = RepositoryArtifactDraft( - family="reports", content=StoredArtifactContent(schema_version="1", content=content) - ) - async with db.transaction() as connection: - revised = await repository.revise( - connection, "scope", await repository.get(connection, "scope", original.as_ref()), draft - ) - pending = await publisher.prepare("scope", "reports", "1", body("too-late", [2, 0])) - assert await builder.build("scope", "reports") - repository = custom_artifact_repository("reports", prepared={"a": pending}) - with pytest.raises(ValueError, match="prepare again"): - async with db.transaction() as connection: - await repository.revise( - connection, - "scope", - revised, - RepositoryArtifactDraft( - family="reports", - content=StoredArtifactContent(schema_version="1", content=body("too-late", [2, 0])), - ), - ) - assert (await service(db, "reports").search("scope", "reports", {"query": "recovered"})).artifacts[ - 0 - ].revision == 2 - - asyncio.run(run()) - - -def test_superseded_build_cannot_write_clean_or_publish_and_cleanup_resumes(): - async def run(): - from powercontext.builtin.persistence.artifact_indexes import COVERAGE, TEXTS, entries - - async with store() as (db, builder, publisher): - await register(db, "reports", declaration()) - for scope, family in (("scope", "reports"), ("other-scope", "reports"), ("scope", "other-family")): - if family != "reports": - await register(db, family, declaration()) - await write(db, publisher, scope, family, "a", body("保留", [1, 0])) - await builder.refresh(scope, family) - assert await builder.build(scope, family) - await builder.refresh("scope", "reports", rebuild=True) - async with db.transaction() as connection: - old = ( - ( - await connection.execute( - select(STATES).where(STATES.c.scope_id == "scope", STATES.c.family == "reports") - ) - ) - .mappings() - .one() - ) - assert not await builder.clean("scope", "reports", old["build_id"], limit=1) - # Restart repair with only part of the old rows removed. - await builder.refresh("scope", "reports", rebuild=True) - assert not await builder.clean("scope", "reports", old["build_id"]) - definition = entries(old["manifest"])[0][1]["index_def_id"] - assert not await builder._project_current("scope", "reports", old["build_id"], definition, "a") - assert not await builder._validate_and_publish("scope", "reports", old["build_id"]) - restarted = ArtifactIndexBuilder(db) - assert await restarted.build("scope", "reports") - for scope, family in (("scope", "reports"), ("other-scope", "reports"), ("scope", "other-family")): - assert (await service(db, family).search(scope, family, {"query": "保留"})).matches - async with db.transaction() as connection: - current = ( - ( - await connection.execute( - select(STATES).where(STATES.c.scope_id == "scope", STATES.c.family == "reports") - ) - ) - .mappings() - .one() - ) - assert current["cleanup_targets"] == [] - for table in (TEXTS, COVERAGE): - assert not ( - await connection.execute( - select(table).where( - table.c.scope_id == "scope", - table.c.family == "reports", - table.c.build_id != current["build_id"], - ) - ) - ).all() - # A crashed final validator must not leave the build permanently occupied. - await restarted.refresh("scope", "reports", rebuild=True) - async with db.transaction() as connection: - await connection.execute( - update(STATES) - .where(STATES.c.scope_id == "scope", STATES.c.family == "reports") - .values(validation_token="interrupted") # noqa: S106 - Persisted build fencing token, not a credential. - ) - assert await ArtifactIndexBuilder(db).build("scope", "reports") - - asyncio.run(run()) - - -def test_repair_restart_rejects_an_inflight_encoder_and_keeps_new_build_unavailable(): - async def run(): - entered, release = asyncio.Event(), asyncio.Event() - - class Encoder: - profile = PROFILE - - async def embed(self, texts): - entered.set() - await release.wait() - return EmbeddingResult(vectors=tuple((1.0, 0.0) for _ in texts)) - - async with store(model=Encoder()) as (db, builder, publisher): - config = ArtifactSearchDeclaration( - version="1", - indexes=( - VectorIndex( - name="semantic", fields=("text",), source="text", profile=PROFILE, projection_version="1" - ), - ), - modes=("vector",), - default_mode="vector", - ) - await register(db, "reports", config) - await write(db, publisher, "scope", "reports", "a", {"text": "数据库"}) - await builder.refresh("scope", "reports") - old_task = asyncio.create_task(builder.build("scope", "reports")) - try: - await asyncio.wait_for(entered.wait(), 5) - await builder.refresh("scope", "reports", rebuild=True) - release.set() - assert not await old_task - request = {"query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}} - with pytest.raises(ArtifactSearchUnavailable): - await service(db, "reports").search("scope", "reports", request) - assert await builder.build("scope", "reports") - assert (await service(db, "reports").search("scope", "reports", request)).matches - finally: - release.set() - await old_task - - asyncio.run(run()) - - -@pytest.mark.parametrize("during_build", [False, True]) -def test_query_policy_update_preserves_build_and_does_not_reencode(during_build): - async def run(): - entered, release = asyncio.Event(), asyncio.Event() - - class Encoder: - profile = PROFILE - encoded = False - - async def embed(self, texts): - assert not self.encoded, "query policy must not re-encode stored content" - self.encoded = True - entered.set() - await release.wait() - return EmbeddingResult(vectors=tuple((1.0, 0.0) for _ in texts)) - - async with store(model=Encoder()) as (db, builder, publisher): - initial = ArtifactSearchDeclaration( - version="1", - indexes=( - TextIndex(name="title", fields=("title",), projection_version="1"), - TextIndex(name="body", fields=("text",), projection_version="1"), - VectorIndex( - name="semantic", fields=("text",), source="text", profile=PROFILE, projection_version="1" - ), - ), - modes=("text", "vector"), - default_mode="text", - defaults={"text": {"text": ("title",)}, "vector": {"vector": ("semantic",)}}, - ) - await register(db, "reports", initial) - await write(db, publisher, "scope", "reports", "a", {"title": "标题", "text": "正文"}) - await builder.refresh("scope", "reports") - build = asyncio.create_task(builder.build("scope", "reports")) - try: - async with asyncio.timeout(5): - await entered.wait() - if not during_build: - release.set() - assert await build - async with db.transaction() as connection: - before = dict((await connection.execute(select(STATES))).mappings().one()) - changed = initial.model_copy( - update={ - "default_mode": "vector", - "defaults": {"text": {"text": ("body",)}, "vector": {"vector": ("semantic",)}}, - "candidate_limit": 17, - "rrf_k": 11, - "text_weight": 2, - "vector_weight": 3, - } - ) - await register(db, "reports", changed) - await builder.refresh("scope", "reports") - async with db.transaction() as connection: - after = dict((await connection.execute(select(STATES))).mappings().one()) - assert after == before # The build identity, progress and readiness must survive policy changes. - release.set() - assert await build - assert await builder.build("scope", "reports") - search = service(db, "reports") - result = await search.search( - "scope", - "reports", - { - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}, - }, - ) - assert [item.artifact_id for item in result.artifacts] == ["a"] - assert (await search.search("scope", "reports", {"mode": "text", "query": "正文"})).artifacts - assert not (await search.search("scope", "reports", {"mode": "text", "query": "标题"})).artifacts - finally: - release.set() - await build - - asyncio.run(run()) - - -def test_inflight_query_keeps_policy_while_next_query_uses_update(): - async def run(): - entered, release = asyncio.Event(), asyncio.Event() - - class QueryEncoder: - profile = PROFILE - - async def embed(self, texts): - entered.set() - await release.wait() - return EmbeddingResult(vectors=((1.0, 0.0),)) - - async with store() as (db, builder, publisher): - initial = declaration().model_copy(update={"default_mode": "vector"}) - await register(db, "reports", initial) - await write(db, publisher, "scope", "reports", "a", body("数据库", [1, 0])) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports", QueryEncoder()) - request = {"query": "no lexical match"} - pending = asyncio.create_task(search.search("scope", "reports", request)) - try: - async with asyncio.timeout(5): - await entered.wait() - await register(db, "reports", initial.model_copy(update={"modes": ("text",), "default_mode": "text"})) - release.set() - assert [item.artifact_id for item in (await pending).artifacts] == ["a"] - assert not (await search.search("scope", "reports", request)).artifacts - finally: - release.set() - await pending - - asyncio.run(run()) diff --git a/tests/builtin/persistence/test_artifact_search_schema.py b/tests/builtin/persistence/test_artifact_search_schema.py deleted file mode 100644 index 65d5becf28..0000000000 --- a/tests/builtin/persistence/test_artifact_search_schema.py +++ /dev/null @@ -1,130 +0,0 @@ -# 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. - -"""Artifact FTS initialization preserves the caller's transaction during DDL contention.""" - -import asyncio -from typing import cast -from unittest.mock import AsyncMock - -import pytest -import sqlalchemy -from pymysql.err import OperationalError as MySQLOperationalError -from sqlalchemy.dialects import mysql -from sqlalchemy.exc import OperationalError -from sqlalchemy.ext.asyncio import AsyncConnection - -from powercontext.builtin.persistence import schema -from powercontext.builtin.persistence.tables import ARTIFACT_SEARCH_TABLES, ARTIFACT_SEARCH_TEXT_TABLE - -_DDL = "CREATE FULLTEXT INDEX ix_pc_artifact_search_fts ON pc_artifact_search_text (searchable_text) WITH PARSER SPACE" - - -class _SearchSchema: - dialect = mysql.dialect() - - def __init__(self, failures, *, concurrent_index=False): - self.failures = list(failures) - self.concurrent_index = concurrent_index - self.index_exists = False - self.statements = [] - - def get_table_names(self): - return [table.name for table in ARTIFACT_SEARCH_TABLES] - - def has_table(self, name, **_kwargs): - return name in self.get_table_names() - - def get_columns(self, name): - return [{"name": column.name} for table in ARTIFACT_SEARCH_TABLES if table.name == name for column in table.c] - - def get_indexes(self, _name): - return [{"name": "ix_pc_artifact_search_fts"}] if self.index_exists else [] - - def exec_driver_sql(self, statement): - self.statements.append(statement) - assert statement == _DDL - if self.failures: - if self.concurrent_index: - self.index_exists = True - raise self.failures.pop(0) - self.index_exists = True - - -def _connection(monkeypatch, backend): - monkeypatch.setattr(schema, "inspect", lambda _connection: backend) - monkeypatch.setattr(sqlalchemy, "inspect", lambda _connection: backend) - connection = AsyncMock(spec=AsyncConnection) - connection.dialect = backend.dialect - - async def run_sync(callback): - return callback(backend) - - connection.run_sync.side_effect = run_sync - return connection - - -@pytest.mark.parametrize("concurrent_index", [False, True]) -def test_artifact_fts_recovers_from_a_transient_ddl_lock_without_ending_the_transaction(monkeypatch, concurrent_index): - async def scenario(): - error = OperationalError(_DDL, None, MySQLOperationalError(1205, "DDL lock conflict")) - backend = _SearchSchema([error], concurrent_index=concurrent_index) - connection = _connection(monkeypatch, backend) - await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) - assert backend.index_exists - assert backend.statements == ([_DDL] if concurrent_index else [_DDL, _DDL]) - connection.commit.assert_not_awaited() - connection.rollback.assert_not_awaited() - - asyncio.run(scenario()) - - -def test_artifact_fts_bounds_persistent_lock_retries(monkeypatch): - async def scenario(): - error = OperationalError(_DDL, None, MySQLOperationalError(1205, "DDL lock conflict")) - backend = _SearchSchema([error] * 20) - connection = _connection(monkeypatch, backend) - with pytest.raises(OperationalError) as raised: - async with asyncio.timeout(5): - await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) - assert raised.value is error - assert 1 <= len(backend.statements) <= 6 - connection.commit.assert_not_awaited() - connection.rollback.assert_not_awaited() - - asyncio.run(scenario()) - - -@pytest.mark.parametrize("failure", [1142, 1213, "cancelled", "other-statement"]) -def test_artifact_fts_preserves_other_errors_and_cancellation(monkeypatch, failure): - async def scenario(): - error = ( - asyncio.CancelledError() - if failure == "cancelled" - else OperationalError( - "SHOW FULL COLUMNS FROM pc_artifact_search_text" if failure == "other-statement" else _DDL, - None, - MySQLOperationalError(1205 if failure == "other-statement" else failure, "DDL failed"), - ) - ) - backend = _SearchSchema([error]) - connection = _connection(monkeypatch, backend) - with pytest.raises(type(error)) as raised: - await schema.create_tables(cast(AsyncConnection, connection), (ARTIFACT_SEARCH_TEXT_TABLE,)) - assert raised.value is error - assert not backend.index_exists - connection.commit.assert_not_awaited() - connection.rollback.assert_not_awaited() - - asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_artifact_storage_lifecycle.py b/tests/builtin/persistence/test_artifact_storage_lifecycle.py deleted file mode 100644 index 563527b7cc..0000000000 --- a/tests/builtin/persistence/test_artifact_storage_lifecycle.py +++ /dev/null @@ -1,362 +0,0 @@ -# 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. - -"""Fixed revisions preserve Artifact declarations and online native spaces.""" - -import asyncio -import json -import shutil - -import pytest -from sqlalchemy import insert, select, update -from sqlalchemy.schema import CreateTable - -from powercontext.builtin.persistence.artifact_indexes import ( - SCHEMAS, - STATES, - ArtifactIndexBuilder, - ArtifactProjectionPublisher, - register_definition, -) -from powercontext.builtin.persistence.artifact_vector_space import ( - native_vector_table, - prepare_family_spaces, - verify_native_space, -) -from powercontext.builtin.persistence.migrations.bundle import MigrationBundle -from powercontext.builtin.persistence.migrations.models import MigrationError -from powercontext.builtin.persistence.migrations.mysql import MySQLMigrationRunner -from powercontext.builtin.persistence.migrations.mysql_schema import _parse_table, mysql_inventory -from powercontext.builtin.persistence.migrations.server_readiness import check_server_schema -from powercontext.builtin.persistence.tables import ARTIFACT_VECTOR_INDEXES_TABLE as DEFINITIONS -from powercontext.builtin.persistence.tables import ARTIFACT_VECTOR_SPACES_TABLE as SPACES -from tests.builtin.persistence.test_artifact_indexes import PROFILE, body, declaration, service, write -from tests.builtin.persistence.test_mysql_migration_runner import ( - _apply, - _full_server_runner, - _interrupt_after_ddl, - backend, # noqa: F401 - Real disposable backend fixture. -) -from tests.e2e.atomic_memory_migration_backend import migration_profile - - -def test_fixed_artifact_storage_resumes_after_registration_columns(backend): # noqa: F811 - runner = _full_server_runner(backend) - _interrupt_after_ddl( - runner, - "ALTER TABLE pc_artifact_processing_binding_states ADD COLUMN plugin_id", - policy="auto", - ) - (journal,) = backend.evidence_directory.rglob("maintenance.json") - interrupted = json.loads(journal.read_text()) - assert interrupted["last_revision"] == "v1_4_1_006" - assert interrupted["pending_revision"] == "v1_5_0_001" - recovery = _full_server_runner(backend).plan() - assert recovery.state == "recovery_required" and recovery.source_revision == "v1_4_1_006" - # The DDL returned and its owned connection closed before the injected failure. - resumed = _full_server_runner(backend, acknowledge_previous_run=(recovery.coordination or {}).get("pending_run_id")) - result = _apply(resumed) - assert result.state == "ready" and result.revision == resumed.bundle.head - assert resumed.verify().state == "ready" - completed = json.loads(journal.read_text()) - assert completed["state"] == "complete" - assert completed["maintenance_window_id"] == interrupted["maintenance_window_id"] - assert completed["source_fingerprint"] == interrupted["source_fingerprint"] - - -def _next_fixed_revision(bundle, directory): - """A subsequent fixed-table addition leaves the space format untouched.""" - shutil.copytree(bundle.directory, directory) - revision = "v1_5_0_002" - sql = "CREATE TABLE pc_lifecycle_probe (id INTEGER NOT NULL, PRIMARY KEY (id))" - name, layout = _parse_table(sql) - resource = "schemas/mysql-lifecycle-probe.json" - (directory / resource).write_text( - json.dumps({ - "format": "powercontext.mysql-state-layouts.v1", - "revisions": {revision: {"statements": [sql], "changes": [{name: layout}]}}, - }) - ) - (directory / "versions/v1_5_0_002_probe.py").write_text( - f"revision = '{revision}'\ndown_revision = '{bundle.head}'\ndef upgrade():\n pass\n" - ) - (directory / "mysql/versions/v1_5_0_002.py").write_text( - "from alembic import context\n" - f"revision = '{revision}'\ndown_revision = '{bundle.head}'\n" - "def upgrade():\n context.config.attributes['execute_revision'](revision)\n" - ) - path = directory / "manifest.json" - manifest = json.loads(path.read_text()) - manifest["revision_resources"][revision] = [resource] - manifest["mysql_state_layouts"].append(resource) - manifest["sqlite_fingerprints"][revision] = manifest["sqlite_fingerprints"][bundle.head] - manifest["domain_readiness"][revision] = manifest["domain_readiness"][bundle.head] - manifest["affected_objects"].append(name) - path.write_text(json.dumps(manifest)) - return MigrationBundle(directory) - - -def test_native_space_online_recovery_and_fixed_upgrade(backend, tmp_path): # noqa: F811 - runner = _full_server_runner(backend) - _apply(runner) - search = declaration() - vector = search.indexes[1] - - async def exercise(): - async with migration_profile(backend.config) as profile: - db = profile.database - async with db.transaction() as connection: - await connection.execute( - insert(SCHEMAS).values( - family="lifecycle", - schema_version="1", - declaration={ - "family": "lifecycle", - "schema_version": "1", - "search": search.model_dump(mode="json"), - }, - ) - ) - await register_definition(connection, "lifecycle", "1", vector) - async with db.transaction() as connection: - space = dict( - ( - await connection.execute( - select(SPACES).where( - SPACES.c.space_id.in_( - select(DEFINITIONS.c.space_id).where(DEFINITIONS.c.family == "lifecycle") - ) - ) - ) - ) - .mappings() - .one() - ) - assert space["provision_state"] == "declared" - await connection.run_sync(lambda c: check_server_schema(c, _startup=True)) - with pytest.raises(MigrationError): - await asyncio.to_thread(runner.plan, backup_policy="manual") - # Interrupt after CREATE TABLE, before the standalone index or ready. - async with db.transaction() as connection: - await connection.run_sync(lambda c: c.execute(CreateTable(native_vector_table(space)))) - await asyncio.gather(prepare_family_spaces(db, "lifecycle"), prepare_family_spaces(db, "lifecycle")) - async with db.transaction() as connection: - ready = dict( - ( - await connection.execute( - select(SPACES).where( - SPACES.c.space_id.in_( - select(DEFINITIONS.c.space_id).where(DEFINITIONS.c.family == "lifecycle") - ) - ) - ) - ) - .mappings() - .one() - ) - assert ready["space_id"] == space["space_id"] and ready["provision_state"] == "ready" - assert (await connection.execute(select(STATES))).first() is None - await connection.run_sync(lambda c: verify_native_space(c, ready)) - await register_definition(connection, "lifecycle", "1", vector) - # A table-created-before-ready interruption also resumes without DDL. - async with db.transaction() as connection: - await connection.execute( - update(SPACES).where(SPACES.c.space_id == ready["space_id"]).values(provision_state="declared") - ) - await prepare_family_spaces(db, "lifecycle") - publisher = ArtifactProjectionPublisher(db) - await write(db, publisher, "scope", "lifecycle", "preserved", body("retained", [1, 0])) - builder = ArtifactIndexBuilder(db) - await builder.refresh("scope", "lifecycle") - assert await builder.build("scope", "lifecycle") - found = await service(db, "lifecycle").search( - "scope", - "lifecycle", - { - "mode": "vector", - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}, - }, - ) - assert [item.artifact_id for item in found.artifacts] == ["preserved"] - return ready - - async def server_registration(): - import httpx - from pydantic import SecretStr - - from powercontext.builtin.runtime.config import RuntimeConfig - from powercontext.server.factory import create_server_app - from powercontext.server.settings import ( - BearerAuthConfig, - DashboardConfig, - McpConfig, - MetricsConfig, - ServerSettings, - ) - from tests.e2e.test_artifact_plugins import ADMIN_AUTHORIZATION_VALUE, EXAMPLE, deploy_plugin, reload_plugin - from tests.e2e.test_artifact_vector_http import _DECLARATION - - settings = ServerSettings( - database=backend.config, - workspace=tmp_path, - artifact_plugins_dir=tmp_path / "plugins", - runtime=RuntimeConfig(artifact_processing_families=()), - auth=BearerAuthConfig(enabled=True, token=SecretStr(ADMIN_AUTHORIZATION_VALUE)), - dashboard=DashboardConfig(enabled=False), - mcp=McpConfig(enabled=False), - metrics=MetricsConfig(enabled=False), - ) - app = create_server_app(settings=settings) - async with ( - app.router.lifespan_context(app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - headers={"Authorization": f"Bearer {ADMIN_AUTHORIZATION_VALUE}"}, - ) as client, - migration_profile(backend.config) as profile, - ): - plugin = deploy_plugin(EXAMPLE, tmp_path / "http-plugin", None) - entry = plugin / "plugin.py" - entry.write_text(entry.read_text() + _DECLARATION.replace("features-v1", "online-http-profile")) - await reload_plugin(client, tmp_path, plugin, 0) - async with asyncio.timeout(30): - while True: - async with profile.database.transaction() as connection: - spaces = (await connection.execute(select(SPACES))).mappings().all() - if any( - row["profile_json"]["profile_id"] == "online-http-profile" - and row["provision_state"] == "ready" - for row in spaces - ): - assert ( - await connection.execute(select(STATES).where(STATES.c.family == "incident-report")) - ).first() is None - break - await asyncio.sleep(0.1) - - asyncio.run(server_registration()) - ready = asyncio.run(exercise()) - next_bundle = _next_fixed_revision(runner.bundle, tmp_path / "next") - upgrade = MySQLMigrationRunner( - backend.connections(), - next_bundle, - evidence_directory=backend.evidence_directory, - configuration=runner.configuration, - ) - plan = upgrade.plan(backup_policy="manual") - assert ready["physical_table"] in plan.affected_objects - - async def concurrent_registration(): - async with migration_profile(backend.config) as profile: - db = profile.database - other_search = declaration(profile=PROFILE.model_copy(update={"profile_id": "later-profile"})) - - async def register(family): - async with db.transaction() as connection: - await connection.execute( - insert(SCHEMAS).values( - family=family, - schema_version="1", - declaration={ - "family": family, - "schema_version": "1", - "search": other_search.model_dump(mode="json"), - }, - ) - ) - await register_definition(connection, family, "1", other_search.indexes[1]) - - await asyncio.gather(register("other-a"), register("other-b")) - await asyncio.gather(prepare_family_spaces(db, "other-a"), prepare_family_spaces(db, "other-b")) - async with db.transaction() as connection: - assert len((await connection.execute(select(SPACES))).all()) == 3 - - asyncio.run(concurrent_registration()) - with pytest.raises(MigrationError, match="plan_changed"): - upgrade.apply( - plan_id=plan.plan_id, - accepted=True, - maintenance_confirmed=True, - backup_policy="manual", - backup_confirmed=True, - ) - before = backend.connections().run(lambda c, _i, _g: mysql_inventory(c)) - result = _apply(upgrade, policy="manual") - assert result.changed and result.backup_state == "user_confirmed" - after = backend.connections().run(lambda c, _i, _g: mysql_inventory(c)) - assert before["artifact_spaces"] == after["artifact_spaces"] - assert before["tables"][ready["physical_table"]] == after["tables"][ready["physical_table"]] - - # Ready damage is rejected, never recreated by online preparation. - async def damage(): - async with migration_profile(backend.config) as profile: - found = await service(profile.database, "lifecycle").search( - "scope", - "lifecycle", - { - "mode": "vector", - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}, - }, - ) - assert [item.artifact_id for item in found.artifacts] == ["preserved"] - async with profile.database.transaction() as connection: - await connection.exec_driver_sql(f"DROP TABLE `{ready['physical_table']}`") - with pytest.raises(ValueError, match="absent"): - await prepare_family_spaces(profile.database, "lifecycle") - - asyncio.run(damage()) - with pytest.raises(MigrationError): - upgrade.plan(backup_policy="manual") - - -def test_managed_readiness_requires_durable_plugin_binding(tmp_path): - from sqlalchemy import create_engine, text - - from tests.test_managed_maintenance_evidence import _managed - - path, _settings, _runner = _managed(tmp_path, None) - engine = create_engine(f"sqlite:///{path}") - try: - with engine.begin() as connection: - connection.execute( - insert(SCHEMAS).values( - family="lifecycle", - schema_version="1", - declaration={"family": "lifecycle", "schema_version": "1"}, - ) - ) - connection.exec_driver_sql( - "INSERT INTO pc_artifact_processing_binding_states " - "(binding_name,plugin_id,plugin_family,desired_state,phase) " - "VALUES ('custom-handler','plugin','lifecycle','unloaded','unloaded')" - ) - connection.exec_driver_sql( - "INSERT INTO pc_artifact_processing_intents " - "(binding_name,scope_id,pending_sequence,dirty_generation,clean_generation,requested_generation," - "handled_generation,last_auto_scan_generation) VALUES ('custom-handler','scope',1,0,0,1,0,0)" - ) - with engine.connect() as connection: - check_server_schema(connection, _startup=True) - with engine.begin() as connection: - connection.execute( - text( - "UPDATE pc_artifact_processing_binding_states SET plugin_family='undeclared' " - "WHERE binding_name='custom-handler'" - ) - ) - with engine.connect() as connection, pytest.raises(MigrationError, match="unsupported_task_format"): - check_server_schema(connection, _startup=True) - finally: - engine.dispose() diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 7b05f1ddaf..a60c79869c 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -43,7 +43,7 @@ def test_managed_sqlite_vector_schema_includes_registered_nullable_description(t database = tmp_path / "vector.db" runner = SQLiteMigrationRunner(database, bundle, configuration=configuration) result = runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) - assert result.revision == "v1_5_0_004" + assert result.revision == "v1_5_0_005" with closing(sqlite3.connect(database)) as connection: columns = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)")} assert columns["description"][2:4] == ("VARCHAR(200)", 0) @@ -156,11 +156,24 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) plan = upgraded.plan() assert plan.source_revision == "v1_4_1_004" - assert plan.target_revision == "v1_5_0_004" - assert plan.revisions == ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") - assert plan.domain_steps == ("artifact_storage", "atomic_memory_evidence", "atomic_memory_evidence_projection") + assert plan.target_revision == "v1_5_0_005" + assert plan.revisions == ( + "v1_4_1_005", + "v1_4_1_006", + "v1_5_0_001", + "v1_5_0_002", + "v1_5_0_003", + "v1_5_0_004", + "v1_5_0_005", + ) + assert plan.domain_steps == ( + "artifact_storage", + "atomic_memory_evidence", + "atomic_memory_evidence_projection", + "plugin_artifacts", + ) assert upgraded.evidence_path.read_bytes() == original_evidence_bytes - assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_004" + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_005" assert upgraded.verify().state == "ready" evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) for revision, checksum in original_evidence["release_checksums"].items(): diff --git a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py index 0d9f2bc9a6..34a15a0b03 100644 --- a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py +++ b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py @@ -167,11 +167,13 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, EVIDENCE_REVISION, PROJECTION_REVISION, "v1_5_0_004", + "v1_5_0_005", ) assert plan.domain_steps == ( "artifact_storage", "atomic_memory_evidence", "atomic_memory_evidence_projection", + "plugin_artifacts", ) assert upgraded.evidence_path.read_bytes() == old_evidence_bytes result = upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) @@ -194,6 +196,7 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, EVIDENCE_REVISION, PROJECTION_REVISION, "v1_5_0_004", + "v1_5_0_005", } ready = reopened.plan() assert ready.state == "ready" @@ -247,8 +250,8 @@ def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_ before = old.evidence_path.read_bytes() upgraded = SQLiteMigrationRunner(database, current, configuration=config) plan = upgraded.plan() - assert plan.revisions == (PROJECTION_REVISION, "v1_5_0_004") - assert plan.domain_steps == ("atomic_memory_evidence_projection",) + assert plan.revisions == (PROJECTION_REVISION, "v1_5_0_004", "v1_5_0_005") + assert plan.domain_steps == ("atomic_memory_evidence_projection", "plugin_artifacts") assert old.evidence_path.read_bytes() == before assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == current.head assert upgraded.verify().state == "ready" diff --git a/tests/builtin/persistence/test_custom_artifact_search.py b/tests/builtin/persistence/test_custom_artifact_search.py deleted file mode 100644 index 8294ab9709..0000000000 --- a/tests/builtin/persistence/test_custom_artifact_search.py +++ /dev/null @@ -1,121 +0,0 @@ -# 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. - -"""Initialize missing search tables in existing Artifact storage, preserving exact historical content.""" - -import asyncio -import sqlite3 - -from sqlalchemy import insert - -from powercontext.artifacts import Artifact -from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder -from powercontext.builtin.persistence.artifacts import ArtifactRepository, RepositoryArtifactDraft -from powercontext.builtin.persistence.custom_artifacts import StoredArtifactContent -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import ( - ARTIFACT_FAMILY_SCHEMAS_TABLE, - ARTIFACT_SEARCH_TABLES, - BUILTIN_TABLES, -) -from tests.builtin.persistence.test_artifact_indexes import service - - -def test_existing_database_adds_search_tables_and_rebuilds_legacy_declarations(tmp_path): - path = tmp_path / "existing.db" - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{path}") - - async def legacy(): - tables = tuple(table for table in BUILTIN_TABLES if table not in ARTIFACT_SEARCH_TABLES) - async with SQLiteProfile.open(config, tables=tables) as profile: - artifact_type = type("Legacy", (Artifact[StoredArtifactContent],), {"family": "reports"}) - repository = ArtifactRepository((artifact_type,)) - async with profile.database.transaction() as connection: - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family="reports", - schema_version="1", - declaration={"family": "reports", "schema_version": "1", "search_fields": ["body"]}, - ) - ) - first = await repository.create( - connection, - "scope", - "a", - RepositoryArtifactDraft( - family="reports", - content=StoredArtifactContent(schema_version="1", content={"body": "数据库 TIMEOUT"}), - ), - ) - await repository.revise( - connection, - "scope", - first, - RepositoryArtifactDraft( - family="reports", - content=StoredArtifactContent(schema_version="1", content={"body": "连接恢复 RECOVERED"}), - ), - ) - - asyncio.run(legacy()) - with sqlite3.connect(path) as connection: - before = connection.execute("SELECT * FROM pc_artifacts ORDER BY revision").fetchall() - assert not connection.execute("SELECT 1 FROM sqlite_master WHERE name='pc_artifact_search_states'").fetchall() - - async def rebuilt(): - async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile: - builder = ArtifactIndexBuilder(profile.database) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(profile.database, "reports") - assert not (await search.search("scope", "reports", {"query": "TIMEOUT"})).matches - result = await search.search("scope", "reports", {"query": "RECOVERED"}) - assert result.artifacts[0].revision == 2 - assert result.artifacts[0].content.model_dump() == {"body": "连接恢复 RECOVERED"} - - asyncio.run(rebuilt()) - with sqlite3.connect(path) as connection: - assert connection.execute("SELECT * FROM pc_artifacts ORDER BY revision").fetchall() == before - assert not connection.execute("SELECT 1 FROM sqlite_master WHERE name='pc_artifact_search_schema'").fetchall() - connection.execute("DROP TRIGGER pc_artifact_search_fts_update") - # Missing full-text support is recreated; repeated initialization preserves exact content. - asyncio.run(rebuilt()) - - -def test_intermediate_search_schema_is_rejected_without_deleting_existing_rows(tmp_path): - import pytest - - path = tmp_path / "incompatible.db" - - async def initialize_database(): - tables = tuple(table for table in BUILTIN_TABLES if table not in ARTIFACT_SEARCH_TABLES) - async with SQLiteProfile.open(SQLiteConfig(url=f"sqlite+aiosqlite:///{path}"), tables=tables): - pass - - asyncio.run(initialize_database()) - with sqlite3.connect(path) as connection: - connection.execute( - "CREATE TABLE pc_artifact_search_states (scope_id TEXT, family TEXT, release_id INTEGER, serving_manifest TEXT, desired_manifest TEXT, change_seq INTEGER, PRIMARY KEY(scope_id, family))" - ) - connection.execute("INSERT INTO pc_artifact_search_states VALUES ('scope', 'reports', 3, '{}', '{}', 9)") - before = connection.execute("SELECT * FROM pc_artifact_search_states").fetchall() - - async def open_old_database(): - with pytest.raises(ValueError, match=r"incompatible.*migration"): - async with SQLiteProfile.open(SQLiteConfig(url=f"sqlite+aiosqlite:///{path}"), tables=BUILTIN_TABLES): - pytest.fail("Old search layout must not be treated as upgraded") - - asyncio.run(open_old_database()) - with sqlite3.connect(path) as connection: - assert connection.execute("SELECT * FROM pc_artifact_search_states").fetchall() == before diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 066c748d1f..5e873b53d2 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,7 +73,7 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_004" + assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_005" assert plan.revisions == ( "s1400", "s1401", @@ -85,6 +85,7 @@ def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", + "v1_5_0_005", ) assert dump(source.database) == before result = apply(source) diff --git a/tests/builtin/persistence/test_mysql_initialization_preflight.py b/tests/builtin/persistence/test_mysql_initialization_preflight.py index 87abc9190a..496196e83b 100644 --- a/tests/builtin/persistence/test_mysql_initialization_preflight.py +++ b/tests/builtin/persistence/test_mysql_initialization_preflight.py @@ -253,7 +253,7 @@ def test_evidence_recovery_resumes_before_final_schema_marker(tmp_path, monkeypa before = database.read_bytes(), journal.path.read_bytes() plan = runner.plan() assert plan.state == "recovery_required" and plan.source_revision == predecessor - assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004") + assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004", "v1_5_0_005") assert (database.read_bytes(), journal.path.read_bytes()) == before assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [] diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 2894087ff8..3f24ea5bf5 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -706,7 +706,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_5_0_004" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_005" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -837,7 +837,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_005", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -847,7 +847,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_5_0_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_005", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -914,7 +914,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_004", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_005", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -982,7 +982,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_5_0_004", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_5_0_005", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -1146,7 +1146,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_004" + assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_005" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1168,7 +1168,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_5_0_004" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_005" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1248,7 +1248,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_004" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_005" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index c95e4961d1..f908d698d4 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -74,7 +74,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks ) expected_plugins = [("tickets", b"retained definition")] if previous_revision == "v1_4_1_006" else [] assert connection.execute("SELECT * FROM pc_source_write_plugins").fetchall() == expected_plugins - assert connection.execute("SELECT COUNT(*) FROM pc_artifact_family_schemas").fetchone() == (0,) + # v1_5_0_005 drops the empty storage of the removed Artifact plugin framework. + assert not connection.execute("SELECT 1 FROM sqlite_schema WHERE name='pc_artifact_family_schemas'").fetchall() fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", current, configuration=configuration(embedding)) apply(fresh) assert upgraded.plan().schema_fingerprint == fresh.plan().schema_fingerprint diff --git a/tests/builtin/persistence/test_plugin_storage_migration.py b/tests/builtin/persistence/test_plugin_storage_migration.py new file mode 100644 index 0000000000..089d3944c8 --- /dev/null +++ b/tests/builtin/persistence/test_plugin_storage_migration.py @@ -0,0 +1,111 @@ +# 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. + +"""v1_5_0_005 removes the previous plugin framework's storage only when it holds no data.""" + +from __future__ import annotations + +import json +import shutil +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest + +from powercontext.builtin.persistence.migrations.bundle import MigrationBundle +from powercontext.builtin.persistence.migrations.deployment import server_bundle +from powercontext.builtin.persistence.migrations.models import MigrationError +from powercontext.builtin.persistence.migrations.sqlite import SQLiteMigrationRunner +from powercontext.builtin.runtime.config import BuiltinConfig +from powercontext.builtin.runtime.processing_registry import canonical_processing_manifest + +PREVIOUS = "v1_5_0_004" +RETIRED_TABLES = { + "pc_artifact_family_schemas", + "pc_artifact_vector_spaces", + "pc_artifact_vector_indexes", + "pc_artifact_search_states", + "pc_artifact_index_coverage", + "pc_artifact_index_changes", + "pc_artifact_vectors", + "pc_artifact_search_text", + "pc_artifact_search_fts", +} +RETIRED_COLUMNS = {"plugin_id", "plugin_family", "plugin_spec", "binding_generation", "desired_state", "phase"} + + +def previous_bundle(directory: Path) -> MigrationBundle: + """The current resources without the revisions registered after ``PREVIOUS``.""" + + current = server_bundle() + shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) + manifest_path = directory / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + appended = current.revisions[current.revisions.index(PREVIOUS) + 1 :] + for revision in appended: + for resource in manifest["revision_resources"].pop(revision): + (directory / resource).unlink() + (directory / Path(current.script.get_revision(revision).path).relative_to(current.snapshot_directory)).unlink() + (directory / "mysql" / "versions" / f"{revision}.py").unlink() + del manifest["sqlite_fingerprints"][revision] + del manifest["domain_readiness"][revision] + manifest["domain_steps"] = [step for step in manifest["domain_steps"] if step["revision"] not in appended] + manifest["mysql_state_layouts"] = [name for name in manifest["mysql_state_layouts"] if (directory / name).exists()] + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + return MigrationBundle(directory) + + +def configuration(): + return {"processing_manifest": canonical_processing_manifest(BuiltinConfig()), "embedding_enabled": False} + + +def apply(runner): + return runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) + + +def test_upgrade_refuses_retained_plugin_data_and_drops_only_empty_storage(tmp_path): + database = tmp_path / "managed.db" + old = SQLiteMigrationRunner(database, previous_bundle(tmp_path / "previous"), configuration=configuration()) + assert apply(old).revision == PREVIOUS + with closing(sqlite3.connect(database)) as connection, connection: + connection.execute( + "INSERT INTO pc_scopes (scope_id,title,summary,scope_id_search,title_search,summary_search,version) " + "VALUES ('kept','title','summary','kept','title','summary',1)" + ) + connection.execute( + "INSERT INTO pc_artifact_family_schemas (family,schema_version,declaration) VALUES ('tickets','1','{}')" + ) + + upgraded = SQLiteMigrationRunner(database, server_bundle(), configuration=configuration()) + with pytest.raises(MigrationError) as refused: + upgraded.plan() + assert refused.value.code == "unsupported_plugin_data" + assert "pc_artifact_family_schemas" in str(refused.value) + + with closing(sqlite3.connect(database)) as connection, connection: + connection.execute("DELETE FROM pc_artifact_family_schemas") + plan = upgraded.plan() + assert plan.revisions == ("v1_5_0_005",) + assert plan.domain_steps == ("plugin_artifacts",) + assert apply(upgraded).revision == "v1_5_0_005" + assert upgraded.verify().state == "ready" + with closing(sqlite3.connect(database)) as connection: + tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_schema WHERE type='table'")} + assert not RETIRED_TABLES & tables + states = {row[1] for row in connection.execute("PRAGMA table_info(pc_artifact_processing_binding_states)")} + assert not RETIRED_COLUMNS & states + artifacts = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_artifacts)")} + assert artifacts["metadata"][3] == 0 + assert connection.execute("SELECT scope_id FROM pc_scopes").fetchall() == [("kept",)] diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index d016bed258..3aad19bcad 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -37,7 +37,10 @@ def _created_tables(path): resource = json.loads(path.read_text(encoding="utf-8")) if "layout" in resource: return {row[1] for row in resource["layout"] if row[0] == "table"} - payload = resource["create"] if "create" in resource else resource.get("statements", resource["statement"]) + if "create" in resource: + payload = resource["create"] + else: + payload = resource["statements"] if "statements" in resource else resource["statement"] statements = [payload] if isinstance(payload, str) else payload return { match[1] @@ -127,8 +130,9 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", + "v1_5_0_005", ) - assert apply(upgraded).revision == "v1_5_0_004" + assert apply(upgraded).revision == "v1_5_0_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) @@ -187,11 +191,20 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v bundle = server_bundle(embedding_profile=embedding) upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) assert upgraded.plan().revisions == ( - ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") + ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", "v1_5_0_005") if include_owner - else ("v1_4_1_004", "v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004") + else ( + "v1_4_1_004", + "v1_4_1_005", + "v1_4_1_006", + "v1_5_0_001", + "v1_5_0_002", + "v1_5_0_003", + "v1_5_0_004", + "v1_5_0_005", + ) ) - assert apply(upgraded).revision == "v1_5_0_004" + assert apply(upgraded).revision == "v1_5_0_005" definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' with closing(sqlite3.connect(database)) as connection, connection: assert connection.execute("SELECT title FROM pc_scopes").fetchall() == [("preserved",)] diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index 450d1acecc..70bcc4d12d 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -157,6 +157,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", + "v1_5_0_005", ) @@ -370,7 +371,7 @@ def connect(): finally: engine.dispose() - assert _apply(runner).revision == "v1_5_0_004" + assert _apply(runner).revision == "v1_5_0_005" assert runner.verify().state == "ready" assert not _apply(runner).changed app = create_server_app( diff --git a/tests/builtin/persistence/test_sources.py b/tests/builtin/persistence/test_sources.py index 0148034628..17ea20d4e1 100644 --- a/tests/builtin/persistence/test_sources.py +++ b/tests/builtin/persistence/test_sources.py @@ -39,7 +39,6 @@ from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import ( - ARTIFACT_PROCESSING_BINDING_STATES_TABLE, SHARED_TABLES, SOURCES_TABLE, ) @@ -282,29 +281,15 @@ async def scenario() -> None: assert [item.declaration for item in evidence] == [declaration, MemoryEvidenceDeclaration(), declaration] -def test_source_batches_notify_matching_artifact_plugins_only_for_new_inputs() -> None: +def test_source_batches_notify_matching_plugin_bindings_only_for_new_inputs() -> None: + from powercontext.builtin.plugins import families + async def scenario() -> None: intents = ArtifactProcessingIntentRepository() async with ( repository_profile() as (profile, repositories), profile.database.transaction() as connection, ): - await connection.execute( - insert(ARTIFACT_PROCESSING_BINDING_STATES_TABLE), - [ - { - "binding_name": name, - "plugin_spec": specification, - "desired_state": "disabled", - } - for name, specification in ( - ("all-scopes", {"manifest": {"scope_ids": None}}), - ("matching-scope", {"manifest": {"scope_ids": ["scope-a"]}}), - ("other-scope", {"manifest": {"scope_ids": ["scope-b"]}}), - ("unloaded", None), - ) - ], - ) first, second, third = ( NoteSource(name=f"note-{number}", materialization=SourceMaterialization.CAPTURED, body="Input") for number in range(3) @@ -312,24 +297,31 @@ async def scenario() -> None: batch = (first, first, second) created = await repositories.sources.add_many_with_status(connection, "scope-a", batch) assert [is_new for _, is_new in created] == [True, False, True] - for name in ("all-scopes", "matching-scope"): + for name in ("plugin:all-scopes", "plugin:matching-scope"): pending = await intents.load(connection, "scope-a", name) assert pending is not None and pending.dirty_generation == 2 replay = await repositories.sources.add_many_with_status(connection, "scope-a", batch) assert all(not is_new for _, is_new in replay) - for name in ("all-scopes", "matching-scope"): + for name in ("plugin:all-scopes", "plugin:matching-scope"): pending = await intents.load(connection, "scope-a", name) assert pending is not None and pending.dirty_generation == 2 await repositories.sources.add(connection, "scope-a", third) - for name in ("all-scopes", "matching-scope"): + for name in ("plugin:all-scopes", "plugin:matching-scope"): pending = await intents.load(connection, "scope-a", name) assert pending is not None and pending.dirty_generation == 3 - for name in ("other-scope", "unloaded"): - assert await intents.load(connection, "scope-a", name) is None - - asyncio.run(scenario()) + assert await intents.load(connection, "scope-a", "plugin:other-scope") is None + + saved = dict(families._SOURCE_BINDINGS) + families.register_source_binding("plugin:all-scopes", None, frozenset({"note"})) + families.register_source_binding("plugin:matching-scope", frozenset({"scope-a"}), frozenset({"note"})) + families.register_source_binding("plugin:other-scope", frozenset({"scope-b"}), frozenset({"note"})) + try: + asyncio.run(scenario()) + finally: + families._SOURCE_BINDINGS.clear() + families._SOURCE_BINDINGS.update(saved) def test_reusing_a_stable_source_identity_with_different_payload_is_a_conflict() -> None: diff --git a/tests/builtin/plugins/test_plugin_inputs.py b/tests/builtin/plugins/test_plugin_inputs.py new file mode 100644 index 0000000000..1bb74597ec --- /dev/null +++ b/tests/builtin/plugins/test_plugin_inputs.py @@ -0,0 +1,47 @@ +# 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 plugin may receive each Source type in its own Projection.""" + +import pytest +from pydantic import ValidationError + +from powercontext.plugins import Inputs +from powercontext.sources import SourceProjectionKey + + +def test_each_source_type_uses_its_own_projection_or_the_default(): + inputs = Inputs( + source_types=("content", "upload", "edit"), + projection="powercontext.text-evidence@1", + projections={"upload": "kb.upload@1", "edit": "kb.edit@2"}, + ) + + assert inputs.projection_key("upload") == SourceProjectionKey(name="kb.upload", version="1") + assert inputs.projection_key("edit") == SourceProjectionKey(name="kb.edit", version="2") + assert inputs.projection_key("content") == SourceProjectionKey(name="powercontext.text-evidence", version="1") + + +@pytest.mark.parametrize( + "declaration", + [ + # A type without a per-type Projection and no default. + {"source_types": ("upload", "edit"), "projections": {"upload": "kb.upload@1"}}, + # A per-type Projection for a type the plugin does not consume. + {"source_types": ("upload",), "projection": "kb.upload@1", "projections": {"other": "kb.other@1"}}, + ], +) +def test_declarations_without_a_projection_for_every_type_are_rejected(declaration): + with pytest.raises(ValidationError): + Inputs.model_validate(declaration) diff --git a/tests/builtin/plugins/test_plugin_writer.py b/tests/builtin/plugins/test_plugin_writer.py new file mode 100644 index 0000000000..e9db91e183 --- /dev/null +++ b/tests/builtin/plugins/test_plugin_writer.py @@ -0,0 +1,313 @@ +# 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. + +"""Plugin writes commit with the consumption cursor, or not at all.""" + +import asyncio +import json +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from powercontext.builtin.persistence.cursors import SourceCursorRepository +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE +from powercontext.builtin.plugins.manifest import load_plugin +from powercontext.builtin.runtime.composition import open_builtin_contexts +from powercontext.builtin.runtime.config import BuiltinConfig, SQLiteConfig +from powercontext.builtin.scope import ScopeDraft +from tests.plugin_support import current, invoke, write_plugin + +REPORTS = """ +from powercontext.plugins import ArtifactWriter, GenerateRequest + + +class Reports: + def __init__(self, ctx): + self.fail = (ctx.plugin_dir / "fail").exists() + + async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None: + async with writer.transaction() as tx: + for source in request.sources: + text = source.projection["content"] + ref = await tx.put( + writer.stable_id(source.ref.source_id), content={"text": text}, source_refs=[source.ref] + ) + summary = await tx.get("summary") + count = 0 if summary is None else summary.content["count"] + await tx.put("summary", content={"count": count + 1}, source_refs=[source.ref]) + # A plugin-owned store would be written here, after the real revision is known. + assert ref.ref.revision >= 1 + if self.fail: + raise RuntimeError("model output rejected") + + +async def setup(ctx): + return Reports(ctx) +""" + + +@pytest.fixture +def runtime(tmp_path): + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'plugin.db'}")) + return config + + +async def _scope(contexts) -> str: + draft = ScopeDraft(title="Plugins", summary="Plugins", idempotency_key=str(uuid4())) + return (await contexts.scopes.create(draft)).scope_id + + +def test_writes_commit_with_cursor_and_failures_leave_both_unchanged(tmp_path, runtime): + directory = write_plugin(tmp_path / "plugins", "reports", REPORTS) + + async def scenario(): + async with open_builtin_contexts(runtime) as contexts: + database = contexts.database + scope = await _scope(contexts) + first = await contexts.records.create_source(scope, "content", "Deploy to east") + await contexts.records.create_source(scope, "content", "Rotate keys") + + (directory / "fail").touch() + with pytest.raises(RuntimeError, match="model output rejected"): + await invoke(database, directory, scope) + async with database.transaction() as connection: + assert await SourceCursorRepository().load(connection, scope, "plugin:reports") is None + with pytest.raises(RepositoryNotFoundError): + await current(database, "reports", scope, "summary") + + (directory / "fail").unlink() + await invoke(database, directory, scope) + summary = await current(database, "reports", scope, "summary") + assert summary.content.content == {"count": 2} + assert summary.revision == 2 + # Each revision records the plugin and code version that wrote it. + manifest, _ = load_plugin(directory) + async with database.transaction() as connection: + metadata = await connection.scalar( + select(ARTIFACTS_TABLE.c.metadata).where( + ARTIFACTS_TABLE.c.scope_id == scope, + ARTIFACTS_TABLE.c.family == "reports", + ARTIFACTS_TABLE.c.artifact_id == "summary", + ARTIFACTS_TABLE.c.revision == 2, + ) + ) + assert metadata is not None + assert json.loads(metadata) == {"plugin": "reports", "version": manifest.fingerprint} + async with database.transaction() as connection: + cursor = await SourceCursorRepository().load(connection, scope, "plugin:reports") + assert cursor is not None and cursor.cursor.sequence == 2 + + # The committed window is never delivered again. + await invoke(database, directory, scope) + assert (await current(database, "reports", scope, "summary")).revision == 2 + assert first.source_id + + asyncio.run(scenario()) + + +# Each Source carries a JSON list of writer operations, so one window drives one transaction. +LIFECYCLE = """ +import json + +from powercontext.plugins import ArtifactWriter, GenerateRequest + + +class Lifecycle: + async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None: + results = {} + async with writer.transaction() as tx: + commands = [item for source in request.sources for item in json.loads(source.projection["content"])] + for command in commands: + op = command["op"] + if op == "put": + await tx.put(command["id"], content={"text": command["text"]}) + elif op == "merge": + await tx.merge(command["inputs"], into=command["into"], content={"text": command["text"]}) + elif op == "undo": + await tx.undo_merge(command["id"]) + elif op == "lifecycle": + await tx.set_lifecycle(command["id"], command["state"]) + elif op == "revert": + await tx.revert(command["id"], to_revision=command["revision"]) + elif op == "tags": + await tx.set_tags(command["id"], command["tags"]) + elif op == "handle": + snapshot = await tx.get(command["id"]) + await tx.bind_handle(command["key"], snapshot.ref) + elif op == "find": + # Active Artifacts whose text contains the query, read inside the open transaction. + page = await tx.list(limit=200) + results[command["query"]] = [ + item.ref.artifact_id for item in page.items if command["query"] in item.content.get("text", "") + ] + if results: + await tx.put("search-log", content={"results": results}) + + +async def setup(ctx): + return Lifecycle() +""" + + +def test_writer_covers_shared_lifecycle_merge_and_reads(tmp_path, runtime): + directory = write_plugin(tmp_path / "plugins", "facts", LIFECYCLE) + + async def scenario(): + async with open_builtin_contexts(runtime) as contexts: + database = contexts.database + scope = await _scope(contexts) + + async def run(database, scope, *commands): + await contexts.records.create_source(scope, "content", json.dumps(commands)) + await invoke(database, directory, scope) + + await run( + database, + scope, + {"op": "put", "id": "a", "text": "region east"}, + {"op": "put", "id": "b", "text": "region north"}, + {"op": "put", "id": "a", "text": "region east"}, + {"op": "find", "query": "region"}, + ) + log = await current(database, "facts", scope, "search-log") + # Reads inside the transaction see writes not yet committed. + assert sorted(log.content.content["results"]["region"]) == ["a", "b"] + assert (await current(database, "facts", scope, "a")).revision == 1 + + await run( + database, scope, {"op": "merge", "inputs": ["a", "b"], "into": "c", "text": "region east and north"} + ) + await run( + database, scope, {"op": "merge", "inputs": ["a", "b"], "into": "c", "text": "region east and north"} + ) + await run(database, scope, {"op": "find", "query": "region"}) + log = await current(database, "facts", scope, "search-log") + assert log.content.content["results"]["region"] == ["c"] + + await run(database, scope, {"op": "undo", "id": "c"}) + await run(database, scope, {"op": "find", "query": "region"}) + log = await current(database, "facts", scope, "search-log") + assert sorted(log.content.content["results"]["region"]) == ["a", "b"] + assert (await current(database, "facts", scope, "a")).revision == 2 + + await run( + database, + scope, + {"op": "put", "id": "a", "text": "region west"}, + {"op": "revert", "id": "a", "revision": 1}, + {"op": "lifecycle", "id": "b", "state": "deprecated"}, + {"op": "tags", "id": "a", "tags": ["Ops", "region"]}, + {"op": "handle", "id": "a", "key": "primary-region"}, + {"op": "find", "query": "region"}, + ) + log = await current(database, "facts", scope, "search-log") + assert log.content.content["results"]["region"] == ["a"] + reverted = await current(database, "facts", scope, "a") + assert reverted.revision == 4 and reverted.content.content == {"text": "region east"} + + await run(database, scope, {"op": "lifecycle", "id": "b", "state": "active"}) + await run(database, scope, {"op": "lifecycle", "id": "b", "state": "retired"}) + with pytest.raises(Exception, match="retired"): + await run(database, scope, {"op": "lifecycle", "id": "b", "state": "active"}) + + asyncio.run(scenario()) + + +def _writer(database, scope: str, **options): + from powercontext.builtin.persistence.sources import SourceRepository + from powercontext.builtin.plugins.families import register_plugin_family + from powercontext.builtin.plugins.writer import PluginArtifactWriter + from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY + + register_plugin_family("notes") + return PluginArtifactWriter( + database=database, scope_id=scope, family="notes", sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), **options + ) + + +def test_a_write_decided_on_a_stale_read_conflicts_instead_of_overwriting(runtime): + from powercontext.builtin.plugins.writer import UserChange + from powercontext.errors import RevisionConflictError + + async def scenario(): + async with open_builtin_contexts(runtime) as contexts: + database = contexts.database + scope = await _scope(contexts) + async with _writer(database, scope).transaction() as tx: + await tx.put("a", content={"text": "first"}) + + stale = _writer(database, scope) + assert (await stale.get("a")).ref.revision == 1 + async with _writer(database, scope, change=UserChange("update", "a")).transaction() as tx: + await tx.put("a", content={"text": "user edit"}, expected_revision=1) + + with pytest.raises(RevisionConflictError): + async with stale.transaction() as tx: + await tx.put("a", content={"text": "derived from first"}) + assert (await current(database, "notes", scope, "a")).content.content == {"text": "user edit"} + + asyncio.run(scenario()) + + +def test_user_changes_complete_exactly_their_write_and_always_record_a_revision(runtime): + from powercontext.builtin.plugins.writer import PluginContractError, UserChange + + async def scenario(): + async with open_builtin_contexts(runtime) as contexts: + database = contexts.database + scope = await _scope(contexts) + async with _writer(database, scope).transaction() as tx: + await tx.put("a", content={"text": "same"}) + await tx.put("b", content={"text": "other"}) + + with pytest.raises(PluginContractError): + async with _writer(database, scope, change=UserChange("update", "a")).transaction(): + pass + with pytest.raises(PluginContractError): + async with _writer(database, scope, change=UserChange("update", "a")).transaction() as tx: + await tx.put("b", content={"text": "not the target"}) + assert (await current(database, "notes", scope, "b")).revision == 1 + + # Unchanged content from a user still appends a revision bound to its lineage-only Source. + async with _writer(database, scope, change=UserChange("update", "a")).transaction() as tx: + edited = await tx.put("a", content={"text": "same"}, expected_revision=1) + assert edited.ref.revision == 2 + assert [ref.source_type for ref in edited.lineage.sources] == ["content"] + + # Background writes with unchanged content add nothing, as in Atomic Memory. + async with _writer(database, scope).transaction() as tx: + kept = await tx.put("a", content={"text": "same"}) + assert kept.ref.revision == 2 + + asyncio.run(scenario()) + + +def test_lifecycle_changes_report_the_target_and_its_group(runtime): + async def scenario(): + async with open_builtin_contexts(runtime) as contexts: + database = contexts.database + scope = await _scope(contexts) + async with _writer(database, scope).transaction() as tx: + await tx.put("a", content={"text": "a"}) + await tx.put("b", content={"text": "b"}) + await tx.merge(("a", "b"), into="ab", content={"text": "a and b"}) + async with _writer(database, scope).transaction() as tx: + restored = await tx.set_lifecycle("a", "active") + assert restored.artifact.lifecycle_state == "active" + assert {ref.artifact_id for ref in restored.restored} == {"b"} + assert {ref.artifact_id for ref in restored.retired} == {"ab"} + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_artifact_plugin_worker.py b/tests/builtin/runtime/test_artifact_plugin_worker.py deleted file mode 100644 index ad559cac4b..0000000000 --- a/tests/builtin/runtime/test_artifact_plugin_worker.py +++ /dev/null @@ -1,119 +0,0 @@ -# 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. - -"""Plugin Workers preserve deployment inference identity when opening storage.""" - -import asyncio -import hashlib -import json - -import pytest -import yaml -from sqlalchemy import insert - -from powercontext.builtin.artifact_plugin_validation import _PluginManifest, describe_module, load_plugin -from powercontext.builtin.persistence.custom_artifacts import custom_artifact_repository -from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository -from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.persistence.supervision import ArtifactProcessingLeaseRepository -from powercontext.builtin.persistence.tables import ( - ARTIFACT_FAMILY_SCHEMAS_TABLE, - ARTIFACT_PROCESSING_BINDING_STATES_TABLE, -) -from powercontext.builtin.runtime.artifact_plugins import ArtifactPluginWorkerSpec, run_artifact_plugin_worker -from powercontext.builtin.runtime.composition import open_builtin_contexts -from powercontext.builtin.runtime.config import BuiltinConfig, InferenceConfig, SQLiteConfig -from powercontext.builtin.runtime.processing_contracts import ArtifactProcessingWorkAssignment -from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY, ContentSource -from powercontext.sources import SourceMaterialization -from tests.e2e.test_artifact_plugins import EXAMPLE, deploy_plugin - - -@pytest.mark.parametrize("configured_inference", [False, True]) -def test_plugin_worker_generates_with_deployment_inference(tmp_path, configured_inference): - directory = str(deploy_plugin(EXAMPLE, tmp_path / "plugin", None)) - manifest = _PluginManifest.model_validate(yaml.safe_load((tmp_path / "plugin/plugin.yml").read_text())) - definition = describe_module(load_plugin(directory, manifest), manifest, directory) - definition["directory"] = directory - declaration = definition["declaration"] - family, binding = declaration["family"], manifest.binding_name - # Only storage composition is used: this model is never called by the deterministic plugin. - inference = InferenceConfig(generation_model="openai:gpt-4o-mini") if configured_inference else None - config = BuiltinConfig( - database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'worker.db'}"), - inference=inference or InferenceConfig(), - ) - - async def prepare(): - async with open_builtin_contexts(config) as storage, storage.database.transaction() as connection: - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=family, - schema_version="1", - declaration=declaration, - ) - ) - await connection.execute( - insert(ARTIFACT_PROCESSING_BINDING_STATES_TABLE).values( - binding_name=binding, - binding_generation=1, - phase="enabled", - ) - ) - source = await SourceRepository(BUILTIN_SOURCE_REGISTRY).add( - connection, - "scope", - ContentSource( - name="ticket", - materialization=SourceMaterialization.CAPTURED, - content="Example ticket", - ), - ) - lease = await ArtifactProcessingLeaseRepository().start_single_process_term(connection, "test-owner") - intent = await ArtifactProcessingIntentRepository().request(connection, "scope", binding) - assignment = ArtifactProcessingWorkAssignment( - binding_name=binding, - scope_id="scope", - artifact_family=family, - claimed_request_generation=intent.requested_generation, - fence=lease.fence("single-process"), - worker_id="test-worker", - ) - return assignment, source.ref - - assignment, source_ref = asyncio.run(prepare()) - run_artifact_plugin_worker( - ArtifactPluginWorkerSpec( - config.database, - binding, - 1, - definition, - inference_config=inference, - runtime_config=config.runtime, - ), - assignment, - ) - - async def verify(): - async with open_builtin_contexts(config) as storage, storage.database.transaction() as connection: - identity = json.dumps(source_ref.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) - artifact_id = hashlib.sha256(identity.encode()).hexdigest() - report = await custom_artifact_repository(family).latest(connection, "scope", family, artifact_id) - assert report.content.content["evidence_text"] == "Example ticket" - assert report.lineage.sources == (source_ref,) - intent = await ArtifactProcessingIntentRepository().load(connection, "scope", binding) - assert intent is not None - assert intent.handled_generation == assignment.claimed_request_generation - - asyncio.run(verify()) diff --git a/tests/builtin/runtime/test_artifact_read_context.py b/tests/builtin/runtime/test_artifact_read_context.py deleted file mode 100644 index 578217c2a6..0000000000 --- a/tests/builtin/runtime/test_artifact_read_context.py +++ /dev/null @@ -1,482 +0,0 @@ -# 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 shared read context preserves scope, family and caller permissions.""" - -import asyncio -from contextlib import AsyncExitStack -from datetime import UTC, datetime -from functools import partial -from typing import cast - -import pytest -from sqlalchemy import insert, select - -from powercontext.artifact_plugins import ArtifactDraft, GenerationContext -from powercontext.artifacts import ArtifactRef -from powercontext.artifacts.search import ArtifactSearchUnsupported -from powercontext.builtin.artifacts.profile.models import Profile, ProfileContent, ProfileGeneration -from powercontext.builtin.artifacts.topic_memory.models import TopicMemory, TopicMemoryContent -from powercontext.builtin.authz import ( - AccessAuditContext, - AccessControlService, - AccessDecision, - AccessDeniedError, - AccessRole, - BuiltinAuthorizationProvider, - CreateBinding, - PrincipalRef, - ResourceRef, -) -from powercontext.builtin.authz.composition import open_builtin_access_control -from powercontext.builtin.authz.profiles import register_custom_artifact_access_family -from powercontext.builtin.authz.repository import RelationalAccessRepository -from powercontext.builtin.authz.worker import WorkerSecurity -from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder -from powercontext.builtin.persistence.artifacts import ArtifactRepository, RepositoryArtifactDraft -from powercontext.builtin.persistence.custom_artifacts import ( - StoredArtifactContent, - custom_artifact_repository, -) -from powercontext.builtin.persistence.errors import RepositoryNotFoundError -from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository -from powercontext.builtin.persistence.sources import SourceRepository -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.sqlite.experience_index import SQLiteExperienceFTSIndex -from powercontext.builtin.persistence.supervision import ArtifactProcessingLeaseRepository -from powercontext.builtin.persistence.tables import ARTIFACT_FAMILY_SCHEMAS_TABLE, BUILTIN_TABLES, SOURCE_CURSORS_TABLE -from powercontext.builtin.records import BaseValueNotFoundError, InvalidBaseAccessRequestError -from powercontext.builtin.runtime.artifact_plugins import ArtifactPluginWorkerSpec, _commit_window -from powercontext.builtin.runtime.artifact_reads import ArtifactReadSession -from powercontext.builtin.runtime.processing_contracts import ArtifactProcessingWorkAssignment -from powercontext.builtin.runtime.processing_execution import ScopeInvocation -from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY - - -class FilteredProvider(BuiltinAuthorizationProvider): - async def check_batch(self, requests, /): - decisions = await super().check_batch(requests) - return tuple( - AccessDecision(False, "restricted-artifact", decision.policy_revision) - if request.resource.artifact_id == "a-hidden" - else decision - for request, decision in zip(requests, decisions, strict=True) - ) - - -def test_generation_reads_share_permission_filtered_candidates_and_exact_evidence(tmp_path): - async def scenario(): - admin = PrincipalRef(type="service", id="admin") - alice, bob = PrincipalRef(type="user", id="alice"), PrincipalRef(type="user", id="bob") - audit = AccessAuditContext(transport="test", operation="artifact-reads") - family = "context-items" - register_custom_artifact_access_family(family) - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'reads.db'}") - async with AsyncExitStack() as resources: - profile = await resources.enter_async_context(SQLiteProfile.open(config, tables=BUILTIN_TABLES)) - access = await resources.enter_async_context( - open_builtin_access_control(config, bootstrap_administrators=(admin,)) - ) - await access.create_binding( - admin, - CreateBinding( - subject=alice, - resource=ResourceRef.scope("scope"), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="scope-viewer", - ), - context=audit, - ) - repository = custom_artifact_repository(family) - async with profile.database.transaction() as connection: - await SQLiteExperienceFTSIndex().initialize(connection) - topic = await ArtifactRepository((TopicMemory,)).create( - connection, - "scope", - "topic", - RepositoryArtifactDraft( - family="topic-memory", - content=TopicMemoryContent(title="Topic", summary="Summary", detail="Evidence"), - ), - ) - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=family, - schema_version="1", - declaration={"search_fields": ["text"], "schema_version": "1"}, - ) - ) - for scope, identity in (("scope", "a-hidden"), ("scope", "b-visible"), ("other", "foreign")): - await repository.create( - connection, - scope, - identity, - RepositoryArtifactDraft( - family=family, - content=StoredArtifactContent(schema_version="1", content={"text": "数据库 TIMEOUT"}), - ), - ) - builder = ArtifactIndexBuilder(profile.database) - await builder.refresh("scope", family) - assert await builder.build("scope", family) - for identity, owner in (("a-hidden", bob), ("b-visible", alice)): - await access.establish_artifact_owner( - ResourceRef.artifact("scope", family=family, artifact_id=identity), - owner, - idempotency_key=identity, - context=audit, - ) - builtin_reads = ArtifactReadSession( - profile.database, "scope", (family,), access=access, principal=alice, audit=audit - ) - builtin_page = await builtin_reads.list(family) - assert {ref.artifact_id for ref in builtin_page.items} == {"a-hidden", "b-visible"} - assert (await builtin_reads.read(family, "a-hidden", 1)).content["text"] == "数据库 TIMEOUT" - builtin_matches = await builtin_reads.search(family, "数据库", limit=2) - assert {hit.artifact_ref.artifact_id for hit in builtin_matches.matches} == {"a-hidden", "b-visible"} - # An injected policy may narrow object visibility; it is not the Worker provider. - access = AccessControlService( - FilteredProvider(cast(RelationalAccessRepository, access.relationships)), - relationships=access.relationships, - audit=access.audit, - ) - reads = ArtifactReadSession( - profile.database, "scope", (family, "topic-memory"), access=access, principal=alice, audit=audit - ) - context = GenerationContext( - "scope", resources, reads.read, list_artifacts=reads.list, search_artifacts=reads.search - ) - page = await context.list_artifacts(family, limit=1) - assert [item.artifact_id for item in page.items] == ["b-visible"] - assert page.next_cursor is None - matches = await context.search_artifacts(family, "数据库", limit=1) - assert [hit.artifact_ref.artifact_id for hit in matches.matches] == ["b-visible"] - assert matches.artifacts is not None - assert matches.artifacts[0].content.model_dump() == {"text": "数据库 TIMEOUT"} - assert not reads.refs - snapshot = await context.read_revision(matches.matches[0].artifact_ref) - assert snapshot.content["text"] == "数据库 TIMEOUT" - assert reads.refs == {(family, "b-visible", 1)} - with pytest.raises(AccessDeniedError): - await context.read_latest(family, "a-hidden") - with pytest.raises(InvalidBaseAccessRequestError): - await context.read_latest("undeclared", "any") - with pytest.raises(InvalidBaseAccessRequestError): - await context.list_artifacts("undeclared") - with pytest.raises(InvalidBaseAccessRequestError): - await context.search_artifacts("undeclared", "数据库") - assert await context.read_latest(family, "foreign") is None - denied = ArtifactReadSession( - profile.database, "other", (family,), access=access, principal=alice, audit=audit - ) - foreign_context = GenerationContext( - "other", resources, denied.read, list_artifacts=denied.list, search_artifacts=denied.search - ) - with pytest.raises(AccessDeniedError): - await foreign_context.read_latest(family, "foreign") - with pytest.raises(AccessDeniedError): - await foreign_context.list_artifacts(family) - with pytest.raises(AccessDeniedError): - await foreign_context.search_artifacts(family, "数据库") - topic_page = await context.list_artifacts("topic-memory") - assert topic_page.items == (topic.as_ref(),) - assert (await context.read_revision(topic.as_ref())).content["detail"] == "Evidence" - assert ("topic-memory", "topic", 1) in reads.refs - unauthorized = ArtifactReadSession( - profile.database, "scope", ("topic-memory",), access=access, principal=bob, audit=audit - ) - with pytest.raises(AccessDeniedError): - await unauthorized.list("topic-memory") - with pytest.raises(AccessDeniedError): - await unauthorized.read("topic-memory", "topic", 1) - - asyncio.run(scenario()) - - -def test_generation_context_reads_fixed_history_across_families_and_pages_current_references(tmp_path): - async def scenario(): - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'history.db'}") - async with AsyncExitStack() as resources: - profile = await resources.enter_async_context(SQLiteProfile.open(config, tables=BUILTIN_TABLES)) - items = custom_artifact_repository("context-items") - roots = custom_artifact_repository("context-roots") - async with profile.database.transaction() as connection: - await SQLiteExperienceFTSIndex().initialize(connection) - for family in ("context-items", "context-roots"): - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=family, - schema_version="1", - declaration={ - "schema_version": "1", - "search_fields": ["text"] if family == "context-items" else [], - }, - ) - ) - first = await items.create( - connection, - "scope", - "a", - RepositoryArtifactDraft( - family="context-items", - content=StoredArtifactContent(schema_version="1", content={"text": "旧证据"}), - ), - ) - await roots.create( - connection, - "scope", - "root", - RepositoryArtifactDraft( - family="context-roots", - content=StoredArtifactContent( - schema_version="1", content={"ref": first.as_ref().model_dump(mode="json")} - ), - ), - ) - for identity in ("b", "c"): - await items.create( - connection, - "scope", - identity, - RepositoryArtifactDraft( - family="context-items", - content=StoredArtifactContent(schema_version="1", content={"text": "其他"}), - ), - ) - builtin = await ArtifactRepository((Profile,)).create( - connection, - "scope", - "profile", - RepositoryArtifactDraft( - family="profile", - content=ProfileContent( - content="使用中文", - generation=ProfileGeneration(mode="manual_create", created_at=datetime.now(UTC)), - ), - ), - ) - await connection.execute( - insert(SOURCE_CURSORS_TABLE).values( - scope_id="scope", binding_name="reports", cursor=b'{"through":7}', generation=3 - ) - ) - reads = ArtifactReadSession(profile.database, "scope", ("context-items", "context-roots", "profile")) - context = GenerationContext( - "scope", resources, reads.read, list_artifacts=reads.list, search_artifacts=reads.search - ) - root = await context.read_latest("context-roots", "root") - assert root is not None - fixed_ref = ArtifactRef.model_validate(root.content["ref"]) - async with profile.database.transaction() as connection: - await items.revise( - connection, - "scope", - first, - RepositoryArtifactDraft( - family="context-items", - content=StoredArtifactContent(schema_version="1", content={"text": "数据库 NEW"}), - ), - ) - builder = ArtifactIndexBuilder(profile.database) - await builder.refresh("scope", "context-items") - assert await builder.build("scope", "context-items") - first_page = await context.list_artifacts("context-items", limit=2) - assert [(ref.artifact_id, ref.revision) for ref in first_page.items] == [("a", 2), ("b", 1)] - assert first_page.next_cursor is not None - last_page = await context.list_artifacts("context-items", limit=2, cursor=first_page.next_cursor) - assert [(ref.artifact_id, ref.revision) for ref in last_page.items] == [("c", 1)] - assert last_page.next_cursor is None - assert not (await context.search_artifacts("context-items", "旧证据")).matches - current = await context.search_artifacts("context-items", "数据库") - assert current.matches[0].artifact_ref.revision == 2 - assert (await context.read_revision(current.matches[0].artifact_ref)).content["text"] == "数据库 NEW" - assert (await context.read_revision(fixed_ref)).content["text"] == "旧证据" - snapshot = await context.read_revision(builtin.as_ref()) - assert snapshot.schema_version is None - assert snapshot.content == builtin.content.model_dump(mode="json", by_alias=True) - with pytest.raises(BaseValueNotFoundError): - await context.read_revision(ArtifactRef(family="context-items", artifact_id="a", revision=99)) - with pytest.raises(ArtifactSearchUnsupported): - await context.search_artifacts("context-items", "数据库", mode="hybrid") - async with profile.database.transaction() as connection: - cursor = (await connection.execute(select(SOURCE_CURSORS_TABLE))).mappings().one() - assert cursor["cursor"] == b'{"through":7}' and cursor["generation"] == 3 - - asyncio.run(scenario()) - - -@pytest.mark.parametrize( - "evidence_family,revoked,update_foreign", - [ - ("topic-memory", False, False), - ("topic-memory", True, False), - ("profile", False, False), - ("profile", True, False), - ("topic-memory", False, True), - ], -) -def test_plugin_commit_rechecks_evidence_permissions(tmp_path, evidence_family, revoked, update_foreign): - async def scenario(): - admin = PrincipalRef(type="service", id="admin") - alice = PrincipalRef(type="user", id="alice") - audit = AccessAuditContext(transport="test", operation="artifact-reads") - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'commit.db'}") - output_family = "context-reports" - register_custom_artifact_access_family(output_family) - async with AsyncExitStack() as resources: - profile = await resources.enter_async_context(SQLiteProfile.open(config, tables=BUILTIN_TABLES)) - access = await resources.enter_async_context( - open_builtin_access_control(config, bootstrap_administrators=(admin,)) - ) - binding = await access.create_binding( - admin, - CreateBinding( - subject=alice, - resource=ResourceRef.scope("scope"), - role=AccessRole.SCOPE_CONTRIBUTOR, - idempotency_key="scope-viewer", - ), - context=audit, - ) - async with profile.database.transaction() as connection: - if evidence_family == "topic-memory": - artifact_type = TopicMemory - content = TopicMemoryContent(title="Topic", summary="Summary", detail="Evidence") - else: - artifact_type = Profile - content = ProfileContent( - content="Evidence", - generation=ProfileGeneration(mode="manual_create", created_at=datetime.now(UTC)), - ) - evidence = await ArtifactRepository((artifact_type,)).create( - connection, - "scope", - "a-hidden", - RepositoryArtifactDraft(family=evidence_family, content=content), - ) - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=output_family, - schema_version="1", - declaration={"schema_version": "1"}, - ) - ) - lease = await ArtifactProcessingLeaseRepository().start_single_process_term(connection, "test-owner") - intent = await ArtifactProcessingIntentRepository().request(connection, "scope", "reports") - assignment = ArtifactProcessingWorkAssignment( - binding_name="reports", - scope_id="scope", - artifact_family=output_family, - claimed_request_generation=intent.requested_generation, - fence=lease.fence("single-process"), - worker_id="test-worker", - ) - security = WorkerSecurity(access, alice) - invocation = ScopeInvocation( - assignment, authorize_transaction=partial(security.authorize_transaction, scope_id="scope") - ) - await invocation.start(connection) - if evidence_family == "profile": - await access.establish_artifact_owner( - ResourceRef.artifact("scope", family=evidence_family, artifact_id="a-hidden"), - alice, - idempotency_key="evidence-owner", - context=audit, - ) - reads = ArtifactReadSession( - profile.database, - "scope", - (evidence_family, output_family), - access=access, - principal=alice, - audit=audit, - guard=invocation.guard, - ) - snapshot = await reads.read(evidence_family, "a-hidden", 1) - assert snapshot.ref == evidence.as_ref() - if revoked: - await access.revoke_binding( - admin, binding.binding_id, expected_version=1, idempotency_key="revoke-scope", context=audit - ) - with pytest.raises(AccessDeniedError): - await reads.read(evidence_family, "a-hidden", 1) - with pytest.raises(AccessDeniedError): - await reads.list(evidence_family) - if update_foreign: - async with profile.database.transaction() as connection: - await custom_artifact_repository(output_family).create( - connection, - "scope", - "report", - RepositoryArtifactDraft( - family=output_family, - content=StoredArtifactContent(schema_version="1", content={"text": "Original"}), - ), - ) - await access.establish_artifact_owner( - ResourceRef.artifact("scope", family=output_family, artifact_id="report"), - PrincipalRef(type="user", id="bob"), - idempotency_key="output-owner", - context=audit, - ) - previous = await reads.read(output_family, "report", 1) - assert previous.content == {"text": "Original"} - draft = ArtifactDraft( - family=output_family, - schema_version="1", - artifact_id="report", - expected_revision=1 if update_foreign else 0, - content={"text": "Result"}, - source_refs=(), - artifact_refs=(snapshot.ref,), - ) - - async def commit(): - await _commit_window( - profile.database, - ArtifactPluginWorkerSpec(config, "reports", 1, {}), - assignment, - {"family": output_family, "schema_version": "1", "content_schema": {"type": "object"}}, - invocation, - SourceRepository(BUILTIN_SOURCE_REGISTRY), - None, - 0, - frozenset(), - reads.refs, - [draft], - security, - ) - - if revoked or update_foreign: - with pytest.raises(AccessDeniedError): - await commit() - else: - await commit() - async with profile.database.transaction() as connection: - repository = custom_artifact_repository(output_family) - if revoked: - with pytest.raises(RepositoryNotFoundError): - await repository.latest(connection, "scope", output_family, "report") - else: - result = await repository.latest(connection, "scope", output_family, "report") - if update_foreign: - assert result.revision == 1 - assert result.content.content == {"text": "Original"} - else: - assert result.lineage.artifacts == (snapshot.ref,) - current = await ArtifactProcessingIntentRepository().load(connection, "scope", "reports") - assert current is not None - assert current.handled_generation == (0 if revoked or update_foreign else intent.requested_generation) - - asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_prepared_context.py b/tests/builtin/runtime/test_prepared_context.py index b55c7b4c47..b11f316d67 100644 --- a/tests/builtin/runtime/test_prepared_context.py +++ b/tests/builtin/runtime/test_prepared_context.py @@ -395,7 +395,8 @@ def test_profile_budget_preserves_exact_origin_and_literal_snapshot(max_bytes) - [ None, {"format": "json"}, - {"sections": [{"family": "skill", "limit": 1}]}, + {"sections": [{"family": "Skill", "limit": 1}]}, + {"sections": [{"family": "memory", "limit": 1, "search": {"mode": "fts"}}]}, {"sections": [{"family": "memory", "limit": 1}, {"family": "memory", "limit": 2}]}, {"sections": [{"family": "experience", "limit": 3}]}, {"sections": [{"family": "profile", "limit": 1}, {"family": "profile", "limit": 1}]}, diff --git a/tests/e2e/plugin_server.py b/tests/e2e/plugin_server.py new file mode 100644 index 0000000000..6b4b8e659f --- /dev/null +++ b/tests/e2e/plugin_server.py @@ -0,0 +1,137 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the installed Server CLI with a plugin directory and talk to its public HTTP API.""" + +from __future__ import annotations + +import asyncio +import json +import os +import socket +import sys +from collections.abc import Callable +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import httpx + +ADMIN_AUTHORIZATION_VALUE = "plugin-e2e-admin" +REPOSITORY = Path(__file__).resolve().parents[2] + + +@asynccontextmanager +async def running_server(directory: Path, plugins: Path | None = None, *, extra_env: dict[str, str] | None = None): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + env = {key: value for key, value in os.environ.items() if not key.startswith("POWERCONTEXT_SERVER_")} + env.update({ + "POWERCONTEXT_SERVER_DATABASE": json.dumps({ + "kind": "sqlite", + "url": f"sqlite+aiosqlite:///{directory / 'server.db'}", + }), + "POWERCONTEXT_SERVER_RUNTIME": json.dumps({"artifact_processing_families": []}), + "POWERCONTEXT_SERVER_WORKSPACE": str(directory), + "POWERCONTEXT_HOME": str(directory / "home"), + "POWERCONTEXT_SERVER_AUTH_ENABLED": "true", + "POWERCONTEXT_SERVER_AUTH_TOKEN": ADMIN_AUTHORIZATION_VALUE, + "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", + "POWERCONTEXT_SERVER_MCP_ENABLED": "false", + "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", + # Worker children import test-owned plugin helpers from the repository. + "PYTHONPATH": os.pathsep.join(filter(None, (str(REPOSITORY), env.get("PYTHONPATH")))), + **(extra_env or {}), + }) + if plugins is not None: + env["POWERCONTEXT_SERVER_PLUGINS_DIR"] = str(plugins) + entrypoint = Path(sys.executable).with_name("powercontext") + with (directory / "server.log").open("a") as log: + process = await asyncio.create_subprocess_exec( + sys.executable, + 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=f"http://127.0.0.1:{port}", + headers={"Authorization": f"Bearer {ADMIN_AUTHORIZATION_VALUE}"}, + timeout=20, + ) as client: + async with asyncio.timeout(60): + while True: + assert process.returncode is None, (directory / "server.log").read_text() + try: + if (await client.get("/health/ready")).status_code == 200: + break + except httpx.ConnectError: + pass + await asyncio.sleep(0.1) + yield client + 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 create_scope(client: httpx.AsyncClient, key: str) -> str: + response = await client.post( + "/v1/scopes", json={"title": key, "summary": "Plugin acceptance", "idempotency_key": key} + ) + assert response.status_code == 201, response.text + return response.json()["scope_id"] + + +async def capture(client: httpx.AsyncClient, scope: str, source_id: str, content: str) -> None: + response = await client.post( + "/v1/sources/content", json={"scope_id": scope, "source_id": source_id, "content": content} + ) + assert response.status_code == 202, response.text + + +async def wait_for( + read: Callable[[], Any], ready: Callable[[Any], bool], *, seconds: float = 60, log: Path | None = None +) -> Any: + value = None + try: + async with asyncio.timeout(seconds): + while True: + value = await read() + if ready(value): + return value + await asyncio.sleep(0.2) + except TimeoutError: + detail = "" if log is None else (await asyncio.to_thread(log.read_text))[-4000:] + raise AssertionError(f"condition not reached; last value: {value!r}\n{detail}") from None # noqa: TRY003 + + +async def items(client: httpx.AsyncClient, scope: str, family: str) -> list[dict[str, Any]]: + response = await client.get(f"/v1/scopes/{scope}/artifacts/{family}") + assert response.status_code == 200, response.text + return response.json()["items"] diff --git a/tests/e2e/test_artifact_authorization_startup.py b/tests/e2e/test_artifact_authorization_startup.py deleted file mode 100644 index 895ac7219a..0000000000 --- a/tests/e2e/test_artifact_authorization_startup.py +++ /dev/null @@ -1,498 +0,0 @@ -# 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. - -"""Persisted custom artifacts remain authorized without a plugin manager.""" - -import asyncio - -import httpx -import pytest -from sqlalchemy import insert - -from powercontext.builtin.authz import AccessAuditContext, AccessRole, CreateBinding, PrincipalRef, ResourceRef -from powercontext.builtin.authz.composition import open_builtin_access_control -from powercontext.builtin.authz.profiles import ARTIFACT_FAMILY_PROFILES -from powercontext.builtin.persistence.artifacts import RepositoryArtifactDraft -from powercontext.builtin.persistence.custom_artifacts import StoredArtifactContent, custom_artifact_repository -from powercontext.builtin.persistence.tables import ARTIFACT_FAMILY_SCHEMAS_TABLE -from powercontext.builtin.runtime.config import RuntimeConfig -from powercontext.server.authentication import StaticBearerAuthenticationProvider -from powercontext.server.factory import create_server_app -from powercontext.server.settings import AccessControlConfig, DashboardConfig, McpConfig, MetricsConfig, ServerSettings -from tests.e2e.server_storage import prepare_server_database -from tests.e2e.test_atomic_memory_search_authorization_snapshot import _isolated_database - - -@pytest.mark.parametrize( - "backend,role,mode", - [("sqlite", "all", "global"), ("sqlite", "all", "dedicated"), ("oceanbase", "api", "global")], -) -def test_custom_artifact_authorization_survives_server_restart(tmp_path, monkeypatch, backend, role, mode): - async def scenario(): - family = "startup-report" - admin = PrincipalRef(type="service", id="admin") - reader = PrincipalRef(type="user", id="reader") - async with _isolated_database(backend, tmp_path) as database: - settings = ServerSettings( - database=database, - workspace=tmp_path, - access=AccessControlConfig(mode="enforced"), - runtime=RuntimeConfig( - artifact_processing_families=(), - artifact_processing_role=role, - artifact_processing_supervisor_mode=mode, - ), - dashboard=DashboardConfig(enabled=False), - mcp=McpConfig(enabled=False), - metrics=MetricsConfig(enabled=False), - ) - await asyncio.to_thread(prepare_server_database, settings, evidence_dir=tmp_path / "evidence") - async with open_builtin_access_control(database, bootstrap_administrators=(admin,)) as access: - - def application(principal): - return create_server_app( - settings=settings, - access_control=access, - authentication_provider=StaticBearerAuthenticationProvider("test-token", principal), - ) - - app = application(admin) - async with ( - app.router.lifespan_context(app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - headers={"Authorization": "Bearer test-token"}, - ) as client, - ): - scopes = [] - for name in ("visible", "forbidden"): - response = await client.post( - "/v1/scopes", json={"title": name, "summary": name, "idempotency_key": name} - ) - assert response.status_code == 201, response.text - scopes.append(response.json()["scope_id"]) - async with app.state.application._records().database.transaction() as connection: - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=family, - schema_version="1", - declaration={ - "family": family, - "schema_version": "1", - "content_schema": {"type": "object"}, - "search_fields": [], - }, - ) - ) - for scope in scopes: - await custom_artifact_repository(family).create( - connection, - scope, - "report", - RepositoryArtifactDraft( - family=family, - content=StoredArtifactContent(schema_version="1", content={"text": "retained"}), - ), - ) - await access.create_binding( - admin, - CreateBinding( - subject=reader, - resource=ResourceRef.scope(scopes[0]), - role=AccessRole.SCOPE_VIEWER, - idempotency_key="reader", - ), - context=AccessAuditContext(transport="test", operation="artifact-startup"), - ) - # Model an empty process-local registry on restart, preserving built-in profiles. - monkeypatch.delitem(ARTIFACT_FAMILY_PROFILES, family, raising=False) - discovery_app = application(admin) - async with ( - discovery_app.router.lifespan_context(discovery_app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=discovery_app), - base_url="http://testserver", - headers={"Authorization": "Bearer test-token"}, - ) as client, - ): - metadata = await client.get("/v1/capabilities") - assert metadata.status_code == 200, metadata.text - assert family in metadata.json()["artifact_families"] - assert next( - item for item in metadata.json()["custom_artifact_schemas"] if item["family"] == family - ) == { - "family": family, - "schema_version": "1", - "content_schema": {"type": "object"}, - "search_fields": [], - "search": None, - } - monkeypatch.delitem(ARTIFACT_FAMILY_PROFILES, family, raising=False) - app = application(reader) - async with ( - app.router.lifespan_context(app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), - base_url="http://testserver", - headers={"Authorization": "Bearer test-token"}, - ) as client, - ): - path = f"/v1/scopes/{scopes[0]}/artifacts/{family}/report" - for suffix in ("", "/revisions/1"): - response = await client.get(path + suffix) - assert response.status_code == 200, response.text - assert response.json()["content"] == {"text": "retained"} - assert response.json()["revision"] == 1 - denied = await client.get(f"/v1/scopes/{scopes[1]}/artifacts/{family}/report") - assert denied.status_code == 403, denied.text - unknown = await client.get(f"/v1/scopes/{scopes[0]}/artifacts/unregistered-family/report") - assert unknown.status_code in {404, 422}, unknown.text - - asyncio.run(scenario()) - - -def test_live_family_discovery_and_access_query_budget(tmp_path, monkeypatch): - """External storage writes do not update this Server's process-local profiles.""" - from sqlalchemy import event - - async def scenario(): - async with _isolated_database("sqlite", tmp_path) as database: - settings = ServerSettings( - database=database, - workspace=tmp_path, - runtime=RuntimeConfig(artifact_processing_families=(), artifact_processing_supervisor_mode="dedicated"), - access=AccessControlConfig(mode="enforced"), - dashboard=DashboardConfig(enabled=False), - mcp=McpConfig(enabled=False), - metrics=MetricsConfig(enabled=True), - ) - await asyncio.to_thread(prepare_server_database, settings, evidence_dir=tmp_path / "evidence") - admin = PrincipalRef(type="service", id="admin") - async with open_builtin_access_control(database, bootstrap_administrators=(admin,)) as access: - app = create_server_app( - settings=settings, - access_control=access, - authentication_provider=StaticBearerAuthenticationProvider("test-token", admin), - ) - async with ( - app.router.lifespan_context(app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - headers={"Authorization": "Bearer test-token"}, - ) as client, - ): - response = await client.post( - "/v1/scopes", json={"title": "live", "summary": "live", "idempotency_key": "live"} - ) - assert response.status_code == 201, response.text - scope = response.json()["scope_id"] - db = app.state.application._records().database - queries = [] - binding_reads = [] - assert access.relationships is not None - get_binding = access.relationships.get_binding - - async def counted_get_binding(binding_id): - binding_reads.append(binding_id) - return await get_binding(binding_id) - - monkeypatch.setattr(access.relationships, "get_binding", counted_get_binding) - - def track(_conn, _cursor, statement, _parameters, _context, _many): - if ( - statement.lstrip().upper().startswith("SELECT") - and "pc_artifact_family_schemas" in statement - ): - queries.append(statement) - - event.listen(db.engine.sync_engine, "before_cursor_execute", track) - try: - for entry in ( - "read", - "capabilities", - "access/check", - "roles/list", - "bindings/list", - "bindings/create", - "resources/list", - "bindings/revoke", - "bindings/replace", - ): - family = "live-" + entry.replace("/", "-") - monkeypatch.delitem(ARTIFACT_FAMILY_PROFILES, family, raising=False) - # Model a different process committing storage without touching this registry. - async with db.transaction() as connection: - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=family, - schema_version="1", - declaration={ - "family": family, - "schema_version": "1", - "content_schema": {}, - "search_fields": [], - }, - ) - ) - await custom_artifact_repository(family).create( - connection, - scope, - "report", - RepositoryArtifactDraft( - family=family, - content=StoredArtifactContent(schema_version="1", content={"text": "live"}), - ), - ) - assert family not in ARTIFACT_FAMILY_PROFILES - path = f"/v1/scopes/{scope}/artifacts/{family}/report" - queries.clear() - await _assert_family_display(client, family, present=False) - assert queries == [], "Family display must not refresh the schema registry" - binding_reads.clear() - result = await _request_family_operation(client, access, admin, entry, family, scope, path) - expected_status = ( - 422 if entry in {"bindings/create", "bindings/replace", "resources/list"} else 200 - ) - assert result.status_code == expected_status, result.text - assert len(queries) == (2 if entry == "read" else 1), queries - _assert_discovered_family(entry, family, result.json()) - if entry in {"bindings/revoke", "bindings/replace"}: - assert binding_reads == [family], "Binding ID must be resolved only once" - if entry in { - "roles/list", - "bindings/list", - "bindings/create", - "resources/list", - "bindings/replace", - }: - queries.clear() - repeated = await _request_family_operation( - client, access, admin, entry, family, scope, path - ) - assert repeated.status_code == result.status_code, repeated.text - assert repeated.json() == result.json() - assert queries == [], "Known family management must not refresh the registry" - queries.clear() - await _assert_family_display(client, family, present=True) - assert queries == [], "Family display must use the local registry" - for suffix in ("", "/revisions/1"): - read = await client.get(path + suffix) - assert read.status_code == 200, read.text - assert len(queries) == 2, queries # Existing persisted-family validation: one per read. - finally: - event.remove(db.engine.sync_engine, "before_cursor_execute", track) - - asyncio.run(scenario()) - - -def _assert_discovered_family(entry, family, payload): - if entry == "capabilities": - assert family in payload["artifact_families"] - assert any(item["family"] == family for item in payload["custom_artifact_schemas"]) - elif entry == "access/check": - assert payload["allowed"] is True - - -async def _assert_family_display(client, family, *, present): - response = await client.get("/v1/access/me") - assert response.status_code == 200, response.text - assert any(item["family"] == family for item in response.json()["artifact_families"]) is present - response = await client.get("/health/ready") - assert response.status_code == 200, response.text - assert (f"{family}:enabled" in response.json()["checks"]["access_artifact_families"]) is present - - -async def _request_family_operation(client, access, admin, entry, family, scope, path): - if entry == "read": - return await client.get(path) - if entry == "capabilities": - return await client.get("/v1/capabilities") - resource = {"type": "artifact", "scope_id": scope, "identity": {"family": family, "artifact_id": "report"}} - subject = {"type": "user", "id": "recipient"} - payloads = { - "access/check": {"requirements": [{"action": "artifact.read", "resource": resource}], "match": "all"}, - "roles/list": {"resource_type": "artifact", "family": family}, - "bindings/list": {"management_resource": resource}, - "bindings/create": { - "resource": resource, - "subject": subject, - "role": "artifact.viewer", - "idempotency_key": family, - }, - "resources/list": {"resource_type": "artifact", "family": family, "action": "artifact.read"}, - } - if entry in {"bindings/revoke", "bindings/replace"}: - await _seed_retained_binding(access, admin, family, scope) - payload = {"binding_id": family, "expected_version": 1, "idempotency_key": family + "-change"} - if entry == "bindings/replace": - payload["replacement"] = {"subject": subject} - else: - payload = payloads[entry] - endpoint = entry if entry == "access/check" else "access/" + entry - return await client.post("/v1/" + endpoint, json=payload) - - -async def _seed_retained_binding(access, admin, family, scope): - from datetime import UTC, datetime - - from powercontext.builtin.authz.models import AccessBinding, AccessBindingState - - # Exercise an existing relationship row; custom families still cannot grant direct roles through HTTP. - await access.relationships.create_binding( - AccessBinding( - binding_id=family, - subject=admin, - resource=ResourceRef.artifact(scope, family=family, artifact_id="report"), - role=AccessRole.ARTIFACT_OWNER, - granted_by=admin, - reason=None, - created_at=datetime.now(UTC), - expires_at=None, - state=AccessBindingState.ACTIVE, - version=1, - policy_revision="pending", - idempotency_key=family, - ) - ) - - -class _FilteringProvider: - """A custom Provider that retains Scope grants but denies one exact Artifact.""" - - def __init__(self, delegate): - self.delegate = delegate - - async def check(self, request): - from dataclasses import replace - - decision = await self.delegate.check(request) - if request.resource.identity is not None and request.resource.identity.artifact_id == "hidden": - return replace( - decision, allowed=False, reason_code="no-matching-policy", matched_subject=None, matched_binding_id=None - ) - return decision - - async def check_batch(self, requests): - return tuple([await self.check(request) for request in requests]) - - async def resolve_resource_filter(self, request): - return await self.delegate.resolve_resource_filter(request) - - -def test_first_search_loads_external_family_with_custom_provider(tmp_path, monkeypatch): - from sqlalchemy import event - - from powercontext.artifacts.indexes import ArtifactSearchDeclaration, TextIndex - from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder - - async def scenario(): - family = "live-search-report" - monkeypatch.delitem(ARTIFACT_FAMILY_PROFILES, family, raising=False) - async with _isolated_database("sqlite", tmp_path) as database: - settings = ServerSettings( - database=database, - workspace=tmp_path, - access=AccessControlConfig(mode="enforced"), - runtime=RuntimeConfig(artifact_processing_families=(), artifact_processing_supervisor_mode="dedicated"), - dashboard=DashboardConfig(enabled=False), - mcp=McpConfig(enabled=False), - metrics=MetricsConfig(enabled=False), - ) - await asyncio.to_thread(prepare_server_database, settings, evidence_dir=tmp_path / "evidence") - admin = PrincipalRef(type="service", id="admin") - async with open_builtin_access_control(database, bootstrap_administrators=(admin,)) as access: - access.provider = _FilteringProvider(access.provider) - app = create_server_app( - settings=settings, - access_control=access, - authentication_provider=StaticBearerAuthenticationProvider("test-token", admin), - ) - async with ( - app.router.lifespan_context(app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), - base_url="http://testserver", - headers={"Authorization": "Bearer test-token"}, - ) as client, - ): - response = await client.post( - "/v1/scopes", json={"title": "search", "summary": "search", "idempotency_key": "search"} - ) - assert response.status_code == 201, response.text - scope = response.json()["scope_id"] - db = app.state.application._records().database - search = ArtifactSearchDeclaration( - version="1", - indexes=(TextIndex(name="text", fields=("text",), projection_version="1"),), - modes=("text",), - default_mode="text", - ) - # Commit external storage changes without touching this Server's profile registry. - async with db.transaction() as connection: - await connection.execute( - insert(ARTIFACT_FAMILY_SCHEMAS_TABLE).values( - family=family, - schema_version="1", - declaration={ - "family": family, - "schema_version": "1", - "content_schema": {}, - "search_fields": [], - "search": search.model_dump(mode="json"), - }, - ) - ) - for identity in ("visible", "hidden"): - await custom_artifact_repository(family).create( - connection, - scope, - identity, - RepositoryArtifactDraft( - family=family, - content=StoredArtifactContent( - schema_version="1", content={"text": "database timeout"} - ), - ), - ) - builder = ArtifactIndexBuilder(db) - await builder.refresh(scope, family) - assert await builder.build(scope, family) - assert family not in ARTIFACT_FAMILY_PROFILES - queries = [] - - def track(_conn, _cursor, statement, _parameters, _context, _many): - if ( - statement.lstrip().upper().startswith("SELECT") - and "pc_artifact_family_schemas" in statement - ): - queries.append(statement) - - event.listen(db.engine.sync_engine, "before_cursor_execute", track) - try: - path = f"/v1/scopes/{scope}/artifacts/{family}/search" - first = await client.post(path, json={"query": "timeout"}) - assert first.status_code == 200, first.text - assert [item["artifact_id"] for item in first.json()["results"]] == ["visible"] - first_queries = len(queries) - queries.clear() - second = await client.post(path, json={"query": "timeout"}) - assert second.status_code == 200, second.text - assert second.json() == first.json() - assert len(queries) == first_queries - 1, "Only the first search may load the missing profile" - finally: - event.remove(db.engine.sync_engine, "before_cursor_execute", track) - - asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_index_backends.py b/tests/e2e/test_artifact_index_backends.py deleted file mode 100644 index cf95d858d4..0000000000 --- a/tests/e2e/test_artifact_index_backends.py +++ /dev/null @@ -1,105 +0,0 @@ -# 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. - -"""Real backend projection transactions, native initialization and exact search.""" - -import asyncio -import json - -import pytest - -from powercontext.artifacts.search import ArtifactSearchUnavailable -from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder, ArtifactProjectionPublisher -from powercontext.builtin.persistence.oceanbase import OceanBaseProfile -from powercontext.builtin.persistence.seekdb import SeekDBConfig, SeekDBProfile -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import BUILTIN_TABLES -from powercontext.builtin.runtime.custom_artifact_search import CustomArtifactSearcher, CustomArtifactSearchRequest -from tests.builtin.persistence.test_artifact_indexes import PROFILE, body, declaration, register, service, write - - -def test_search_projection_backends_require_native_provisioning_and_preserve_revisions(migration_backend): - config = migration_backend.config - - def open_storage(): - if isinstance(config, SQLiteConfig): - return SQLiteProfile.open(config, tables=BUILTIN_TABLES, load_vector_extension=True) - if isinstance(config, SeekDBConfig): - return SeekDBProfile.open(config, tables=BUILTIN_TABLES) - return OceanBaseProfile.open(config, tables=BUILTIN_TABLES) - - async def declare(): - async with open_storage() as storage: - db = storage.database - await register(db, "reports", declaration()) - publisher = ArtifactProjectionPublisher(db) - await write(db, publisher, "scope", "reports", "a", body("数据库 timeout", *([[0, 0]] * 8))) - await write(db, publisher, "scope", "reports", "b", body("数据库 normal", [1, 0])) - builder = ArtifactIndexBuilder(db) - await builder.refresh("scope", "reports") - if isinstance(config, SQLiteConfig): - assert await builder.build("scope", "reports") - else: - with pytest.raises(ArtifactSearchUnavailable, match="database initialization"): - await builder.build("scope", "reports") - - asyncio.run(declare()) - - async def verify(): - async with open_storage() as storage: - db = storage.database - builder = ArtifactIndexBuilder(db) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - search = service(db, "reports") - vector = {"profile": PROFILE.model_dump(mode="json"), "vector": [0, 0]} - request = {"mode": "vector", "query_vector": vector, "limit": 2, "include_scores": True} - found = await search.search("scope", "reports", request) - assert [item.artifact_id for item in found.artifacts] == ["a", "b"] - assert found.matches[0].channel_scores["vector"].raw == 0 - hybrid = await search.search("scope", "reports", {**request, "mode": "hybrid", "query": "数据库"}) - assert {item.artifact_id for item in hybrid.artifacts} == {"a", "b"} - # Pause only to order the concurrent operations; all reads and writes use the real backend. - reading, published = asyncio.Event(), asyncio.Event() - - class SnapshotReader(CustomArtifactSearcher): - async def _candidates(self, *args, **kwargs): - reading.set() - await published.wait() - return await super()._candidates(*args, **kwargs) - - reader = asyncio.create_task( - SnapshotReader(db, "reports").search( - "scope", - CustomArtifactSearchRequest.model_validate_json( - json.dumps({**request, "mode": "hybrid", "query": "数据库"}) - ), - ) - ) - try: - await asyncio.wait_for(reading.wait(), 10) - await builder.refresh("scope", "reports", rebuild=True) - with pytest.raises(ArtifactSearchUnavailable): - await search.search("scope", "reports", request) - assert await builder.build("scope", "reports") - finally: - published.set() - snapshot = await reader - assert {item.artifact_id for item in snapshot.artifacts} == {"a", "b"} - publisher = ArtifactProjectionPublisher(db) - await write(db, publisher, "scope", "reports", "a", body("recovered")) - assert [item.artifact_id for item in (await search.search("scope", "reports", request)).artifacts] == ["b"] - assert not (await search.search("scope", "reports", {"query": "timeout"})).matches - - asyncio.run(verify()) diff --git a/tests/e2e/test_artifact_plugin_search.py b/tests/e2e/test_artifact_plugin_search.py deleted file mode 100644 index 25ad575733..0000000000 --- a/tests/e2e/test_artifact_plugin_search.py +++ /dev/null @@ -1,412 +0,0 @@ -# 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. - -"""Custom Artifact search through generation, revisions, unload and index recovery.""" - -import asyncio -import json -import shutil -from pathlib import Path - -from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import BUILTIN_TABLES -from tests.e2e.test_artifact_plugins import ( - EXAMPLE, - REGISTRATION, - _wait_for_search_ready, - change, - create_scope, - deploy_plugin, - enable_all, - plugin_state, - publish_plugin, - reload_plugin, - running_server, -) - -_PIPELINE = """ - -class SearchPipeline: - def __init__(self, ctx): - self.ctx = ctx - - async def generate(self, batch): - drafts = [] - for source in batch.sources: - value = json.loads(TextEvidence.model_validate(source.value).content) - artifact_id = value.pop("artifact_id") - previous = await self.ctx.read_latest("incident-report", artifact_id) - drafts.append(ArtifactDraft( - family="incident-report", schema_version=families[0].schema_version, - artifact_id=artifact_id, - expected_revision=0 if previous is None else previous.ref.revision, - content=value, source_refs=(source.ref,), - )) - return drafts - -async def create_pipeline(ctx, config): - return SearchPipeline(ctx) -""" - - -async def _write_report(client, scope, source_id, artifact_id, body, *, title="Report", revision=1, fragments=None): - response = await client.post( - "/v1/sources/content", - json={ - "scope_id": scope, - "source_id": source_id, - "content": json.dumps( - { - "artifact_id": artifact_id, - "title": title, - "evidence_text": body, - "private_text": "不可检索 secretword", - **({"fragments": fragments} if fragments is not None else {}), - }, - ensure_ascii=False, - ), - }, - ) - assert response.status_code == 202, response.text - async with asyncio.timeout(45): - while True: - page = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report") - assert page.status_code == 200, page.text - if any( - item["artifact_id"] == artifact_id and item["revision"] == revision for item in page.json()["items"] - ): - response = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/{artifact_id}") - assert response.status_code == 200, response.text - return response.json() - await asyncio.sleep(0.2) - - -async def _search(client, scope, query): - response = await client.post(f"/v1/scopes/{scope}/artifacts/incident-report/search", json={"query": query}) - assert response.status_code == 200, response.text - return response.json() - - -def test_custom_search_preserves_full_content_and_rebuilds_retained_revisions(tmp_path: Path) -> None: - asyncio.run(_search_through_lifecycle(tmp_path)) - - -async def _search_through_lifecycle(directory: Path) -> None: - async with running_server(directory) as client: - scope = await create_scope(client, "search") - other_scope = await create_scope(client, "other-search") - plugin = deploy_plugin(EXAMPLE, directory / "search-v1", [scope, other_scope]) - entry = plugin / "plugin.py" - entry.write_text( - entry.read_text().replace( - "class IncidentReport(BaseModel):", "class IncidentReport(BaseModel):\n private_text: str" - ) - + _PIPELINE, - encoding="utf-8", - ) - await reload_plugin(client, directory, plugin, 0) - await enable_all(client) - state = await plugin_state(client, "enabled") - removed = await client.post(f"/v1/scopes/{scope}/artifacts/incident-report/query", json={"query": "数据库"}) - assert removed.status_code == 405 # The remaining GET /{artifact_id} route has no POST operation. - for invalid in ( - {"query": "数据库", "limit": 201}, - {"query": "数据库", "indexes": {"text": []}}, - {"query": "数据库", "mode": "fts"}, - ): - response = await client.post(f"/v1/scopes/{scope}/artifacts/incident-report/search", json=invalid) - assert response.status_code == 422, response.text - - original = "数据库连接超时 TIMEOUT,重试后恢复。" # noqa: RUF001 - first = await _write_report(client, scope, "first", "report", original, title="故障记录") - # The first write creates the durable build request; empty scopes no longer - # receive prebuilt releases when the plugin is loaded. - await _wait_for_search_ready(client, scope, "数据库") - for query in ("数据库", "连接超时", "timeout", "数据库 timeout", "故障记录"): - hits = (await _search(client, scope, query))["results"] - assert [{key: hit[key] for key in ("family", "artifact_id", "revision")} for hit in hits] == [ - {"family": "incident-report", "artifact_id": "report", "revision": 1} - ] - assert hits[0]["content"] == first["content"] - assert set(hits[0]) == {"family", "artifact_id", "revision", "content", "lineage"} - for query in ("不存在的英文token", "secretword", "不可检索", "!!!"): - assert (await _search(client, scope, query))["results"] == [] - assert (await _search(client, other_scope, "数据库"))["results"] == [] - - # Normalization can change code point counts; excerpts must still use the exact original. - for index, (needle, query) in enumerate(( - ("磁盘告警", "磁盘告警"), - ("Straße", "strasse"), - ("Cafe\u0301", "café"), - ("customer_id", "customer_id"), - ("pcu123", "pcu123"), - )): - body = "prefix ß e\u0301. " * 80 + needle + " suffix." * 80 - await _write_report(client, scope, f"long-{index}", f"long-{index}", body) - hits = (await _search(client, scope, query))["results"] - assert len(hits) == 1 - assert hits[0]["content"]["evidence_text"] == body - for query in ("cafe", "customer", "id"): - assert (await _search(client, scope, query))["results"] == [] - - # Replacing a head must remove its old words while retaining the exact historical revision. - await _write_report(client, scope, "updated", "report", "网络恢复正常 recovered", revision=2) - assert (await _search(client, scope, "数据库 timeout"))["results"] == [] - assert (await _search(client, scope, "网络恢复"))["results"][0]["revision"] == 2 - historical = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/report/revisions/1") - assert historical.status_code == 200 - assert historical.json()["content"] == first["content"] - - # A schema bump must not bypass the fixed search declaration. - replacement = deploy_plugin(plugin, directory / "search-v2", [scope, other_scope], version="2") - entry = replacement / "plugin.py" - entry.write_text( - entry - .read_text() - .replace('schema_version="1"', 'schema_version="2"') - .replace('fields=("title", "evidence_text")', 'fields=("evidence_text",)'), - encoding="utf-8", - ) - publish_plugin(directory, replacement) - rejected = await client.post( - f"{REGISTRATION}/reload", json={"expected_generation": state["binding_generation"]} - ) - assert rejected.status_code == 409, rejected.text - assert (await _search(client, scope, "网络恢复"))["results"][0]["revision"] == 2 - # Code-only reload remains supported with exactly the same search declaration. - entry.write_text((plugin / "plugin.py").read_text()) - state = await reload_plugin(client, directory, replacement, state["binding_generation"], "enabled") - await _write_report(client, scope, "after-reload", "after-reload", "缓存刷新 complete") - await _wait_for_search_ready(client, scope, "缓存刷新") - disabled = await change(client, "disable", state["binding_generation"], "disabled") - assert (await _search(client, scope, "缓存刷新"))["results"][0]["content"][ - "evidence_text" - ] == "缓存刷新 complete" - await change(client, "unload", disabled["binding_generation"], "unloaded") - before = { - query: await _search(client, scope, query) - for query in ("网络恢复", "磁盘告警", "strasse", "café", "customer_id", "pcu123", "缓存刷新") - } - saved = (await client.get(f"/v1/scopes/{scope}/artifacts/incident-report")).json() - - # Persist a rebuild request, then recover it without either plugin's code. - async with SQLiteProfile.open( - SQLiteConfig(url=f"sqlite+aiosqlite:///{directory / 'server.db'}"), tables=BUILTIN_TABLES - ) as storage: - await ArtifactIndexBuilder(storage.database).refresh(scope, "incident-report", rebuild=True) - shutil.rmtree(plugin) - shutil.rmtree(replacement) - async with running_server(directory) as client: - assert (await client.get(REGISTRATION)).json()["phase"] == "unloaded" - assert (await client.get(f"/v1/scopes/{scope}/artifacts/incident-report")).json() == saved - # Server startup no longer waits for rebuilding retained data. Only the - # affected search can be unavailable until its complete manifest is published. - await _wait_for_search_ready(client, scope, "网络恢复") - for query, expected in before.items(): - assert await _search(client, scope, query) == expected - assert (await _search(client, scope, "数据库 timeout hiddenfield secretword"))["results"] == [] - assert (await client.get(f"/v1/scopes/{scope}/artifacts/incident-report")).json() == saved - - -def test_pending_vector_index_suspends_all_search_but_not_generation(tmp_path): - async def run(): - async with running_server(tmp_path) as client: - scope = await create_scope(client, "pending-search") - plugin = deploy_plugin(EXAMPLE, tmp_path / "initial", [scope]) - entry = plugin / "plugin.py" - entry.write_text( - entry.read_text().replace( - "class IncidentReport(BaseModel):", "class IncidentReport(BaseModel):\n private_text: str" - ) - + _PIPELINE - ) - entry.write_text( - entry.read_text() - + """ -from dataclasses import replace -from powercontext.artifact_plugins import VectorIndex -from powercontext.builtin.artifacts.memory import EmbeddingProfile -families = (replace(families[0], search=ArtifactSearchDeclaration( - version="2", indexes=(*families[0].search.indexes, VectorIndex( - name="semantic", projection_version="1", source="text", fields=("evidence_text",), - profile=EmbeddingProfile(profile_id="unavailable", model="unconfigured", dimension=2), - )), modes=("text", "vector"), default_mode="text", -)),) -""" - ) - await reload_plugin(client, tmp_path, plugin, 0) - await enable_all(client) - await plugin_state(client, "enabled") - await _write_report(client, scope, "first", "report", "outage") - response = await client.post( - f"/v1/scopes/{scope}/artifacts/incident-report/search", json={"mode": "vector", "query": "outage"} - ) - assert response.status_code == 503 - response = await client.post( - f"/v1/scopes/{scope}/artifacts/incident-report/search", json={"query": "outage"} - ) - assert response.status_code == 503 - latest = await _write_report(client, scope, "second", "report", "recovered", revision=2) - assert latest["revision"] == 2 - historical = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/report/revisions/1") - assert historical.status_code == 200, historical.text - assert historical.json()["revision"] == 1 - assert historical.json()["content"]["evidence_text"] == "outage" - response = await client.post( - f"/v1/scopes/{scope}/artifacts/incident-report/search", json={"query": "recovered"} - ) - assert response.status_code == 503 - - asyncio.run(run()) - - -def test_index_definitions_stay_fixed_while_query_policy_survives_reload_and_restart(tmp_path): - from tests.e2e.test_artifact_plugins import artifacts, capture - - def deploy(version, profile_id, index_name="visual"): - plugin = deploy_plugin(EXAMPLE, tmp_path / version, None, version=version) - entry = plugin / "plugin.py" - entry.write_text( - entry - .read_text() - .replace( - "class IncidentReport(BaseModel):", - "from powercontext.builtin.artifacts.memory import EmbeddingProfile\n" - f'VECTOR_PROFILE = EmbeddingProfile(profile_id="{profile_id}", model="fixture", dimension=2)\n' - "class IncidentReport(BaseModel):\n vector: list[float]\n profile: EmbeddingProfile", - ) - .replace('title=f"', 'vector=[1.0, 0.0], profile=VECTOR_PROFILE, title=f"') - + f''' -from dataclasses import replace -from powercontext.artifact_plugins import VectorIndex -families = (replace(families[0], search=ArtifactSearchDeclaration( - version="1", indexes=(*families[0].search.indexes, VectorIndex( - name="{index_name}", projection_version="1", source="content", - vector_field="vector", profile_field="profile", profile=VECTOR_PROFILE, - )), modes=("text", "vector", "hybrid"), default_mode="text", -)),) -''' - ) - return plugin - - async def run(): - async with running_server(tmp_path) as client: - scope = await create_scope(client, "profile-admission") - first = deploy("1", "features-v1") - state = await reload_plugin(client, tmp_path, first, 0) - # No content exists yet; the declaration is nevertheless already fixed. - second = deploy("2", "features-v2") - publish_plugin(tmp_path, second) - rejected = await client.post( - f"{REGISTRATION}/reload", json={"expected_generation": state["binding_generation"]} - ) - assert rejected.status_code == 409, rejected.text - assert (await client.get(REGISTRATION)).json() == state - publish_plugin(tmp_path, first) - await enable_all(client) - state = await plugin_state(client, "enabled") - await capture(client, scope, "original") - items = await artifacts(client, scope, 1) - await _wait_for_search_ready(client, scope, "Outage") - state = await change(client, "disable", state["binding_generation"], "disabled") - before = await _search(client, scope, "Outage") - vector_request = { - "mode": "vector", - "query_vector": { - "profile": before["results"][0]["content"]["profile"], - "vector": [1.0, 0.0], - }, - } - url = f"/v1/scopes/{scope}/artifacts/incident-report/search" - vector_before = await client.post(url, json=vector_request) - assert vector_before.status_code == 200, vector_before.text - assert vector_before.json()["results"] - # Neither profile, name, projection version, schema identity nor index membership may change. - edits = ( - ("features-v1", "features-v2"), - ('name="visual"', 'name="renamed"'), - ('projection_version="1"', 'projection_version="2"'), - ('schema_version="1"', 'schema_version="2"'), - ('version="1", indexes=', 'version="2", indexes='), - ("indexes=(*families[0].search.indexes, VectorIndex(", "indexes=(VectorIndex("), - ) - for number, (old, new) in enumerate(edits, start=3): - replacement = deploy(str(number), "features-v1") - entry = replacement / "plugin.py" - assert old in entry.read_text() - code = entry.read_text().replace(old, new) - if old.startswith("indexes="): - code = code.replace(")), modes=", "),), modes=") - code = code.replace( - 'modes=("text", "vector", "hybrid"), default_mode="text"', - 'modes=("vector",), default_mode="vector"', - ) - entry.write_text(code) - publish_plugin(tmp_path, replacement) - rejected = await client.post( - f"{REGISTRATION}/reload", json={"expected_generation": state["binding_generation"]} - ) - assert rejected.status_code == 409, rejected.text - assert (await client.get(REGISTRATION)).json() == state - assert await _search(client, scope, "Outage") == before - unchanged = await client.post(url, json=vector_request) - assert unchanged.status_code == 200, unchanged.text - assert unchanged.json() == vector_before.json() - assert await artifacts(client, scope, 1) == items - policy = deploy("query-policy", "features-v1") - entry = policy / "plugin.py" - entry.write_text( - entry.read_text().replace( - 'modes=("text", "vector", "hybrid"), default_mode="text",', - 'modes=("text", "vector"), default_mode="vector", ' - 'defaults={"text": {"text": ("lexical",)}, "vector": {"vector": ("visual",)}}, ' - "candidate_limit=17, rrf_k=11, text_weight=2, vector_weight=3,", - ) - ) - state = await reload_plugin(client, tmp_path, policy, state["binding_generation"]) - default_vector = {"query_vector": vector_request["query_vector"]} - result = await client.post(url, json=default_vector) - assert result.status_code == 200, result.text - assert result.json() == vector_before.json() - assert (await client.post(url, json={"mode": "text", "query": "Outage"})).json() == before - assert (await client.post(url, json={"mode": "hybrid", "query": "Outage"})).status_code == 422 - assert await artifacts(client, scope, 1) == items - # Invalid policy must not replace the last accepted configuration. - entry.write_text(entry.read_text().replace('("visual",)}', '("missing",)}')) - publish_plugin(tmp_path, policy) - invalid = await client.post( - f"{REGISTRATION}/reload", json={"expected_generation": state["binding_generation"]} - ) - assert invalid.status_code == 422, invalid.text - assert (await client.get(REGISTRATION)).json() == state - assert (await client.post(url, json=default_vector)).json() == vector_before.json() - state = await change(client, "unload", state["binding_generation"], "unloaded") - async with running_server(tmp_path) as client: - # Unload clears the runtime definition, but the persisted contract still rejects changes. - publish_plugin(tmp_path, second) - rejected = await client.post( - f"{REGISTRATION}/reload", json={"expected_generation": state["binding_generation"]} - ) - assert rejected.status_code == 409, rejected.text - assert (await client.get(REGISTRATION)).json() == state - assert (await client.post(url, json={"mode": "text", "query": "Outage"})).json() == before - assert (await client.post(url, json=default_vector)).json() == vector_before.json() - assert (await client.post(url, json={"mode": "hybrid", "query": "Outage"})).status_code == 422 - compatible = deploy("code-only", "features-v1") - state = await reload_plugin(client, tmp_path, compatible, state["binding_generation"]) - assert await _search(client, scope, "Outage") == before - assert (await client.get(REGISTRATION)).json()["binding_generation"] == state["binding_generation"] - - asyncio.run(run()) diff --git a/tests/e2e/test_artifact_plugins.py b/tests/e2e/test_artifact_plugins.py deleted file mode 100644 index c8ddc03bee..0000000000 --- a/tests/e2e/test_artifact_plugins.py +++ /dev/null @@ -1,1141 +0,0 @@ -# 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. - -"""Use a delivered plugin through the installed Server CLI and public HTTP APIs.""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -import socket -import sys -from contextlib import asynccontextmanager -from pathlib import Path -from typing import Any - -import httpx -import pytest -import yaml - -from powercontext.builtin.authz import AccessAction, AccessAuditContext, AccessDeniedError, PrincipalRef, ResourceRef -from powercontext.builtin.authz.composition import open_builtin_access_control -from powercontext.builtin.authz.owner_recovery import OwnerRecovery -from powercontext.builtin.authz.profiles import register_custom_artifact_access_family -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile - -EXAMPLE = Path(__file__).resolve().parents[2] / "examples/artifact_plugins/incident_report" -SUMMARY_EXAMPLE = EXAMPLE.parent / "ticket_summary" -ADMIN_AUTHORIZATION_VALUE = "artifact-plugin-e2e-admin" -REGISTRATION = "/v1/admin/artifact-plugins/incident-reports" -SUMMARY_REGISTRATION = "/v1/admin/artifact-plugins/ticket-summaries" - - -def deploy_plugin(source: Path, directory: Path, scope_ids: list[str] | None, **parameters: Any) -> Path: - """Prepare a deployed version outside the discovery directory.""" - - shutil.copytree(source, directory, ignore=shutil.ignore_patterns("__pycache__")) - path = directory / "plugin.yml" - manifest = yaml.safe_load(path.read_text()) - manifest.update(parameters) - if scope_ids is None: - manifest.pop("scope_ids", None) - else: - manifest["scope_ids"] = scope_ids - path.write_text(yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding="utf-8") - return directory - - -def publish_plugin(directory: Path, plugin: Path, binding_name: str | None = None) -> Path: - """Switch a user's discovery entry without changing any previous version.""" - - if binding_name is None: - binding_name = yaml.safe_load((plugin / "plugin.yml").read_text())["binding_name"] - root = directory / "plugins" - root.mkdir(exist_ok=True) - entry = root / binding_name - entry.unlink(missing_ok=True) - entry.symlink_to(plugin, target_is_directory=True) - return entry - - -async def reload_plugin( - client: httpx.AsyncClient, - directory: Path, - plugin: Path, - generation: int, - phase: str = "disabled", - registration: str = REGISTRATION, -) -> dict[str, Any]: - publish_plugin(directory, plugin, registration.rsplit("/", 1)[1]) - response = await client.post(f"{registration}/reload", json={"expected_generation": generation}) - assert response.status_code == 202, response.text - assert response.headers["location"] == registration - return await plugin_state(client, phase, registration) - - -async def enable_all( - client: httpx.AsyncClient, *, expected_failures: frozenset[str] = frozenset() -) -> dict[str, dict[str, Any]]: - response = await client.post("/v1/admin/artifact-plugins/enable-all") - assert response.status_code == 200, response.text - items = response.json()["items"] - failed = frozenset(item["binding_name"] for item in items if item["outcome"] == "failed") - assert failed == expected_failures, response.text - assert all(item["status_url"] == f"/v1/admin/artifact-plugins/{item['binding_name']}" for item in items) - return {item["binding_name"]: item for item in items} - - -@asynccontextmanager -async def running_server( - directory: Path, - *, - envfile: Path | None = None, - directory_from_env: bool = True, - database_url: str | None = None, - database_settings: dict[str, Any] | None = None, - access_mode: str = "enforced", -): - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - env = {key: value for key, value in os.environ.items() if not key.startswith("POWERCONTEXT_SERVER_")} - env.update({ - "POWERCONTEXT_SERVER_DATABASE": json.dumps( - database_settings - or { - "kind": "sqlite", - "url": database_url or f"sqlite+aiosqlite:///{directory / 'server.db'}", - } - ), - "POWERCONTEXT_SERVER_RUNTIME": json.dumps({"artifact_processing_families": []}), - "POWERCONTEXT_SERVER_WORKSPACE": str(directory), - "POWERCONTEXT_HOME": str(directory / "home"), - "POWERCONTEXT_SERVER_AUTH_ENABLED": "true" if access_mode != "disabled" else "false", - "POWERCONTEXT_SERVER_ACCESS_MODE": access_mode, - "POWERCONTEXT_SERVER_MCP_ENABLED": "false", - "POWERCONTEXT_SERVER_METRICS_ENABLED": "true", - "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", - }) - if access_mode != "disabled": - env["POWERCONTEXT_SERVER_AUTH_TOKEN"] = ADMIN_AUTHORIZATION_VALUE - (directory / "plugins").mkdir(exist_ok=True) - if directory_from_env: - env["POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR"] = str(directory / "plugins") - envfile_args = ["--no-env-file"] if envfile is None else ["--env-file", str(envfile)] - entrypoint = Path(sys.executable).with_name("powercontext") - with (directory / "server.log").open("a") as log: - process = await asyncio.create_subprocess_exec( - sys.executable, - str(entrypoint), - "server", - "run", - *envfile_args, - "--host", - "127.0.0.1", - "--port", - str(port), - cwd=directory, - env=env, - stdout=log, - stderr=log, - ) - try: - async with httpx.AsyncClient( - base_url=f"http://127.0.0.1:{port}", - headers={"Authorization": f"Bearer {ADMIN_AUTHORIZATION_VALUE}"}, - timeout=20, - ) as client: - async with asyncio.timeout(40): - while True: - assert process.returncode is None, (directory / "server.log").read_text() - try: - response = await client.get("/health/ready") - if response.status_code == 200: - break - except httpx.ConnectError: - pass - await asyncio.sleep(0.1) - yield client - 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 create_scope(client: httpx.AsyncClient, key: str) -> str: - response = await client.post( - "/v1/scopes", json={"title": key, "summary": "Artifact plugin acceptance", "idempotency_key": key} - ) - assert response.status_code == 201, response.text - return response.json()["scope_id"] - - -async def capture(client: httpx.AsyncClient, scope: str, source_id: str) -> None: - response = await client.post( - "/v1/sources/content", - json={ - "scope_id": scope, - "source_id": source_id, - "content": f"Outage {source_id}: database recovered after retry.", - }, - ) - assert response.status_code == 202, response.text - - -async def plugin_state(client: httpx.AsyncClient, phase: str, registration: str = REGISTRATION) -> dict[str, Any]: - async with asyncio.timeout(40): - while True: - response = await client.get(registration) - assert response.status_code == 200, response.text - state = response.json() - assert state["phase"] != "failed" or phase == "failed", state - if state["phase"] == phase: - return state - await asyncio.sleep(0.1) - - -async def artifacts( - client: httpx.AsyncClient, scope: str, count: int, family: str = "incident-report" -) -> list[dict[str, Any]]: - async with asyncio.timeout(45): - while True: - response = await client.get(f"/v1/scopes/{scope}/artifacts/{family}") - assert response.status_code == 200, response.text - items = response.json()["items"] - if len(items) == count: - return items - await asyncio.sleep(0.2) - - -async def query(client: httpx.AsyncClient, scope: str, family: str = "incident-report") -> dict[str, Any]: - response = await client.post(f"/v1/scopes/{scope}/artifacts/{family}/search", json={"query": "Outage"}) - assert response.status_code == 200, response.text - return response.json() - - -async def _wait_for_search_ready(client, scope, query): - async with asyncio.timeout(30): - while True: - response = await client.post(f"/v1/scopes/{scope}/artifacts/incident-report/search", json={"query": query}) - if response.status_code != 503: - assert response.status_code == 200, response.text - return - assert response.json()["error"]["code"] == "artifact_search_unavailable", response.text - await asyncio.sleep(0.1) - - -async def change( - client: httpx.AsyncClient, action: str, generation: int, phase: str, registration: str = REGISTRATION -) -> dict[str, Any]: - response = await client.post(f"{registration}/{action}", json={"expected_generation": generation}) - assert response.status_code == 202, response.text - assert response.headers["location"] == registration - return await plugin_state(client, phase, registration) - - -async def wait_for_worker_metric(client: httpx.AsyncClient, metric: str, family: str = "incident-report") -> None: - async with asyncio.timeout(40): - while True: - response = await client.get("/metrics") - assert response.status_code == 200, response.text - if any( - f'family="{family}"' in line and float(line.rsplit(" ", 1)[1]) > 0 - for line in response.text.splitlines() - if line.startswith(f"powercontext_server_artifact_processing_{metric}{{") - ): - return - await asyncio.sleep(0.2) - - -async def worker_metric(client: httpx.AsyncClient, metric: str, family: str) -> float: - response = await client.get("/metrics") - assert response.status_code == 200, response.text - values = [ - float(line.rsplit(" ", 1)[1]) - for line in response.text.splitlines() - if line.startswith(f'powercontext_server_artifact_processing_{metric}{{family="{family}"}} ') - ] - assert len(values) == 1, response.text - return values[0] - - -async def contents(client: httpx.AsyncClient, scope: str, family: str, count: int) -> list[dict[str, Any]]: - result = [] - for item in await artifacts(client, scope, count, family): - response = await client.get(f"/v1/scopes/{scope}/artifacts/{family}/{item['artifact_id']}") - assert response.status_code == 200, response.text - result.append(response.json()) - return result - - -def test_strict_plugin_configuration_survives_generation_reload_and_restart(tmp_path: Path) -> None: - asyncio.run(_use_strict_plugin_configuration(tmp_path)) - - -async def _use_strict_plugin_configuration(tmp_path: Path) -> None: - plugin = deploy_plugin(EXAMPLE, tmp_path / "strict-v1", None) - entry = plugin / "plugin.py" - entry.write_text( - entry - .read_text() - .replace("import hashlib", "import hashlib\nfrom datetime import date\nfrom enum import Enum") - .replace('ConfigDict(extra="forbid", frozen=True)', 'ConfigDict(extra="forbid", frozen=True, strict=True)') - .replace( - "class Config(BaseModel):", - 'class Mode(str, Enum):\n NORMAL = "normal"\n URGENT = "urgent"\n\n\nclass Config(BaseModel):', - ) - .replace( - ' title_prefix: str = "Incident report"', - ' mode: Mode = Mode.NORMAL\n observed_at: date = date(2026, 10, 10)\n labels: tuple[str, ...] = ("初始",)\n title_prefix: str = "Incident report"', - ) - .replace( - " return IncidentReportPipeline(config)", - ' prefix = f"{config.mode.value}/{config.observed_at.isoformat()}/{config.labels}"\n return IncidentReportPipeline(config.model_copy(update={"title_prefix": prefix}))', - ) - ) - async with running_server(tmp_path) as client: - scope = await create_scope(client, "strict-configuration") - await reload_plugin(client, tmp_path, plugin, 0) - await change(client, "enable", 1, "enabled") - await capture(client, scope, "before-restart") - reports = await contents(client, scope, "incident-report", 1) - assert reports[0]["content"]["title"].startswith("normal/2026-10-10/('初始',):") - async with running_server(tmp_path) as client: - state = await plugin_state(client, "enabled") - await capture(client, scope, "after-restart") - reports = await contents(client, scope, "incident-report", 2) - assert all(item["content"]["title"].startswith("normal/2026-10-10/('初始',):") for item in reports) - replacement = deploy_plugin( - plugin, - tmp_path / "strict-v2", - None, - version="2", - config={"mode": "urgent", "observed_at": "2026-10-11", "labels": ["更新"]}, - ) - await reload_plugin(client, tmp_path, replacement, state["binding_generation"], "enabled") - await capture(client, scope, "after-reload") - reports = await contents(client, scope, "incident-report", 3) - assert next(item for item in reports if item["sources"][0]["source_id"] == "after-reload")["content"][ - "title" - ].startswith("urgent/2026-10-11/('更新',):") - - -def test_plugin_cannot_register_the_builtin_atomic_memory_family(tmp_path: Path) -> None: - asyncio.run(_reject_builtin_atomic_memory_plugin(tmp_path)) - - -async def _reject_builtin_atomic_memory_plugin(tmp_path: Path) -> None: - plugin = deploy_plugin(EXAMPLE, tmp_path / "builtin-family", None) - entry = plugin / "plugin.py" - entry.write_text(entry.read_text().replace('"incident-report"', '"atomic-memory"')) - async with running_server(tmp_path) as client: - publish_plugin(tmp_path, plugin) - response = await client.post(f"{REGISTRATION}/reload", json={"expected_generation": 0}) - assert response.status_code == 422, response.text - response = await client.get("/v1/admin/artifact-plugins") - assert response.status_code == 200, response.text - assert response.json()["items"] == [] - - -def test_in_memory_database_rejects_plugin_enable_without_changing_registration(tmp_path: Path) -> None: - asyncio.run(_reject_in_memory_plugin_generation(tmp_path)) - - -async def _reject_in_memory_plugin_generation(tmp_path: Path) -> None: - plugin = deploy_plugin(EXAMPLE, tmp_path / "memory-plugin", None) - publish_plugin(tmp_path, plugin) - async with running_server(tmp_path, database_url="sqlite+aiosqlite:///:memory:") as client: - state = await plugin_state(client, "disabled") - scope = await create_scope(client, "in-memory-input") - await capture(client, scope, "pending-input") - response = await client.post( - f"{REGISTRATION}/enable", json={"expected_generation": state["binding_generation"]} - ) - assert response.status_code == 422, response.text - assert await plugin_state(client, "disabled") == state - item = (await enable_all(client, expected_failures=frozenset({"incident-reports"})))["incident-reports"] - assert item["outcome"] == "failed" - assert item["error"] == "invalid_plugin" - assert item["state"] == state - assert await artifacts(client, scope, 0) == [] - - -def test_plugin_manifests_reject_enable_fields_without_replacing_saved_definition(tmp_path: Path) -> None: - asyncio.run(_reject_manifest_enable_fields(tmp_path)) - - -async def _reject_manifest_enable_fields(tmp_path: Path) -> None: - report = deploy_plugin(EXAMPLE, tmp_path / "report-v1", None, config={"title_prefix": "Saved"}) - publish_plugin(tmp_path, report) - invalid_registrations = [] - for field in ("enable", "enabled"): - binding_name = f"invalid-{field}" - plugin = deploy_plugin( - SUMMARY_EXAMPLE, - tmp_path / binding_name, - None, - binding_name=binding_name, - **{field: True}, - ) - publish_plugin(tmp_path, plugin) - invalid_registrations.append((field, binding_name)) - - async with running_server(tmp_path) as client: - state = await plugin_state(client, "disabled") - response = await client.get("/v1/admin/artifact-plugins") - assert response.status_code == 200, response.text - assert response.json()["items"] == [state] - for field, binding_name in invalid_registrations: - registration = f"/v1/admin/artifact-plugins/{binding_name}" - assert (await client.get(registration)).status_code == 404 - for value in (True, False): - candidate = deploy_plugin( - SUMMARY_EXAMPLE, - tmp_path / f"{binding_name}-{value}", - None, - binding_name=binding_name, - **{field: value}, - ) - publish_plugin(tmp_path, candidate) - rejected = await client.post(f"{registration}/reload", json={"expected_generation": 0}) - assert rejected.status_code == 422, rejected.text - assert (await client.get(registration)).status_code == 404 - - assert set(await enable_all(client)) == {"incident-reports"} - state = await plugin_state(client, "enabled") - scope = await create_scope(client, "manifest-validation") - await capture(client, scope, "before-invalid-update") - await artifacts(client, scope, 1) - for field in ("enable", "enabled"): - for value in (True, False): - candidate = deploy_plugin( - EXAMPLE, - tmp_path / f"report-{field}-{value}", - None, - config={"title_prefix": "Rejected"}, - **{field: value}, - ) - publish_plugin(tmp_path, candidate) - rejected = await client.post( - f"{REGISTRATION}/reload", json={"expected_generation": state["binding_generation"]} - ) - assert rejected.status_code == 422, rejected.text - assert await plugin_state(client, "enabled") == state - await capture(client, scope, "after-invalid-update") - results = await contents(client, scope, "incident-report", 2) - assert all(item["content"]["title"].startswith("Saved:") for item in results) - - -def test_user_plugin_through_server_hot_management_and_restart(tmp_path: Path) -> None: - asyncio.run(_use_plugin_through_server(tmp_path)) - - -async def _use_plugin_through_server(tmp_path: Path) -> None: - # Existing Scopes and inputs are created through the delivered HTTP API. - async with running_server(tmp_path) as client: - scope = await create_scope(client, "reports") - other_scope = await create_scope(client, "other") - await capture(client, scope, "ticket-1") - plugin = deploy_plugin(EXAMPLE, tmp_path / "incident-report-v1", [scope], config={"title_prefix": "Report"}) - factory_signal = tmp_path / "pipeline-created" - entry = plugin / "plugin.py" - entry.write_text( - entry.read_text().replace( - " return IncidentReportPipeline(config)", - f" from pathlib import Path\n Path({str(factory_signal)!r}).write_text('created')\n return IncidentReportPipeline(config)", - ) - ) - publish_plugin(tmp_path, plugin) - broken = deploy_plugin( - SUMMARY_EXAMPLE, tmp_path / "broken-v1", [scope], binding_name="broken-summary", config={"unknown": True} - ) - publish_plugin(tmp_path, broken) - unregistered = deploy_plugin(SUMMARY_EXAMPLE, tmp_path / "unregistered-plugin", [scope]) - # Neither version directories outside the root nor nested entries are discovered. - nested = tmp_path / "plugins" / "nested" - nested.mkdir() - (nested / "ticket-summaries").symlink_to(unregistered, target_is_directory=True) - envfile = tmp_path / "server.env" - envfile.write_text(f"POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR={tmp_path / 'plugins'}\n") - async with running_server(tmp_path, envfile=envfile, directory_from_env=False) as client: - state = await plugin_state(client, "disabled") - assert state["binding_generation"] == 1 - registrations = (await client.get("/v1/admin/artifact-plugins")).json()["items"] - assert [item["binding_name"] for item in registrations] == ["incident-reports"] - await asyncio.sleep(2) - assert not factory_signal.exists(), "Loading a declaration must not construct the generation pipeline" - assert await artifacts(client, scope, 0) == [] - metadata = (await client.get("/v1/capabilities")).json() - assert "incident-report" in metadata["artifact_families"] - schema = next(item for item in metadata["custom_artifact_schemas"] if item["family"] == "incident-report") - assert schema["schema_version"] == "1" - assert schema["search"]["indexes"][0]["fields"] == ["title", "evidence_text"] - denied = await client.post("/v1/admin/artifact-plugins/enable-all", headers={"Authorization": ""}) - assert denied.status_code == 401 - obsolete = await client.post( - f"{REGISTRATION}/reload", - json={ - "expected_generation": 1, - "directory": str(plugin), - "scope_ids": [scope], - "config": {}, - "enabled": True, - }, - ) - assert obsolete.status_code == 422, obsolete.text - assert (await client.put(REGISTRATION, json={"expected_generation": 1})).status_code == 405 - accepted = await enable_all(client) - assert accepted["incident-reports"]["outcome"] == "accepted" - state = await plugin_state(client, "enabled") - assert state["binding_generation"] == 2 - await artifacts(client, scope, 1) - assert factory_signal.exists() - assert (await enable_all(client))["incident-reports"]["outcome"] == "skipped" - await capture(client, scope, "ticket-2") - await capture(client, other_scope, "outside-scope") - items = await artifacts(client, scope, 2) - assert {item["revision"] for item in items} == {1} - page = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report", params={"limit": 1}) - assert page.status_code == 200, page.text - next_page = await client.get( - f"/v1/scopes/{scope}/artifacts/incident-report", params={"limit": 1, "cursor": page.json()["next_cursor"]} - ) - assert next_page.status_code == 200, next_page.text - assert {page.json()["items"][0]["artifact_id"], next_page.json()["items"][0]["artifact_id"]} == { - item["artifact_id"] for item in items - } - for item in items: - result = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/{item['artifact_id']}") - assert result.status_code == 200, result.text - artifact = result.json() - assert artifact["schema_version"] == "1" - assert artifact["content"]["title"].startswith("Report: ticket-") - assert artifact["content"]["evidence_text"].startswith("Outage ticket-") - assert artifact["sources"] == [ - {"source_type": "content", "source_id": artifact["content"]["title"].split(": ")[1]} - ] - cached = await client.get( - f"/v1/scopes/{scope}/artifacts/incident-report/{item['artifact_id']}", - headers={"If-None-Match": result.headers["etag"]}, - ) - assert cached.status_code == 304 - assert len((await query(client, scope))["results"]) == 2 - assert (await query(client, other_scope))["results"] == [] - await capture(client, scope, "ticket-1") - stale = await client.post(f"{REGISTRATION}/disable", json={"expected_generation": 99}) - assert stale.status_code == 409 - state = await change(client, "disable", 2, "disabled") - await capture(client, scope, "ticket-3") - assert len((await query(client, scope))["results"]) == 2 - assert "enabled" not in yaml.safe_load((plugin / "plugin.yml").read_text()) - replacement = deploy_plugin( - EXAMPLE, tmp_path / "incident-report-v2", [scope], version="2", config={"title_prefix": "Replacement"} - ) - publish_plugin(tmp_path, replacement) - # Environment variables take priority over an envfile. Scanning cannot reset a saved disable or update its code. - envfile.write_text(f"POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR={tmp_path / 'wrong-discovery-root'}\n") - async with running_server(tmp_path, envfile=envfile) as client: - state = await plugin_state(client, "disabled") - assert state["binding_generation"] == 3 - assert len((await query(client, scope))["results"]) == 2 - assert len((await client.get("/v1/admin/artifact-plugins")).json()["items"]) == 1 - state = await change(client, "enable", state["binding_generation"], "enabled") - assert state["binding_generation"] == 4 - results = await contents(client, scope, "incident-report", 3) - assert all(item["content"]["title"].startswith("Report:") for item in results) - # Enabled registrations resume without another batch enable and keep the resolved saved version. - async with running_server(tmp_path) as client: - state = await plugin_state(client, "enabled") - assert state["binding_generation"] > 4 - await capture(client, scope, "ticket-4") - results = await contents(client, scope, "incident-report", 4) - assert all(item["content"]["title"].startswith("Report:") for item in results) - # Explicit reload picks up the switched entry and keeps this plugin enabled. - state = await reload_plugin(client, tmp_path, replacement, state["binding_generation"], "enabled") - unchanged = await reload_plugin(client, tmp_path, replacement, state["binding_generation"], "enabled") - assert unchanged["binding_generation"] == state["binding_generation"] - unloaded = await change(client, "unload", state["binding_generation"], "unloaded") - shutil.rmtree(plugin) - # Startup must not load even a valid discovery entry for an unloaded registration. - async with running_server(tmp_path) as client: - state = await plugin_state(client, "unloaded") - assert state["binding_generation"] == unloaded["binding_generation"] - assert (await enable_all(client))["incident-reports"]["outcome"] == "skipped" - assert len((await query(client, scope))["results"]) == 4 - await capture(client, scope, "ticket-5") - assert len(await artifacts(client, scope, 4)) == 4 - for item in results: - result = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/{item['artifact_id']}") - assert result.status_code == 200, result.text - state = await reload_plugin(client, tmp_path, replacement, state["binding_generation"]) - assert len((await query(client, scope))["results"]) == 4 - await change(client, "enable", state["binding_generation"], "enabled") - results = await contents(client, scope, "incident-report", 5) - assert ( - next(item for item in results if item["sources"][0]["source_id"] == "ticket-5")["content"]["title"] - == "Replacement: ticket-5" - ) - - -def test_generation_failure_retries_uncommitted_inputs_after_config_update(tmp_path: Path) -> None: - asyncio.run(_recover_generation_failure(tmp_path)) - - -async def _recover_generation_failure(tmp_path: Path) -> None: - code = tmp_path / "user-plugin-code" - shutil.copytree(EXAMPLE, code, ignore=shutil.ignore_patterns("__pycache__")) - entry = code / "plugin.py" - entry.write_text( - entry - .read_text() - .replace( - ' title_prefix: str = "Incident report"', - ' deployment_label: str = Field(alias="label")\n title_prefix: str = "Incident report"', - ) - .replace( - " return IncidentReportPipeline(config)", - ' if config.deployment_label != "recovery":\n raise ValueError("Invalid deployment label")\n return IncidentReportPipeline(config)', - ) - ) - async with running_server(tmp_path) as client: - scope = await create_scope(client, "reports") - replacement_scope = await create_scope(client, "replacement") - plugin = deploy_plugin( - code, tmp_path / "plugin-v1", [scope], config={"fail_on_text": "Outage", "label": "recovery"} - ) - await capture(client, scope, "failed-ticket") - await reload_plugin(client, tmp_path, plugin, 0) - await change(client, "enable", 1, "enabled") - # The product exposes Worker failures through its authenticated metrics. - await wait_for_worker_metric(client, "failed_total") - assert await artifacts(client, scope, 0) == [] - invalid_directory = deploy_plugin(plugin, tmp_path / "invalid-config", [scope], config={"unknown": True}) - publish_plugin(tmp_path, invalid_directory, "incident-reports") - invalid = await client.post(f"{REGISTRATION}/reload", json={"expected_generation": 2}) - assert invalid.status_code == 422, invalid.text - assert (await plugin_state(client, "enabled"))["binding_generation"] == 2 - invalid_entry = deploy_plugin(plugin, tmp_path / "invalid-entry", [scope]) - entry = invalid_entry / "plugin.py" - entry.write_text( - entry.read_text().replace( - "async def create_pipeline(ctx: GenerationContext, config: Config)", - "async def create_pipeline(config: Config)", - ) - ) - publish_plugin(tmp_path, invalid_entry, "incident-reports") - invalid = await client.post(f"{REGISTRATION}/reload", json={"expected_generation": 2}) - assert invalid.status_code == 422, invalid.text - assert (await plugin_state(client, "enabled"))["binding_generation"] == 2 - mismatch = deploy_plugin(plugin, tmp_path / "wrong-binding", [scope], binding_name="another-binding") - publish_plugin(tmp_path, mismatch, "incident-reports") - invalid = await client.post(f"{REGISTRATION}/reload", json={"expected_generation": 2}) - assert invalid.status_code == 422, invalid.text - assert (await plugin_state(client, "enabled"))["binding_generation"] == 2 - incompatible = deploy_plugin(plugin, tmp_path / "incompatible-schema", [scope]) - entry = incompatible / "plugin.py" - entry.write_text(entry.read_text().replace("Field(min_length=1)", "Field(min_length=2)")) - publish_plugin(tmp_path, incompatible, "incident-reports") - conflict = await client.post(f"{REGISTRATION}/reload", json={"expected_generation": 2}) - assert conflict.status_code == 409, conflict.text - assert (await plugin_state(client, "enabled"))["binding_generation"] == 2 - replacement = deploy_plugin( - plugin, tmp_path / "plugin-v2", [replacement_scope], version="2", config={"label": "recovery"} - ) - await reload_plugin(client, tmp_path, replacement, 2, "enabled") - await capture(client, replacement_scope, "replacement-ticket") - await artifacts(client, replacement_scope, 1) - await wait_for_worker_metric(client, "completed_total") - assert await artifacts(client, scope, 0) == [] - metrics = await client.get("/metrics") - assert metrics.status_code == 200, metrics.text - failures = [ - float(line.rsplit(" ", 1)[1]) - for line in metrics.text.splitlines() - if line.startswith('powercontext_server_artifact_processing_failed_total{family="incident-report"}') - ] - assert failures == [0.0], "Removed Scopes must not keep dispatching failed Workers" - # Re-adding a Scope resumes its retained input instead of losing the failed window. - restored = deploy_plugin( - replacement, tmp_path / "plugin-v3", [scope, replacement_scope], version="3", config={"label": "recovery"} - ) - await reload_plugin(client, tmp_path, restored, 3, "enabled") - items = await artifacts(client, scope, 1) - assert items[0]["revision"] == 1 - # First publication is asynchronous even after generation has committed. - await _wait_for_search_ready(client, scope, "Outage") - assert len((await query(client, scope))["results"]) == 1 - - -def test_disabling_an_active_worker_reclaims_it_before_reenabling(tmp_path: Path) -> None: - asyncio.run(_disable_active_generation(tmp_path)) - - -async def _disable_active_generation(tmp_path: Path) -> None: - code = tmp_path / "user-plugin-code" - shutil.copytree(EXAMPLE, code, ignore=shutil.ignore_patterns("__pycache__")) - entry = code / "plugin.py" - entry.write_text( - entry - .read_text() - .replace("import hashlib", "import asyncio\nimport hashlib") - .replace( - ' title_prefix: str = "Incident report"', - ' block_generation: bool = False\n title_prefix: str = "Incident report"', - ) - + """ - -class SummaryPipeline: - def __init__(self, ctx, config): - self.ctx = ctx - self.config = config - - async def generate(self, batch): - if self.config.block_generation: - await asyncio.Future() - previous = await self.ctx.read_latest("incident-report", "latest-report") - old_text = "" if previous is None else previous.content["evidence_text"] + "\\n" - return [ArtifactDraft( - family="incident-report", schema_version="1", artifact_id="latest-report", - expected_revision=0 if previous is None else previous.ref.revision, - content={"title": self.config.title_prefix, - "evidence_text": old_text + "\\n".join(TextEvidence.model_validate(source.value).content for source in batch.sources)}, - source_refs=tuple(source.ref for source in batch.sources), - artifact_refs=() if previous is None else (previous.ref,), - )] - -async def create_pipeline(ctx, config): - return SummaryPipeline(ctx, config) -""" - ) - async with running_server(tmp_path) as client: - scope = await create_scope(client, "reports") - plugin = deploy_plugin(code, tmp_path / "plugin-v1", [scope], config={"block_generation": True}) - await capture(client, scope, "active-ticket") - await reload_plugin(client, tmp_path, plugin, 0) - await change(client, "enable", 1, "enabled") - await wait_for_worker_metric(client, "used_workers") - await change(client, "disable", 2, "disabled") - assert await artifacts(client, scope, 0) == [] - replacement = deploy_plugin( - plugin, - tmp_path / "plugin-v2", - [scope], - version="2", - config={"block_generation": False, "title_prefix": "Replacement"}, - ) - state = await reload_plugin(client, tmp_path, replacement, 3) - assert await artifacts(client, scope, 0) == [] - await change(client, "enable", state["binding_generation"], "enabled") - items = await artifacts(client, scope, 1) - response = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/{items[0]['artifact_id']}") - assert response.status_code == 200, response.text - assert response.json()["revision"] == 1 - assert response.json()["content"]["title"] == "Replacement" - await capture(client, scope, "update-ticket") - async with asyncio.timeout(45): - while True: - response = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/latest-report") - assert response.status_code == 200, response.text - if response.json()["revision"] == 2: - break - await asyncio.sleep(0.2) - result = response.json() - assert "active-ticket" in result["content"]["evidence_text"] - assert "update-ticket" in result["content"]["evidence_text"] - assert result["sources"] == [{"source_type": "content", "source_id": "update-ticket"}] - assert result["artifacts"] == [{"family": "incident-report", "artifact_id": "latest-report", "revision": 1}] - assert (await query(client, scope))["results"][0]["revision"] == 2 - previous = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/latest-report/revisions/1") - assert previous.status_code == 200, previous.text - assert "update-ticket" not in previous.json()["content"]["evidence_text"] - - -def test_multiple_plugin_manifests_apply_independent_configuration(tmp_path: Path) -> None: - asyncio.run(_use_independent_plugins(tmp_path)) - - -async def _use_independent_plugins(tmp_path: Path) -> None: - summary_registration = "/v1/admin/artifact-plugins/ticket-summaries" - async with running_server(tmp_path) as client: - report_scope = await create_scope(client, "reports") - other_report_scope = await create_scope(client, "other-reports") - summary_scope = await create_scope(client, "summaries") - report = deploy_plugin( - EXAMPLE, - tmp_path / "report-v1", - [report_scope, other_report_scope], - max_sources=1, - processing={"interval_seconds": 0.5, "max_workers": 2, "timeout_seconds": 60}, - config={"title_prefix": "独立报告"}, - ) - # This user pipeline accepts a single evidence item per invocation. - entry = report / "plugin.py" - entry.write_text( - entry.read_text().replace( - " drafts = []", - ' if len(batch.sources) != 1:\n raise ValueError("Expected one ticket per report invocation")\n drafts = []', - ) - ) - summary = deploy_plugin( - SUMMARY_EXAMPLE, - tmp_path / "summary-v1", - [summary_scope], - max_sources=2, - processing={"interval_seconds": 15, "max_workers": 1, "timeout_seconds": 90}, - config={"language": "Français"}, - ) - for scope in (report_scope, summary_scope): - for number in range(3): - await capture(client, scope, f"ticket-{number}") - await capture(client, other_report_scope, "another-ticket") - assert (await client.get("/v1/admin/artifact-plugins")).json()["items"] == [] - for registration, directory in ((REGISTRATION, report), (summary_registration, summary)): - await reload_plugin(client, tmp_path, directory, 0, registration=registration) - assert await artifacts(client, report_scope, 0) == [] - assert await artifacts(client, summary_scope, 0, "ticket-summary") == [] - # A temporarily unavailable deployed version fails independently; successful items stay accepted. - unavailable = summary.with_name("summary-v1-unavailable") - summary.rename(unavailable) - results = await enable_all(client, expected_failures=frozenset({"ticket-summaries"})) - assert results["incident-reports"]["outcome"] == "accepted" - assert results["ticket-summaries"]["outcome"] == "failed" - assert results["ticket-summaries"]["error"] == "invalid_plugin" - assert (await plugin_state(client, "disabled", summary_registration))["binding_generation"] == 1 - await plugin_state(client, "enabled") - summary = unavailable.rename(summary) - results = await enable_all(client) - assert results["incident-reports"]["outcome"] == "skipped" - assert results["ticket-summaries"]["outcome"] == "accepted" - await plugin_state(client, "enabled", summary_registration) - assert all(item["outcome"] == "skipped" for item in (await enable_all(client)).values()) - started = asyncio.get_running_loop().time() - assert await worker_metric(client, "max_workers", "incident-report") == 2 - assert await worker_metric(client, "max_workers", "ticket-summary") == 1 - # No second automatic window may be admitted before this binding's interval. - # The first window can run immediately and must contain at most two Sources. - while asyncio.get_running_loop().time() < started + 14: - response = await client.get(f"/v1/scopes/{summary_scope}/artifacts/ticket-summary") - assert response.status_code == 200, response.text - assert len(response.json()["items"]) <= 1, "The summary binding ignored its 15-second interval" - await asyncio.sleep(0.2) - reports = await contents(client, report_scope, "incident-report", 3) - assert all(item["content"]["title"].startswith("独立报告:") for item in reports) - assert all(len(item["sources"]) == 1 for item in reports) - await artifacts(client, other_report_scope, 1) - summaries = await contents(client, summary_scope, "ticket-summary", 2) - assert sorted(len(item["sources"]) for item in summaries) == [1, 2] - assert all(item["content"]["language"] == "Français" for item in summaries) - assert {source["source_id"] for item in summaries for source in item["sources"]} == { - "ticket-0", - "ticket-1", - "ticket-2", - } - assert (await query(client, report_scope, "ticket-summary"))["results"] == [] - assert (await query(client, summary_scope))["results"] == [] - assert "enabled" not in yaml.safe_load((report / "plugin.yml").read_text()) - assert "enabled" not in yaml.safe_load((summary / "plugin.yml").read_text()) - replacement = deploy_plugin( - summary, - tmp_path / "summary-v2", - [summary_scope], - version="2", - max_sources=1, - processing={"interval_seconds": 0.5, "max_workers": 2, "timeout_seconds": 60}, - config={"language": "Deutsch"}, - ) - await reload_plugin(client, tmp_path, replacement, 2, "enabled", summary_registration) - for number in (3, 4): - await capture(client, summary_scope, f"ticket-{number}") - summaries = await contents(client, summary_scope, "ticket-summary", 4) - updated = [item for item in summaries if item["content"]["language"] == "Deutsch"] - assert len(updated) == 2 - assert all(len(item["sources"]) == 1 for item in updated) - assert await worker_metric(client, "max_workers", "ticket-summary") == 2 - assert await worker_metric(client, "max_workers", "incident-report") == 2 - await change(client, "unload", 3, "unloaded", summary_registration) - await capture(client, report_scope, "report-after-summary-unload") - await artifacts(client, report_scope, 4) - assert len((await query(client, summary_scope, "ticket-summary"))["results"]) == 4 - - -def test_worker_timeout_is_local_to_its_plugin_and_code_update_recovers(tmp_path: Path) -> None: - asyncio.run(_recover_timed_out_plugin(tmp_path)) - - -async def _recover_timed_out_plugin(tmp_path: Path) -> None: - summary_registration = "/v1/admin/artifact-plugins/ticket-summaries" - async with running_server(tmp_path) as client: - report_scope = await create_scope(client, "timed-out-reports") - summary_scope = await create_scope(client, "healthy-summaries") - blocked = deploy_plugin( - EXAMPLE, - tmp_path / "blocked-v1", - [report_scope], - processing={"interval_seconds": 1, "max_workers": 1, "timeout_seconds": 5}, - ) - entry = blocked / "plugin.py" - entry.write_text( - entry - .read_text() - .replace("import hashlib", "import asyncio\nimport hashlib") - .replace(" drafts = []", " await asyncio.Future()\n drafts = []") - ) - healthy = deploy_plugin(SUMMARY_EXAMPLE, tmp_path / "healthy-v1", [summary_scope]) - for registration, directory in ((REGISTRATION, blocked), (summary_registration, healthy)): - await reload_plugin(client, tmp_path, directory, 0, registration=registration) - assert all(item["outcome"] == "accepted" for item in (await enable_all(client)).values()) - await plugin_state(client, "enabled") - await plugin_state(client, "enabled", summary_registration) - await capture(client, report_scope, "timed-out-ticket") - await capture(client, summary_scope, "healthy-ticket") - await wait_for_worker_metric(client, "timeouts_total") - assert await artifacts(client, report_scope, 0) == [] - healthy_result = await contents(client, summary_scope, "ticket-summary", 1) - assert healthy_result[0]["content"]["source_ids"] == ["healthy-ticket"] - assert (await plugin_state(client, "enabled", summary_registration))["binding_generation"] == 2 - recovered = deploy_plugin( - EXAMPLE, - tmp_path / "report-v2", - [report_scope], - version="2", - config={"title_prefix": "Recovered"}, - ) - await reload_plugin(client, tmp_path, recovered, 2, "enabled") - result = await contents(client, report_scope, "incident-report", 1) - assert result[0]["revision"] == 1 - assert result[0]["content"]["title"] == "Recovered: timed-out-ticket" - assert len((await query(client, summary_scope, "ticket-summary"))["results"]) == 1 - - -def test_enable_all_fixes_its_snapshot_and_reports_concurrent_changes(tmp_path: Path) -> None: - asyncio.run(_enable_all_with_concurrent_changes(tmp_path)) - - -async def _enable_all_with_concurrent_changes(tmp_path: Path) -> None: - async with running_server(tmp_path) as client: - scope = await create_scope(client, "batch-management") - report = deploy_plugin(EXAMPLE, tmp_path / "report-v1", [scope]) - gate = tmp_path / "validation-paused" - entered = tmp_path / "validation-entered" - entry = report / "plugin.py" - # A user module with a slow import allows independent HTTP management during validation. - entry.write_text( - entry.read_text() - + f""" - -from pathlib import Path -import time -if Path({str(gate)!r}).exists(): - Path({str(entered)!r}).write_text("entered") - while Path({str(gate)!r}).exists(): - time.sleep(0.05) -""" - ) - await reload_plugin(client, tmp_path, report, 0) - summary = deploy_plugin(SUMMARY_EXAMPLE, tmp_path / "summary-v1", [scope]) - publish_plugin(tmp_path, summary) - # Merely publishing an entry after startup does not make it eligible for a batch enable. - assert (await client.get(SUMMARY_REGISTRATION)).status_code == 404 - gate.touch() - batch = asyncio.create_task(enable_all(client, expected_failures=frozenset({"incident-reports"}))) - try: - async with asyncio.timeout(10): - while True: - if entered.exists(): - break - await asyncio.sleep(0.05) - await reload_plugin(client, tmp_path, summary, 0, registration=SUMMARY_REGISTRATION) - replacement = deploy_plugin(EXAMPLE, tmp_path / "report-v2", [scope], version="2") - updated = await reload_plugin(client, tmp_path, replacement, 1) - assert updated["binding_generation"] == 2 - finally: - gate.unlink(missing_ok=True) - results = await batch - assert set(results) == {"incident-reports"}, "A batch must not include plugins loaded after its snapshot" - assert results["incident-reports"]["outcome"] == "failed" - assert results["incident-reports"]["error"] == "conflict" - assert results["incident-reports"]["state"]["binding_generation"] == 2 - assert (await plugin_state(client, "disabled"))["binding_generation"] == 2 - assert (await plugin_state(client, "disabled", SUMMARY_REGISTRATION))["binding_generation"] == 1 - assert all(item["outcome"] == "accepted" for item in (await enable_all(client)).values()) - # Transitions and completed enables are both ineligible for an immediate repeat. - assert all(item["outcome"] == "skipped" for item in (await enable_all(client)).values()) - await plugin_state(client, "enabled") - await plugin_state(client, "enabled", SUMMARY_REGISTRATION) - - -def test_failed_restore_waits_for_explicit_reload_and_preserves_progress(tmp_path: Path) -> None: - asyncio.run(_repair_failed_restore(tmp_path)) - - -async def _repair_failed_restore(tmp_path: Path) -> None: - async with running_server(tmp_path) as client: - scope = await create_scope(client, "failed-restore") - healthy_scope = await create_scope(client, "healthy") - report = deploy_plugin(EXAMPLE, tmp_path / "report-v1", [scope]) - summary = deploy_plugin(SUMMARY_EXAMPLE, tmp_path / "summary-v1", [healthy_scope]) - await reload_plugin(client, tmp_path, report, 0) - await change(client, "enable", 1, "enabled") - await reload_plugin(client, tmp_path, summary, 0, registration=SUMMARY_REGISTRATION) - await capture(client, scope, "before-failure") - await artifacts(client, scope, 1) - # An unavailable saved version must fail restoration even if the discovery entry points at a valid update. - report.rename(tmp_path / "report-v1-unavailable") - replacement = deploy_plugin( - EXAMPLE, tmp_path / "report-v2", [scope], version="2", config={"title_prefix": "Repaired"} - ) - publish_plugin(tmp_path, replacement) - async with running_server(tmp_path) as client: - failed = await plugin_state(client, "failed") - assert failed["binding_generation"] == 3 - assert failed["last_error"] - await capture(client, scope, "during-failure") - results = await enable_all(client) - assert results["incident-reports"]["outcome"] == "skipped" - assert results["ticket-summaries"]["outcome"] == "accepted" - await plugin_state(client, "enabled", SUMMARY_REGISTRATION) - await capture(client, healthy_scope, "healthy-ticket") - await artifacts(client, healthy_scope, 1, "ticket-summary") - assert len((await query(client, scope))["results"]) == 1 - # Failed is durable, never auto-enabled by startup or by enable-all. - async with running_server(tmp_path) as client: - state = await plugin_state(client, "failed") - assert state["binding_generation"] == failed["binding_generation"] - assert all(item["outcome"] == "skipped" for item in (await enable_all(client)).values()) - invalid = await client.post(f"{REGISTRATION}/enable", json={"expected_generation": state["binding_generation"]}) - assert invalid.status_code == 422, invalid.text - state = await reload_plugin(client, tmp_path, replacement, state["binding_generation"]) - assert await artifacts(client, scope, 1) - assert (await enable_all(client))["incident-reports"]["outcome"] == "accepted" - await plugin_state(client, "enabled") - results = await contents(client, scope, "incident-report", 2) - repaired = next(item for item in results if item["sources"][0]["source_id"] == "during-failure") - assert repaired["revision"] == 1 - assert repaired["content"]["title"] == "Repaired: during-failure" - assert len((await query(client, healthy_scope, "ticket-summary"))["results"]) == 1 - - -def test_ownership_migration_restores_a_registered_custom_family(tmp_path: Path) -> None: - asyncio.run(_migrate_registered_custom_family(tmp_path)) - - -async def _migrate_registered_custom_family(tmp_path: Path) -> None: - plugin = deploy_plugin(EXAMPLE, tmp_path / "migration-v1", None) - async with running_server(tmp_path, access_mode="disabled") as client: - scope = await create_scope(client, "custom-migration") - await reload_plugin(client, tmp_path, plugin, 0) - await change(client, "enable", 1, "enabled") - await capture(client, scope, "legacy-report") - report = (await contents(client, scope, "incident-report", 1))[0] - # A disabled plugin stays disabled after restart, so no replay repairs the legacy owner. - await change(client, "disable", 2, "disabled") - async with running_server(tmp_path) as client: - plan = (await client.post("/v1/access/ownership/plan", json={"scope_ids": [scope]})).json() - assert [(gap["family"], gap["artifact_id"]) for gap in plan["scopes"][0]["artifact_gaps"]] == [ - ("incident-report", report["artifact_id"]) - ] - applied = await client.post( - "/v1/access/ownership/apply", - json={"scope_ids": [scope], "owner": {"type": "service", "id": "server-token"}}, - ) - assert applied.status_code == 200, applied.text - assert [item["kind"] for item in applied.json()["applied"]] == ["artifact_owner"] - async with open_builtin_access_control(SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}")) as access: - register_custom_artifact_access_family("incident-report") - resource = ResourceRef.artifact(scope, family="incident-report", artifact_id=report["artifact_id"]) - relation = await access.artifact_owner(resource) - assert relation is not None and relation.owner == PrincipalRef(type="service", id="server-token") - await access.require( - relation.owner, - AccessAction.ARTIFACT_WRITE, - resource, - context=AccessAuditContext(transport="test", operation="artifact.write"), - ) - with pytest.raises(AccessDeniedError): - await access.require( - PrincipalRef(type="user", id="worker"), - AccessAction.ARTIFACT_WRITE, - resource, - context=AccessAuditContext(transport="test", operation="artifact.write"), - ) - - -def test_offline_migration_reports_pending_custom_family_owner(tmp_path: Path) -> None: - asyncio.run(_offline_migration_reports_pending_custom_family_owner(tmp_path)) - - -async def _offline_migration_reports_pending_custom_family_owner(tmp_path: Path) -> None: - plugin = deploy_plugin(EXAMPLE, tmp_path / "pending-v1", None) - async with running_server(tmp_path, access_mode="disabled") as client: - scope = await create_scope(client, "custom-pending") - await reload_plugin(client, tmp_path, plugin, 0) - await change(client, "enable", 1, "enabled") - await capture(client, scope, "pending-report") - (report,) = await contents(client, scope, "incident-report", 1) - await change(client, "disable", 2, "disabled") - - resource = ResourceRef.artifact(scope, family="incident-report", artifact_id=report["artifact_id"]) - database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}") - async with open_builtin_access_control(database) as access, SQLiteProfile.open(database, tables=()) as profile: - recovery = OwnerRecovery(profile.database, access) - await recovery.initialize() - async with profile.database.transaction() as connection: - await recovery.for_transaction(connection).establish_artifact_owner( - resource, - PrincipalRef(type="user", id="alice"), - idempotency_key="custom-family-pending", - context=AccessAuditContext(transport="test", operation="artifact.write"), - ) - assert [item.resource for item in await recovery.pending(scope_id=scope)] == [resource] - - # A separate process holds no custom family profile; only persisted declarations identify it. - environment = tmp_path / "offline.env" - environment.write_text( - "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\n" - f"POWERCONTEXT_SERVER_DATABASE_URL=sqlite+aiosqlite:///{(tmp_path / 'server.db').as_posix()}\n" - "POWERCONTEXT_SERVER_ACCESS_MODE=disabled\n" - "POWERCONTEXT_SERVER_RUNTIME_ARTIFACT_PROCESSING_FAMILIES=[]\n", - encoding="utf-8", - ) - result = await asyncio.create_subprocess_exec( - str(Path(sys.executable).with_name("powercontext")), - "server", - "access-ownership-migrate", - "--env-file", - str(environment), - "--action", - "apply", - "--owner", - "user:bob", - "--scope-id", - scope, - "--maintenance-confirmed", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await result.communicate() - assert result.returncode == 1, (stdout + stderr).decode() - migrated = json.loads(stdout) - assert migrated["applied"] == [] - assert [item["detail"] for item in migrated["conflicts"]] == ["owner-recovery-pending"] - - async with open_builtin_access_control(database) as access: - register_custom_artifact_access_family("incident-report") - assert await access.artifact_owner(resource) is None diff --git a/tests/e2e/test_artifact_tags.py b/tests/e2e/test_artifact_tags.py index 06806b3988..521c6282b0 100644 --- a/tests/e2e/test_artifact_tags.py +++ b/tests/e2e/test_artifact_tags.py @@ -396,7 +396,7 @@ async def exercise_tag_http(app, *, token: str | None = None) -> str: matches = await client.query_artifact_tags( scope, QueryArtifactTagsRequest.model_validate({"tags": ["RELEASE"]}) ) - assert {item.target.root.family.value for item in matches.items} == set(contents) + assert {item.target.root.model_dump(mode="json")["family"] for item in matches.items} == set(contents) selected = await client.query_artifact_tags( scope, QueryArtifactTagsRequest.model_validate({"tags": ["release"], "families": list(contents)}) ) diff --git a/tests/e2e/test_artifact_vector_http.py b/tests/e2e/test_artifact_vector_http.py deleted file mode 100644 index e01b7de79d..0000000000 --- a/tests/e2e/test_artifact_vector_http.py +++ /dev/null @@ -1,131 +0,0 @@ -# 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. - -"""Installed Server and spawned generation Worker share text/vector/hybrid search.""" - -import asyncio - -from powercontext.builtin.persistence.oceanbase import OceanBaseConfig -from tests.builtin.persistence.test_artifact_indexes import PROFILE, body -from tests.e2e.test_artifact_plugin_search import _PIPELINE, _write_report -from tests.e2e.test_artifact_plugins import ( - EXAMPLE, - create_scope, - deploy_plugin, - enable_all, - reload_plugin, - running_server, -) - -_DECLARATION = """ -from powercontext.artifact_plugins import ArtifactSearchDeclaration, TextIndex, VectorIndex, FragmentProjection -from powercontext.builtin.artifacts.memory import EmbeddingProfile - -class Feature(BaseModel): - id: str - vector: list[float] - profile: EmbeddingProfile - -class SearchReport(IncidentReport): - private_text: str - fragments: list[Feature] - -families = (ArtifactFamily( - family="incident-report", schema_version="1", content_model=SearchReport, - search=ArtifactSearchDeclaration( - version="1", modes=("text", "vector", "hybrid"), default_mode="text", - indexes=( - TextIndex(name="lexical", fields=("title", "evidence_text"), projection_version="1"), - VectorIndex(name="visual", projection_version="1", source="content", - profile=EmbeddingProfile(profile_id="features-v1", model="fixture", dimension=2, normalization="none"), - vector_field="vector", profile_field="profile", - fragments=FragmentProjection(array_field="fragments", id_field="id")), - ), - ), -),) -inputs = InputContract(projections=inputs.projections, artifact_families=("experience", "skill", "profile")) -""" - - -def test_vector_search_through_http_and_spawned_worker(migration_backend): - config, directory = migration_backend.config, migration_backend.directory - database = config.model_dump(mode="json") - if isinstance(config, OceanBaseConfig): - database["url"] = config.url.get_secret_value() - - async def exercise(): - async with running_server(directory, database_settings=database) as client: - scope = await create_scope(client, "vector-search") - plugin = deploy_plugin(EXAMPLE, directory / "vector-plugin", [scope]) - entry = plugin / "plugin.py" - pipeline = _PIPELINE.replace( - " drafts = []", - """ # These use the built-in Searchers, not the custom-family declaration parser. - assert not (await self.ctx.search_artifacts("experience", "Outage")).matches - assert not (await self.ctx.search_artifacts("skill", "Outage")).matches - found = await self.ctx.search_artifacts("incident-report", "Report") - assert all("evidence_text" in artifact.content.model_dump() for artifact in found.artifacts) - for artifact in found.artifacts: - await self.ctx.read_revision(artifact.as_ref()) - drafts = []""", - ).replace( - "content=value, source_refs=(source.ref,),", - "content=value, source_refs=(source.ref,), artifact_refs=tuple(artifact.as_ref() for artifact in found.artifacts),", - ) - entry.write_text(entry.read_text() + _DECLARATION + pipeline, encoding="utf-8") - await reload_plugin(client, directory, plugin, 0) - await enable_all(client) - first = await _write_report( - client, scope, "first", "a", "数据库 timeout", fragments=body("", *([[0, 0]] * 8))["fragments"] - ) - second = await _write_report( - client, scope, "second", "b", "数据库 normal", fragments=body("", [1, 0])["fragments"] - ) - assert second["artifacts"] == [{"family": "incident-report", "artifact_id": "a", "revision": 1}] - endpoint = f"/v1/scopes/{scope}/artifacts/incident-report/search" - vector = {"profile": PROFILE.model_dump(mode="json"), "vector": [0, 0]} - request = {"mode": "vector", "query_vector": vector, "limit": 2, "include_scores": True} - for mode in ("vector", "hybrid"): - response = await client.post( - endpoint, json={**request, "mode": mode, **({"query": "数据库"} if mode == "hybrid" else {})} - ) - assert response.status_code == 200, response.text - results = response.json()["results"] - assert [result["artifact_id"] for result in results] == ["a", "b"] - assert results[0]["content"] == first["content"] - assert set(results[0]) == {"family", "artifact_id", "revision", "content", "lineage", "scores"} - for invalid in ( - {"query": "Report", "indexes": {"vector": ["visual"]}}, - {"query": "Report", "indexes": {"text": []}}, - {**request, "query_vector": {**vector, "vector": [1]}}, - { - **request, - "mode": "hybrid", - "query": "Report", - "fusion": {"method": "rrf", "params": {"weights": {"text": 0, "vector": 0}}}, - }, - ): - response = await client.post(endpoint, json=invalid) - assert response.status_code == 422, response.text - unavailable = await client.post(endpoint, json={"mode": "vector", "query": "Report"}) - assert unavailable.status_code == 503 - denied = await client.post(endpoint, json=request, headers={"Authorization": ""}) - assert denied.status_code == 401 - await _write_report(client, scope, "update", "a", "recovered", revision=2, fragments=[]) - response = await client.post(endpoint, json=request) - assert [item["artifact_id"] for item in response.json()["results"]] == ["b"] - historical = await client.get(f"/v1/scopes/{scope}/artifacts/incident-report/a/revisions/1") - assert historical.json()["content"] == first["content"] - - asyncio.run(exercise()) diff --git a/tests/e2e/test_atomic_memory_description_upgrade.py b/tests/e2e/test_atomic_memory_description_upgrade.py index 48db983ef8..4aa47d9fb2 100644 --- a/tests/e2e/test_atomic_memory_description_upgrade.py +++ b/tests/e2e/test_atomic_memory_description_upgrade.py @@ -85,9 +85,9 @@ def test_nonempty_released_memory_upgrades_without_rewriting_history(tmp_path, r ) result = _apply(runner) - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_004" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_005" verified = runner.verify() - assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_004" + assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_005" assert not _apply(runner).changed with closing(sqlite3.connect(runner.database)) as connection: assert ( diff --git a/tests/e2e/test_capture_coverage.py b/tests/e2e/test_capture_coverage.py index 90238ffa76..d733abd2c9 100644 --- a/tests/e2e/test_capture_coverage.py +++ b/tests/e2e/test_capture_coverage.py @@ -36,7 +36,7 @@ from powercontext.builtin.sources import ContentCapture, ContentSource, ContentTextEvidenceProjection from powercontext.sources import SourceRef from tests.e2e.dream_support import atomic_memory_pipeline -from tests.e2e.test_artifact_plugins import ADMIN_AUTHORIZATION_VALUE, create_scope, running_server +from tests.e2e.plugin_server import ADMIN_AUTHORIZATION_VALUE, create_scope, running_server from tests.integrations.test_hermes_provider import hermes_modules as hermes_modules FIXTURE = json.loads( diff --git a/tests/e2e/test_context_text_assembly.py b/tests/e2e/test_context_text_assembly.py index 44877a9534..3508941f8d 100644 --- a/tests/e2e/test_context_text_assembly.py +++ b/tests/e2e/test_context_text_assembly.py @@ -230,7 +230,7 @@ async def scenario(): prepared = await client.prepare_context(request.model_copy(update={"include_receipt": True})) assert prepared.content is not None assert prepared.receipt is not None - assert [item.kind.value for item in prepared.receipt.selected] == [ + assert [item.kind for item in prepared.receipt.selected] == [ "profile", "topic-memory", "experience", diff --git a/tests/e2e/test_plugin_pages.py b/tests/e2e/test_plugin_pages.py new file mode 100644 index 0000000000..d9d03f5446 --- /dev/null +++ b/tests/e2e/test_plugin_pages.py @@ -0,0 +1,136 @@ +# 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 plugin's Dashboard page is sandboxed and reaches only its own routes through the session.""" + +import asyncio +import json + +import httpx + +from tests.e2e.plugin_server import ADMIN_AUTHORIZATION_VALUE, running_server + +PLUGIN = """ +from pydantic import BaseModel + +from powercontext.plugins import ApiRequest, ArtifactPlugin, ChatMessage, PluginApi, PluginPages, Rendering, Route + + +class Echo(BaseModel): + text: str + + +class Demo: + def __init__(self, ctx): + self.ctx = ctx + + async def render_artifact(self, request): + return Rendering(text=request.artifact.content.get("text", "")) + + async def echo(self, request: ApiRequest): + return {"text": request.body.text} + + async def upload(self, request: ApiRequest): + return {"size": len(request.body)} + + async def complete(self, request: ApiRequest): + return {"answer": await self.ctx.models.generate([ChatMessage(role="user", content="hello")])} + + +async def setup(ctx): + return Demo(ctx) + + +plugin = ArtifactPlugin( + family="demo-notes", + setup=setup, + api=PluginApi( + routes=( + Route(method="POST", path="/echo", handler="echo", access="authenticated", body="json", request_model=Echo), + Route( + method="POST", + path="/upload", + handler="upload", + access="authenticated", + body="binary", + max_body_bytes=8 * 1024 * 1024, + ), + Route(method="POST", path="/complete", handler="complete", access="authenticated"), + ) + ), + pages=PluginPages(title={"en": "Demo notes", "zh": "演示"}), +) +""" +PAGE = 'Demo' + + +def test_plugin_page_is_sandboxed_and_forwards_only_same_origin_session_requests(tmp_path): + plugins = tmp_path / "plugins" + (plugins / "demo" / "ui").mkdir(parents=True) + (plugins / "demo" / "plugin.py").write_text(PLUGIN, encoding="utf-8") + (plugins / "demo" / "ui" / "index.html").write_text(PAGE, encoding="utf-8") + pages = "/dashboard/plugins/demo" + environment = { + "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "true", + # The deployment ceiling caps the upload route's own 8 MiB limit. + "POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES": "4096", + # pydantic-ai's built-in test model answers every request without a provider. + "POWERCONTEXT_SERVER_INFERENCE": json.dumps({"generation_model": "test"}), + } + + async def scenario(): + async with running_server(tmp_path, plugins, extra_env=environment) as client: + async with httpx.AsyncClient(base_url=client.base_url, timeout=20) as anonymous: + asset = await anonymous.get(f"{pages}/ui/index.html") + assert asset.status_code == 200, asset.text + policy = asset.headers["content-security-policy"] + assert policy.startswith("sandbox allow-scripts") and "allow-same-origin" not in policy + # Only page assets are served: never plugin code, never outside the pages directory. + for path in ("plugin.py", "../plugin.py", "%2e%2e/plugin.py", "index.html/../../plugin.py"): + leaked = await anonymous.get(f"{pages}/ui/{path}") + assert leaked.status_code in {401, 404} and b"import" not in leaked.content, path + assert (await anonymous.get("/dashboard/plugins/_client.js")).status_code == 200 + assert (await anonymous.get(pages)).status_code == 401 + forwarded = await anonymous.post( + f"{pages}/api/echo", json={"text": "x"}, headers={"Sec-Fetch-Site": "same-origin"} + ) + assert forwarded.status_code == 401 + + session = {"powercontext_dashboard_token": ADMIN_AUTHORIZATION_VALUE} + async with httpx.AsyncClient(base_url=client.base_url, cookies=session, timeout=20) as browser: + page = await browser.get(pages, params={"lang": "en"}) + assert page.status_code == 200, page.text + assert 'sandbox="allow-scripts' in page.text and "Demo notes" in page.text + assert f'href="{pages}?' in (await browser.get("/dashboard/home")).text + + same_origin = {"Sec-Fetch-Site": "same-origin"} + echoed = await browser.post(f"{pages}/api/echo", json={"text": "hello"}, headers=same_origin) + assert echoed.status_code == 200, echoed.text + assert echoed.json() == {"text": "hello"} + # The framed page is cross-site with an opaque origin; neither form is forwarded. + for headers in ({"Sec-Fetch-Site": "cross-site"}, {"Origin": "null"}, {}): + rejected = await browser.post(f"{pages}/api/echo", json={"text": "x"}, headers=headers) + assert rejected.status_code == 403 + + large = await browser.post(f"{pages}/api/upload", content=b"x" * 5000, headers=same_origin) + assert large.status_code == 413, large.text + small = await browser.post(f"{pages}/api/upload", content=b"x" * 3000, headers=same_origin) + assert small.json() == {"size": 3000} + + # Plugins call the Server's generation model without seeing its address or credentials. + completed = await client.post("/v1/plugins/demo/api/complete") + assert completed.status_code == 200, completed.text + assert completed.json()["answer"] + + asyncio.run(scenario()) diff --git a/tests/e2e/test_plugins.py b/tests/e2e/test_plugins.py new file mode 100644 index 0000000000..7d71eb05d2 --- /dev/null +++ b/tests/e2e/test_plugins.py @@ -0,0 +1,411 @@ +# 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 deployed plugin is called by the Server and read through the public Artifact API.""" + +import asyncio + +from tests.e2e.plugin_server import capture, create_scope, items, running_server, wait_for +from tests.plugin_support import write_plugin + +NOTES = """ +from powercontext.plugins import ArtifactWriter, GenerateRequest, InvalidContent, Rendering, SearchHit, SearchResult + + +class Notes: + def __init__(self, ctx): + self.ctx = ctx + + async def render_artifact(self, request) -> Rendering: + return Rendering(text="# Note\\n\\n" + request.artifact.content["text"]) + + async def search_artifacts(self, request) -> SearchResult: + # The plugin owns its search: here, a substring match over its current Artifacts. + unsupported = set(request.search) - {"query", "limit"} + if unsupported: + raise InvalidContent("unsupported search fields: " + ", ".join(sorted(unsupported))) + query = request.search["query"].casefold() + page = await self.ctx.reads(request.scope_id).list(limit=200) + hits = [ + SearchHit(ref=item.ref, text=item.content["text"], score=1.0) + for item in page.items + if query in item.content["text"].casefold() + ] + return SearchResult(hits=hits[: request.search.get("limit", 10)]) + + async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None: + async with writer.transaction() as tx: + for source in request.sources: + text = source.projection["content"] + await tx.put(writer.stable_id(source.ref.source_id), content={"text": text}, source_refs=[source.ref]) + + async def create_artifact(self, request, writer) -> None: + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=request.content) + + async def update_artifact(self, request, writer) -> None: + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=request.content, expected_revision=request.expected_revision) + + async def forget_artifact(self, request, writer) -> None: + async with writer.transaction() as tx: + await tx.set_lifecycle(request.artifact_id, "deprecated") + + async def merge_artifacts(self, request, writer) -> None: + async with writer.transaction() as tx: + await tx.merge([item.artifact_id for item in request.inputs], into=request.into, content=request.content) + + async def restore_artifact(self, request, writer) -> None: + async with writer.transaction() as tx: + if request.operation == "undo_merge": + await tx.undo_merge(request.artifact_id) + elif request.revision is not None: + await tx.revert(request.artifact_id, to_revision=request.revision) + else: + await tx.set_lifecycle(request.artifact_id, "active") + + +async def setup(ctx): + return Notes(ctx) +""" + + +async def governance(client, path): + response = await client.get(path) + assert response.status_code == 200, response.text + return response.json() + + +BROKEN = """ +raise ImportError("missing dependency") +""" + + +def test_server_loads_plugins_dispatches_on_sources_and_serves_the_artifact_api(tmp_path): + plugins = tmp_path / "plugins" + write_plugin(plugins, "notes", NOTES) + write_plugin(plugins, "broken", BROKEN) + + async def search(client, scope, query): + response = await client.post(f"/v1/scopes/{scope}/artifacts/notes/search", json={"query": query}) + assert response.status_code == 200, response.text + return [item["content"]["text"] for item in response.json()["results"]] + + async def scenario(): + async with running_server(tmp_path, plugins) as client: + listing = {item["name"]: item for item in (await client.get("/v1/plugins")).json()["items"]} + assert {name: item["status"] for name, item in listing.items()} == {"broken": "failed", "notes": "loaded"} + assert listing["notes"]["source_types"] == ["content"] and listing["notes"]["triggers"] == ["sources"] + assert "notes" in (await client.get("/v1/capabilities")).json()["artifact_families"] + + scope = await create_scope(client, "plugin-e2e") + await capture(client, scope, "deploy", "Deploys go to the east region") + await capture(client, scope, "keys", "Rotate signing keys monthly") + log = tmp_path / "server.log" + notes = await wait_for(lambda: items(client, scope, "notes"), lambda value: len(value) == 2, log=log) + bodies = {} + for item in notes: + response = await client.get(f"/v1/scopes/{scope}/artifacts/notes/{item['artifact_id']}") + assert response.status_code == 200, response.text + bodies[item["artifact_id"]] = response.json()["content"]["text"] + assert set(bodies.values()) == {"Deploys go to the east region", "Rotate signing keys monthly"} + assert await search(client, scope, "region") == ["Deploys go to the east region"] + # The whole search request reaches the plugin, which rejects what it does not support. + rejected_search = await client.post( + f"/v1/scopes/{scope}/artifacts/notes/search", json={"query": "region", "mode": "vector"} + ) + assert rejected_search.status_code == 422, rejected_search.text + east = next(key for key, text in bodies.items() if "east" in text) + rendering = await client.get(f"/v1/scopes/{scope}/artifacts/notes/{east}/rendering") + assert rendering.status_code == 200, rendering.text + assert rendering.json() == { + "family": "notes", + "artifact_id": east, + "revision": 1, + "format": "markdown", + "text": "# Note\n\nDeploys go to the east region", + } + builtin_rendering = await client.get(f"/v1/scopes/{scope}/artifacts/skill/x/rendering") + assert builtin_rendering.status_code == 422, builtin_rendering.text + prepared = await client.post( + "/v1/context/prepare", + json={ + "scope_id": scope, + "query": "region", + "include_receipt": True, + "assembly": {"sections": [{"family": "notes", "limit": 2}]}, + }, + ) + assert prepared.status_code == 200, prepared.text + assert "## notes" in prepared.json()["content"] + assert "Deploys go to the east region" in prepared.json()["content"] + plugin_search_on_builtin = await client.post( + "/v1/context/prepare", + json={ + "scope_id": scope, + "query": "region", + "assembly": {"sections": [{"family": "memory", "limit": 2, "search": {"mode": "fts"}}]}, + }, + ) + assert plugin_search_on_builtin.status_code == 422, plugin_search_on_builtin.text + + # The generic create route takes a plugin family's content; core allocates the ID. + created = await client.post( + f"/v1/scopes/{scope}/artifacts", json={"family": "notes", "content": {"text": "Freeze on Fridays"}} + ) + assert created.status_code == 201, created.text + assert (created.json()["family"], created.json()["revision"]) == ("notes", 1) + assert created.headers["ETag"] == '"revision:1"' + assert (await client.get(created.headers["Location"])).json()["content"] == {"text": "Freeze on Fridays"} + assert await search(client, scope, "fridays") == ["Freeze on Fridays"] + await client.post( + f"/v1/scopes/{scope}/artifacts/notes/{created.json()['artifact_id']}/forget", + json={ + "revision": 1, + "state_version": (await governance(client, f"{created.headers['Location']}/governance"))[ + "state_version" + ], + }, + ) + # Built-in names keep their own contract; a family no plugin provides is invalid. + builtin = await client.post( + f"/v1/scopes/{scope}/artifacts", json={"family": "memory", "content": {"text": "x"}} + ) + assert builtin.status_code == 422, builtin.text + unknown = await client.post(f"/v1/scopes/{scope}/artifacts", json={"family": "nope", "content": {}}) + assert unknown.status_code == 422, unknown.text + + async def progress(): + response = await client.get("/v1/plugins/notes/scopes") + assert response.status_code == 200, response.text + return {item["scope_id"]: item for item in response.json()["items"]} + + state = (await wait_for(progress, lambda value: not value[scope]["pending"], log=log))[scope] + assert state["consumed_position"] == state["source_position"] + + # Users change plugin Artifacts through the generic Artifact API; the plugin's host process applies it. + target = next(key for key, text in bodies.items() if "east" in text) + path = f"/v1/scopes/{scope}/artifacts/notes/{target}" + edited = await client.put( + path, + json={"content": {"text": "Deploys go to the west region"}}, + headers={"If-Match": (await client.get(path)).headers["ETag"]}, + ) + assert edited.status_code == 200, edited.text + assert edited.json()["revision"] == 2 + assert await search(client, scope, "west") == ["Deploys go to the west region"] + stale = await client.put(path, json={"content": {"text": "lost update"}}, headers={"If-Match": '"1"'}) + assert stale.status_code == 412, stale.text + + # Tags, forget, merge and restoration use the generic routes every registered family shares. + tags_path = f"{path}/tags" + tagged = await client.put( + tags_path, + json={"tags": ["release"]}, + headers={"If-Match": (await client.get(tags_path)).headers["ETag"]}, + ) + assert tagged.status_code == 200, tagged.text + matches = await client.post(f"/v1/scopes/{scope}/artifact-tags/query", json={"tags": ["Release"]}) + assert matches.status_code == 200, matches.text + assert [item["target"]["artifact_id"] for item in matches.json()["items"]] == [target] + + state = await governance(client, f"{path}/governance") + assert (state["lifecycle_state"], state["artifact"]["revision"]) == ("active", 2) + forgotten = await client.post( + f"{path}/forget", json={"revision": 2, "state_version": state["state_version"]} + ) + assert forgotten.status_code == 200, forgotten.text + assert forgotten.json()["records"][0]["lifecycle_state"] == "deprecated" + assert await search(client, scope, "region") == [] + stale_forget = await client.post( + f"{path}/forget", json={"revision": 2, "state_version": state["state_version"]} + ) + assert stale_forget.status_code == 409, stale_forget.text + restored = await client.post(f"{path}/restorations", json={}) + assert restored.status_code == 200, restored.text + assert await search(client, scope, "west") == ["Deploys go to the west region"] + + inputs = [] + for artifact_id in bodies: + current = await governance(client, f"/v1/scopes/{scope}/artifacts/notes/{artifact_id}/governance") + inputs.append({ + "artifact_id": artifact_id, + "revision": current["artifact"]["revision"], + "state_version": current["state_version"], + }) + merged = await client.post( + f"/v1/scopes/{scope}/artifacts/notes/merges", + json={"inputs": inputs, "content": {"text": "Deploys go west; rotate keys monthly"}}, + ) + assert merged.status_code == 200, merged.text + records = {item["artifact"]["artifact_id"]: item for item in merged.json()["records"]} + result = next(key for key in records if key not in bodies) + assert {records[key]["merged_into_id"] for key in bodies} == {result} + assert await search(client, scope, "monthly") == ["Deploys go west; rotate keys monthly"] + undone = await client.post( + f"/v1/scopes/{scope}/artifacts/notes/{result}/restorations", json={"operation": "undo_merge"} + ) + assert undone.status_code == 200, undone.text + assert [ref["artifact_id"] for ref in undone.json()["retired"]] == [result] + assert sorted(await search(client, scope, "monthly")) == ["Rotate signing keys monthly"] + + # Families without a registered merge service are rejected before any work. + for family in ("unknown", "skill"): + rejected = await client.post( + f"/v1/scopes/{scope}/artifacts/{family}/x/forget", json={"revision": 1, "state_version": 0} + ) + assert rejected.status_code == 422, rejected.text + + asyncio.run(scenario()) + + +def test_generic_merge_routes_serve_registered_built_in_families(tmp_path): + from tests.e2e.test_artifact_merge_experience import _content + + plugins = tmp_path / "plugins" + plugins.mkdir() + + async def scenario(): + async with running_server(tmp_path, plugins) as client: + scope = await create_scope(client, "generic-merge-e2e") + created = [] + for name in ("canary", "pause"): + response = await client.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "experience", "content": _content(name).model_dump(mode="json", exclude_none=True)}, + ) + assert response.status_code == 201, response.text + created.append(response.json()["artifact_id"]) + inputs = [] + for artifact_id in created: + state = await governance(client, f"/v1/scopes/{scope}/artifacts/experience/{artifact_id}/governance") + inputs.append({"artifact_id": artifact_id, "revision": 1, "state_version": state["state_version"]}) + merged = await client.post( + f"/v1/scopes/{scope}/artifacts/experience/merges", + json={"inputs": inputs, "content": _content("combined").model_dump(mode="json", exclude_none=True)}, + ) + assert merged.status_code == 200, merged.text + frozen = {item["artifact"]["artifact_id"]: item["merged_into_id"] for item in merged.json()["records"]} + (result,) = set(frozen) - set(created) + assert {frozen[key] for key in created} == {result} + + first = f"/v1/scopes/{scope}/artifacts/experience/{created[0]}" + restored = await client.post(f"{first}/restorations", json={}) + assert restored.status_code == 200, restored.text + assert [ref["artifact_id"] for ref in restored.json()["retired"]] == [result] + state = await governance(client, f"{first}/governance") + assert (state["lifecycle_state"], state["merged_into_id"]) == ("active", None) + + asyncio.run(scenario()) + + +TICKETS = """ +from pydantic import BaseModel, Field + +from powercontext.plugins import ArtifactPlugin, Inputs, Rendering +from powercontext.sources import Source, SourceMaterialization + + +class TicketInput(BaseModel): + key: str = Field(pattern=r"^[A-Z]+-[0-9]+$") + title: str = Field(min_length=1) + + +class TicketSource(Source): + title: str + + +class TicketProjection: + name = "tickets.ticket" + version = "1" + source_class = TicketSource + output_class = TicketInput + + def project(self, source, /): + return TicketInput(key=source.name, title=source.title) + + +class TicketDefinition: + input_class = TicketInput + name = "ticket" + source_class = TicketSource + version = "1" + projections = (TicketProjection(),) + + async def resolve(self, value, /): + return TicketSource( + name=value.key, materialization=SourceMaterialization.CAPTURED, description="A ticket", title=value.title + ) + + async def read(self, source, /): + return TicketProjection().project(source) + + +class Tickets: + async def render_artifact(self, request): + return Rendering(format="text", text=request.artifact.content["title"]) + + async def generate(self, request, writer): + async with writer.transaction() as tx: + for source in request.sources: + key = source.projection["key"] + await tx.put(writer.stable_id(key), content=source.projection, source_refs=[source.ref]) + + +async def setup(ctx): + return Tickets() + + +plugin = ArtifactPlugin( + family="tickets", + inputs=Inputs(source_types=("ticket",), projection="tickets.ticket@1"), + on_sources=True, + definitions=(TicketDefinition(),), + setup=setup, +) +""" + + +def test_generic_source_api_writes_plugin_source_types(tmp_path): + plugins = tmp_path / "plugins" + (plugins / "tickets").mkdir(parents=True) + (plugins / "tickets" / "plugin.py").write_text(TICKETS, encoding="utf-8") + + async def scenario(): + async with running_server(tmp_path, plugins) as client: + scope = await create_scope(client, "plugin-sources-e2e") + sources = f"/v1/scopes/{scope}/sources" + ticket = {"source_type": "ticket", "content": {"key": "OPS-7", "title": "Disk full on db-1"}} + created = await client.post(sources, json=ticket) + assert created.status_code == 201, created.text + assert (created.json()["source_type"], created.json()["source_id"]) == ("ticket", "OPS-7") + stored = await client.get(created.headers["Location"]) + assert stored.status_code == 200, stored.text + # The same input resolves to the same Source. + assert (await client.post(sources, json=ticket)).json()["position"] == created.json()["position"] + + invalid = await client.post(sources, json={"source_type": "ticket", "content": {"key": "bad"}}) + assert invalid.status_code == 422, invalid.text + unknown = await client.post(sources, json={"source_type": "nope", "content": {}}) + assert unknown.status_code == 404, unknown.text + content = await client.post(sources, json={"content": "built-in content still works"}) + assert content.status_code == 201, content.text + + tickets = await wait_for( + lambda: items(client, scope, "tickets"), lambda value: len(value) == 1, log=tmp_path / "server.log" + ) + body = await client.get(f"/v1/scopes/{scope}/artifacts/tickets/{tickets[0]['artifact_id']}") + assert body.json()["content"] == {"key": "OPS-7", "title": "Disk full on db-1"} + + asyncio.run(scenario()) diff --git a/tests/e2e/test_prepared_context_receipt.py b/tests/e2e/test_prepared_context_receipt.py index 31e0a5fade..15c7e3690b 100644 --- a/tests/e2e/test_prepared_context_receipt.py +++ b/tests/e2e/test_prepared_context_receipt.py @@ -135,7 +135,7 @@ async def scenario(): == "sha256:" + hashlib.sha256(normalize_query(large_query).encode("utf-8")).hexdigest() ) assert any( - item.family.value == "atomic-memory" and item.reason.value == "not_retrieved" + item.family == "atomic-memory" and item.reason.value == "not_retrieved" for item in empty.receipt.omitted ) ref = receipt["selected"][0]["artifact_address"]["artifact"] diff --git a/tests/e2e/test_prompt_management_cleanup.py b/tests/e2e/test_prompt_management_cleanup.py deleted file mode 100644 index 8e262b8375..0000000000 --- a/tests/e2e/test_prompt_management_cleanup.py +++ /dev/null @@ -1,122 +0,0 @@ -# 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. - -"""Live-fixture cleanup preserves other Scopes and their real vector projections.""" - -from __future__ import annotations - -import asyncio - -import pytest -from sqlalchemy import inspect - -from powercontext.builtin.artifacts.memory import EmbeddingProfile -from powercontext.builtin.inference import EmbeddingResult -from powercontext.builtin.persistence.artifact_indexes import ArtifactIndexBuilder, ArtifactProjectionPublisher -from powercontext.builtin.persistence.artifact_vector_space import prepare_family_spaces -from powercontext.builtin.persistence.oceanbase import OceanBaseConfig -from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts -from powercontext.builtin.runtime.config import InferenceConfig -from powercontext.builtin.scope import ScopeDraft -from powercontext.server.settings import ServerSettings -from tests.builtin.persistence.test_artifact_indexes import PROFILE, body, declaration, register, service, write -from tests.e2e.atomic_memory_migration_backend import MigrationBackend -from tests.e2e.server_storage import prepare_server_database -from tests.e2e.test_prompt_management_live import _cleanup - - -class _Embedding: - profile = EmbeddingProfile(profile_id="cleanup-fixture", model="fixture", dimension=3, normalization="unit") - - async def embed(self, texts: tuple[str, ...], /) -> EmbeddingResult: - return EmbeddingResult(vectors=((1.0, 0.0, 0.0),) * len(texts)) - - -@pytest.mark.parametrize("migration_backend", ("sqlite", "oceanbase"), indirect=True) -def test_cleanup_removes_scoped_native_vectors_and_preserves_other_scope(migration_backend: MigrationBackend) -> None: - embedding = _Embedding() - settings = ServerSettings( - database=migration_backend.config, - inference=InferenceConfig( - embedding_model=embedding.profile.model, - embedding_profile_id=embedding.profile.profile_id, - embedding_dimension=embedding.profile.dimension, - ), - ) - prepare_server_database( - settings, evidence_dir=migration_backend.directory / "migration", embedding_profile=embedding.profile - ) - config = BuiltinConfig(database=settings.database, inference=settings.inference, runtime=settings.runtime) - - async def scenario() -> None: - async with open_builtin_contexts(config, embedding_model=embedding) as contexts: - scopes = tuple([ - await contexts.scopes.create(ScopeDraft(title=key, summary="Cleanup evidence", idempotency_key=key)) - for key in ("cleanup-owned", "other-project") - ]) - memories = [] - family = "cleanup-report" - database = contexts.database - await register(database, family, declaration()) - publisher = ArtifactProjectionPublisher(database) - builder = ArtifactIndexBuilder(database) - for scope in scopes: - (created,) = await contexts.records.create_atomic_memories( - scope.scope_id, - ({"kind": "fact", "text": "A retry retains one committed write.", "description": "Retry policy"},), - ) - memories.append(await contexts.atomic_memory.for_scope(scope.scope_id).get(created.artifact_id)) - await write(database, publisher, scope.scope_id, family, "report", body("Retry report", [1, 0])) - await builder.refresh(scope.scope_id, family) - await prepare_family_spaces(database, family) - for scope in scopes: - assert await builder.build(scope.scope_id, family) - async with database.transaction() as connection: - names = await connection.run_sync(lambda sync: set(inspect(sync).get_table_names())) - if isinstance(settings.database, OceanBaseConfig): - assert "pc_artifact_vectors" not in names - assert any(name.startswith("pc_av_") for name in names) - - search = service(database, family) - query = { - "mode": "vector", - "query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}, - "include_scores": True, - } - for scope in scopes: - found = await search.search(scope.scope_id, family, query) - assert tuple(item.artifact_id for item in found.artifacts) == ("report",) - keep = scopes[1].scope_id - before = await contexts.records.get_artifact_revision(keep, family, "report", 1) - before_vector = await search.search(keep, family, query) - - await _cleanup(contexts, [scopes[0].scope_id]) - await _cleanup(contexts, [scopes[0].scope_id]) - - assert await contexts.scopes.list(scope_ids=[scope.scope_id for scope in scopes]) == (scopes[1],) - assert await contexts.records.get_artifact_revision(keep, family, "report", 1) == before - after_vector = await search.search(keep, family, query) - assert after_vector.artifacts == before_vector.artifacts - assert ( - after_vector.matches[0].channel_scores["vector"].raw - == before_vector.matches[0].channel_scores["vector"].raw - ) - memory = contexts.atomic_memory.for_scope(keep) - assert await memory.get(memories[1].ref.artifact_id) == memories[1] - result = await memory.search("Retried write", mode="vector") - assert tuple(hit.hit.artifact_ref for hit in result.hits) == (memories[1].ref,) - result = await memory.search("retry committed", mode="text") - assert tuple(hit.hit.artifact_ref for hit in result.hits) == (memories[1].ref,) - - asyncio.run(scenario()) diff --git a/tests/e2e/test_source_artifact_plugins.py b/tests/e2e/test_source_artifact_plugins.py deleted file mode 100644 index 2a29f35f0c..0000000000 --- a/tests/e2e/test_source_artifact_plugins.py +++ /dev/null @@ -1,91 +0,0 @@ -# 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 Source plugin snapshot flows through a real Artifact Worker to searchable evidence.""" - -import asyncio -import shutil - -from fastmcp import Client - -from tests.e2e.test_artifact_plugins import EXAMPLE, artifacts, change, deploy_plugin, reload_plugin -from tests.source_helpers import ROOT, TOKENS, call, create_scope, grant, load_write_cli, read, running_server - - -def test_source_plugin_to_artifact_search_and_exact_read(tmp_path): - asyncio.run(_source_to_artifact(tmp_path)) - - -async def _source_to_artifact(directory): - source_plugin = directory / "tickets" - shutil.copytree( - ROOT / "examples/source_plugins/tickets", source_plugin, ignore=shutil.ignore_patterns("__pycache__") - ) - report_plugin = deploy_plugin(EXAMPLE, directory / "reports", None) - entry = report_plugin / "plugin.py" - # Use the delivered generator with the Source plugin's persisted text projection. - entry.write_text( - entry.read_text().replace( - '"content": TEXT_EVIDENCE_PROJECTION_KEY', '"example-ticket": TEXT_EVIDENCE_PROJECTION_KEY' - ) - ) - value = { - "namespace": "support", - "project": "powercontext", - "ticket_id": "PC-123", - "title": "数据库恢复", - "body": "Outage resolved after database retry.", - } - environment = {"POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR": str(directory / "plugins")} - (directory / "plugins").mkdir() - async with running_server(directory, environment=environment) as (admin, url, _process): - scope = await create_scope(admin, "source-artifact-chain") - await grant(admin, scope, "deployer") - await grant(admin, scope) - await load_write_cli(source_plugin / "write.yml", url) - await reload_plugin(admin, directory, report_plugin, 0) - await change(admin, "enable", 1, "enabled") - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - captured = await call( - host, - "pc_source_add", - { - "scope_id": scope, - "capability": "ticket.write", - "capability_version": "1", - "input": value, - }, - ) - source_ref = captured["result"]["source_refs"][0] - assert source_ref["source_type"] == "example-ticket" - assert (await read(admin, scope, source_ref))["content"]["payload"]["capture"]["body"] == value["body"] - [artifact] = await artifacts(admin, scope, 1) - async with asyncio.timeout(30): - while True: - response = await admin.post( - f"/v1/scopes/{scope}/artifacts/incident-report/search", json={"query": "数据库恢复"} - ) - if response.status_code == 200 and response.json()["results"]: - break - assert response.status_code in (200, 503), response.text - await asyncio.sleep(0.1) - [hit] = response.json()["results"] - assert hit["artifact_id"] == artifact["artifact_id"] - assert hit["revision"] == artifact["revision"] - exact = await admin.get( - f"/v1/scopes/{scope}/artifacts/incident-report/{hit['artifact_id']}/revisions/{hit['revision']}" - ) - assert exact.status_code == 200, exact.text - assert exact.json()["content"]["evidence_text"] == value["title"] + "\n\n" + value["body"] - assert exact.json()["sources"] == [source_ref] diff --git a/tests/e2e/test_source_citation_locators.py b/tests/e2e/test_source_citation_locators.py index 4a21fd5c2a..cfb0d400f6 100644 --- a/tests/e2e/test_source_citation_locators.py +++ b/tests/e2e/test_source_citation_locators.py @@ -16,9 +16,11 @@ import json import sqlite3 +from pathlib import Path from fastapi.testclient import TestClient +from powercontext.builtin.persistence import migrations from powercontext.builtin.persistence.migrations.deployment import deployment_runner from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.server.factory import create_server_app @@ -67,13 +69,20 @@ def test_locators_survive_upgrade_reopen_http_and_mcp(tmp_path): legacy_id = legacy.json()["artifact_id"] # Reconstruct the previous registered layout, preserving its real historical evidence. + # v1_5_0_005 is undone first: its retired plugin storage returns from its frozen resource. + retired = json.loads( + (Path(migrations.__file__).parent / "server_resources/schemas/sqlite-v1_5_0_001.json").read_text() + ) with sqlite3.connect(database) as connection: + connection.execute("ALTER TABLE pc_artifacts DROP COLUMN metadata") + for statement in retired["statements"]: + connection.execute(statement) connection.execute("DROP TABLE pc_artifact_source_locators") connection.execute("UPDATE pc_schema_revision SET version_num = 'v1_5_0_003'") runner = deployment_runner(_settings(database)) plan = runner.plan() - assert plan.revisions == ("v1_5_0_004",) + assert plan.revisions == ("v1_5_0_004", "v1_5_0_005") runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) locators = [ diff --git a/tests/plugin_support.py b/tests/plugin_support.py new file mode 100644 index 0000000000..71b0283573 --- /dev/null +++ b/tests/plugin_support.py @@ -0,0 +1,111 @@ +# 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. + +"""Invoke a plugin through the real executor with a single-process Supervisor lease.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path +from typing import Any + +from powercontext.artifacts.search import ArtifactSearchExecutionContext +from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.supervision import ArtifactProcessingLeaseRepository +from powercontext.builtin.plugins.executor import PluginWorkerSpec, execute_plugin +from powercontext.builtin.plugins.families import plugin_artifact_repository +from powercontext.builtin.plugins.manifest import load_plugin +from powercontext.builtin.plugins.writer import PluginArtifactWriter, UserChange, plugin_merge_service +from powercontext.builtin.runtime.artifact_merge import ArtifactMergeAccess, ArtifactMergeApplication +from powercontext.builtin.runtime.config import SQLiteConfig +from powercontext.builtin.runtime.processing_contracts import ArtifactProcessingWorkAssignment +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY + +DECLARATION = """ + +from powercontext.plugins import ArtifactPlugin as _ArtifactPlugin, Inputs as _Inputs + +plugin = _ArtifactPlugin( + family={family!r}, + inputs=_Inputs(source_types=("content",), projection="powercontext.text-evidence@1"), + setup=setup, + **{spec!r}, +) +""" + + +def write_plugin(root: Path, name: str, code: str, **spec: Any) -> Path: + """Write ``code`` as ``plugin.py`` and declare it as a plugin consuming content Sources.""" + + directory = root / name + directory.mkdir(parents=True) + declaration = DECLARATION.format(family=name, spec={"on_sources": True, **spec}) + (directory / "plugin.py").write_text(textwrap.dedent(code) + declaration, encoding="utf-8") + return directory + + +async def invoke(database, directory: Path, scope_id: str): + manifest, _ = load_plugin(directory) + async with database.transaction() as connection: + lease = await ArtifactProcessingLeaseRepository().start_single_process_term(connection, "test-owner") + intent = await ArtifactProcessingIntentRepository().request(connection, scope_id, manifest.binding_name) + assignment = ArtifactProcessingWorkAssignment( + binding_name=manifest.binding_name, + scope_id=scope_id, + artifact_family=manifest.spec.family, + claimed_request_generation=intent.requested_generation, + fence=lease.fence("single-process"), + worker_id="test-worker", + ) + # The executor uses the caller's open database instead of the configuration. + spec = PluginWorkerSpec( + database=SQLiteConfig(), + directory=str(directory), + manifest=manifest.model_dump(mode="json"), + storage_root=str(directory.parent.parent / "plugin-storage"), + ) + return await execute_plugin(spec, assignment, database=database) + + +async def edit(database, directory: Path, scope_id: str, artifact_id: str, content, *, expected_revision: int): + """Replace a plugin Artifact the way the host's ``update_artifact`` writer does.""" + + manifest, _ = load_plugin(directory) + writer = PluginArtifactWriter( + database=database, + scope_id=scope_id, + family=manifest.spec.family, + sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), + change=UserChange("update", artifact_id), + ) + async with writer.transaction() as tx: + return await tx.put(artifact_id, content=content, expected_revision=expected_revision) + + +def governance(database, directory: Path, scope_id: str): + """The merge, forget and restoration the Server's generic routes run for a plugin family.""" + + manifest, _ = load_plugin(directory) + family = manifest.spec.family + service = plugin_merge_service(family, ArtifactMergeAccess(family), SourceRepository(BUILTIN_SOURCE_REGISTRY)) + application = ArtifactMergeApplication( + database, (service,), default_context=ArtifactSearchExecutionContext(trusted_local=True) + ) + return application.for_scope(scope_id, family) + + +async def current(database, family: str, scope_id: str, artifact_id: str): + async with database.transaction() as connection: + return await plugin_artifact_repository(family).latest(connection, scope_id, family, artifact_id) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index e2af63aaf8..d3664fce60 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -40,9 +40,11 @@ CommittedHandoff, ContinueHandoffRequest, CreateArtifactRequest, + CreateBuiltinArtifactRequest, CreateMemoryArtifactContent, CreateMemoryArtifactEntry, CreateMemoryArtifactRequest, + CreatePluginArtifactRequest, CreateSourceRequest, CreateWorkContractRequest, EntryChangeOperation, @@ -326,7 +328,6 @@ def test_capabilities_report_semantics_without_runtime_tuning_values() -> None: assert set(properties) == { "artifact_dreaming", "artifact_dreaming_operations", - "custom_artifact_schemas", "source_types", "artifact_families", "memory_extraction", @@ -904,6 +905,10 @@ def test_base_access_contract_includes_revision_history_and_tags() -> None: expected_operations = { ("/v1/scopes/{scope_id}/artifacts/{family}/search", "post"): "search_artifacts", + ("/v1/scopes/{scope_id}/artifacts/{family}/merges", "post"): "merge_artifacts", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", "get"): "get_artifact_governance", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", "post"): "forget_artifact", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", "post"): "restore_artifact", ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", "get"): "get_artifact_tags", ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", "put"): "replace_artifact_tags", ( @@ -928,6 +933,7 @@ def test_base_access_contract_includes_revision_history_and_tags() -> None: "get", ): "get_atomic_memory_state", ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", "get"): "list_artifact_revisions", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", "get"): "get_artifact_rendering", ( "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", "get", @@ -989,10 +995,15 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - source = schemas["CreateSourceRequest"] assert source["required"] == ["content"] assert set(source["properties"]) == {"source_type", "content"} - assert source["properties"]["source_type"]["enum"] == ["content"] + assert source["properties"]["source_type"]["pattern"] == "^[a-z][a-z0-9._-]*$" + assert schemas["SourceType"]["enum"] == ["content"] assert source["properties"]["source_type"]["default"] == "content" - artifact = schemas["CreateArtifactRequest"] + assert schemas["CreateArtifactRequest"]["oneOf"] == [ + {"$ref": "#/components/schemas/CreateBuiltinArtifactRequest"}, + {"$ref": "#/components/schemas/CreatePluginArtifactRequest"}, + ] + artifact = schemas["CreateBuiltinArtifactRequest"] assert len(artifact["oneOf"]) == 8 assert {"$ref": "#/components/schemas/CreateAtomicMemoryArtifactRequest"} in artifact["oneOf"] assert artifact["discriminator"]["propertyName"] == "family" @@ -1005,24 +1016,28 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - "CreateExperienceArtifactRequest", "CreateSkillArtifactRequest", "CreateHandoffArtifactRequest", + "CreatePluginArtifactRequest", ): family_request = schemas[name] assert family_request["required"] == ["family", "content"] assert set(family_request["properties"]) == {"family", "content"} assert not {"scope_id", "source_id", "artifact_id"} & set(family_request["properties"]) - assert CreateSourceRequest(content="evidence").source_type is SourceType.CONTENT - assert ( - CreateArtifactRequest( + assert CreateSourceRequest(content="evidence").source_type == SourceType.CONTENT + memory_request = CreateArtifactRequest( + root=CreateBuiltinArtifactRequest( root=CreateMemoryArtifactRequest( family="memory", content=CreateMemoryArtifactContent( entries=[CreateMemoryArtifactEntry(kind="preference", text="Use Chinese")] ), ) - ).root.family - == "memory" - ) + ) + ).root + assert isinstance(memory_request, CreateBuiltinArtifactRequest) + assert memory_request.root.family == "memory" + plugin_request = CreateArtifactRequest.model_validate({"family": "document", "content": {"title": "x"}}) + assert isinstance(plugin_request.root, CreatePluginArtifactRequest) memory_entry = schemas["CreateMemoryArtifactEntry"]["properties"] assert memory_entry["kind"]["minLength"] == 1 assert memory_entry["kind"]["maxLength"] == 128 @@ -1038,6 +1053,8 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - (CreateSourceRequest, {"scope_id": "scope", "content": "evidence"}), (CreateSourceRequest, {"source_id": "source", "content": "evidence"}), (CreateArtifactRequest, {"artifact_id": "artifact", "family": "memory", "content": {}}), + # A built-in family name never falls through to the plugin branch. + (CreateArtifactRequest, {"family": "memory", "content": {"title": "x"}}), ): with pytest.raises(ValidationError): model.model_validate(payload) diff --git a/tests/test_artifact_index_maintenance.py b/tests/test_artifact_index_maintenance.py deleted file mode 100644 index eb34552420..0000000000 --- a/tests/test_artifact_index_maintenance.py +++ /dev/null @@ -1,266 +0,0 @@ -# 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. - -"""Idle index maintenance must not require SQLite's writer lock or delay startup.""" - -import asyncio - -import pytest -from sqlalchemy import insert, select - -from powercontext.artifacts.indexes import ArtifactSearchDeclaration, TextIndex, VectorIndex -from powercontext.artifacts.search import ArtifactSearchUnavailable, ArtifactSearchUnsupported -from powercontext.builtin.inference.models import EmbeddingResult -from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.persistence.tables import ARTIFACT_SEARCH_STATES_TABLE, SCOPES_TABLE -from powercontext.server.artifact_plugins import ArtifactPluginManager -from tests.builtin.persistence.test_artifact_indexes import PROFILE, register, service, store, write - - -@pytest.mark.parametrize("after_rebuild", [False, True]) -def test_unchanged_maintenance_does_not_wait_for_an_unrelated_writer(tmp_path, after_rebuild): - async def run(): - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'store.db'}", busy_timeout_ms=50) - async with store(config) as (db, builder, publisher): - declaration = ArtifactSearchDeclaration( - version="1", - indexes=(TextIndex(name="text", fields=("text",), projection_version="1"),), - modes=("text",), - default_mode="text", - ) - await register(db, "reports", declaration) - await write(db, publisher, "scope", "reports", "a", {"text": "数据库故障"}) - await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - if after_rebuild: - await builder.refresh("scope", "reports", rebuild=True) - assert await builder.build("scope", "reports") - # A separate connection holds SQLite's writer lock. Idle maintenance - # must still complete using WAL reads, without a busy timeout failure. - async with db.engine.connect() as blocker: - await blocker.exec_driver_sql("BEGIN IMMEDIATE") - try: - assert await builder.refresh("scope", "reports") - assert await builder.build("scope", "reports") - finally: - await blocker.rollback() - assert (await service(db, "reports").search("scope", "reports", {"query": "故障"})).matches - - asyncio.run(run()) - - -def test_manager_startup_does_not_wait_for_existing_data_encoding(): - async def run(): - started, release = asyncio.Event(), asyncio.Event() - - class Encoder: - profile = PROFILE - - async def embed(self, texts): - started.set() - await release.wait() - return EmbeddingResult(vectors=tuple((1.0, 0.0) for _ in texts)) - - async with store() as (db, _, publisher): - await register( - db, - "startup_reports", - ArtifactSearchDeclaration( - version="1", - indexes=( - VectorIndex( - name="semantic", fields=("text",), projection_version="1", source="text", profile=PROFILE - ), - ), - modes=("vector",), - default_mode="vector", - ), - ) - async with db.transaction() as connection: - await connection.execute( - insert(SCOPES_TABLE).values( - scope_id="scope", - title="", - summary="", - scope_id_search="scope", - title_search="", - summary_search="", - version=1, - ) - ) - await write(db, publisher, "scope", "startup_reports", "a", {"text": "数据库故障"}) - manager = ArtifactPluginManager( - database=db, - config=SQLiteConfig(), - supervisor=None, - security_builder=lambda: None, - on_supervisor=lambda _: None, - embedding_model=Encoder(), - ) - try: - async with asyncio.timeout(5): - await manager.__aenter__() - await started.wait() - release.set() - async with asyncio.timeout(5): - while True: - try: - result = await service(db, "startup_reports").search( - "scope", - "startup_reports", - {"query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1.0, 0.0]}}, - ) - assert result.matches - break - except ArtifactSearchUnavailable: - await asyncio.sleep(0.01) - finally: - release.set() - await manager.__aexit__() - - asyncio.run(run()) - - -def test_manager_discovers_first_write_without_creating_empty_scope_states(): - async def run(): - async with store() as (db, _, publisher): - await register( - db, - "reports", - ArtifactSearchDeclaration( - version="1", - indexes=(TextIndex(name="text", fields=("text",), projection_version="1"),), - modes=("text",), - default_mode="text", - ), - ) - async with db.transaction() as connection: - for scope in ("active", "empty"): - await connection.execute( - insert(SCOPES_TABLE).values( - scope_id=scope, - title="", - summary="", - scope_id_search=scope, - title_search="", - summary_search="", - version=1, - ) - ) - async with ArtifactPluginManager( - database=db, - config=SQLiteConfig(), - supervisor=None, - security_builder=lambda: None, - on_supervisor=lambda _: None, - ): - # Allow startup discovery to finish before the first content arrives. - await asyncio.sleep(0.1) - assert not (await service(db, "reports").search("empty", "reports", {"query": "故障"})).matches - with pytest.raises(ArtifactSearchUnsupported): - await service(db, "reports").search( - "empty", "reports", {"query": "故障", "indexes": {"text": ["unknown"]}} - ) - await write(db, publisher, "active", "reports", "a", {"text": "数据库故障"}) - async with asyncio.timeout(6): - while True: - try: - result = await service(db, "reports").search("active", "reports", {"query": "故障"}) - assert result.matches - break - except ArtifactSearchUnavailable: - await asyncio.sleep(0.05) - async with db.transaction() as connection: - scopes = (await connection.execute(select(ARTIFACT_SEARCH_STATES_TABLE.c.scope_id))).scalars().all() - assert scopes == ["active"] - - asyncio.run(run()) - - -def test_manager_recovers_failed_build_and_cleans_before_rebuilding(): - async def run(): - from powercontext.builtin.persistence.artifact_indexes import COVERAGE, STATES - - failed = asyncio.Event() - - class Encoder: - profile = PROFILE - available = False - - async def embed(self, texts): - if not self.available: - failed.set() - raise RuntimeError("encoder unavailable") # noqa: TRY003 - return EmbeddingResult(vectors=tuple((1.0, 0.0) for _ in texts)) - - encoder = Encoder() - async with store(model=encoder) as (db, builder, publisher): - await register( - db, - "reports", - ArtifactSearchDeclaration( - version="1", - indexes=( - VectorIndex( - name="semantic", fields=("text",), projection_version="1", source="text", profile=PROFILE - ), - ), - modes=("vector",), - default_mode="vector", - ), - ) - await write(db, publisher, "scope", "reports", "a", {"text": "数据库故障"}) - - def manager(): - return ArtifactPluginManager( - database=db, - config=SQLiteConfig(), - supervisor=None, - security_builder=lambda: None, - on_supervisor=lambda _: None, - embedding_model=encoder, - ) - - async with manager(): - async with asyncio.timeout(5): - await failed.wait() - encoder.available = True - async with manager(): - async with asyncio.timeout(6): - while True: - try: - result = await service(db, "reports").search( - "scope", - "reports", - {"query_vector": {"profile": PROFILE.model_dump(mode="json"), "vector": [1, 0]}}, - ) - assert result.matches - break - except ArtifactSearchUnavailable: - await asyncio.sleep(0.05) - async with db.transaction() as connection: - old = await connection.scalar(select(STATES.c.build_id).where(STATES.c.state == "ready")) - await builder.refresh("scope", "reports", rebuild=True) - async with asyncio.timeout(6): - while True: - async with db.transaction() as connection: - state = await connection.scalar(select(STATES.c.state).where(STATES.c.build_id != old)) - remains = await connection.scalar( - select(COVERAGE.c.artifact_id).where(COVERAGE.c.build_id == old) - ) - if state == "ready" and remains is None: - break - await asyncio.sleep(0.05) - - asyncio.run(run()) diff --git a/tests/test_artifact_plugin_leases.py b/tests/test_artifact_plugin_leases.py deleted file mode 100644 index ebb5104132..0000000000 --- a/tests/test_artifact_plugin_leases.py +++ /dev/null @@ -1,63 +0,0 @@ -# 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. - -"""Plugin activation preserves the renewable Supervisor ownership contract.""" - -import asyncio - -from pydantic import SecretStr - -from powercontext.builtin.persistence.oceanbase import OceanBaseConfig -from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.supervision import ArtifactProcessingLeaseRepository -from powercontext.builtin.persistence.tables import BUILTIN_TABLES -from powercontext.server.artifact_plugins import ArtifactPluginManager -from tests.e2e.test_artifact_plugins import EXAMPLE, deploy_plugin - - -def test_first_plugin_activation_preserves_an_unexpired_oceanbase_owner(tmp_path): - deploy_plugin(EXAMPLE, tmp_path / "plugins" / "incident-reports", None) - - async def run(): - # SQLite hosts real lease state; OceanBaseConfig exercises configuration - # selection without claiming acceptance against an OceanBase server. - async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as storage: - database = storage.database - leases = ArtifactProcessingLeaseRepository() - async with database.transaction() as connection: - owner = await leases.try_acquire(connection, "existing-background-owner", 3600) - assert owner is not None and owner.lease_expires_at is not None - fence = owner.fence("oceanbase") - async with ArtifactPluginManager( - database=database, - config=OceanBaseConfig(url=SecretStr("mysql+aoceanbase://test@localhost:2881/test?charset=utf8mb4")), - supervisor=None, - security_builder=lambda: None, - on_supervisor=lambda _: None, - plugins_dir=tmp_path / "plugins", - ) as manager: - registration = await manager.get("incident-reports") - assert registration.phase == "disabled" - await manager.change("incident-reports", registration.binding_generation, "enabled") - async with asyncio.timeout(10): - while (state := await manager.get("incident-reports")).phase == "activating": # noqa: ASYNC110 - await asyncio.sleep(0.01) - assert state.phase == "enabled", state.last_error - async with database.transaction() as connection: - assert await leases.require_fence(connection, fence) == owner - # Closing the standby must not release another Supervisor's term. - async with database.transaction() as connection: - assert await leases.require_fence(connection, fence) == owner - - asyncio.run(run()) diff --git a/tests/test_artifact_plugin_validation.py b/tests/test_artifact_plugin_validation.py deleted file mode 100644 index 5ac51ef99d..0000000000 --- a/tests/test_artifact_plugin_validation.py +++ /dev/null @@ -1,88 +0,0 @@ -# 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. - -"""Declaration validation in a real child within its existing startup budget.""" - -import asyncio -import os - -import httpx -import pytest - -from powercontext.builtin.artifact_plugin_validation import validate_plugin -from tests.e2e.test_artifact_plugins import EXAMPLE, REGISTRATION, deploy_plugin, enable_all - - -def test_declaration_validation_does_not_spend_its_budget_starting_the_runtime(tmp_path, monkeypatch): - plugin = deploy_plugin(EXAMPLE, tmp_path / "plugin", None, config={}) - entry = plugin / "plugin.py" - entry.write_text( - entry.read_text() - + '\nimport os\nos.environ["PC_TEST_PLUGIN_IMPORTED"] = "child"\n' - + 'print("plugin import output is not the declaration protocol")\n' - ) - monkeypatch.delenv("PC_TEST_PLUGIN_IMPORTED", raising=False) - # A slow runtime bootstrap must not consume declaration validation's 15-second budget. - # This hook is installed only in new Python processes, not in the pytest process. - hook = tmp_path / "startup" - hook.mkdir() - (hook / "sitecustomize.py").write_text( - "import sys, time\n" - "class SlowRuntime:\n" - " def find_spec(self, fullname, path=None, target=None):\n" - " if fullname == 'powercontext.builtin.runtime':\n" - " time.sleep(20)\n" - "sys.meta_path.insert(0, SlowRuntime())\n" - ) - monkeypatch.setenv("PYTHONPATH", os.pathsep.join(filter(None, (str(hook), os.environ.get("PYTHONPATH"))))) - - async def run(): - described = await validate_plugin(str(plugin)) - restored = await validate_plugin(str(plugin), described["manifest"]) - assert "PC_TEST_PLUGIN_IMPORTED" not in os.environ - assert restored == described - assert described["declaration"]["family"] == "incident-report" - assert described["manifest"]["config"]["title_prefix"] == "Incident report" - - asyncio.run(run()) - - -def test_invalid_declaration_is_rejected_by_the_validation_child(tmp_path): - plugin = deploy_plugin(EXAMPLE, tmp_path / "invalid", None, config={"unknown": True}) - - async def run(): - with pytest.raises(ValueError, match="plugin declaration or configuration validation failed"): - await validate_plugin(str(plugin)) - - asyncio.run(run()) - - -def test_enable_all_reports_unexpected_item_failure_immediately(): - item = { - "binding_name": "incident-reports", - "outcome": "failed", - "status_url": REGISTRATION, - "state": {"phase": "disabled"}, - "error": "invalid_plugin", - } - - async def run(): - transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"items": [item]})) - async with httpx.AsyncClient(base_url="http://test", transport=transport) as client: - with pytest.raises(AssertionError, match="invalid_plugin"): - await enable_all(client) - results = await enable_all(client, expected_failures=frozenset({"incident-reports"})) - assert results["incident-reports"] == item - - asyncio.run(run()) diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 50992d4929..82e2d13d90 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_5_0_004" + assert plan["target_revision"] == "v1_5_0_005" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_005",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index 989ca7e786..54c56c3365 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_005",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 3de63bddb7..4cc5ea46a2 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -65,9 +65,10 @@ def invoke(action, *options): "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", + "v1_5_0_005", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_5_0_004", "name": "v1_5_0_004", "software_version": "1.5.0", "sequence": 4} + target = {"revision": "v1_5_0_005", "name": "v1_5_0_005", "software_version": "1.5.0", "sequence": 5} assert plan["target_migration"] == target applied = invoke( @@ -82,7 +83,7 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_005",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -98,6 +99,7 @@ def invoke(action, *options): "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", + "v1_5_0_005", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_plugin_capabilities.py b/tests/test_plugin_capabilities.py new file mode 100644 index 0000000000..d6dcfea02f --- /dev/null +++ b/tests/test_plugin_capabilities.py @@ -0,0 +1,38 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Capabilities list only the plugin families this Server loaded.""" + +from fastapi.testclient import TestClient + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.plugins.families import register_plugin_family +from powercontext.server.factory import create_server_app +from powercontext.server.settings import McpConfig, ServerSettings + + +def test_families_registered_elsewhere_in_the_process_are_not_capabilities(tmp_path) -> None: + # Another plugin manager or a plugin test in the same process registered this family. + register_plugin_family("stray-notes") + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'capabilities.db'}"), + mcp=McpConfig(enabled=False), + ) + ) + with TestClient(app) as client: + response = client.get("/v1/capabilities") + + assert response.status_code == 200, response.text + assert "stray-notes" not in response.json()["artifact_families"] diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 1a1e73264c..26a2f0e355 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -66,7 +66,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_004" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_005" assert plan["readiness_scope"] == "server" assert plan["compatibility_steps"] == [] full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) From 45a20ee9ed22bab3707c415abf12d8e9b7f3d111 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 12:19:37 -0800 Subject: [PATCH 02/38] feat(plugins): give each plugin a scaling group of host processes and cancel only timed-out calls - Each plugin keeps one host process and starts more while every process is full and requests queue, up to `host_processes` (overridable per plugin with POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES, capped in total by POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES); extra processes stop after 5 idle minutes. - A call past its time limit is cancelled in the host and returns 504 `plugin_timeout`; other calls continue. A host whose heartbeat stops for 5 seconds is terminated, its other calls return 503, and it restarts after a backoff. - Prepared Context leaves out a plugin section whose plugin is unavailable and records the failed search in the receipt. - `Cron` without a time zone uses the machine's time zone. Co-Authored-By: Claude Opus 5.5 --- docs/en/docs/develop/plugins.md | 25 +- docs/en/docs/operate/configuration.md | 2 + docs/zh/docs/develop/plugins.md | 21 +- docs/zh/docs/operate/configuration.md | 2 + pyproject.toml | 1 + src/powercontext/builtin/plugins/host.py | 26 +- .../builtin/plugins/host_protocol.py | 12 + .../builtin/runtime/application.py | 8 + src/powercontext/builtin/runtime/models.py | 6 +- src/powercontext/plugins.py | 5 +- src/powercontext/server/app.py | 35 +- src/powercontext/server/factory.py | 2 + src/powercontext/server/plugin_hosts.py | 405 ++++++++++++------ src/powercontext/server/plugins.py | 13 +- src/powercontext/server/settings.py | 6 +- tests/e2e/test_plugin_hosts.py | 115 +++++ tests/test_server.py | 11 + uv.lock | 10 + 18 files changed, 548 insertions(+), 157 deletions(-) create mode 100644 tests/e2e/test_plugin_hosts.py diff --git a/docs/en/docs/develop/plugins.md b/docs/en/docs/develop/plugins.md index f170395c7c..4a82104caa 100644 --- a/docs/en/docs/develop/plugins.md +++ b/docs/en/docs/develop/plugins.md @@ -122,10 +122,11 @@ This plugin has the basic parts of a plugin: | `family` | The Artifact family the plugin owns; it must not reuse a built-in family or another plugin's | | `inputs` | `Inputs(source_types=..., projection=..., projections={...}, max_sources=50)`: the Source types consumed, the Projection each type arrives in, and the most Sources delivered at once. `projection` applies to every type; `projections` names one per type | | `on_sources` | Run when new Sources arrive | -| `schedule` | `Interval(seconds=...)` or `Cron(expression=..., timezone=...)` to run on a schedule. With neither `schedule` nor `on_sources`, the plugin has no background generation and need not implement `generate`; its Artifacts are written only by user changes and its own APIs | +| `schedule` | `Interval(seconds=...)` or `Cron(expression=..., timezone=...)` to run on a schedule; without `timezone`, the time zone of the machine running Server applies. With neither `schedule` nor `on_sources`, the plugin has no background generation and need not implement `generate`; its Artifacts are written only by user changes and its own APIs | | `scopes` | Serve only these Scopes; required for plugins that set `schedule` but consume no Sources | | `timeout_seconds`, `transaction_timeout_seconds` | Time limits for one generation run and its write transaction | -| `change_timeout_seconds` | Time limit for one user change or API call | +| `change_timeout_seconds` | Time limit for one user change, presentation, or search | +| `host_processes` | The most host processes of this plugin in each Server process, 1 by default; deployers can override it | | `definitions` | The plugin's own Source Definitions; see "Define Source types" | | `api` | The plugin's APIs; see "Provide APIs" | | `pages` | Dashboard pages; see "Dashboard pages" | @@ -374,9 +375,20 @@ plugin = ArtifactPlugin( plugin when a body is too large. - Each call is bounded by `timeout_seconds` (30 seconds by default) and returns 504 when it runs over. -APIs, presentation, search, and user-change methods run in a long-lived host process that starts once the plugin loads; -the factory `setup(ctx)` runs once in it, and the plugin object serves every request. A host process that crashes or times out is -restarted, and APIs return 503 meanwhile. +APIs, presentation, search, and user-change methods run in host processes. Each plugin has its own group of host +processes and shares none with other plugins: the first starts once the plugin loads and always stays; another starts +while every process is busy and requests queue, up to `host_processes`; extra processes stop after 5 idle minutes. The +factory `setup(ctx)` runs once in each host process, and the plugin object serves every request that process receives, so +do not assume all requests reach the same process. + +- A host process handles several requests at once on one event loop. When a call runs past its time limit, core cancels + only that request (its write transaction rolls back) and returns 504; other requests in the process are unaffected. +- Blocking the event loop keeps cancellation from taking effect and also stops the host process's heartbeat. Five seconds + after the heartbeat stops, core terminates the process: the timed-out request returns 504, the process's other requests + return 503, and the process restarts after a backoff. Put CPU-heavy or synchronously blocking code in + `await asyncio.to_thread(...)`, and leave work that takes minutes to the background `generate`. +- In Prepared Context, a plugin section is left out when the plugin is unavailable (a timeout, no available host process, + or a failed search); the receipt records the family's failed search, and the other sections are returned as usual. ## Dedicated storage and models @@ -439,7 +451,8 @@ plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html | Symptom | What to do | | --- | --- | | The plugin is `failed` in `/v1/plugins` | Read `error`: for import failures, check that dependencies are installed in the Server environment; for a missing required method, add it | -| APIs return 503 | The host process is starting or restarting; check the Server log for plugin errors | +| APIs return 503 | The host process is starting or restarting, or was terminated because its event loop was blocked; check the Server log for plugin errors | +| APIs return 504 `plugin_timeout` | The call ran past its time limit; move slow work into `asyncio.to_thread` or `generate`, or raise the limit | | APIs return 500 `plugin_error` | A handler raised an unhandled exception; the Server log records its type | | Workers stop running after a plugin change | Plugin files changed while Server was running; restart Server | | Generated Artifacts do not appear | Check `GET /v1/plugins//scopes` for Scopes with pending Sources | diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index a08339d313..30daec691b 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -119,6 +119,8 @@ still occupies an HTTP slot until it finishes; reserve other slots for concurren | `POWERCONTEXT_SERVER_HTTP_UDS_EXTERNAL_SCHEME` | unset | Operator-declared external `http` or `https` scheme for a trusted proxy on the private UDS; requires `HTTP_UDS`. Set `https` only with trusted TLS termination. Forwarded scheme and client-IP headers remain ignored | | `POWERCONTEXT_SERVER_HTTP_MAX_REQUEST_BODY_BYTES` | `16777216` | Maximum external HTTP body bytes before parsing, including chunked uploads | | `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` | `33554432` | Request and response body limit of plugin routes (`/v1/plugins/{name}/api/...` and their Dashboard page forwarding), in place of the previous setting; a plugin route may declare a lower limit, never a higher one | +| `POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES` | `{}` | JSON object overriding plugins' declared `host_processes` by plugin name, such as `{"knowledge-base": 4}`; each value is the most host processes of that plugin in each Server process | +| `POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES` | unset | The most host processes of all plugins in each Server process; at the limit, no process is added for load and requests queue on existing ones. Each plugin's first, always-running process is exempt | | `POWERCONTEXT_SERVER_HTTP_MAX_CONCURRENT_REQUESTS` | `64` | Per-process external HTTP requests in flight, including full streaming responses | | `POWERCONTEXT_SERVER_WORKSPACE` | Server startup directory | Resolution root for local project Agent Skill folders | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | Enable Streamable HTTP MCP | diff --git a/docs/zh/docs/develop/plugins.md b/docs/zh/docs/develop/plugins.md index 7d8aed7428..33252ff800 100644 --- a/docs/zh/docs/develop/plugins.md +++ b/docs/zh/docs/develop/plugins.md @@ -116,10 +116,11 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/jso | `family` | 插件拥有的 Artifact family,不能与内置 family 或其他插件重复 | | `inputs` | `Inputs(source_types=..., projection=..., projections={...}, max_sources=50)`:消费哪些 Source 类型、每种类型以哪个 Projection 交付、一次最多交付多少条。`projection` 适用于所有类型,`projections` 按类型单独指定 | | `on_sources` | 有新 Source 时运行 | -| `schedule` | `Interval(seconds=...)` 或 `Cron(expression=..., timezone=...)`,定时运行。`schedule` 和 `on_sources` 都不填时插件没有后台生成,不需要实现 `generate`,Artifact 只由用户修改和插件接口写入 | +| `schedule` | `Interval(seconds=...)` 或 `Cron(expression=..., timezone=...)`,定时运行;`timezone` 不填时使用 Server 所在机器的时区。`schedule` 和 `on_sources` 都不填时插件没有后台生成,不需要实现 `generate`,Artifact 只由用户修改和插件接口写入 | | `scopes` | 只服务这些 Scope;配置了 `schedule` 但不消费 Source 的插件必须填写 | | `timeout_seconds`、`transaction_timeout_seconds` | 一次生成和其中写事务的时限 | -| `change_timeout_seconds` | 一次用户修改或接口调用的时限 | +| `change_timeout_seconds` | 一次用户修改、展示或检索的时限 | +| `host_processes` | 每个 Server 进程中本插件宿主进程数的上限,默认 1;部署者可以覆盖 | | `definitions` | 插件自己的 Source 定义,见"定义 Source 类型" | | `api` | 插件的接口,见"提供接口" | | `pages` | Dashboard 页面,见"Dashboard 页面" | @@ -348,8 +349,17 @@ plugin = ArtifactPlugin( `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES`。超出时 core 返回 413,不调用插件。 - 每次调用受 `timeout_seconds`(默认 30 秒)约束,超时返回 504。 -接口、展示、检索和用户修改方法在一个常驻的宿主进程中执行,插件加载后就启动;工厂 `setup(ctx)` 在宿主进程中只执行一次, -插件对象服务所有请求。宿主进程崩溃或超时会被重启,期间接口返回 503。 +接口、展示、检索和用户修改方法在宿主进程中执行。每个插件有自己的一组宿主进程,不与其他插件共用:插件加载后启动第一个并 +一直保留;所有进程都忙、且有请求排队时再启动一个,直到 `host_processes`;多出的进程空闲 5 分钟后停止。工厂 `setup(ctx)` +在每个宿主进程中执行一次,插件对象服务该进程收到的所有请求,所以不要假设所有请求都在同一个进程中。 + +- 一个宿主进程同时处理多个请求,都在同一个事件循环上。调用超过时限时,core 只取消这个请求(写事务随之回滚)并返回 504, + 同一进程中的其他请求不受影响。 +- 阻塞事件循环会让取消无法生效,宿主进程的心跳也随之中断。心跳中断 5 秒后 core 终止该进程:超时的请求返回 504,进程中的 + 其他请求返回 503,进程退避后重启。CPU 密集或同步阻塞的代码放进 `await asyncio.to_thread(...)`;需要几分钟的工作交给 + 后台的 `generate`。 +- Prepared Context 中的插件 section 在插件不可用(超时、宿主进程不可用或检索失败)时省略,回执中记录该 family 的检索 + 失败,其他 section 照常返回。 ## 专属存储和模型 @@ -408,7 +418,8 @@ plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html | 现象 | 处理 | | --- | --- | | `/v1/plugins` 中插件为 `failed` | 查看 `error`:导入失败时检查依赖是否装进了 Server 环境;缺少必须实现的方法时补上方法 | -| 接口返回 503 | 宿主进程正在启动或重启;查看 Server 日志中的插件错误 | +| 接口返回 503 | 宿主进程正在启动或重启,或者因事件循环被阻塞而被终止;查看 Server 日志中的插件错误 | +| 接口返回 504 `plugin_timeout` | 调用超过了时限;耗时的工作放进 `asyncio.to_thread` 或交给 `generate`,或者调大时限 | | 接口返回 500 `plugin_error` | 处理函数抛出了未处理的异常;Server 日志记录了异常类型 | | 修改插件后 Worker 不再执行 | 插件文件在运行中被修改,重启 Server | | 生成的 Artifact 没有出现 | 用 `GET /v1/plugins/<插件名>/scopes` 查看各 Scope 是否还有待处理的 Source | diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 7da1bfb9cd..2bdadf7711 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -105,6 +105,8 @@ MCP 使用 stateless Streamable HTTP,每次工具执行都采用当前请求 | `POWERCONTEXT_SERVER_HTTP_UDS_EXTERNAL_SCHEME` | 未设置 | 私有 UDS 上可信代理的外部协议,只接受 `http` 或 `https`,必须同时设置 `HTTP_UDS`;仅在可信代理终止 TLS 时设置 `https`。仍忽略转发的协议与客户端 IP header | | `POWERCONTEXT_SERVER_HTTP_MAX_REQUEST_BODY_BYTES` | `16777216` | 解析前允许的外部 HTTP 请求体字节数,包括分块上传 | | `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` | `33554432` | 插件接口(`/v1/plugins/{name}/api/...` 及 Dashboard 插件页面的转发)的请求体和响应体上限,替代上一项;插件路由可以声明更小的上限,不能超过此值 | +| `POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES` | `{}` | 按插件名覆盖插件声明的 `host_processes`,JSON 对象,例如 `{"knowledge-base": 4}`;值是每个 Server 进程中该插件宿主进程数的上限 | +| `POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES` | 未设置 | 每个 Server 进程中所有插件的宿主进程总数上限;达到上限时不再为负载增加进程,请求在已有进程上排队。每个插件常驻的第一个进程不受此限制 | | `POWERCONTEXT_SERVER_HTTP_MAX_CONCURRENT_REQUESTS` | `64` | 每进程进行中的外部 HTTP 请求上限,包括完整流式响应 | | `POWERCONTEXT_SERVER_WORKSPACE` | Server 启动目录 | 本机项目级 Agent Skill 目录的解析根目录 | | `POWERCONTEXT_SERVER_MCP_ENABLED` | `true` | 启用 Streamable HTTP MCP | diff --git a/pyproject.toml b/pyproject.toml index 4da809be88..5a3fe84a4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ builtin = [ "sqlalchemy[asyncio]>=2,<3", "sqlite-vec>=0.1.9,<0.2", "tzdata>=2026.3", + "tzlocal>=5.2,<6", ] seekdb = [ "powercontext[builtin]", diff --git a/src/powercontext/builtin/plugins/host.py b/src/powercontext/builtin/plugins/host.py index b2a3efb5e0..210d1e8476 100644 --- a/src/powercontext/builtin/plugins/host.py +++ b/src/powercontext/builtin/plugins/host.py @@ -40,7 +40,9 @@ from powercontext.builtin.plugins.executor import open_plugin_database from powercontext.builtin.plugins.families import register_plugin_family, register_source_binding, revision_metadata from powercontext.builtin.plugins.host_protocol import ( + HostCancel, HostFailed, + HostHeartbeat, HostReady, HostRequest, HostResponse, @@ -80,6 +82,7 @@ logger = logging.getLogger(__name__) +HEARTBEAT_SECONDS = 1.0 _CHANGE_METHODS = { "create_artifact": "create", "update_artifact": "update", @@ -135,7 +138,8 @@ async def serve(self) -> None: ) return await self._send(HostReady(implemented=implemented)) - tasks: set[asyncio.Task[None]] = set() + heartbeat = asyncio.create_task(self._beat()) + tasks: dict[str, asyncio.Task[None]] = {} while True: try: message = await asyncio.to_thread(self.connection.recv) @@ -143,11 +147,25 @@ async def serve(self) -> None: break if isinstance(message, HostShutdown): break + if isinstance(message, HostCancel): + running = tasks.get(message.id) + if running is not None: + running.cancel() + continue task = asyncio.create_task(self._handle(message)) - tasks.add(task) - task.add_done_callback(tasks.discard) + tasks[message.id] = task + task.add_done_callback(lambda _, key=message.id: tasks.pop(key, None)) if tasks: - await asyncio.wait(tasks, timeout=10) + await asyncio.wait(tasks.values(), timeout=10) + heartbeat.cancel() + + async def _beat(self) -> None: + while True: + try: + await self._send(HostHeartbeat()) + except (EOFError, OSError): + return + await asyncio.sleep(HEARTBEAT_SECONDS) async def _start(self, resources: AsyncExitStack) -> tuple[str, ...]: spec = self.spec diff --git a/src/powercontext/builtin/plugins/host_protocol.py b/src/powercontext/builtin/plugins/host_protocol.py index 3db39f7287..b3363bb919 100644 --- a/src/powercontext/builtin/plugins/host_protocol.py +++ b/src/powercontext/builtin/plugins/host_protocol.py @@ -98,3 +98,15 @@ class HostResponse: @dataclass(frozen=True) class HostShutdown: pass + + +@dataclass(frozen=True) +class HostCancel: + """The request outlived its time limit; cancel its task so its write transaction rolls back.""" + + id: str + + +@dataclass(frozen=True) +class HostHeartbeat: + """Sent every second by a task on the host's event loop, so a blocked loop stops sending it.""" diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index d34eda4b26..77072929ea 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -1454,6 +1454,11 @@ async def _prepare_build( for section in (request.assembly.sections if request.assembly is not None else ()) if section.family not in BUILTIN_CONTEXT_FAMILIES }, + failed_plugins=[ + section.family + for section in (request.assembly.sections if request.assembly is not None else ()) + if section.search_failed + ], ) started_build = perf_counter() if evidence is not None else None with self._runtime._stage( @@ -1590,9 +1595,12 @@ def _record_unretrieved( code: Sequence[PreparedCodeCandidate], *, plugins: Mapping[str, bool], + failed_plugins: Sequence[str] = (), ) -> None: if evidence is None: return + for family in failed_plugins: + evidence.search(family, "none", "failed") retrieved = { **plugins, MEMORY_FAMILY: any(group.hits for group in memory), diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 17ec7f374c..d9f176ba44 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -309,19 +309,21 @@ class ContextAssemblySection(_PreparedContextModel): A plugin family takes its entries from ``hits``, which the Server fills from the plugin's ``search_artifacts`` before assembly; ``search`` carries the caller's - search fields for that plugin and is rejected for built-in families. + search fields for that plugin and is rejected for built-in families. The Server sets + ``search_failed`` when the plugin was unavailable, so the section stays empty. """ family: Annotated[str, Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9-]*$")] limit: Annotated[int, Field(ge=1, le=8)] search: dict[str, JsonValue] | None = None hits: tuple[ContextPluginHit, ...] = () + search_failed: bool = False @model_validator(mode="after") def validate_limit(self) -> ContextAssemblySection: if self.family == "experience" and self.limit > 2: raise ValueError("Experience sections cannot include more than two entries") # noqa: TRY003 - if self.family in BUILTIN_CONTEXT_FAMILIES and (self.search is not None or self.hits): + if self.family in BUILTIN_CONTEXT_FAMILIES and (self.search is not None or self.hits or self.search_failed): raise ValueError("search applies only to plugin families") # noqa: TRY003 if any(hit.artifact.family != self.family for hit in self.hits): raise ValueError("hits must belong to the section family") # noqa: TRY003 diff --git a/src/powercontext/plugins.py b/src/powercontext/plugins.py index b7a71fe370..75e3c8c52b 100644 --- a/src/powercontext/plugins.py +++ b/src/powercontext/plugins.py @@ -76,7 +76,8 @@ class Cron(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) expression: str = Field(min_length=1) - timezone: str = "Asia/Shanghai" + # An IANA time zone name; None uses the time zone of the machine running the Server. + timezone: str | None = None _PROJECTION = r"^[^@\s]+@[^@\s]+$" @@ -230,6 +231,8 @@ class ArtifactPluginSpec(BaseModel): transaction_timeout_seconds: float = Field(default=30, gt=0) # Time limit of one user change or plugin API call in the host process. change_timeout_seconds: float = Field(default=30, gt=0) + # Suggested ceiling on this plugin's host processes in each Server process; deployers may override it. + host_processes: int = Field(default=1, ge=1) api: PluginApiSpec | None = None pages: PluginPages | None = None diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index d40aa040d6..bd9922dc21 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -3638,7 +3638,7 @@ async def _call_plugin_host( ) -> Any: """Forward an authenticated request to the plugin host and translate its outcome.""" - from powercontext.server.plugin_hosts import PluginHostUnavailable + from powercontext.server.plugin_hosts import PluginHostTimeout, PluginHostUnavailable access = access_control_for_mode(http_request.app.state.access_control, mode=http_request.app.state.access_mode) caller = None if access is None else _require_principal() @@ -3649,6 +3649,8 @@ async def _call_plugin_host( ) except PluginHostUnavailable: raise PluginCallError(503, "plugin_unavailable", "The plugin is temporarily unavailable.") from None + except PluginHostTimeout: + raise PluginCallError(504, "plugin_timeout", "The plugin did not finish within its time limit.") from None if response.outcome == "ok": return response.result if response.outcome == "invalid_content": @@ -4100,15 +4102,28 @@ async def _with_plugin_sections(http_request: Request, scope_id: str, request: A ) manifest, host = found search = {**(section.search or {}), "query": request.query, "limit": section.limit} - result = await _call_plugin_host( - http_request, - host, - "search_artifacts", - scope_id=scope_id, - payload={"search": search, "max_bytes": request.max_bytes}, - timeout=manifest.spec.change_timeout_seconds, - operation=PREPARE_CONTEXT.operation_id, - ) + try: + result = await _call_plugin_host( + http_request, + host, + "search_artifacts", + scope_id=scope_id, + payload={"search": search, "max_bytes": request.max_bytes}, + timeout=manifest.spec.change_timeout_seconds, + operation=PREPARE_CONTEXT.operation_id, + ) + except PluginCallError as error: + if error.status < 500: + raise + # An unavailable plugin leaves its section empty; the receipt records the failed search. + log_safely( + logger, + logging.INFO, + "Plugin context unavailable", + extra={"event": "context.plugin.unavailable", "reason": error.code}, + ) + sections.append(section.model_copy(update={"search_failed": True})) + continue hits = tuple( ContextPluginHit(artifact=ArtifactRef.model_validate(hit["ref"]), text=hit["text"]) for hit in result["hits"][: section.limit] diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 638d1c6506..b106b7a1e8 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -373,6 +373,8 @@ def bind_supervisor(supervisors) -> None: enabled=True, injected=configured_access_control is not None, ), + host_processes=resolved.plugin_host_processes, + host_processes_total=resolved.plugin_total_host_processes, ) ) if active_access_control is not None: diff --git a/src/powercontext/server/plugin_hosts.py b/src/powercontext/server/plugin_hosts.py index 5bd275bb99..5b256f9a7a 100644 --- a/src/powercontext/server/plugin_hosts.py +++ b/src/powercontext/server/plugin_hosts.py @@ -14,10 +14,12 @@ """Server-side management of plugin host processes. -Each Server process keeps at most one host process per plugin. It is a spawned, -fresh interpreter connected by a duplex Pipe; the Server forwards authenticated -requests to it, terminates it when a request outlives its time limit, and restarts -it after a backoff. +Each Server process keeps a group of host processes per plugin; plugins never share one. +A host process is a spawned, fresh interpreter connected by a duplex Pipe. The group +always keeps one process, starts another while every process is full and requests +queue, up to the plugin's limit and the Server's total, and stops extra processes once +they idle. A request past its time limit is cancelled in the host; a host whose +heartbeat stops (its event loop is blocked) is terminated and restarted after a backoff. """ from __future__ import annotations @@ -33,7 +35,9 @@ from powercontext._logging import log_safely from powercontext.builtin.plugins.host import run_plugin_host from powercontext.builtin.plugins.host_protocol import ( + HostCancel, HostFailed, + HostHeartbeat, HostMethod, HostReady, HostRequest, @@ -46,112 +50,279 @@ READY_TIMEOUT_SECONDS = 60.0 HOST_CONCURRENCY = 8 +HEARTBEAT_TIMEOUT_SECONDS = 5.0 +IDLE_STOP_SECONDS = 300.0 +_WATCH_SECONDS = 1.0 +_SHUTDOWN_SECONDS = 10.0 +_TERMINATE_SECONDS = 5.0 _BACKOFF_START = 1.0 _BACKOFF_LIMIT = 60.0 _HEALTHY_RESET_SECONDS = 300.0 class PluginHostUnavailable(RuntimeError): - """The host process is starting, in restart backoff, or was terminated during the call.""" + """No host process can take the call: starting, in restart backoff, or terminated during it.""" -class PluginHost: - def __init__(self, name: str, spec: PluginHostSpec) -> None: - self.name = name - self.spec = spec - self.implemented: tuple[str, ...] = () - # Why the plugin cannot run at all, such as a missing required method; it is not restarted. - self.failure: str | None = None - self._process: Any = None - self._connection: Any = None - self._ready: asyncio.Future[None] | None = None - self._reader: asyncio.Task[None] | None = None - self._pending: dict[str, asyncio.Future[HostResponse]] = {} - self._slots = asyncio.Semaphore(HOST_CONCURRENCY) - self._send_lock = asyncio.Lock() - self._start_lock = asyncio.Lock() - self._backoff = 0.0 - self._not_before = 0.0 - self._started_at = 0.0 - self._closing = False +class PluginHostTimeout(RuntimeError): + """The call outlived its time limit and was cancelled in the host.""" - async def start(self) -> None: - """Start in the background; callers wait for readiness within their own time limit.""" - async with self._start_lock: - if self._ready is not None or self._closing: - return - if self.failure is not None: - raise PluginHostUnavailable(self.name) - if time.monotonic() < self._not_before: - raise PluginHostUnavailable(self.name) - context = multiprocessing.get_context("spawn") - parent, child = context.Pipe(duplex=True) - process = context.Process( - target=run_plugin_host, args=(child, self.spec), name=f"powercontext-plugin-host-{self.name}" - ) - await asyncio.to_thread(process.start) - child.close() - self._process, self._connection = process, parent - self._ready = asyncio.get_running_loop().create_future() - self._started_at = time.monotonic() - self._reader = asyncio.create_task(self._read(parent), name=f"plugin-host-{self.name}") - log_safely(logger, logging.INFO, f"Plugin host {self.name} started") - - async def _read(self, connection: Any) -> None: - ready = self._ready +class HostProcessBudget: + """The ceiling on host processes of all plugins in one Server process.""" + + def __init__(self, total: int | None = None) -> None: + self.total = total + self.running = 0 + + def take(self, *, resident: bool) -> bool: + # The one process each plugin keeps always starts; the ceiling limits growth under load. + if not resident and self.total is not None and self.running >= self.total: + return False + self.running += 1 + return True + + def give_back(self) -> None: + self.running -= 1 + + +class _HostProcess: + def __init__(self, group: PluginHost) -> None: + self.group = group + self.process: Any = None + self.connection: Any = None + self.ready = False + # False once the process is being stopped; it takes no new calls. + self.accepting = True + self.in_flight = 0 + self.pending: dict[str, asyncio.Future[HostResponse]] = {} + self.started_at = time.monotonic() + self.last_beat = self.started_at + self.idle_since = self.started_at + self.reason = "crash" + self._send_lock = asyncio.Lock() + self._exited = asyncio.Event() + + async def run(self) -> None: + group = self.group + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe(duplex=True) + self.process = context.Process( + target=run_plugin_host, args=(child, group.spec), name=f"powercontext-plugin-host-{group.name}" + ) + self.connection = parent try: + await asyncio.to_thread(self.process.start) + child.close() + log_safely(logger, logging.INFO, f"Plugin host {group.name} started") while True: - message = await asyncio.to_thread(connection.recv) + message = await asyncio.to_thread(parent.recv) + self.last_beat = time.monotonic() + if isinstance(message, HostHeartbeat): + continue if isinstance(message, HostReady): - self.implemented = message.implemented - if ready is not None and not ready.done(): - ready.set_result(None) + group.implemented = message.implemented + self.ready = True + self.idle_since = time.monotonic() + group.notify() elif isinstance(message, HostFailed): - log_safely(logger, logging.WARNING, f"Plugin host {self.name} failed to start ({message.error})") + log_safely(logger, logging.WARNING, f"Plugin host {group.name} failed to start ({message.error})") if message.permanent: - self.failure = message.error + group.failure = message.error break elif isinstance(message, HostResponse): - future = self._pending.pop(message.id, None) + future = self.pending.pop(message.id, None) if future is not None and not future.done(): future.set_result(message) except (EOFError, OSError): pass - except asyncio.CancelledError: - raise - await self._terminate("crash") + except Exception as error: + log_safely(logger, logging.WARNING, f"Plugin host {group.name} failed ({type(error).__name__})") + finally: + await asyncio.shield(self._cleanup()) - async def _terminate(self, reason: str) -> None: - process, connection, ready = self._process, self._connection, self._ready - if process is None: - return - self._process = self._connection = self._ready = None - for future in self._pending.values(): + async def _cleanup(self) -> None: + self.accepting = self.ready = False + for future in self.pending.values(): + if not future.done(): + future.set_exception(PluginHostUnavailable(self.group.name)) + self.pending.clear() + if self.process is not None and self.process.pid is not None: + await asyncio.to_thread(_stop_process, self.process) + self.connection.close() + self._exited.set() + self.group.exited(self) + + async def send(self, message: object) -> None: + if self.connection is None: + raise OSError("the host process has not started") # noqa: TRY003 + async with self._send_lock: + await asyncio.to_thread(self.connection.send, message) + + async def request(self, request: HostRequest, deadline: float) -> HostResponse: + future: asyncio.Future[HostResponse] = asyncio.get_running_loop().create_future() + self.pending[request.id] = future + try: + try: + await self.send(request) + except OSError: + raise PluginHostUnavailable(self.group.name) from None + try: + return await asyncio.wait_for(asyncio.shield(future), max(0.0, deadline - time.monotonic())) + except TimeoutError: + raise PluginHostTimeout(self.group.name) from None + finally: if not future.done(): - future.set_exception(PluginHostUnavailable(self.name)) - self._pending.clear() - if ready is not None and not ready.done(): - ready.set_exception(PluginHostUnavailable(self.name)) - ready.exception() - if connection is not None: - connection.close() - await asyncio.to_thread(_stop_process, process) - if not self._closing and self.failure is None: - healthy = time.monotonic() - self._started_at >= _HEALTHY_RESET_SECONDS - self._backoff = _BACKOFF_START if healthy or not self._backoff else min(self._backoff * 2, _BACKOFF_LIMIT) - self._not_before = time.monotonic() + self._backoff - log_safely(logger, logging.WARNING, f"Plugin host {self.name} stopped ({reason}); restarting later") - asyncio.get_running_loop().call_later(self._backoff, self._restart) + # Timed out or the caller went away: cancel the task so its write transaction rolls back. + self.pending.pop(request.id, None) + self.group.spawn_task(self._cancel(request.id)) + + async def _cancel(self, request_id: str) -> None: + with suppress(OSError): + await self.send(HostCancel(request_id)) + + async def stop(self, reason: str) -> None: + """Terminate the process; ``run`` then sees the Pipe close, reaps it, and cleans up.""" + + self.accepting = False + self.reason = reason + if self.process is None or self.process.pid is None: + return + self.process.terminate() + try: + await asyncio.wait_for(self._exited.wait(), _TERMINATE_SECONDS) + except TimeoutError: + self.process.kill() + + async def shut_down(self, reason: str) -> None: + """Ask the host to finish its calls and exit, and terminate it if it does not.""" + + self.accepting = False + self.reason = reason + with suppress(OSError): + await self.send(HostShutdown()) + try: + await asyncio.wait_for(self._exited.wait(), _SHUTDOWN_SECONDS) + except TimeoutError: + await self.stop(reason) + + +class PluginHost: + """The host processes of one plugin in this Server process.""" + + def __init__( + self, name: str, spec: PluginHostSpec, *, limit: int = 1, budget: HostProcessBudget | None = None + ) -> None: + self.name = name + self.spec = spec + self.limit = limit + self.implemented: tuple[str, ...] = () + # Why the plugin cannot run at all, such as a missing required method; it is not restarted. + self.failure: str | None = None + self._budget = budget or HostProcessBudget() + self._processes: list[_HostProcess] = [] + self._tasks: set[asyncio.Task[Any]] = set() + self._changed = asyncio.Event() + self._watcher: asyncio.Task[None] | None = None + self._backoff = 0.0 + self._not_before = 0.0 + self._closing = False + + async def start(self) -> None: + """Start the process the plugin always keeps; callers wait for readiness within their own time limit.""" + + if self._watcher is None and not self._closing: + self._watcher = asyncio.create_task(self._watch(), name=f"plugin-host-watch-{self.name}") + if not self._processes and not self._launch(resident=True): + raise PluginHostUnavailable(self.name) + + def _launch(self, *, resident: bool) -> bool: + if self._closing or self.failure is not None or time.monotonic() < self._not_before: + return False + if not self._budget.take(resident=resident): + return False + process = _HostProcess(self) + self._processes.append(process) + self.spawn_task(process.run()) + return True + + def spawn_task(self, coroutine: Any) -> None: + task = asyncio.create_task(coroutine) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + def notify(self) -> None: + # Wake every waiter; each takes the current event before it waits. + self._changed.set() + self._changed = asyncio.Event() + + def exited(self, process: _HostProcess) -> None: + self._processes.remove(process) + self._budget.give_back() + self.notify() + if process.reason == "idle" or self._closing or self.failure is not None: + return + log_safely(logger, logging.WARNING, f"Plugin host {self.name} stopped ({process.reason})") + if self._processes: + return + healthy = time.monotonic() - process.started_at >= _HEALTHY_RESET_SECONDS + self._backoff = _BACKOFF_START if healthy or not self._backoff else min(self._backoff * 2, _BACKOFF_LIMIT) + self._not_before = time.monotonic() + self._backoff + asyncio.get_running_loop().call_later(self._backoff, self._restart) def _restart(self) -> None: - if not self._closing: - task = asyncio.ensure_future(self._restart_quietly()) - task.add_done_callback(lambda done: done.exception()) + if not self._processes: + self._launch(resident=True) - async def _restart_quietly(self) -> None: - with suppress(PluginHostUnavailable): - await self.start() + async def _watch(self) -> None: + while True: + await asyncio.sleep(_WATCH_SECONDS) + now = time.monotonic() + serving = [process for process in self._processes if process.accepting] + for process in serving: + if process.ready and now - process.last_beat > HEARTBEAT_TIMEOUT_SECONDS: + self.spawn_task(process.stop("unresponsive")) + elif not process.ready and now - process.started_at > READY_TIMEOUT_SECONDS: + self.spawn_task(process.stop("start_timeout")) + idle = [ + process + for process in serving + if process.ready and process.in_flight == 0 and now - process.idle_since >= IDLE_STOP_SECONDS + ] + # The group always keeps one process; stop the newest idle extras first. + for process in reversed(idle[: max(0, len(serving) - 1)]): + self.spawn_task(process.shut_down("idle")) + + def _grow(self) -> None: + serving = [process for process in self._processes if process.accepting] + if len(serving) < self.limit and all(process.ready for process in serving): + self._launch(resident=False) + + async def _acquire(self, deadline: float) -> _HostProcess: + """Take a slot on the least busy ready process, queueing until one frees up.""" + + while True: + if self.failure is not None: + raise PluginHostUnavailable(self.name) + serving = [process for process in self._processes if process.accepting] + ready = [process for process in serving if process.ready] + free = [process for process in ready if process.in_flight < HOST_CONCURRENCY] + if free: + chosen = min(free, key=lambda process: process.in_flight) + chosen.in_flight += 1 + return chosen + if ready: + self._grow() + elif not serving and not self._launch(resident=True): + raise PluginHostUnavailable(self.name) + changed = self._changed + try: + await asyncio.wait_for(changed.wait(), max(0.0, deadline - time.monotonic())) + except TimeoutError: + # Queued behind busy processes counts against the call; waiting for a start does not. + if any(process.ready and process.accepting for process in self._processes): + raise PluginHostTimeout(self.name) from None + raise PluginHostUnavailable(self.name) from None async def call( self, @@ -162,54 +333,38 @@ async def call( audit: Any, payload: dict[str, Any], body: Any = None, - timeout: float, # noqa: ASYNC109 - the deadline also decides when to replace the process + timeout: float, # noqa: ASYNC109 - the deadline also decides when the host cancels the call ) -> HostResponse: deadline = time.monotonic() + timeout - if self._ready is None: + if self._watcher is None: await self.start() - ready = self._ready - if ready is None: - raise PluginHostUnavailable(self.name) + process = await self._acquire(deadline) + request = HostRequest( + id=uuid4().hex, + method=method, + scope_id=scope_id, + caller=caller, + audit=audit, + payload=payload, + body=body, + ) try: - await asyncio.wait_for(asyncio.shield(ready), max(0.0, deadline - time.monotonic())) - except TimeoutError: - raise PluginHostUnavailable(self.name) from None - async with self._slots: - connection = self._connection - if connection is None: - raise PluginHostUnavailable(self.name) - request = HostRequest( - id=uuid4().hex, - method=method, - scope_id=scope_id, - caller=caller, - audit=audit, - payload=payload, - body=body, - ) - future: asyncio.Future[HostResponse] = asyncio.get_running_loop().create_future() - self._pending[request.id] = future - async with self._send_lock: - await asyncio.to_thread(connection.send, request) - try: - return await asyncio.wait_for(asyncio.shield(future), max(0.0, deadline - time.monotonic())) - except TimeoutError: - # A blocked plugin cannot be interrupted reliably; replace the whole process. - await self._terminate("timeout") - raise PluginHostUnavailable(self.name) from None + return await process.request(request, deadline) + finally: + process.in_flight -= 1 + if process.in_flight == 0: + process.idle_since = time.monotonic() + self.notify() async def close(self) -> None: self._closing = True - connection = self._connection - if connection is not None: - with suppress(OSError): - connection.send(HostShutdown()) - process = self._process - if process is not None: - await asyncio.to_thread(process.join, 10) - await self._terminate("server_stop") - if self._reader is not None: - self._reader.cancel() + if self._watcher is not None: + self._watcher.cancel() + await asyncio.gather( + *(process.shut_down("server_stop") for process in list(self._processes)), return_exceptions=True + ) + if self._tasks: + await asyncio.wait(list(self._tasks), timeout=_SHUTDOWN_SECONDS) def _stop_process(process: Any) -> None: diff --git a/src/powercontext/server/plugins.py b/src/powercontext/server/plugins.py index 7cf40ef3ad..909da1449a 100644 --- a/src/powercontext/server/plugins.py +++ b/src/powercontext/server/plugins.py @@ -27,7 +27,7 @@ import json import logging import sys -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import AsyncExitStack from dataclasses import dataclass, field from datetime import timedelta @@ -35,6 +35,7 @@ from pathlib import Path from typing import Any +import tzlocal from sqlalchemy import select from powercontext._logging import log_safely @@ -62,7 +63,7 @@ from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY from powercontext.http._generated.models import PluginPage, PluginScopePage, PluginScopeState, PluginState from powercontext.plugins import Cron, Interval -from powercontext.server.plugin_hosts import PluginHost +from powercontext.server.plugin_hosts import HostProcessBudget, PluginHost logger = logging.getLogger(__name__) CHECK_TIMEOUT_SECONDS = 60 @@ -148,6 +149,8 @@ def __init__( storage_root: Path | None = None, host_security_builder: Callable[[], dict[str, Any] | None] | None = None, serve_requests: bool = True, + host_processes: Mapping[str, int] | None = None, + host_processes_total: int | None = None, ) -> None: self.database = database self.register_merge = register_merge @@ -165,6 +168,8 @@ def __init__( self.host_security_builder = host_security_builder or security_builder # Instances that receive no requests (the background role) start no host processes. self.serve_requests = serve_requests + self.host_processes = dict(host_processes or {}) + self.host_budget = HostProcessBudget(host_processes_total) async def __aenter__(self) -> PluginManager: directory = self.plugins_dir @@ -219,6 +224,8 @@ def _prepare_hosts(self) -> None: source_bindings=bindings, storage_root=self.storage_root, ), + limit=self.host_processes.get(plugin.name, manifest.spec.host_processes), + budget=self.host_budget, ) # Every loaded plugin's host starts now, so the first request does not wait for a spawn. task = asyncio.create_task(self._start_host(plugin.host), name=f"plugin-host-start-{plugin.name}") @@ -311,7 +318,7 @@ async def in_scope(connection, candidates): worker_timeout_seconds=generate.timeout_seconds, automatic_processing_interval=interval, cron=None if cron is None else cron.expression, - timezone="Asia/Shanghai" if cron is None else cron.timezone, + timezone="Asia/Shanghai" if cron is None else cron.timezone or tzlocal.get_localzone_name(), pending_provider=None if inputs is None else SourceProcessingPendingProvider(self.database, manifest.binding_name, generate.family), diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 86cf77af53..3e2a1e7dc9 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -20,7 +20,7 @@ from contextlib import suppress from ipaddress import ip_address from pathlib import Path -from typing import Literal +from typing import Annotated, Literal from urllib.parse import urlsplit from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator @@ -269,6 +269,10 @@ class ServerSettings(BaseSettings): plugins_dir: Path | None = None # Ceiling on plugin route request and response bodies; a route may declare a lower limit. plugin_api_max_body_bytes: int = Field(default=32 * 1024 * 1024, ge=1024) + # Per-plugin ceilings on host processes, by plugin name, replacing the plugin's declared host_processes. + plugin_host_processes: dict[str, Annotated[int, Field(ge=1)]] = Field(default_factory=dict) + # Ceiling on host processes of all plugins; each plugin's first process starts regardless. + plugin_total_host_processes: int | None = Field(default=None, ge=1) source_plugins_dir: Path | None = None database: DatabaseConfig = Field(default_factory=_default_database, discriminator="kind") handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig) diff --git a/tests/e2e/test_plugin_hosts.py b/tests/e2e/test_plugin_hosts.py new file mode 100644 index 0000000000..e035f56a0f --- /dev/null +++ b/tests/e2e/test_plugin_hosts.py @@ -0,0 +1,115 @@ +"""Plugin host processes cancel only timed-out calls, survive a blocked plugin, and grow under load.""" + +import asyncio + +from tests.e2e.plugin_server import create_scope, running_server, wait_for + +PLUGIN = """ +import asyncio +import os +import time + +from pydantic import BaseModel + +from powercontext.plugins import ApiRequest, ArtifactPlugin, PluginApi, Rendering, Route, SearchResult + + +class Wait(BaseModel): + seconds: float + + +class Slow: + async def render_artifact(self, request): + return Rendering(text="") + + async def search_artifacts(self, request): + await asyncio.sleep(30) + return SearchResult(hits=[]) + + async def wait(self, request: ApiRequest): + await asyncio.sleep(request.body.seconds) + return {"pid": os.getpid()} + + async def block(self, request: ApiRequest): + time.sleep(request.body.seconds) + return {"pid": os.getpid()} + + +async def setup(ctx): + return Slow() + + +def route(path, handler, timeout): + return Route( + method="POST", + path=path, + handler=handler, + access="authenticated", + body="json", + request_model=Wait, + timeout_seconds=timeout, + ) + + +plugin = ArtifactPlugin( + family="slow-notes", + setup=setup, + change_timeout_seconds=1, + host_processes=2, + api=PluginApi(routes=(route("/wait", "wait", 3), route("/hold", "wait", 30), route("/block", "block", 1))), +) +""" + + +def test_plugin_hosts_cancel_timed_out_calls_replace_blocked_hosts_and_grow_under_load(tmp_path): + plugins = tmp_path / "plugins" + (plugins / "slow").mkdir(parents=True) + (plugins / "slow" / "plugin.py").write_text(PLUGIN, encoding="utf-8") + log = tmp_path / "server.log" + + async def scenario(): + async with running_server(tmp_path, plugins) as client: + api = "/v1/plugins/slow/api" + + async def call(path, seconds): + return await client.post(f"{api}{path}", json={"seconds": seconds}, timeout=60) + + first = await wait_for(lambda: call("/wait", 0), lambda response: response.status_code == 200, log=log) + pid = first.json()["pid"] + + # A call past its time limit is cancelled alone; the other call on the same host completes. + late, prompt = await asyncio.gather(call("/wait", 10), call("/wait", 0.5)) + assert (late.status_code, late.json()["error"]["code"]) == (504, "plugin_timeout"), late.text + assert prompt.status_code == 200, prompt.text + assert prompt.json()["pid"] == pid + assert (await call("/wait", 0)).json()["pid"] == pid + + # A section whose plugin times out is left out; the rest of the context is still prepared. + scope = await create_scope(client, "plugin-hosts-e2e") + prepared = await client.post( + "/v1/context/prepare", + json={ + "scope_id": scope, + "query": "anything", + "include_receipt": True, + "assembly": {"sections": [{"family": "slow-notes", "limit": 2}, {"family": "memory", "limit": 2}]}, + }, + timeout=60, + ) + assert prepared.status_code == 200, prepared.text + searches = prepared.json()["receipt"]["retrieval"]["searches"] + assert any(item["family"] == "slow-notes" and item["outcome"] == "failed" for item in searches), searches + + # Blocking the event loop stops the heartbeat: the timed-out call gets 504, the others 503. + blocked, waiting = await asyncio.gather(call("/block", 60), call("/hold", 20)) + assert (blocked.status_code, blocked.json()["error"]["code"]) == (504, "plugin_timeout"), blocked.text + assert (waiting.status_code, waiting.json()["error"]["code"]) == (503, "plugin_unavailable"), waiting.text + restarted = await wait_for(lambda: call("/wait", 0), lambda response: response.status_code == 200, log=log) + assert restarted.json()["pid"] != pid + + # Sixteen long calls fill one host's eight slots, so a second host starts; the limit is two. + held = await asyncio.gather(*(call("/hold", 8) for _ in range(16))) + assert all(response.status_code == 200 for response in held), [response.text for response in held] + assert len({response.json()["pid"] for response in held}) == 2 + + asyncio.run(scenario()) diff --git a/tests/test_server.py b/tests/test_server.py index 36e9a3f182..f216bf0c66 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -423,6 +423,17 @@ def test_env_example_loads_server_settings(monkeypatch) -> None: assert settings.inference.embedding_dimension == 1536 +def test_server_settings_read_both_plugin_host_process_limits(monkeypatch) -> None: + # The per-plugin map and the total are separate variables, though "_" also nests settings. + monkeypatch.setenv("POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES", '{"knowledge-base": 4}') + monkeypatch.setenv("POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES", "6") + + settings = ServerSettings() + + assert settings.plugin_host_processes == {"knowledge-base": 4} + assert settings.plugin_total_host_processes == 6 + + def test_server_settings_select_oceanbase(monkeypatch) -> None: url = "mysql+aoceanbase://root:test@127.0.0.1:2881/powercontext?charset=utf8mb4" monkeypatch.setenv("POWERCONTEXT_SERVER_DATABASE_KIND", "oceanbase") diff --git a/uv.lock b/uv.lock index 4bdbeca18b..efad293bce 100644 --- a/uv.lock +++ b/uv.lock @@ -2918,6 +2918,7 @@ builtin = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlite-vec" }, { name = "tzdata" }, + { name = "tzlocal" }, ] cli = [ { name = "aiosqlite" }, @@ -2943,6 +2944,7 @@ cli = [ { name = "sqlite-vec" }, { name = "typer" }, { name = "tzdata" }, + { name = "tzlocal" }, ] client = [ { name = "httpx", extra = ["socks"] }, @@ -2988,6 +2990,7 @@ ops = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlite-vec" }, { name = "tzdata" }, + { name = "tzlocal" }, { name = "uvicorn" }, ] seekdb = [ @@ -3010,6 +3013,7 @@ seekdb = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlite-vec" }, { name = "tzdata" }, + { name = "tzlocal" }, ] server = [ { name = "aiosqlite" }, @@ -3040,6 +3044,7 @@ server = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlite-vec" }, { name = "tzdata" }, + { name = "tzlocal" }, { name = "uvicorn" }, ] source-images = [ @@ -3248,6 +3253,11 @@ requires-dist = [ { name = "tzdata", marker = "extra == 'ops'", specifier = ">=2026.3" }, { name = "tzdata", marker = "extra == 'seekdb'", specifier = ">=2026.3" }, { name = "tzdata", marker = "extra == 'server'", specifier = ">=2026.3" }, + { name = "tzlocal", marker = "extra == 'builtin'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'cli'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'ops'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'seekdb'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'server'", specifier = ">=5.2,<6" }, { name = "uvicorn", marker = "extra == 'ops'", specifier = ">=0.34,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] From ac19e83ca282e807e3ae5051017107129d31f0ee Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 14:50:25 -0800 Subject: [PATCH 03/38] build(integrations): rebuild plugin bundles from the current operation tables Co-Authored-By: Claude Opus 5.5 --- .../powercontext-hooks/node/worker.cjs | 181 ++++++++++-------- .../dsh/plugins/powercontext/lib/index.js | 181 ++++++++++-------- .../plugins/powercontext/lib/index.js | 181 ++++++++++-------- .../opencode/plugins/powercontext/lib/tui.js | 181 ++++++++++-------- 4 files changed, 416 insertions(+), 308 deletions(-) diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index fcd1a89482..a5001d5dd6 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -266,83 +266,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { - method: "GET", - path: "/v1/admin/artifact-plugins", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - get_artifact_plugin: { - method: "GET", - path: "/v1/admin/artifact-plugins/{binding_name}", - location: null, - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - enable_all_artifact_plugins: { - method: "POST", - path: "/v1/admin/artifact-plugins/enable-all", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - reload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/reload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - enable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/enable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - disable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/disable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - unload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/unload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -726,6 +649,95 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_plugins: { + method: "GET", + path: "/v1/plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_plugin: { + method: "GET", + path: "/v1/plugins/{name}", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + list_plugin_scopes: { + method: "GET", + path: "/v1/plugins/{name}/scopes", + location: "query", + scopeMode: "none", + pathParameters: ["name"], + queryParams: ["cursor", "limit"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + merge_artifacts: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/merges", + location: "body", + scopeMode: "current", + pathParameters: ["scope_id", "family"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_governance: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", + location: null, + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + forget_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + restore_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, search_artifacts: { method: "POST", path: "/v1/scopes/{scope_id}/artifacts/{family}/search", @@ -1514,6 +1526,21 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", + location: "query", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: ["revision"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_artifact_revisions: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index c959003a49..cdbd312f18 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -334,83 +334,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { - method: "GET", - path: "/v1/admin/artifact-plugins", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - get_artifact_plugin: { - method: "GET", - path: "/v1/admin/artifact-plugins/{binding_name}", - location: null, - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - enable_all_artifact_plugins: { - method: "POST", - path: "/v1/admin/artifact-plugins/enable-all", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - reload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/reload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - enable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/enable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - disable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/disable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - unload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/unload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -794,6 +717,95 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_plugins: { + method: "GET", + path: "/v1/plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_plugin: { + method: "GET", + path: "/v1/plugins/{name}", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + list_plugin_scopes: { + method: "GET", + path: "/v1/plugins/{name}/scopes", + location: "query", + scopeMode: "none", + pathParameters: ["name"], + queryParams: ["cursor", "limit"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + merge_artifacts: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/merges", + location: "body", + scopeMode: "current", + pathParameters: ["scope_id", "family"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_governance: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", + location: null, + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + forget_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + restore_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, search_artifacts: { method: "POST", path: "/v1/scopes/{scope_id}/artifacts/{family}/search", @@ -1582,6 +1594,21 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", + location: "query", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: ["revision"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_artifact_revisions: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 0265d282e9..751187f0ec 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -234,83 +234,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { - method: "GET", - path: "/v1/admin/artifact-plugins", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - get_artifact_plugin: { - method: "GET", - path: "/v1/admin/artifact-plugins/{binding_name}", - location: null, - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - enable_all_artifact_plugins: { - method: "POST", - path: "/v1/admin/artifact-plugins/enable-all", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - reload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/reload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - enable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/enable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - disable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/disable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - unload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/unload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -694,6 +617,95 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_plugins: { + method: "GET", + path: "/v1/plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_plugin: { + method: "GET", + path: "/v1/plugins/{name}", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + list_plugin_scopes: { + method: "GET", + path: "/v1/plugins/{name}/scopes", + location: "query", + scopeMode: "none", + pathParameters: ["name"], + queryParams: ["cursor", "limit"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + merge_artifacts: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/merges", + location: "body", + scopeMode: "current", + pathParameters: ["scope_id", "family"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_governance: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", + location: null, + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + forget_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + restore_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, search_artifacts: { method: "POST", path: "/v1/scopes/{scope_id}/artifacts/{family}/search", @@ -1482,6 +1494,21 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", + location: "query", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: ["revision"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_artifact_revisions: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index a672ee25f7..9ebda3d3b1 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -234,83 +234,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { - method: "GET", - path: "/v1/admin/artifact-plugins", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - get_artifact_plugin: { - method: "GET", - path: "/v1/admin/artifact-plugins/{binding_name}", - location: null, - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - enable_all_artifact_plugins: { - method: "POST", - path: "/v1/admin/artifact-plugins/enable-all", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - reload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/reload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - enable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/enable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - disable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/disable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - unload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/unload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -694,6 +617,95 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_plugins: { + method: "GET", + path: "/v1/plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_plugin: { + method: "GET", + path: "/v1/plugins/{name}", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + list_plugin_scopes: { + method: "GET", + path: "/v1/plugins/{name}/scopes", + location: "query", + scopeMode: "none", + pathParameters: ["name"], + queryParams: ["cursor", "limit"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + merge_artifacts: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/merges", + location: "body", + scopeMode: "current", + pathParameters: ["scope_id", "family"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_governance: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", + location: null, + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + forget_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + restore_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, search_artifacts: { method: "POST", path: "/v1/scopes/{scope_id}/artifacts/{family}/search", @@ -1482,6 +1494,21 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", + location: "query", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: ["revision"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_artifact_revisions: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", From 72bcf5b42cdeda5ee425689d137564bf6be11cdc Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:15:30 -0800 Subject: [PATCH 04/38] fix(plugins): require the caller's artifact.write for tag, handle, and lifecycle writes Plugin route writers act as the caller, but set_tags, bind_handle, and the direct lifecycle transition (retirement, deprecation with a replacement) wrote through repositories without the shared Artifact access check, so a Scope contributor could retag or retire another user's Artifact through a plugin route. These writes now run the same artifact.write check as content changes; Worker writers keep their unchanged security. The new acceptance test also restores the read-side assertions removed with the old plugin authorization suite: a viewer of one Scope gets 403 for plugin Artifacts, renderings, search, and Prepared Context sections of another. Co-Authored-By: Claude Opus 5.5 --- src/powercontext/builtin/plugins/writer.py | 16 +- tests/e2e/plugin_server.py | 11 +- tests/e2e/test_plugin_access.py | 186 +++++++++++++++++++++ 3 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/test_plugin_access.py diff --git a/src/powercontext/builtin/plugins/writer.py b/src/powercontext/builtin/plugins/writer.py index d3d71195df..879ce7b276 100644 --- a/src/powercontext/builtin/plugins/writer.py +++ b/src/powercontext/builtin/plugins/writer.py @@ -370,6 +370,12 @@ async def _require(self, artifact_id: str) -> ArtifactMergeRecord: raise RepositoryNotFoundError("artifact", (self._owner.scope_id, self._owner.family, artifact_id)) return record + async def _authorize_write(self, ref: ArtifactRef) -> None: + """Writes that bypass the governance engine still need the caller's ``artifact.write``.""" + + owner = self._owner + await owner.service.security.authorize(self._connection, owner.scope_id, owner.context, "write", ref) + async def put( self, artifact_id: str, @@ -475,6 +481,7 @@ async def set_lifecycle( self._connection, owner.scope_id, artifact_id, owner.context, operation="restore" ) return await self._commit_change(plan, artifact_id) + await self._authorize_write(current.artifact.as_ref()) await owner.states.transition( self._connection, owner.scope_id, @@ -558,6 +565,7 @@ async def bind_handle(self, handle_key: str, ref: ArtifactRef, /) -> ArtifactRef owner = self._owner if ref.family != owner.family: raise PluginWriteError("handles must bind this plugin's family") # noqa: TRY003 + await self._authorize_write(ref) existing = await self.resolve_handle(handle_key) if existing is not None: if existing != ref: @@ -579,11 +587,9 @@ async def set_tags(self, artifact_id: str, tags: Sequence[str], /) -> tuple[str, self._admit() self._agree("tags", artifact_id) owner = self._owner - await owner.artifacts.lock_heads( - self._connection, - owner.scope_id, - ((await self._require(artifact_id)).artifact.as_ref(),), - ) + ref = (await self._require(artifact_id)).artifact.as_ref() + await self._authorize_write(ref) + await owner.artifacts.lock_heads(self._connection, owner.scope_id, (ref,)) desired = normalize_tags(tuple(tags)) await self._connection.execute( delete(ARTIFACT_TAGS_TABLE).where(*_tag_identity(owner.scope_id, owner.family, artifact_id)) diff --git a/tests/e2e/plugin_server.py b/tests/e2e/plugin_server.py index 6b4b8e659f..cead80a63b 100644 --- a/tests/e2e/plugin_server.py +++ b/tests/e2e/plugin_server.py @@ -33,7 +33,10 @@ @asynccontextmanager -async def running_server(directory: Path, plugins: Path | None = None, *, extra_env: dict[str, str] | None = None): +async def running_server( + directory: Path, plugins: Path | None = None, *, extra_env: dict[str, str | None] | None = None +): + """Start the Server; an ``extra_env`` value of ``None`` removes that default variable.""" with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] @@ -53,8 +56,12 @@ async def running_server(directory: Path, plugins: Path | None = None, *, extra_ "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", # Worker children import test-owned plugin helpers from the repository. "PYTHONPATH": os.pathsep.join(filter(None, (str(REPOSITORY), env.get("PYTHONPATH")))), - **(extra_env or {}), }) + for key, value in (extra_env or {}).items(): + if value is None: + env.pop(key, None) + else: + env[key] = value if plugins is not None: env["POWERCONTEXT_SERVER_PLUGINS_DIR"] = str(plugins) entrypoint = Path(sys.executable).with_name("powercontext") diff --git a/tests/e2e/test_plugin_access.py b/tests/e2e/test_plugin_access.py new file mode 100644 index 0000000000..b285c246b2 --- /dev/null +++ b/tests/e2e/test_plugin_access.py @@ -0,0 +1,186 @@ +# 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. + +"""Plugin families follow the shared Artifact access policy on every read and write path.""" + +import asyncio +import hashlib +import json + +import httpx + +from tests.e2e.plugin_server import ADMIN_AUTHORIZATION_VALUE, create_scope, running_server + +PLUGIN = """ +from powercontext.plugins import ApiRequest, ArtifactPlugin, PluginApi, Rendering, Route, SearchHit, SearchResult + + +class Notes: + def __init__(self, ctx): + self.ctx = ctx + + async def render_artifact(self, request): + return Rendering(text=request.artifact.content["text"]) + + async def search_artifacts(self, request): + page = await self.ctx.reads(request.scope_id).list(limit=50) + return SearchResult(hits=[SearchHit(ref=item.ref, text=item.content["text"], score=1.0) for item in page.items]) + + async def create_artifact(self, request, writer): + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=request.content) + + async def tag(self, request: ApiRequest): + writer = self.ctx.writer(request.scope_id) + async with writer.transaction() as tx: + await tx.set_tags(request.path_params["artifact_id"], ["pinned"]) + + async def retire(self, request: ApiRequest): + writer = self.ctx.writer(request.scope_id) + async with writer.transaction() as tx: + await tx.set_lifecycle(request.path_params["artifact_id"], "retired") + + async def bind(self, request: ApiRequest): + writer = self.ctx.writer(request.scope_id) + current = await writer.get(request.path_params["artifact_id"]) + async with writer.transaction() as tx: + await tx.bind_handle("latest-" + current.ref.artifact_id, current.ref) + + +async def setup(ctx): + return Notes(ctx) + + +def _route(name): + return Route( + method="POST", + path="/scopes/{scope_id}/notes/{artifact_id}/" + name, + handler=name, + access="scope.contribute", + scope_param="scope_id", + ) + + +plugin = ArtifactPlugin( + family="notes", + setup=setup, + api=PluginApi(routes=(_route("tag"), _route("retire"), _route("bind"))), +) +""" + +USERS = ("alice", "bob", "carol", "dave") + + +def _identities(tmp_path): + def entry(name, token): + digest = hashlib.sha256(token.encode()).hexdigest() + return {"credential_id": name, "token_digest": digest, "subject": {"type": "user", "id": name}} + + path = tmp_path / "identities.json" + entries = [entry("admin", ADMIN_AUTHORIZATION_VALUE), *(entry(name, f"{name}-token") for name in USERS)] + path.write_text(json.dumps({"identities": entries}), encoding="utf-8") + path.chmod(0o600) + return { + "POWERCONTEXT_SERVER_AUTH_IDENTITIES_FILE": str(path), + "POWERCONTEXT_SERVER_AUTH_ADMINISTRATORS": json.dumps(["user:admin"]), + "POWERCONTEXT_SERVER_AUTH_TOKEN": None, + "POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID": "plugin-worker", + } + + +async def _grant(admin: httpx.AsyncClient, user: str, scope: str, role: str) -> None: + response = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "user", "id": user}, + "resource": {"type": "scope", "scope_id": scope}, + "role": role, + "idempotency_key": f"{user}-{scope}-{role}", + }, + ) + assert response.status_code == 201, response.text + + +def test_plugin_family_reads_and_direct_writes_need_the_callers_permission(tmp_path): + plugins = tmp_path / "plugins" + (plugins / "notes").mkdir(parents=True) + (plugins / "notes" / "plugin.py").write_text(PLUGIN, encoding="utf-8") + + async def scenario(): + async with running_server(tmp_path, plugins, extra_env=_identities(tmp_path)) as admin: + shared = await create_scope(admin, "plugin-access-shared") + private = await create_scope(admin, "plugin-access-private") + await _grant(admin, "alice", shared, "scope.contributor") + await _grant(admin, "bob", shared, "scope.contributor") + await _grant(admin, "carol", shared, "scope.viewer") + await _grant(admin, "dave", private, "scope.contributor") + + users = { + name: httpx.AsyncClient( + base_url=admin.base_url, headers={"Authorization": f"Bearer {name}-token"}, timeout=20 + ) + for name in USERS + } + alice, bob, carol, dave = (users[name] for name in USERS) + try: + secret = await dave.post( + f"/v1/scopes/{private}/artifacts", json={"family": "notes", "content": {"text": "private plan"}} + ) + assert secret.status_code == 201, secret.text + created = await alice.post( + f"/v1/scopes/{shared}/artifacts", json={"family": "notes", "content": {"text": "alice's note"}} + ) + assert created.status_code == 201, created.text + note = created.json()["artifact_id"] + + # A Scope viewer reads plugin Artifacts only in the Scope it was granted. + path = f"/v1/scopes/{shared}/artifacts/notes/{note}" + for url in (path, f"{path}/rendering"): + assert (await carol.get(url)).status_code == 200, url + found = await carol.post(f"/v1/scopes/{shared}/artifacts/notes/search", json={"query": "note"}) + assert found.status_code == 200, found.text + hidden = f"/v1/scopes/{private}/artifacts/notes/{secret.json()['artifact_id']}" + for url in (hidden, f"{hidden}/rendering", f"/v1/scopes/{private}/artifacts/notes"): + assert (await carol.get(url)).status_code == 403, url + searched = await carol.post(f"/v1/scopes/{private}/artifacts/notes/search", json={"query": "plan"}) + assert searched.status_code == 403, searched.text + for scope, expected in ((shared, 200), (private, 403)): + prepared = await carol.post( + "/v1/context/prepare", + json={ + "scope_id": scope, + "query": "note", + "assembly": {"sections": [{"family": "notes", "limit": 2}]}, + }, + ) + assert prepared.status_code == expected, prepared.text + + # Another contributor cannot change Alice's note through a plugin route either. + routes = f"/v1/plugins/notes/api/scopes/{shared}/notes/{note}" + for action in ("tag", "retire", "bind"): + denied = await bob.post(f"{routes}/{action}") + assert denied.status_code == 403, (action, denied.text) + state = (await alice.get(f"{path}/governance")).json() + assert state["lifecycle_state"] == "active" + assert (await alice.get(f"{path}/tags")).json()["tags"] == [] + + for action in ("tag", "bind", "retire"): + allowed = await alice.post(f"{routes}/{action}") + assert allowed.status_code == 204, (action, allowed.text) + assert (await alice.get(f"{path}/governance")).json()["lifecycle_state"] == "retired" + finally: + for client in users.values(): + await client.aclose() + + asyncio.run(scenario()) From 22fadfe4bcccd8a76084e69dc29879a79dfe1af3 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:15:30 -0800 Subject: [PATCH 05/38] fix(plugins): enforce the caller's revisions inside user-change writes and report the committed revision The host checked expected revisions and governance versions before calling the plugin, but the writer then decided forget, merge, and update against the rows it reread, so a write committed after the precheck could be forgotten or merged by a stale request. User changes now carry the caller's expectations into the transaction, which rejects a changed target with 409 (412 for updates) whether or not the plugin repeats the check. Responses no longer reread the head after the plugin returns: the writer keeps the snapshot and records its agreed write committed, and the Server builds replace, create, forget, and merge responses from them, so a later write cannot appear as the result of an earlier request. Co-Authored-By: Claude Opus 5.5 --- src/powercontext/builtin/plugins/host.py | 18 ++- src/powercontext/builtin/plugins/writer.py | 62 ++++++-- src/powercontext/server/app.py | 42 ++++-- tests/e2e/test_plugin_concurrent_changes.py | 158 ++++++++++++++++++++ 4 files changed, 257 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/test_plugin_concurrent_changes.py diff --git a/src/powercontext/builtin/plugins/host.py b/src/powercontext/builtin/plugins/host.py index 210d1e8476..361af62698 100644 --- a/src/powercontext/builtin/plugins/host.py +++ b/src/powercontext/builtin/plugins/host.py @@ -364,7 +364,12 @@ async def _change(self, request: HostRequest) -> HostResponse: await self._authorize(scope_id, "create", None) for item in inputs: await self._precheck(scope_id, item.artifact_id, item.revision, item.state_version, "write") - change = UserChange("merge", target, tuple(item.artifact_id for item in inputs)) + change = UserChange( + "merge", + target, + tuple(item.artifact_id for item in inputs), + {item.artifact_id: (item.revision, item.state_version) for item in inputs}, + ) plugin_request = MergeRequest( scope_id=scope_id, inputs=inputs, into=target, content=payload["content"], caller=caller ) @@ -373,7 +378,7 @@ async def _change(self, request: HostRequest) -> HostResponse: await self._authorize(scope_id, "write", target) if operation == "update": await self._precheck(scope_id, target, payload["expected_revision"], None, "precondition") - change = UserChange("update", target) + change = UserChange("update", target, expected={target: (payload["expected_revision"], None)}) plugin_request = UpdateRequest( scope_id=scope_id, artifact_id=target, @@ -385,7 +390,11 @@ async def _change(self, request: HostRequest) -> HostResponse: await self._precheck( scope_id, target, payload["expected_revision"], payload["expected_state_version"], "write" ) - change = UserChange("forget", target) + change = UserChange( + "forget", + target, + expected={target: (payload["expected_revision"], payload["expected_state_version"])}, + ) plugin_request = ForgetRequest( scope_id=scope_id, artifact_id=target, @@ -407,13 +416,14 @@ async def _change(self, request: HostRequest) -> HostResponse: await method(plugin_request, writer) if not writer.committed: raise PluginContractError(f"{request.method} returned without its agreed write") # noqa: TRY003 - snapshot = await self._writer(scope_id).get(target) + snapshot = writer.written if writer.written is not None else await self._writer(scope_id).get(target) restored, retired, undone = writer.outcome return HostResponse( request.id, "ok", result={ "artifact": None if snapshot is None else snapshot.model_dump(mode="json"), + "records": [record.model_dump(mode="json") for record in writer.records], "restored": [ref.model_dump(mode="json") for ref in restored], "retired": [ref.model_dump(mode="json") for ref in retired], "undo_merge_results": list(undone), diff --git a/src/powercontext/builtin/plugins/writer.py b/src/powercontext/builtin/plugins/writer.py index 879ce7b276..40bbe932a1 100644 --- a/src/powercontext/builtin/plugins/writer.py +++ b/src/powercontext/builtin/plugins/writer.py @@ -26,7 +26,7 @@ import hashlib from collections.abc import Awaitable, Callable, Collection, Sequence from contextlib import asynccontextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import UTC, datetime from functools import partial from typing import Any, Literal, cast @@ -59,6 +59,7 @@ plugin_artifact_repository, record_revision_metadata, ) +from powercontext.builtin.records import ArtifactRevisionPreconditionError from powercontext.builtin.runtime.artifact_merge import ArtifactMergeAccess, merge_artifact_tags from powercontext.builtin.sources.content import ContentSource, ContentSourceInternal, ContentSourceTarget from powercontext.builtin.tags import normalize_tags @@ -85,6 +86,8 @@ class UserChange: operation: Literal["create", "update", "merge", "forget", "restore", "undo_merge"] artifact_id: str inputs: tuple[str, ...] = () + # The caller's (revision, state_version) per Artifact, enforced against the rows the transaction writes. + expected: dict[str, tuple[int, int | None]] = field(default_factory=dict) _AGREED = { @@ -349,15 +352,17 @@ def _admit(self, content: dict[str, JsonValue] | None = None, sources: Sequence[ async def _committed(self, plan, direct_source: SourceRef | None = None): owner = self._owner prepared = await owner.service.prepare_change(plan) - return await owner.service.commit(self._connection, prepared, owner.context, direct_source=direct_source) + result = await owner.service.commit(self._connection, prepared, owner.context, direct_source=direct_source) + owner.records = tuple(_snapshot(record) for record in result.records) + return result async def _commit(self, plan) -> ArtifactSnapshot: - return _snapshot((await self._committed(plan)).primary) + return self._written(_snapshot((await self._committed(plan)).primary)) async def _commit_change(self, plan, artifact_id: str) -> ArtifactChange: result = await self._committed(plan) change = ArtifactChange( - artifact=_snapshot(result.primary), + artifact=self._written(_snapshot(result.primary)), restored=tuple(ref for ref in result.restored if ref.artifact_id != artifact_id), retired=tuple(ref for ref in result.retired if ref.artifact_id != artifact_id), ) @@ -370,12 +375,41 @@ async def _require(self, artifact_id: str) -> ArtifactMergeRecord: raise RepositoryNotFoundError("artifact", (self._owner.scope_id, self._owner.family, artifact_id)) return record + def _expect(self, record: ArtifactMergeRecord) -> None: + """A user change writes only the revision and governance state its caller read.""" + + change = self._owner.change + expected = None if change is None else change.expected.get(record.artifact.artifact_id) + if expected is None: + return + revision, state_version = expected + if record.artifact.revision != revision: + if change is not None and change.operation == "update": + raise ArtifactRevisionPreconditionError(f'"{revision}"', f'"{record.artifact.revision}"') + raise self._owner.service.errors.conflict("the Artifact changed after it was read") # noqa: TRY003 + if state_version is not None and record.state.governance_generation != state_version: + raise self._owner.service.errors.conflict("the Artifact governance changed after it was read") # noqa: TRY003 + + def _put_revision(self, artifact_id: str, current: ArtifactMergeRecord | None, requested: int | None) -> int | None: + """The revision a put is decided against: the caller's in a user change, else the plugin's or its last read.""" + + change = self._owner.change + if current is not None: + self._expect(current) + if change is not None and artifact_id in change.expected: + return change.expected[artifact_id][0] + return self._owner.observed.get(artifact_id) if requested is None else requested + async def _authorize_write(self, ref: ArtifactRef) -> None: """Writes that bypass the governance engine still need the caller's ``artifact.write``.""" owner = self._owner await owner.service.security.authorize(self._connection, owner.scope_id, owner.context, "write", ref) + def _written(self, snapshot: ArtifactSnapshot) -> ArtifactSnapshot: + self._owner.written = snapshot + return snapshot + async def put( self, artifact_id: str, @@ -392,8 +426,7 @@ async def put( self._agree("put", artifact_id) body = owner.body(content) current = await self._record(self._connection, artifact_id) - if expected_revision is None: - expected_revision = owner.observed.get(artifact_id) + expected_revision = self._put_revision(artifact_id, current, expected_revision) if change is not None: if (change.operation == "create") != (current is None): raise owner.service.errors.conflict( @@ -435,7 +468,7 @@ async def put( "artifact_create" if current is None else "artifact_replace", 1 if current is None else current.artifact.revision + 1, ) - return _snapshot((await self._committed(plan, direct)).primary) + return self._written(_snapshot((await self._committed(plan, direct)).primary)) async def revert(self, artifact_id: str, /, *, to_revision: int) -> ArtifactChange: self._admit() @@ -461,10 +494,11 @@ async def set_lifecycle( artifact_id, ) current = await self._require(artifact_id) + self._expect(current) if current.state.lifecycle_state is requested and current.state.replacement_artifact_id == ( replacement_artifact_id ): - return ArtifactChange(artifact=_snapshot(current)) + return ArtifactChange(artifact=self._written(_snapshot(current))) if requested is ArtifactLifecycleState.DEPRECATED and replacement_artifact_id is None: plan = await owner.service.inspect_forget( self._connection, @@ -510,9 +544,11 @@ async def merge( if change is not None and tuple(inputs) != change.inputs: raise PluginContractError("merge_artifacts must merge exactly the requested inputs") # noqa: TRY003 records = [await self._require(identity) for identity in inputs] + for record in records: + self._expect(record) result = await self._record(self._connection, into) if result is not None and all(record.state.merged_into_id == into for record in records): - return _snapshot(result) + return self._written(_snapshot(result)) reads = tuple(record.as_read() for record in records) lineage = ArtifactLineage( sources=tuple(source_refs), @@ -554,6 +590,10 @@ async def undo_merge(self, artifact_id: str, /) -> UndoMergePreview: owner = self._owner prepared = await owner.service.prepare_change(await self._undo_plan(artifact_id)) result = await owner.service.commit(self._connection, prepared, owner.context) + owner.records = tuple(_snapshot(record) for record in result.records) + target = next((record for record in result.records if record.ref.artifact_id == artifact_id), None) + if target is not None: + self._written(_snapshot(target)) owner.outcome = (result.restored, result.retired, result.undo_merge_results) return UndoMergePreview( restore=result.restored, retire=result.retired, undo_merge_results=result.undo_merge_results @@ -662,6 +702,10 @@ def __init__( self.observed: dict[str, int] = {} # Restored and retired refs of the last lifecycle write, which user changes report back. self.outcome: tuple[tuple[ArtifactRef, ...], tuple[ArtifactRef, ...], tuple[str, ...]] = ((), (), ()) + # The snapshot the agreed write committed, and every record it changed, which user changes return + # instead of a later head. + self.written: ArtifactSnapshot | None = None + self.records: tuple[ArtifactSnapshot, ...] = () self.opened = False self.committed = False diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index bd9922dc21..21c49b5057 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -1945,7 +1945,7 @@ async def merge_artifacts( ) inputs = [item.artifact_id for item in request.inputs] merged = result["artifact"]["ref"]["artifact_id"] - return await _plugin_governance(http_request, scope_id, family, [merged, *inputs]) + return await _plugin_governance(http_request, scope_id, family, [merged, *inputs], result) scoped = _merge_scope(http_request, scope_id, family) content = _merge_content(http_request, family, request.content) inputs = tuple( @@ -1980,7 +1980,7 @@ async def forget_artifact( found = _plugin_family_host(http_request, family) if found is not None: manifest, host = found - await _call_plugin_host( + result = await _call_plugin_host( http_request, host, "forget_artifact", @@ -1993,7 +1993,7 @@ async def forget_artifact( timeout=manifest.spec.change_timeout_seconds, operation="forget_artifact", ) - return await _plugin_governance(http_request, scope_id, family, [artifact_id]) + return await _plugin_governance(http_request, scope_id, family, [artifact_id], result) result = await _merge_scope(http_request, scope_id, family).forget( artifact_id, expected_revision=request.revision, @@ -3667,11 +3667,29 @@ def _plugin_family_host(http_request: Request, family: str) -> tuple[Any, Any] | return None if manager is None else manager.host_for_family(family) -async def _plugin_governance(http_request: Request, scope_id: str, family: str, ids: Sequence[str]): +async def _plugin_governance( + http_request: Request, scope_id: str, family: str, ids: Sequence[str], result: dict[str, Any] +) -> ArtifactGovernanceResponse: + """Report the governance the plugin's change committed; a record it left unchanged is read as it is now.""" + + committed = {item["ref"]["artifact_id"]: item for item in result["records"]} scoped = _merge_scope(http_request, scope_id, family) context = _merge_execution_context(http_request, "get_artifact_governance") - records = [await scoped.get(artifact_id, context=context) for artifact_id in ids] - return ArtifactGovernanceResponse(changed=True, records=[_governance_record(record) for record in records]) + records = [] + for artifact_id in ids: + item = committed.get(artifact_id) + if item is None: + records.append(_governance_record(await scoped.get(artifact_id, context=context))) + continue + records.append( + ArtifactGovernanceRecord( + artifact=_artifact_reference(ArtifactRef.model_validate(item["ref"])), + lifecycle_state=TransportArtifactLifecycleState(item["lifecycle_state"]), + state_version=item["governance_generation"], + merged_into_id=item["merged_into_id"], + ) + ) + return ArtifactGovernanceResponse(changed=True, records=records) async def _replace_plugin_artifact( @@ -3697,7 +3715,7 @@ async def _replace_plugin_artifact( if found is None: raise BaseValueNotFoundError("artifact_family", family) manifest, host = found - await _call_plugin_host( + written = await _call_plugin_host( http_request, host, "update_artifact", @@ -3706,7 +3724,10 @@ async def _replace_plugin_artifact( timeout=manifest.spec.change_timeout_seconds, operation=REPLACE_ARTIFACT.operation_id, ) - result = await application.records.for_scope(scope_id).get_artifact(family, artifact_id) + # The revision this change committed, not a head a later write may already have replaced. + result = await application.records.for_scope(scope_id).get_artifact_revision( + family, artifact_id, written["artifact"]["ref"]["revision"] + ) response.headers["ETag"] = _artifact_etag(result.revision) return _artifact_revision_response(result) @@ -3732,8 +3753,9 @@ async def _create_plugin_artifact( timeout=manifest.spec.change_timeout_seconds, operation=CREATE_ARTIFACT.operation_id, ) - created = await application.records.for_scope(scope_id).get_artifact( - request.family, result["artifact"]["ref"]["artifact_id"] + ref = result["artifact"]["ref"] + created = await application.records.for_scope(scope_id).get_artifact_revision( + request.family, ref["artifact_id"], ref["revision"] ) response.headers["Location"] = _artifact_location(created) response.headers["ETag"] = _artifact_etag(created.revision) diff --git a/tests/e2e/test_plugin_concurrent_changes.py b/tests/e2e/test_plugin_concurrent_changes.py new file mode 100644 index 0000000000..719a70feb1 --- /dev/null +++ b/tests/e2e/test_plugin_concurrent_changes.py @@ -0,0 +1,158 @@ +# 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. + +"""User changes on plugin families apply to the revision the caller read and report the revision they wrote.""" + +import asyncio +from pathlib import Path + +from tests.e2e.plugin_server import create_scope, running_server, wait_for + +# Each user-change method can pause at a gate so the test commits a competing write at that moment. +PLUGIN = """ +import asyncio +from pathlib import Path + +from powercontext.plugins import ArtifactPlugin, Rendering + +GATES = Path({gates!r}) + + +async def gate(name): + (GATES / (name + ".waiting")).touch() + while not (GATES / (name + ".open")).exists(): + await asyncio.sleep(0.05) + + +class Notes: + def __init__(self, ctx): + self.ctx = ctx + + async def render_artifact(self, request): + return Rendering(text=request.artifact.content["text"]) + + async def create_artifact(self, request, writer): + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=request.content) + + async def update_artifact(self, request, writer): + content = dict(request.content) + hold = content.pop("hold", False) + # Like a plugin that forgets to pass expected_revision, this relies on the framework's check. + async with writer.transaction() as tx: + await tx.put(request.artifact_id, content=content) + if hold: + await gate("updated") + + async def forget_artifact(self, request, writer): + await gate("forget") + async with writer.transaction() as tx: + await tx.set_lifecycle(request.artifact_id, "deprecated") + + async def merge_artifacts(self, request, writer): + await gate("merge") + async with writer.transaction() as tx: + await tx.merge([item.artifact_id for item in request.inputs], into=request.into, content=request.content) + + +async def setup(ctx): + return Notes(ctx) + + +plugin = ArtifactPlugin(family="notes", setup=setup) +""" + + +def test_user_changes_apply_to_the_read_revision_and_report_their_own_write(tmp_path: Path): + plugins = tmp_path / "plugins" + gates = tmp_path / "gates" + gates.mkdir() + (plugins / "notes").mkdir(parents=True) + (plugins / "notes" / "plugin.py").write_text(PLUGIN.format(gates=str(gates)), encoding="utf-8") + log = tmp_path / "server.log" + + async def scenario(): + async with running_server(tmp_path, plugins) as client: + scope = await create_scope(client, "plugin-concurrent-changes") + + async def create(text): + response = await client.post( + f"/v1/scopes/{scope}/artifacts", json={"family": "notes", "content": {"text": text}} + ) + assert response.status_code == 201, response.text + return f"/v1/scopes/{scope}/artifacts/notes/{response.json()['artifact_id']}" + + async def governance(path): + response = await client.get(f"{path}/governance") + assert response.status_code == 200, response.text + return response.json() + + async def edit(path, content): + etag = (await client.get(path)).headers["ETag"] + return await client.put(path, json={"content": content}, headers={"If-Match": etag}) + + async def paused(name): + await wait_for(lambda: asyncio.sleep(0, (gates / f"{name}.waiting").exists()), bool, log=log) + + # A forget decided on revision 1 must not retire revision 2, committed after its precheck. + note = await create("Deploy on Fridays") + state = await governance(note) + forget = asyncio.create_task( + client.post(f"{note}/forget", json={"revision": 1, "state_version": state["state_version"]}) + ) + await paused("forget") + assert (await edit(note, {"text": "Never deploy on Fridays"})).status_code == 200 + (gates / "forget.open").touch() + stale_forget = await forget + assert stale_forget.status_code == 409, stale_forget.text + state = await governance(note) + assert (state["lifecycle_state"], state["artifact"]["revision"]) == ("active", 2) + + # A merge decided on the inputs' read revisions fails when one of them changes first. + first, second = await create("Rotate keys"), await create("Rotate certificates") + inputs = [] + for path in (first, second): + current = await governance(path) + inputs.append({ + "artifact_id": path.rsplit("/", 1)[1], + "revision": current["artifact"]["revision"], + "state_version": current["state_version"], + }) + merge = asyncio.create_task( + client.post( + f"/v1/scopes/{scope}/artifacts/notes/merges", + json={"inputs": inputs, "content": {"text": "Rotate keys and certificates"}}, + ) + ) + await paused("merge") + assert (await edit(first, {"text": "Rotate keys monthly"})).status_code == 200 + (gates / "merge.open").touch() + stale_merge = await merge + assert stale_merge.status_code == 409, stale_merge.text + for path in (first, second): + current = await governance(path) + assert (current["lifecycle_state"], current["merged_into_id"]) == ("active", None) + + # Each update reports the revision it committed, even after a later write moved the head. + held = asyncio.create_task(edit(note, {"text": "Deploy on Tuesdays", "hold": True})) + await paused("updated") + later = await edit(note, {"text": "Deploy on Wednesdays"}) + assert later.status_code == 200, later.text + (gates / "updated.open").touch() + own = await held + assert own.status_code == 200, own.text + assert (own.json()["revision"], own.json()["content"]) == (3, {"text": "Deploy on Tuesdays"}) + assert (later.json()["revision"], later.json()["content"]) == (4, {"text": "Deploy on Wednesdays"}) + + asyncio.run(scenario()) From 91989f77fe85c41261081aa166221367f1422d8b Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:15:55 -0800 Subject: [PATCH 06/38] docs(develop): state the revision and permission checks core enforces on plugin writes Co-Authored-By: Claude Opus 5.5 --- docs/en/docs/develop/plugins.md | 10 +++++++--- docs/zh/docs/develop/plugins.md | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/en/docs/develop/plugins.md b/docs/en/docs/develop/plugins.md index 4a82104caa..7552216d61 100644 --- a/docs/en/docs/develop/plugins.md +++ b/docs/en/docs/develop/plugins.md @@ -240,8 +240,11 @@ class Notes: await tx.set_lifecycle(request.artifact_id, "deprecated") ``` -- Core checks the caller's permissions and the revision before calling the method; the method only validates content and - makes its one agreed write. +- Core checks the caller's permissions and the revision before calling the method, and checks the caller's revision + again when the write commits: if another write changed the Artifact in between, the request returns 409 (412 for + content updates), so the plugin need not repeat the check. The method only validates content and makes its one agreed + write. +- The response reports the revision this write committed, even if later writes have followed. - Raising `InvalidContent` returns 422 with the message shown to the caller. - User changes record provenance as the person who made them, as edits of built-in Artifacts do. @@ -361,7 +364,8 @@ plugin = ArtifactPlugin( lacks them; `ctx.allowed(...)` only answers true or false. - Reading and writing core data: `ctx.reads(scope_id)` reads with the caller's permissions; `ctx.writer(scope_id)` and `ctx.sources.submit(...)` work only for Scopes whose `scope.contribute` was confirmed earlier in the request (by the - route's `access` or a prior `ctx.require`). + route's `access` or a prior `ctx.require`). Changing an existing Artifact, including its tags, handles, and lifecycle, + also needs the caller's `artifact.write` on that Artifact, as through the generic API; otherwise the API returns 403. - Writes in APIs do not record who did what. When you need that provenance, submit the operation as a Source, as in the example above, and let `generate` write the new revision referencing it. - To create a Scope, call `ctx.scopes.create(title=..., summary=..., idempotency_key=...)`; the caller needs diff --git a/docs/zh/docs/develop/plugins.md b/docs/zh/docs/develop/plugins.md index 33252ff800..2cf0e00e2b 100644 --- a/docs/zh/docs/develop/plugins.md +++ b/docs/zh/docs/develop/plugins.md @@ -222,7 +222,9 @@ class Notes: await tx.set_lifecycle(request.artifact_id, "deprecated") ``` -- core 先检查调用者的权限和版本,再调用方法;方法只负责校验内容并完成约定的那一次写入。 +- core 先检查调用者的权限和版本,再调用方法;提交写入时按调用者给出的版本再校验一次,期间被其他写入改变则返回 409 + (修改内容时为 412),插件不必自己重复检查。方法只负责校验内容并完成约定的那一次写入。 +- 接口返回本次写入提交的版本,即使之后已有其他写入。 - 抛出 `InvalidContent` 时接口返回 422,错误信息展示给调用者。 - 用户修改会以修改人的身份记录出处,与内置 Artifact 的修改相同。 @@ -337,7 +339,8 @@ plugin = ArtifactPlugin( - `scope.read` / `scope.contribute`:要求调用者对 `scope_param` 指定的路径参数中的 Scope 具备该权限。 - 处理函数里用 `ctx.require(action, scope_id)` 检查其他权限,不具备时接口返回 403;`ctx.allowed(...)` 只返回真假。 - 读写核心数据:`ctx.reads(scope_id)` 按调用者权限读取;`ctx.writer(scope_id)` 和 `ctx.sources.submit(...)` 只能用于本次 - 请求中已经确认 `scope.contribute` 的 Scope(路由声明了该权限,或处理函数先调用了 `ctx.require`)。 + 请求中已经确认 `scope.contribute` 的 Scope(路由声明了该权限,或处理函数先调用了 `ctx.require`)。修改已有 Artifact + (包括标签、handle 和生命周期)还需要调用者对该 Artifact 有 `artifact.write` 权限,与通用接口相同;没有权限时接口返回 403。 - 接口中的写入不会自动记录"谁做了什么"。需要这类出处时,像上面的例子一样把操作作为 Source 提交,由 `generate` 写入新版本并 引用它。 - 需要创建 Scope 时用 `ctx.scopes.create(title=..., summary=..., idempotency_key=...)`,要求调用者具备 `server.admin`。 From b913b727d248ad93a5ef1a8ca86ea98b164de67c Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:26:09 -0800 Subject: [PATCH 07/38] fix(archive): restore Artifact revisions exported by released versions Schema version 2 artifact_revision records were verified against their archived digest and then staged as version 3 with that same digest, so restoring any archive exported by v1.4.1 through v1.5.1 failed with "record digest does not match content", and the restored revisions would have been reported as immutable conflicts on a repeat restore. A verified earlier-schema record is now re-identified at the current schema for staging and target comparison, while the bundle total digest still covers the archived digests. The regression test restores, re-restores, and re-exports an archive produced by the released v1.5.1 runtime. Co-Authored-By: Claude Opus 5.5 --- docs/en/docs/operate/portable-archive.md | 5 +- docs/zh/docs/operate/portable-archive.md | 4 +- .../builtin/portability/bundle.py | 15 ++++-- tests/e2e/test_portable_archive.py | 45 ++++++++++++++++++ .../v1_5_1_profile_history.pcb | Bin 0 -> 5528 bytes 5 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/portable_archives/v1_5_1_profile_history.pcb diff --git a/docs/en/docs/operate/portable-archive.md b/docs/en/docs/operate/portable-archive.md index 840b757833..efb432490a 100644 --- a/docs/en/docs/operate/portable-archive.md +++ b/docs/en/docs/operate/portable-archive.md @@ -220,7 +220,10 @@ does not replace the archive schema version or record-type compatibility checks. format `0` downgrade. A reader rejects unknown archive or record schema versions before target writes. Keep the old binary available until a restore drill succeeds when upgrading across a PowerContext major release. -`artifact_revision`, `artifact_head`, `artifact_lineage_artifact` and `candidate_head` require record schema version `2`. +`artifact_revision` uses record schema version `3`, which adds `metadata` naming the plugin and plugin version that +wrote the revision. Readers also accept schema version `2` records exported by earlier releases and restore them with +null `metadata`; restoring such an archive again does not report them as conflicts. `artifact_head`, +`artifact_lineage_artifact` and `candidate_head` require record schema version `2`. Incomplete schema version `1` representations of these records are rejected rather than discarding revision metadata or assigning default merge state or Candidate kind. Legacy database revisions retain explicit null metadata; restore does not invent an actor, timestamp or rollback history. A target missing required archive columns must first be diff --git a/docs/zh/docs/operate/portable-archive.md b/docs/zh/docs/operate/portable-archive.md index cf4c0c4848..ee084d05a8 100644 --- a/docs/zh/docs/operate/portable-archive.md +++ b/docs/zh/docs/operate/portable-archive.md @@ -190,7 +190,9 @@ record type。producer version 仅用于诊断,不能替代 archive schema ver writer 只生成版本 `1`,不支持降级到格式 `0`。遇到未知 archive 或 record schema version 时,reader 会在目标写入前拒绝。跨 PowerContext 大版本升级时,应保留旧二进制,直到恢复演练通过。 -`artifact_revision`、`artifact_head`、`artifact_lineage_artifact` 和 `candidate_head` 要求 record schema version `2`。 +`artifact_revision` 使用 record schema version `3`,比版本 `2` 多出 `metadata`,记录写入该 revision 的插件及其版本。 +reader 也接受此前版本导出的 schema version `2` 记录,恢复时 `metadata` 为 null,重复恢复不会把它们视为冲突。 +`artifact_head`、`artifact_lineage_artifact` 和 `candidate_head` 要求 record schema version `2`。 reader 会拒绝这些记录不完整的 schema version `1` 表示,而不是丢弃 revision 元数据或补上默认合并状态、Candidate 类型。 旧数据库中 revision 元数据为空时,归档保留显式 null,不会编造 actor、时间戳或回滚历史。目标缺少归档所需列时, 应先用当前 Runtime 升级;dry-run 会报告不兼容,不会修改目标数据库。 diff --git a/src/powercontext/builtin/portability/bundle.py b/src/powercontext/builtin/portability/bundle.py index 79bc26e1e1..4992e483c3 100644 --- a/src/powercontext/builtin/portability/bundle.py +++ b/src/powercontext/builtin/portability/bundle.py @@ -276,6 +276,8 @@ class _Record: identity: Mapping[str, str | int] payload: Mapping[str, Any] digest: str + # The digest an earlier-schema archive wrote, which its total digest covers; ``digest`` is the current schema's. + archived_digest: str | None = None @dataclass(frozen=True, slots=True) @@ -1224,7 +1226,7 @@ def _parse_bundle( scope_ids.append(str(record.identity["scope_id"])) count += 1 counts[record.record_type] += 1 - digest.add(record.digest) + digest.add(record.archived_digest or record.digest) _report_progress(progress, phase, count, manifest["record_count"]) expected_count = manifest["record_count"] if count != expected_count: @@ -1456,8 +1458,15 @@ def _parse_record(value: object) -> _Record: } if _digest(canonical) != digest: raise BundleFormatError("record digest does not match content") - payload = {name: payload.get(name) for name in spec.payload} - record = _Record(record_type=typed, identity=identity, payload=payload, digest=digest) + if version == spec.schema_version: + record = _Record(record_type=typed, identity=identity, payload=payload, digest=digest) + else: + # Staging and target comparisons use the current schema, so the verified record is re-identified in it. + payload = {name: payload.get(name) for name in spec.payload} + current = {**canonical, "schema_version": spec.schema_version, "payload": payload} + record = _Record( + record_type=typed, identity=identity, payload=payload, digest=_digest(current), archived_digest=digest + ) _validate_record_values(record) _validate_record_safe(record) return record diff --git a/tests/e2e/test_portable_archive.py b/tests/e2e/test_portable_archive.py index 0c3ae668e1..b3185d134f 100644 --- a/tests/e2e/test_portable_archive.py +++ b/tests/e2e/test_portable_archive.py @@ -388,6 +388,51 @@ async def scenario() -> None: asyncio.run(scenario()) +# Exported by the released v1.5.1 runtime: one profile with two revisions, written before revision metadata +# existed, so its Artifact revision records use schema version 2. +RELEASED_ARCHIVE = Path(__file__).parents[1] / "fixtures" / "portable_archives" / "v1_5_1_profile_history.pcb" +RELEASED_SCOPE = "scp_3fqz0yrh8ybj3cbav4ek3hfxee" + + +def test_archive_exported_by_a_released_version_restores_and_restores_again( + tmp_path: Path, target_config: BuiltinConfig +) -> None: + async def authorize(_scopes: tuple[str, ...]) -> None: + pass + + async def scenario() -> None: + async with open_builtin_contexts(target_config) as target: + assert (await target.portability.validate(RELEASED_ARCHIVE)).compatible + restored = await target.portability.restore(RELEASED_ARCHIVE) + assert (restored.inserted, restored.already_present) == (14, 0) + # The same records already present, now stored with unknown metadata, are not conflicts. + again = await target.portability.restore(RELEASED_ARCHIVE) + assert (again.inserted, again.already_present) == (0, 14) + async with open_builtin_contexts(target_config) as target: + [profile] = (await target.records.query_artifacts(RELEASED_SCOPE, "profile", limit=10, cursor=None)).items + page = await target.records.list_artifact_revisions( + RELEASED_SCOPE, "profile", profile.artifact_id, limit=10, cursor=None + ) + assert [item.content["content"] for item in page.items] == [ + "# Profile\n\nUse a different choice.\n", + "# Profile\n\nKeep the original choice.\n", + ] + assert all(item.created_at is not None for item in page.items) + async with target.database.transaction() as connection: + metadata = ( + await connection.execute( + select(ARTIFACTS_TABLE.c.metadata).where(ARTIFACTS_TABLE.c.scope_id == RELEASED_SCOPE) + ) + ).scalars() + assert set(metadata) == {None} + # Re-exported at the current schema, the history is the same records the target already holds. + reexport = tmp_path / "reexport.pcb" + await target.portability.export([RELEASED_SCOPE], reexport, authorize=authorize) + assert (await target.portability.restore(reexport)).inserted == 0 + + asyncio.run(scenario()) + + @pytest.mark.parametrize( ("field", "value"), [("created_at", None), ("created_by", '{"type":"user","id":"another-operator"}')], diff --git a/tests/fixtures/portable_archives/v1_5_1_profile_history.pcb b/tests/fixtures/portable_archives/v1_5_1_profile_history.pcb new file mode 100644 index 0000000000000000000000000000000000000000..136a85a8e5d3e54bfd6f4b59a0f3722a7ecdc11c GIT binary patch literal 5528 zcmZ{oWmFUZ)3%p}r8|}o5JBl~5Re9?q#J3LZrEKKB&9nP1*DNinuSG)Rk}l93F)qd z&*yu8z0W!C%$YN1X3qWZ{xRp$(Zs^00ssK`|B4mhUNzS_OaTB)i30#6|2E$Cb{^jE zeE8hox%havADEe?{25Mzu3C8+AcadiB_Phu%rTV_5n%K362Kz=mri;jF@YtZu+$@j z9n2RKvsYBKGVRpF+YAgw%N8@?QzBty+&^&T1qWQ2_r;DDaq~aIQ=sts-Ct22Ogu+Ypt)I(dN|w8e7r( z-PEHwQU)P>&l1_S+e-y7Tj&?ak&}FvP2T2VA$V{59I(3S(K?z5=1lFp?$OTN2(`c;&6l2&46;bh3}uqUQv)hX%I$q3)5sY#Bq zbBOq>-%260#P^>In|UR&uD^>A{@AERuca)IvXV4@h_36ZdAyAnP-shH=(5NV4S;U^ zK2^_`4H%QTPXd~5R2&^B3TX_k2?iDMr5l!}t4#Wz+;BLt%EhU$9{83j7&9g?pe@wQ zd~T-Z7k^cX7Kc)in2uDKjg(@{Wnav)?h?NpU;_3}IFx!sW3h;WF3d%(Yf)$V+;2@f zHn2R1N8AfE`Ge)B%aJb{PCu6<-o86Ep@PajI8ZM%9 z3)1b86z}!z)_A%r$al4=k0lPT(BA9dvrr1c_q0zUi0YM(`b3giyx>~98(+i&5Y-TU zRxUW1F6+3_XS{ajOeK;o@zUCnJ5vEhLZ{u!_j~kHD&1#IaA;Z~iu!}Mr6<$6!jJ3{ z!VNLKrwm<~SLsPLA}<8?1x$(-qvmCZcQq*nWF4VIp--J$u{Ye{@PW#84|s{u8WV{W zWj$WepvoucI;k*{QywRuQ(%ZrAeethji_jaA+$#y3r4i<|GTM2mv?I#a+PU&UDDlfyqZZ2ROyX7!aG!dT(4dOfEQG}7?R}?bY1aJxh+eBc5VIhO7`1}2f|d+)!h78>WlZe%$sE#%&l7j#;UHqZGD0w?2ry-!_sH15EaYA(?0e(!ur-?8Iw_DAN zsRsd?%ImHPxrqDk@g1w=EY7G z5HOJ!PMh#B_mfRk_jJGnOpJX{Xmq?L?1uKJp zC>vU3F-C_fg|4~PjJ8|5lKMvfyuH~DS4}7|llMMidgvzHQ-X-m$&#$ggiDDgM(BFU zbkuQl5ZYlR+Ze9q8XujbEzcSrCJt!4?x5s&I_2TwNf6oa8x7N)T#mKE7_2xOiDyO6 zekdRC9JU{_4?R-|Ov)GwpKEX&|sh1AC30qV} z8|{n;&^2S^Ki~PZ8FZf`zuLp;2}J|FSbmT16D~`B-9dn4s!#JsH9Bi&#_vqIJ9=ba zzfCw4ME3ViRhBIuW}RUZ8|~Czwj;k&Gg&af+(TW0i$U#l!IZtm6VofDwXSu94 ze?lGKi9~hu6k2`%MfrYeX9=o%Hr9ZpYNc&SVCqwG<^%cFB<}6*h#Vx7_)^lEhVrn> z5G%g4Q>u=M%(lr03ZgtcT&Evndlqh##6^ouebM#F6~QW_lsQM`h2MS`)L^Z(0`gokDki87v==t+HB@;4#&8L*mj4s72_Qb@@CR49WlH?2L+R(YqSF zpmLe-AASDBt@JXubT6Ry-E;M$P7hYg@%Md=mpo3}1y=M^g5RRscm(yZo9tJmoi8V5 znh%3iX5&GSC75P!3n|hN*3x1by!`{nh)`Wwfa}rjot1(G|CB{l{j3fhRD%K z8s|xLKB`!pq>|ty)4gVLy>9BET5BxXPf9-HPnM^bOHwIABzzm*0T0`g(Qe69(lOmt zX(T(vsFbF1xhf*@+DRm!i{lXVLZ!mIuuS;~Hp=WWdcN5hYxG_-nBxQCU_8B9k%L9$mjPh8DzIrtlxRh)zquiE#`KEvf}F z);fK`>!lFU0p8q0=CskUo@dQW9#efqpFEbI4N5nx{lIo<;3xS)&2YKyvway zEY;Bdrm>GR+L#;_n~ct1ENGu6@jm~u)lN5`V~S5+*`RHk*!+&9RVK+VQ3jbLIjn{7 z|HIPEE^3xF`S8kpUMxmlzoS@*p9WJbs%ObTpLOyYp<7S_3k<-DuM@B%0}V%E3l!pWwjpSH-_gmAx&7kP62(b_M0tXus< zxpJ|Roj(uRtnwPvZmYvn7$kHNa52RX#E`ja(`9#dW!#4@%98HAf0R;?bM9LmABI8R zJ^URB$!}75P@=r6OKS>H>U`53`^4;(LWi^n4rl5QTR)22OT2A&YPLK7a4DLUi5OeC za@;b8W6vZ?0}3mEI@UuRIK9jNL=B^yC*y`HmzdeWrA4uz&@6=-!Y_5+RM$J z>yZ^Juiy8MPn285E$fkWa_X4R=njm0n&J_<{pws&8C_&VgQG>ko8~edn^{H!y!#&J z@K`TkYyluqvrpgP`f!zCAWlhW2B`h~rwuM@@Np~^i8Ny^9*}&YgQF4+Q zu~~8vb_Grt&n#Pr)GQv9i)@p!(>MsXm5J(Yy#5*Rz>}A($Ns@XoYl%Rh&tRenZeH> z4kNyjabCeDagh{ELy~uWa}gaw@7aD5w^&EHFMr=c*r+ICNC-L#s1u1 zitbN!+2kOAgFkiCD(AepGHaiBj*yNb8{qIiIz$zY zCN#Q=x*XIIT3~L*vm+$(nkB1PUBtadO!s<0<)L=$K9+WBTuS|X?TzB;sij+}BmW{- z$bC6#8~MA}?X$nblW|t$7yUR=rxll9*nvLBvr-jGjEZNvo19L#qh)^WAGgSR?>?lb zUCPB4^IK;UKVOrR7BP0fK>xCSD;B{ILY!&OZ@PVCa(Ve!Po7@v@hxbrm}Cwd8XD^F zo_C@c$^a$VbD*ST$Uv9D{A|$Ptj9v6Ml{9=WuJRb1yUwa(CqY>c858EHrD+j?8 z;ET4QcVbQFB^M$2*E*Dn$G<5UJ`cxhm9mtOw&|L@)a1*L&AiIKE?hFLs5sp#P{KU* z$UfH(vU1-KV?mLtYZszJP);gT->4hoTo*0fq@SeA8Ude^qY4K5_1&u_^iA8;I&v1L z{F%lr4kK1BKU`f+{?Zu9;1A3#Lh7I5E7~}ie|i}Yz!7W{G$P}>GjQssRRrFi>*v^{ z*33jyUdSyWo^a6>f;zfd{*~t z5ffS8I2?G&Fx7v?QPHf^1mNqD{8_MV;1scK$f6kr8WHFa51+Ojbim}djvuSe{?i<@oxOuHc?HFyNggP;n zT@i;f)ZYn1-;((pbyeHXSO8JznIw!iqEUa-T$HMZT%!^>*l8QiX^O`gL1L%8Ysq8e zy2)fl{bi`qb#v1GNMm@brynBdwD+f-`_~(~CNLudnkoZC5{uMb24#W_#;z_U%$u!P z)?wv%=-g!})51Y4qFEmL3R!m$s^g@N6WsO72$Y*8yZJF2m$!+}O6~YmxDc`*kiwAT zUAkK#Q#dk;tG(Pg6)`y27E4R^NntCxB;Mf#?MZ{w#RpCT8 zs_@a-yNAy|rzbU=BYZV#v=d`Ss80V zk~UuX&$1!w*2iH^Cufcd<{9}ou@8UfT6Jc|fBsIrLyQ4M#X$xy>fmM#;VE{%dj2MO zrSMZJXJ!w+{`(R^>kLJg<#>@zQJh{S&<`oZ6O3dwL`T7OWO#UACL>;;YT3ln7w+@0 zHHjtznuolII!uBoq!+%>+&<)l&vWV*tw0pzD^T}pqJCJ*BDpSdQy#F|qm!5KhjX~d z=&=cAahfBZ`pEWyiTC=kZgvGhQ`i+cHo`hoKwq4QakvVJKi?I}gU}F+RTVufw-;+W z>Q>!OCE?E)^#ON!8y~cQHz*TB##*wFQzg*0dapO=ErV>3C8Wxv8QotFl^=Y! zdw;E92Elo;MVj|5_eHfyokPh9CBYks@Y}L-^Un;2?}Iv~1Y-D_uJbpKjWFQU^)o&f z)E65jaGxsqx20Ak9gc{_(5z=dJ>%<-7&`xpdFeTq*z98f0J>NJ0P#QO1-5Z_cCh#H z<@*ov($+ip_U8!^g!Z9UO0j3kXnx+t37^+8%Ce;PkZBd7RCRS*ruD=Fpf;1zEOORYynSCY1R5qi$^Zf! z^JaB5EEK8J)nKU5i2SRmoyNC(>RZhPwr!?Ky3d|+(E=?q^WV1gWlJ?p=5^U8T1w{1 zRr^E{iEXw00?Nc>d?9>^{a4>`yaq_+@N{XtWQ~tMkRVmg-&k`??L`Kw3Y=m+1c_~< z`1NwOVR(1~kUFie8+(xGS4mfv(dXpnh} zF)2ZB%VD?I-1oLSo|4w&_@JeA~M4s#emX0O{CKcBI4MYEV Date: Fri, 13 Nov 2026 19:31:38 -0800 Subject: [PATCH 08/38] build(integrations): regenerate Dify, Desktop IPC, and the access audit for the plugin API contract The OpenAPI changes removed the managed plugin operations and custom family schemas and opened Artifact families to loaded plugins, but the Dify tool declarations and contract, the Desktop IPC types, and the Bub access audit still described the old contract. Regenerate them, and classify the new plugin listing, detail, and Scope progress operations as returning no Scope content. Co-Authored-By: Claude Opus 5.5 --- desktop/ui/src/generated/ipc.ts | 17 +-- e2e/bub/tests/test_access_audit.py | 11 +- .../plugin/powercontext_dify/contract.json | 103 ++++++++---------- .../plugin/tools/pc_experience_generate.yaml | 36 +++--- .../dify/plugin/tools/pc_memory_get.yaml | 9 -- .../dify/plugin/tools/pc_memory_revise.yaml | 9 -- .../dify/plugin/tools/pc_prepare_context.yaml | 70 ++++++------ .../dify/plugin/tools/pc_review_get.yaml | 34 ++---- .../dify/plugin/tools/pc_review_list.yaml | 36 +++--- .../dify/plugin/tools/pc_skill_generate.yaml | 36 +++--- 10 files changed, 142 insertions(+), 219 deletions(-) diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index 5d4e4c5f28..a31880e358 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -142,11 +142,7 @@ export type ArtifactFamilyAccessCapability = { family: string, enabled: boolean, export type ArtifactInventoryStatistics = { total: number, by_family: Array, }; export type ArtifactReadFamily = string; export type ArtifactReference = { family: string, artifact_id: string, revision: number, }; -export type ArtifactRevision = { scope_id: string, family: string, artifact_id: string, -/** - * Persisted schema version for a custom Artifact family. - */ -schema_version?: string | null, revision: number, content: Record, sources: Array, artifacts: Array, content_digest: string, +export type ArtifactRevision = { scope_id: string, family: string, artifact_id: string, revision: number, content: Record, sources: Array, artifacts: Array, content_digest: string, /** * When this revision was committed. Absent for revisions stored before this field existed. */ @@ -186,7 +182,7 @@ export type BuiltinProfile = "coding" | "conversation"; export type CandidateFamily = "experience" | "skill" | "profile" | "atomic-memory" | "topic-memory" | "handoff" | "prompt"; export type CandidateFamilyCount = { family: CandidateFamily, total: number, pending: number, approved: number, rejected: number, }; export type CandidateInventoryStatistics = { total: number, pending: number, approved: number, rejected: number, by_family: Array, }; -export type Capabilities = { custom_artifact_schemas?: Array, prompts?: { [key in string]: PromptCapability }, +export type Capabilities = { prompts?: { [key in string]: PromptCapability }, /** * Whether asynchronous Artifact Dream execution is configured. */ @@ -221,15 +217,6 @@ export type ConnectionIdentity = { server_id: string, authentication: Connection export type ContextProtectionCapabilities = { check_versions: Array, payload_versions: Array, reuse_versions: Array, reuse_families: Array, enforcing: boolean, limits: ContextProtectionCapabilitiesLimits, }; export type ContextProtectionCapabilitiesLimits = { payload_bytes: number, binding_bytes: number, prepared_contexts: number, dependencies: number, send_seconds: number, correlate_seconds: number, }; export type ContractVersion = { major: number, minor: number, }; -export type CustomArtifactFamilySchema = { family: string, schema_version: string, content_schema: Record, -/** - * Legacy persisted text fields; new plugins use search. - */ -search_fields: Array, -/** - * Persistent declarative indexes and supported search modes. - */ -search?: Record, }; export type DreamOperation = "refine_experience" | "derive_skill" | "revise_skill" | "revise_profile" | "revise_memory" | "revise_topic_memory" | "refresh_handoff" | "revise_prompt" | "revise_tags"; export type Effect = "review_then_publish" | "review_then_publish_configuration" | "review_then_replace_tags"; export type ExactScopeSelection = { mode: ExactScopeSelectionMode, scope_ids: Array, }; diff --git a/e2e/bub/tests/test_access_audit.py b/e2e/bub/tests/test_access_audit.py index 0cf4e5e778..986bf982ad 100644 --- a/e2e/bub/tests/test_access_audit.py +++ b/e2e/bub/tests/test_access_audit.py @@ -260,13 +260,10 @@ def test_a_handoff_report_over_every_scope_reaches_them_although_it_is_authorize "set_scope_binding", "clear_scope_binding", "register_source_definition", - "list_artifact_plugins", - "get_artifact_plugin", - "enable_all_artifact_plugins", - "reload_artifact_plugin", - "enable_artifact_plugin", - "disable_artifact_plugin", - "unload_artifact_plugin", + # Plugin descriptors, and per-Scope processing positions of a plugin's Supervisor binding. + "list_plugins", + "get_plugin", + "list_plugin_scopes", "load_source_write_plugin", "source_configure", "pc_source_instance_set_enabled", diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index 5f8bda476c..ae9679da5e 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -425,17 +425,7 @@ "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "memory", - "atomic-memory", - "experience", - "skill", - "handoff", - "prompt", - "topic-memory", - "profile" - ] + "$ref": "#/components/schemas/ArtifactReadFamily" } }, { @@ -992,17 +982,6 @@ "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$" }, - "schema_version": { - "anyOf": [ - { - "type": "string", - "description": "Persisted schema version for a custom Artifact family." - }, - { - "type": "null" - } - ] - }, "revision": { "type": "integer", "minimum": 1 @@ -1935,15 +1914,6 @@ } } }, - "ContextAssemblyFamily": { - "type": "string", - "enum": [ - "memory", - "experience", - "profile", - "topic-memory" - ] - }, "ContextAssemblyFormat": { "type": "string", "enum": [ @@ -1966,7 +1936,16 @@ ], "properties": { "family": { - "$ref": "#/components/schemas/ContextAssemblyFamily" + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "A built-in family (memory, experience, profile, topic-memory) or a loaded plugin family that implements search. A plugin family's entries come from the plugin's search with the request query and this limit; a family that is neither returns HTTP 422." + }, + "search": { + "type": "object", + "additionalProperties": true, + "description": "Additional generic search fields passed to the plugin's search unchanged, such as mode, filters, fusion, or rerank. Only plugin families accept it; the query and limit come from the request and this section." }, "limit": { "type": "integer", @@ -3452,16 +3431,6 @@ } } }, - "PreparedContextFamily": { - "type": "string", - "enum": [ - "atomic-memory", - "topic-memory", - "experience", - "profile", - "code" - ] - }, "PreparedContextOmittedGroup": { "type": "object", "additionalProperties": false, @@ -3472,7 +3441,11 @@ ], "properties": { "family": { - "$ref": "#/components/schemas/PreparedContextFamily" + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family." }, "reason": { "type": "string", @@ -3867,7 +3840,11 @@ "maximum": 2 }, "family": { - "$ref": "#/components/schemas/PreparedContextFamily" + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family." }, "mode": { "type": "string", @@ -3913,7 +3890,11 @@ ], "properties": { "kind": { - "$ref": "#/components/schemas/PreparedContextFamily" + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "atomic-memory, topic-memory, experience, profile, code, or a loaded plugin family." }, "artifact_address": { "$ref": "#/components/schemas/ArtifactAddress" @@ -4294,6 +4275,9 @@ }, { "$ref": "#/components/schemas/ReplaceProfileArtifactRequest" + }, + { + "$ref": "#/components/schemas/ReplacePluginArtifactRequest" } ] }, @@ -4447,6 +4431,20 @@ } } }, + "ReplacePluginArtifactRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "content" + ], + "description": "Complete content of a plugin Artifact; core stores it unchanged and indexes the declared fields.", + "properties": { + "content": { + "type": "object", + "additionalProperties": true + } + } + }, "ReplaceProfileArtifactRequest": { "type": "object", "additionalProperties": false, @@ -5227,17 +5225,10 @@ }, "TaggableArtifactFamily": { "type": "string", - "description": "All readable Artifact families support logical tags on persisted Artifacts.", - "enum": [ - "memory", - "atomic-memory", - "experience", - "skill", - "handoff", - "profile", - "prompt", - "topic-memory" - ] + "description": "A built-in Family or a loaded plugin family; every readable Family supports logical tags on persisted Artifacts. The collection-level memory Family tags entries instead.", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z][a-z0-9-]*$" }, "TopicMemoryDreamProposal": { "type": "object", diff --git a/integrations/dify/plugin/tools/pc_experience_generate.yaml b/integrations/dify/plugin/tools/pc_experience_generate.yaml index 9e1f2f0d32..47f4fb2100 100644 --- a/integrations/dify/plugin/tools/pc_experience_generate.yaml +++ b/integrations/dify/plugin/tools/pc_experience_generate.yaml @@ -1265,17 +1265,13 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; + every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries + instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 @@ -1532,17 +1528,13 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; + every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries + instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 diff --git a/integrations/dify/plugin/tools/pc_memory_get.yaml b/integrations/dify/plugin/tools/pc_memory_get.yaml index 122a68d6e4..bd8925e85b 100644 --- a/integrations/dify/plugin/tools/pc_memory_get.yaml +++ b/integrations/dify/plugin/tools/pc_memory_get.yaml @@ -80,10 +80,6 @@ output_schema: artifact_id: $ref: '#/definitions/Result/properties/artifact_id' type: string - schema_version: - $ref: '#/definitions/Result/properties/schema_version' - type: string - description: Persisted schema version for a custom Artifact family. revision: $ref: '#/definitions/Result/properties/revision' type: number @@ -186,11 +182,6 @@ output_schema: minLength: 1 maxLength: 128 pattern: ^[\x21-\x7E]+$ - schema_version: - anyOf: - - type: string - description: Persisted schema version for a custom Artifact family. - - type: 'null' revision: type: integer minimum: 1 diff --git a/integrations/dify/plugin/tools/pc_memory_revise.yaml b/integrations/dify/plugin/tools/pc_memory_revise.yaml index 0f16c2202e..944dcf7e15 100644 --- a/integrations/dify/plugin/tools/pc_memory_revise.yaml +++ b/integrations/dify/plugin/tools/pc_memory_revise.yaml @@ -118,10 +118,6 @@ output_schema: artifact_id: $ref: '#/definitions/Result/properties/artifact_id' type: string - schema_version: - $ref: '#/definitions/Result/properties/schema_version' - type: string - description: Persisted schema version for a custom Artifact family. revision: $ref: '#/definitions/Result/properties/revision' type: number @@ -213,11 +209,6 @@ output_schema: minLength: 1 maxLength: 128 pattern: ^[\x21-\x7E]+$ - schema_version: - anyOf: - - type: string - description: Persisted schema version for a custom Artifact family. - - type: 'null' revision: type: integer minimum: 1 diff --git a/integrations/dify/plugin/tools/pc_prepare_context.yaml b/integrations/dify/plugin/tools/pc_prepare_context.yaml index cf1e3295d5..ed7c8e917a 100644 --- a/integrations/dify/plugin/tools/pc_prepare_context.yaml +++ b/integrations/dify/plugin/tools/pc_prepare_context.yaml @@ -151,16 +151,12 @@ output_schema: family: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/searches/items/properties/family' type: string - enum: &id006 - - atomic-memory - - topic-memory - - experience - - profile - - code + description: atomic-memory, topic-memory, experience, profile, + code, or a loaded plugin family. mode: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/searches/items/properties/mode' type: string - enum: &id007 + enum: &id006 - fts - vector - hybrid @@ -170,14 +166,14 @@ output_schema: outcome: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/searches/items/properties/outcome' type: string - enum: &id008 + enum: &id007 - completed - not_run - failed fallback_reason: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/searches/items/properties/fallback_reason' type: string - enum: &id009 + enum: &id008 - none - inference_unavailable - inference_timeout @@ -232,7 +228,7 @@ output_schema: selection: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/rerank_configs/items/properties/prompt/anyOf/0/properties/selection' type: string - enum: &id010 + enum: &id009 - built_in - artifact selected_version: @@ -280,14 +276,14 @@ output_schema: outcome: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/reranks/items/properties/outcome' type: string - enum: &id011 + enum: &id010 - selected - fallback - failed fallback_reason: $ref: '#/definitions/Result/properties/receipt/properties/retrieval/properties/reranks/items/properties/fallback_reason' type: string - enum: &id012 + enum: &id011 - none - empty_selection - inference_unavailable @@ -306,12 +302,8 @@ output_schema: kind: $ref: '#/definitions/Result/properties/receipt/properties/selected/items/properties/kind' type: string - enum: &id013 - - atomic-memory - - topic-memory - - experience - - profile - - code + description: atomic-memory, topic-memory, experience, profile, + code, or a loaded plugin family. artifact_address: $ref: '#/definitions/Result/properties/receipt/properties/selected/items/properties/artifact_address' type: object @@ -373,16 +365,12 @@ output_schema: family: $ref: '#/definitions/Result/properties/receipt/properties/omitted/items/properties/family' type: string - enum: &id014 - - atomic-memory - - topic-memory - - experience - - profile - - code + description: atomic-memory, topic-memory, experience, profile, + code, or a loaded plugin family. reason: $ref: '#/definitions/Result/properties/receipt/properties/omitted/items/properties/reason' type: string - enum: &id015 + enum: &id012 - duplicate - blank - family_limit @@ -534,16 +522,20 @@ output_schema: maximum: 2 family: type: string - enum: *id006 + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ + description: atomic-memory, topic-memory, experience, profile, + code, or a loaded plugin family. mode: type: string - enum: *id007 + enum: *id006 outcome: type: string - enum: *id008 + enum: *id007 fallback_reason: type: string - enum: *id009 + enum: *id008 count: type: integer minimum: 1 @@ -615,7 +607,7 @@ output_schema: type: string selection: type: string - enum: *id010 + enum: *id009 selected_version: type: string compiled_digest: @@ -680,10 +672,10 @@ output_schema: pattern: ^sha256:[0-9a-f]{64}$ outcome: type: string - enum: *id011 + enum: *id010 fallback_reason: type: string - enum: *id012 + enum: *id011 count: type: integer minimum: 1 @@ -700,7 +692,11 @@ output_schema: properties: kind: type: string - enum: *id013 + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ + description: atomic-memory, topic-memory, experience, profile, + code, or a loaded plugin family. artifact_address: type: object additionalProperties: false @@ -780,10 +776,14 @@ output_schema: properties: family: type: string - enum: *id014 + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ + description: atomic-memory, topic-memory, experience, profile, + code, or a loaded plugin family. reason: type: string - enum: *id015 + enum: *id012 count: type: integer minimum: 1 diff --git a/integrations/dify/plugin/tools/pc_review_get.yaml b/integrations/dify/plugin/tools/pc_review_get.yaml index 1ed7e41729..849f1453e3 100644 --- a/integrations/dify/plugin/tools/pc_review_get.yaml +++ b/integrations/dify/plugin/tools/pc_review_get.yaml @@ -1163,17 +1163,12 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; every + readable Family supports logical tags on persisted Artifacts. + The collection-level memory Family tags entries instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 @@ -1429,17 +1424,12 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; every + readable Family supports logical tags on persisted Artifacts. + The collection-level memory Family tags entries instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 diff --git a/integrations/dify/plugin/tools/pc_review_list.yaml b/integrations/dify/plugin/tools/pc_review_list.yaml index bb1b457f19..a5c44130ad 100644 --- a/integrations/dify/plugin/tools/pc_review_list.yaml +++ b/integrations/dify/plugin/tools/pc_review_list.yaml @@ -1275,17 +1275,13 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; + every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries + instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 @@ -1542,17 +1538,13 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; + every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries + instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 diff --git a/integrations/dify/plugin/tools/pc_skill_generate.yaml b/integrations/dify/plugin/tools/pc_skill_generate.yaml index cdc7d775fa..d7ac5eb64f 100644 --- a/integrations/dify/plugin/tools/pc_skill_generate.yaml +++ b/integrations/dify/plugin/tools/pc_skill_generate.yaml @@ -1290,17 +1290,13 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; + every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries + instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 @@ -1557,17 +1553,13 @@ output_schema: - artifact family: type: string - description: All readable Artifact families support logical - tags on persisted Artifacts. - enum: - - memory - - atomic-memory - - experience - - skill - - handoff - - profile - - prompt - - topic-memory + description: A built-in Family or a loaded plugin family; + every readable Family supports logical tags on persisted + Artifacts. The collection-level memory Family tags entries + instead. + minLength: 1 + maxLength: 128 + pattern: ^[a-z][a-z0-9-]*$ artifact_id: type: string minLength: 1 From 90caf8ff034816e6a43af44f6567c3526e86335b Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:38:55 -0800 Subject: [PATCH 09/38] fix(api): describe built-in-or-plugin Artifact request bodies with anyOf A plugin family's create and replace bodies accept any content object, so a built-in family's request also matched the plugin branch and failed oneOf's exactly-one rule in JSON Schema validators; the Dify tools rejected every built-in Artifact edit as an invalid request. The Server selects the branch by family name, which anyOf states accurately. Generated Python models keep the same unions. Co-Authored-By: Claude Opus 5.5 --- openapi/powercontext.yaml | 9 +++++++-- src/powercontext/http/_generated/models.py | 9 ++++++--- src/powercontext/http/_generated/schema.py | 14 +++++++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index b99a0ffc2a..f352948333 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -13163,7 +13163,9 @@ components: A built-in family's creation request, or the complete content of an Artifact in a loaded plugin's family. The family name selects the branch: built-in names select the built-in branch. x-powercontext-family-dispatch: {builtin: CreateBuiltinArtifactRequest, plugin: CreatePluginArtifactRequest} - oneOf: + # A plugin family's content is any object, so built-in requests also match the plugin branch; anyOf states + # that honestly, and the family name selects the branch the Server applies. + anyOf: - $ref: "#/components/schemas/CreateBuiltinArtifactRequest" - $ref: "#/components/schemas/CreatePluginArtifactRequest" CreateBuiltinArtifactRequest: @@ -13473,7 +13475,10 @@ components: maxLength: 4096 nullable: true ReplaceArtifactRequest: - oneOf: + description: >- + The complete content for the family in the path. A plugin family's content is any object, so a built-in + family's request also matches the plugin branch; the path family selects the branch the Server applies. + anyOf: - $ref: "#/components/schemas/ReplaceAtomicMemoryArtifactRequest" - $ref: "#/components/schemas/ReplaceTopicMemoryArtifactRequest" - $ref: "#/components/schemas/ReplaceMemoryArtifactRequest" diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 7cfee7202b..88fdb10dbf 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -6765,7 +6765,7 @@ class ReplaceArtifactRequest( | ReplacePluginArtifactRequest ] ): - root: ( + root: Annotated[ ReplaceAtomicMemoryArtifactRequest | ReplaceTopicMemoryArtifactRequest | ReplaceMemoryArtifactRequest @@ -6774,8 +6774,11 @@ class ReplaceArtifactRequest( | ReplaceHandoffArtifactRequest | ReplacePromptArtifactRequest | ReplaceProfileArtifactRequest - | ReplacePluginArtifactRequest - ) + | ReplacePluginArtifactRequest, + Field( + description="The complete content for the family in the path. A plugin family's content is any object, so a built-in family's request also matches the plugin branch; the path family selects the branch the Server applies." + ), + ] class CandidateHistory(BaseModel): diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3749fed6b0..d9adc33b46 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -13860,7 +13860,7 @@ "required": ["artifact", "title", "summary", "detail", "source_refs"], }, "CreateArtifactRequest": { - "oneOf": [ + "anyOf": [ {"$ref": "#/components/schemas/CreateBuiltinArtifactRequest"}, {"$ref": "#/components/schemas/CreatePluginArtifactRequest"}, ], @@ -14349,7 +14349,7 @@ "type": "object", }, "ReplaceArtifactRequest": { - "oneOf": [ + "anyOf": [ {"$ref": "#/components/schemas/ReplaceAtomicMemoryArtifactRequest"}, {"$ref": "#/components/schemas/ReplaceTopicMemoryArtifactRequest"}, {"$ref": "#/components/schemas/ReplaceMemoryArtifactRequest"}, @@ -14359,7 +14359,15 @@ {"$ref": "#/components/schemas/ReplacePromptArtifactRequest"}, {"$ref": "#/components/schemas/ReplaceProfileArtifactRequest"}, {"$ref": "#/components/schemas/ReplacePluginArtifactRequest"}, - ] + ], + "description": "The complete content for " + "the family in the path. A " + "plugin family's content is " + "any object, so a built-in " + "family's request also " + "matches the plugin branch; " + "the path family selects the " + "branch the Server applies.", }, "ReplacePromptArtifactRequest": { "properties": { From 98324a491598199b79fd6f5930214f33f9248e42 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:49:27 -0800 Subject: [PATCH 10/38] build(desktop): regenerate the contract identity for the anyOf Artifact request bodies Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 1 + desktop/ui/src/generated/operations.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 247b28d34d..dff1ade506 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "6dc5209c2067cb104797f073386fa50dcad1cfbbf90b4f08b86de56ce4febbd9", + "contractSha256": "90eaefdf7d34bdd161b9d0b0882690a73229d5870a56907c403bbafbdd27ca95", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 3ed0ad6cd5..caba3229b1 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -5749,6 +5749,7 @@ export interface components { limit: number; cursor?: string | null; }; + /** @description The complete content for the family in the path. A plugin family's content is any object, so a built-in family's request also matches the plugin branch; the path family selects the branch the Server applies. */ ReplaceArtifactRequest: components["schemas"]["ReplaceAtomicMemoryArtifactRequest"] | components["schemas"]["ReplaceTopicMemoryArtifactRequest"] | components["schemas"]["ReplaceMemoryArtifactRequest"] | components["schemas"]["ReplaceExperienceArtifactRequest"] | components["schemas"]["ReplaceSkillArtifactRequest"] | components["schemas"]["ReplaceHandoffArtifactRequest"] | components["schemas"]["ReplacePromptArtifactRequest"] | components["schemas"]["ReplaceProfileArtifactRequest"] | components["schemas"]["ReplacePluginArtifactRequest"]; ReplacePromptArtifactRequest: { content: components["schemas"]["PromptContent"]; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 221ce7f2ef..6ec2104693 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 = "6dc5209c2067cb104797f073386fa50dcad1cfbbf90b4f08b86de56ce4febbd9"; +export const contractSha256 = "90eaefdf7d34bdd161b9d0b0882690a73229d5870a56907c403bbafbdd27ca95"; export const operations = { "get_liveness": { "method": "GET", From 2d9d9e9216fda0fd8535428749d3f86456df50a0 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:51:06 -0800 Subject: [PATCH 11/38] build(deps): relock the Bub harness and evaluation projects for tzlocal The host process groups added tzlocal to the builtin extras without relocking the projects that depend on powercontext by path, so their --locked checks failed. Co-Authored-By: Claude Opus 5.5 --- e2e/bub/uv.lock | 9 +++++++-- evaluation/uv.lock | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 52b44d0bd5..af7c445b3c 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -906,8 +906,8 @@ name = "httpcore2" version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ @@ -1790,6 +1790,11 @@ requires-dist = [ { name = "tzdata", marker = "extra == 'ops'", specifier = ">=2026.3" }, { name = "tzdata", marker = "extra == 'seekdb'", specifier = ">=2026.3" }, { name = "tzdata", marker = "extra == 'server'", specifier = ">=2026.3" }, + { name = "tzlocal", marker = "extra == 'builtin'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'cli'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'ops'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'seekdb'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'server'", specifier = ">=5.2,<6" }, { name = "uvicorn", marker = "extra == 'ops'", specifier = ">=0.34,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] diff --git a/evaluation/uv.lock b/evaluation/uv.lock index 4e041653fc..39f7229089 100644 --- a/evaluation/uv.lock +++ b/evaluation/uv.lock @@ -1695,6 +1695,7 @@ server = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlite-vec" }, { name = "tzdata" }, + { name = "tzlocal" }, { name = "uvicorn" }, ] @@ -1834,6 +1835,11 @@ requires-dist = [ { name = "tzdata", marker = "extra == 'ops'", specifier = ">=2026.3" }, { name = "tzdata", marker = "extra == 'seekdb'", specifier = ">=2026.3" }, { name = "tzdata", marker = "extra == 'server'", specifier = ">=2026.3" }, + { name = "tzlocal", marker = "extra == 'builtin'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'cli'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'ops'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'seekdb'", specifier = ">=5.2,<6" }, + { name = "tzlocal", marker = "extra == 'server'", specifier = ">=5.2,<6" }, { name = "uvicorn", marker = "extra == 'ops'", specifier = ">=0.34,<1" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34,<1" }, ] @@ -1899,7 +1905,6 @@ resource-build = [ { name = "tzdata", specifier = ">=2026.3" }, { name = "zstandard", specifier = ">=0.23,<1" }, ] - tracing-tests = [ { name = "langfuse", specifier = "==5.0.2" }, { name = "powercontext", extras = ["tracing-otlp"] }, From baa391fbd1c38d3c1f5bae88a8bd7c8b2d880022 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 19:52:35 -0800 Subject: [PATCH 12/38] build(dify): regenerate the contract for the anyOf Artifact request bodies Co-Authored-By: Claude Opus 5.5 --- integrations/dify/plugin/powercontext_dify/contract.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index ae9679da5e..5de19bfbb8 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -4251,7 +4251,8 @@ ] }, "ReplaceArtifactRequest": { - "oneOf": [ + "description": "The complete content for the family in the path. A plugin family's content is any object, so a built-in family's request also matches the plugin branch; the path family selects the branch the Server applies.", + "anyOf": [ { "$ref": "#/components/schemas/ReplaceAtomicMemoryArtifactRequest" }, From 3779a896fdf597da971eaeb25fbc826f22f31b48 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 20:32:10 -0800 Subject: [PATCH 13/38] test(api): expect anyOf for the built-in-or-plugin Artifact create body Co-Authored-By: Claude Opus 5.5 --- tests/test_api_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index d3664fce60..ec32a0d5f8 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -999,7 +999,7 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - assert schemas["SourceType"]["enum"] == ["content"] assert source["properties"]["source_type"]["default"] == "content" - assert schemas["CreateArtifactRequest"]["oneOf"] == [ + assert schemas["CreateArtifactRequest"]["anyOf"] == [ {"$ref": "#/components/schemas/CreateBuiltinArtifactRequest"}, {"$ref": "#/components/schemas/CreatePluginArtifactRequest"}, ] From ff04afd7b2713d63b1b7d6cee277bd045c665ec0 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 20:36:38 -0800 Subject: [PATCH 14/38] test: expect the client's omitted plugin search fields in the Bub harness and fix new test typing The Prepared Context client drops an unused per-section search so an older Server accepts the request; the Bub compatibility check still compared the payload with the full model dump. Co-Authored-By: Claude Opus 5.5 --- e2e/bub/tests/test_bub_core_compatibility.py | 7 ++++++- tests/e2e/plugin_server.py | 4 ++-- tests/e2e/test_portable_archive.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/e2e/bub/tests/test_bub_core_compatibility.py b/e2e/bub/tests/test_bub_core_compatibility.py index bd2afed1a7..fc7343165f 100644 --- a/e2e/bub/tests/test_bub_core_compatibility.py +++ b/e2e/bub/tests/test_bub_core_compatibility.py @@ -103,7 +103,12 @@ def open_client(base_url, **kwargs): if value is None: assert "assembly" not in payload else: - assert payload["assembly"] == settings.context_assembly.model_dump(mode="json") + expected = settings.context_assembly.model_dump(mode="json") + # The client omits unused plugin search fields so an older Server accepts the request. + for section in expected["sections"]: + if section.get("search") is None: + section.pop("search", None) + assert payload["assembly"] == expected """ diff --git a/tests/e2e/plugin_server.py b/tests/e2e/plugin_server.py index cead80a63b..7e9b10cc33 100644 --- a/tests/e2e/plugin_server.py +++ b/tests/e2e/plugin_server.py @@ -21,7 +21,7 @@ import os import socket import sys -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import asynccontextmanager from pathlib import Path from typing import Any @@ -34,7 +34,7 @@ @asynccontextmanager async def running_server( - directory: Path, plugins: Path | None = None, *, extra_env: dict[str, str | None] | None = None + directory: Path, plugins: Path | None = None, *, extra_env: Mapping[str, str | None] | None = None ): """Start the Server; an ``extra_env`` value of ``None`` removes that default variable.""" with socket.socket() as sock: diff --git a/tests/e2e/test_portable_archive.py b/tests/e2e/test_portable_archive.py index b3185d134f..489274c10c 100644 --- a/tests/e2e/test_portable_archive.py +++ b/tests/e2e/test_portable_archive.py @@ -413,7 +413,7 @@ async def scenario() -> None: page = await target.records.list_artifact_revisions( RELEASED_SCOPE, "profile", profile.artifact_id, limit=10, cursor=None ) - assert [item.content["content"] for item in page.items] == [ + assert [item.content and item.content["content"] for item in page.items] == [ "# Profile\n\nUse a different choice.\n", "# Profile\n\nKeep the original choice.\n", ] From 2fa44d810fc28bb15d843d443239847b795b485e Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:12:16 -0800 Subject: [PATCH 15/38] fix(plugins): follow master's move of access control into the built-in runtime and its Dashboard titles Point plugin imports at powercontext.builtin.authz, pass the section to the Prepared Context budget evaluation's section builder, and title plugin pages through the Dashboard's document_title. Co-Authored-By: Claude Opus 5.5 --- evaluation/memory/prepared_context_budget/arms.py | 2 +- src/powercontext/builtin/plugins/executor.py | 2 +- src/powercontext/builtin/plugins/families.py | 2 +- src/powercontext/builtin/plugins/host.py | 4 ++-- src/powercontext/builtin/plugins/writer.py | 2 +- src/powercontext/server/app.py | 1 - src/powercontext/server/dashboard/routes.py | 3 ++- src/powercontext/server/factory.py | 1 - 8 files changed, 8 insertions(+), 9 deletions(-) diff --git a/evaluation/memory/prepared_context_budget/arms.py b/evaluation/memory/prepared_context_budget/arms.py index 6ea2d3db27..e660646a45 100644 --- a/evaluation/memory/prepared_context_budget/arms.py +++ b/evaluation/memory/prepared_context_budget/arms.py @@ -463,7 +463,7 @@ def foreign(scope_id: str) -> str | None: else: groups = [ builder._section_entries( - section.family, + section, case.current_scope_id, case.memory, case.experience, diff --git a/src/powercontext/builtin/plugins/executor.py b/src/powercontext/builtin/plugins/executor.py index 6b89d01a11..87716253f9 100644 --- a/src/powercontext/builtin/plugins/executor.py +++ b/src/powercontext/builtin/plugins/executor.py @@ -97,7 +97,7 @@ async def execute_plugin( # noqa: C901 - one window from selection through ackn async with AsyncExitStack() as resources: if database is None: database = await resources.enter_async_context(open_plugin_database(spec.database)) - from powercontext.server.processing_security import open_worker_security + from powercontext.builtin.authz.worker import open_worker_security security = await resources.enter_async_context(open_worker_security(spec.security, database)) diff --git a/src/powercontext/builtin/plugins/families.py b/src/powercontext/builtin/plugins/families.py index e7dd5e9635..3b9a743ef1 100644 --- a/src/powercontext/builtin/plugins/families.py +++ b/src/powercontext/builtin/plugins/families.py @@ -52,7 +52,7 @@ class PluginContentView(RootModel[dict[str, JsonValue]]): def register_plugin_family(family: str) -> None: - from powercontext.server.authz.profiles import register_plugin_artifact_family + from powercontext.builtin.authz.profiles import register_plugin_artifact_family _PLUGIN_FAMILIES.add(family) register_plugin_artifact_family(family) diff --git a/src/powercontext/builtin/plugins/host.py b/src/powercontext/builtin/plugins/host.py index 361af62698..f50e87c490 100644 --- a/src/powercontext/builtin/plugins/host.py +++ b/src/powercontext/builtin/plugins/host.py @@ -178,7 +178,7 @@ async def _start(self, resources: AsyncExitStack) -> tuple[str, ...]: for binding, scopes, source_types in spec.source_bindings: register_source_binding(binding, None if scopes is None else frozenset(scopes), frozenset(source_types)) self.database = await resources.enter_async_context(open_plugin_database(spec.database)) - from powercontext.server.processing_security import open_worker_security + from powercontext.builtin.authz.worker import open_worker_security security = await resources.enter_async_context(open_worker_security(spec.security, self.database)) if security is not None: @@ -488,7 +488,7 @@ async def require(self, action: str, scope_id: str | None) -> None: if action == "scope.contribute" and scope_id is not None: call.contribute_scopes.add(scope_id) return - from powercontext.server.authz import AccessAction, AccessDeniedError, ResourceRef + from powercontext.builtin.authz import AccessAction, AccessDeniedError, ResourceRef if action == "server.admin": resource = ResourceRef.server() if self.deployment_id is None else ResourceRef.server(self.deployment_id) diff --git a/src/powercontext/builtin/plugins/writer.py b/src/powercontext/builtin/plugins/writer.py index 40bbe932a1..fac4bbc37e 100644 --- a/src/powercontext/builtin/plugins/writer.py +++ b/src/powercontext/builtin/plugins/writer.py @@ -158,7 +158,7 @@ async def authorize_creates(self, connection, scope_id, context, refs): async def establish_owners(self, connection, scope_id, artifact_ids, context): if self.security is None: return - from powercontext.server.authz import ResourceRef + from powercontext.builtin.authz import ResourceRef bound = self.security.access.with_connection(connection) for artifact_id in artifact_ids: diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 21c49b5057..f87ca5ffc0 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -198,7 +198,6 @@ from powercontext.builtin.authz.profiles import ( ARTIFACT_FAMILY_PROFILES, artifact_family_profile, - register_custom_artifact_access_family, ) from powercontext.builtin.authz.service import _require_allowed from powercontext.builtin.catalog_changes.models import CatalogChangeCandidate as RuntimeTagCandidate diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py index f9c1db6c50..a26637ca58 100644 --- a/src/powercontext/server/dashboard/routes.py +++ b/src/powercontext/server/dashboard/routes.py @@ -111,7 +111,8 @@ def render( }.get(ctx["processing_kind"], "processing") elif title_key == "usage": title_key = "usage_resources" - ctx["document_title"] = f"{ctx['t'][title_key]} · PowerContext" + # A plugin page is titled by its plugin's declared title. + ctx["document_title"] = f"{ctx.get('page_title') or ctx['t'][title_key]} · PowerContext" response = HTMLResponse( ENV.get_template(fragment if partial else full).render(plural=plural, **ctx), status_code=ctx["status"], diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index b106b7a1e8..aaf4de9a6b 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -51,7 +51,6 @@ owner_recovery_session, owner_write_access, ) -from powercontext.builtin.authz.profiles import register_custom_artifact_access_family from powercontext.builtin.authz.repository import RelationalAccessRepository from powercontext.builtin.dream.generation import DreamGenerator from powercontext.builtin.inference import EmbeddingModel From 2c4519f29c215cc13217546c3ac5ef7842af260c Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:13:31 -0800 Subject: [PATCH 16/38] build: regenerate the Desktop contract identity and review persistence after rebasing onto master Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- release/upgrade-support.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index dff1ade506..02f15e40ef 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "90eaefdf7d34bdd161b9d0b0882690a73229d5870a56907c403bbafbdd27ca95", + "contractSha256": "6e532edd31a2d4ebb632c1a1aae87826a4013f0c92d6fda1d1c53b08a8c1b70b", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 6ec2104693..855f18c627 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 = "90eaefdf7d34bdd161b9d0b0882690a73229d5870a56907c403bbafbdd27ca95"; +export const contractSha256 = "6e532edd31a2d4ebb632c1a1aae87826a4013f0c92d6fda1d1c53b08a8c1b70b"; export const operations = { "get_liveness": { "method": "GET", diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 3710a3e5d1..a17a6784f8 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "4f61f94576cc809671b04482534b464fd3f5f1a13b8b4c72de9e914a98753c03", - "rationale": "Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces." + "persistence_sha256": "d222849d2532b0ce0d845360052f28923fc171ec76e51cea24f5da201694485e", + "rationale": "Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." }, "cases": [], "automatic_sources": { From 3427cee715b00e7c054f2855afaff8142d624dde Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:14:39 -0800 Subject: [PATCH 17/38] build(integrations): rebuild the DSH pet bundle from the plugin operation tables Co-Authored-By: Claude Opus 5.5 --- integrations/dsh/plugins/dsh-pet/lib/index.js | 181 ++++++++++-------- 1 file changed, 104 insertions(+), 77 deletions(-) diff --git a/integrations/dsh/plugins/dsh-pet/lib/index.js b/integrations/dsh/plugins/dsh-pet/lib/index.js index deb8f4c305..878b897281 100644 --- a/integrations/dsh/plugins/dsh-pet/lib/index.js +++ b/integrations/dsh/plugins/dsh-pet/lib/index.js @@ -267,83 +267,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - list_artifact_plugins: { - method: "GET", - path: "/v1/admin/artifact-plugins", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - get_artifact_plugin: { - method: "GET", - path: "/v1/admin/artifact-plugins/{binding_name}", - location: null, - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - enable_all_artifact_plugins: { - method: "POST", - path: "/v1/admin/artifact-plugins/enable-all", - location: null, - scopeMode: "none", - pathParameters: [], - queryParams: [], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, - reload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/reload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - enable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/enable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - disable_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/disable", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, - unload_artifact_plugin: { - method: "POST", - path: "/v1/admin/artifact-plugins/{binding_name}/unload", - location: "body", - scopeMode: "none", - pathParameters: ["binding_name"], - queryParams: [], - headerParams: [], - successStatuses: [202], - emptyStatuses: [] - }, get_capabilities: { method: "GET", path: "/v1/capabilities", @@ -727,6 +650,95 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + list_plugins: { + method: "GET", + path: "/v1/plugins", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_plugin: { + method: "GET", + path: "/v1/plugins/{name}", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + list_plugin_scopes: { + method: "GET", + path: "/v1/plugins/{name}/scopes", + location: "query", + scopeMode: "none", + pathParameters: ["name"], + queryParams: ["cursor", "limit"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + merge_artifacts: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/merges", + location: "body", + scopeMode: "current", + pathParameters: ["scope_id", "family"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_governance: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance", + location: null, + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + forget_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/forget", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + restore_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/restorations", + location: "body", + scopeMode: "current", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, search_artifacts: { method: "POST", path: "/v1/scopes/{scope_id}/artifacts/{family}/search", @@ -1515,6 +1527,21 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_rendering: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering", + location: "query", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: ["revision"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_artifact_revisions: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions", From 407b2cd777e969642fb37bf71647225ba8497943 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:16:29 -0800 Subject: [PATCH 18/38] test(desktop): qualify the plugin interfaces contract Co-Authored-By: Claude Opus 5.5 --- desktop/VALIDATION.md | 32 +++++++++++++++++++ .../src/connections/compatibility.json | 22 +++++++++++++ 2 files changed, 54 insertions(+) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 40cb7e2756..8c548848b3 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -3432,3 +3432,35 @@ profiles retain their original source, contract, artifact and evidence identitie This is local SQLite protocol qualification, not an installed WebView or credential-persistence test, other-platform qualification, external database deployment, gateway transport acceptance, or release approval. + + +## Server 3427cee7 plugin interfaces protocol qualification + +`sqlite-plugin-interfaces-3427cee7-v1` qualifies all thirteen Desktop protocol operations against +Server source `3427cee715b00e7c054f2855afaff8142d624dde`, which adds directory plugins, removes the +managed Artifact plugin framework, and describes built-in-or-plugin Artifact create and replace +bodies with `anyOf`. + +- Normalized OpenAPI SHA-256: `6e532edd31a2d4ebb632c1a1aae87826a4013f0c92d6fda1d1c53b08a8c1b70b`. +- Wheel: `powercontext-1.5.2.dev163+g3427cee71-py3-none-any.whl`. +- Wheel SHA-256: `b63c19dfc6a58de15de142f051185178943a6566ee738d1abe8dad205e0479f6`. +- Native debug probe SHA-256 (built without debug info): `fe29a7d6426a2062b4bd1d950741761f2a526f23960a3bd0a8141a29c28b454a`. + +On 2026-11-14, Linux 7.0 x86_64 (Ubuntu 24.04, glibc 2.39) and Python 3.12.3 passed anonymous loopback, +static Bearer, HTTPS with explicit CA/base path, and enforced Provider modes of the unchanged +`desktop/tests/real_server.py` through the ordinary product ConnectionManager. The probe was built with +the product Cargo lock without `ci-fixtures` and selected this bundled qualification; the client and +qualified contract digests are identical. `bundled_product_qualification_covers_every_desktop_operation` +passed against the same records. The wheel and report are retained under ignored +`desktop/.artifacts/plugin-interfaces/` and `desktop/.artifacts/real-server.json`. +This qualifies the observed local SQLite protocol; it does not establish installed WebView, native +credential persistence, other operating systems, native database upgrades or release acceptance. + +Reproduce against the recorded artifact: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-plugin-interfaces-3427cee7-v1 \ + --wheel desktop/.artifacts/plugin-interfaces/powercontext-1.5.2.dev163+g3427cee71-py3-none-any.whl +``` diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index bc55044ffc..8fb4afa6cf 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1684,5 +1684,27 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-a211ba84-merged-gateway-contract-protocol-qualification" + }, + { + "id": "sqlite-plugin-interfaces-3427cee7-v1", + "serverCommit": "3427cee715b00e7c054f2855afaff8142d624dde", + "contractSha256": "6e532edd31a2d4ebb632c1a1aae87826a4013f0c92d6fda1d1c53b08a8c1b70b", + "artifactSha256": "b63c19dfc6a58de15de142f051185178943a6566ee738d1abe8dad205e0479f6", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-3427cee7-plugin-interfaces-protocol-qualification" } ] From 546a5e031fd8fd617445d79b2da2cdcb337a80b8 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 13:03:03 -0800 Subject: [PATCH 19/38] feat(plugins): reload changed plugin directories, upgrade plugin data, and expose routes as OpenAPI and MCP tools - Each Server process rescans the plugin directory every POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS (default 60, 0 off). A directory whose content is stable for two scans is copied to a read-only snapshot, checked, and added, updated, or removed without a restart. Code is imported only from snapshots; a new version's host process takes requests once ready while the old one drains, and Workers launch from the version current at dispatch. - `upgrade(ctx)` runs every time a version loads, before setup, as the plugin itself; instances without host processes run it in a short-lived child. A failed check or upgrade keeps the old version and is reported as `last_check_failure`. - GET /v1/plugins/{name}/openapi.json describes a plugin's routes; routes with `mcp=True` are MCP tools named __, listed from the plugins loaded now. - /v1/plugins reports version and declaration digests, activation time, routes, and pages. Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 89 ++- desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/develop/plugins.md | 56 +- docs/en/docs/operate/configuration.md | 2 + docs/en/docs/operate/observability.md | 6 + docs/zh/docs/develop/plugins.md | 47 +- docs/zh/docs/operate/configuration.md | 2 + docs/zh/docs/operate/observability.md | 5 + .../powercontext/src/operations.generated.ts | 1 + .../powercontext/src/operations.generated.ts | 1 + .../powercontext/src/operations.generated.ts | 1 + openapi/powercontext.yaml | 72 +- src/powercontext/builtin/authz/profiles.py | 11 + src/powercontext/builtin/plugins/executor.py | 2 +- src/powercontext/builtin/plugins/families.py | 28 + src/powercontext/builtin/plugins/host.py | 111 +++- .../builtin/plugins/host_protocol.py | 10 + src/powercontext/builtin/plugins/manifest.py | 60 +- .../builtin/runtime/application.py | 5 + .../builtin/runtime/artifact_merge.py | 5 + src/powercontext/http/__init__.py | 6 + src/powercontext/http/_generated/models.py | 56 +- .../http/_generated/operations.py | 27 +- src/powercontext/http/_generated/schema.py | 151 ++++- src/powercontext/plugins.py | 4 + src/powercontext/server/app.py | 9 + .../server/dashboard/plugin_pages.py | 2 +- src/powercontext/server/factory.py | 3 + src/powercontext/server/logging.py | 14 +- src/powercontext/server/mcp.py | 110 +++- src/powercontext/server/plugin_hosts.py | 50 ++ src/powercontext/server/plugin_versions.py | 95 +++ src/powercontext/server/plugins.py | 622 +++++++++++++++--- src/powercontext/server/settings.py | 2 + tests/e2e/test_plugin_reload.py | 146 ++++ tests/test_mcp.py | 2 + 37 files changed, 1678 insertions(+), 139 deletions(-) create mode 100644 src/powercontext/server/plugin_versions.py create mode 100644 tests/e2e/test_plugin_reload.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 02f15e40ef..b8871f5f23 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "6e532edd31a2d4ebb632c1a1aae87826a4013f0c92d6fda1d1c53b08a8c1b70b", + "contractSha256": "b94282cdbd0a0458d0e1502983e58a6db6da929a857beafbe52c0e40b9817a55", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index caba3229b1..44e845e697 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -887,7 +887,7 @@ export interface paths { }; /** * List loaded plugins - * @description Lists the plugins checked at Server startup, whether each loaded or failed, and what each one declares. Plugins have no persisted lifecycle state; change the plugin directory and restart Server. + * @description Lists the plugins in the plugin directory as this Server instance sees them: whether each loaded or failed, the version in use, and what it declares. Plugins have no persisted lifecycle state. Each instance rescans the directory on its own, so instances may briefly differ after a plugin changes. */ get: operations["list_plugins"]; put?: never; @@ -915,6 +915,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/plugins/{name}/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Describe a plugin's own routes + * @description Returns an OpenAPI 3.1 document that core generates from the routes the loaded plugin version declares: paths, methods, the access each route requires, request and response schemas, and the shared error responses. The plugin owns the compatibility of its routes. + */ + get: operations["get_plugin_openapi"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/plugins/{name}/scopes": { parameters: { query?: never; @@ -3515,11 +3535,33 @@ export interface components { PluginLoadStatus: "loaded" | "failed"; /** @enum {string} */ PluginTrigger: "schedule" | "sources"; + PluginRoute: { + /** @enum {string} */ + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** @description Route template under /v1/plugins/{name}/api. */ + path: string; + /** @enum {string} */ + access: "authenticated" | "scope.read" | "scope.contribute"; + /** @description The route is also an MCP tool. */ + mcp: boolean; + }; + /** @description An OpenAPI 3.1 document describing one plugin's routes. */ + PluginOpenApiDocument: { + [key: string]: unknown; + }; + PluginPagesState: { + /** @description Navigation title by language. */ + title: { + [key: string]: string; + }; + /** @description Dashboard path of the plugin's page. */ + path: string; + }; PluginState: { name: string; kind: components["schemas"]["PluginKind"]; status: components["schemas"]["PluginLoadStatus"]; - /** @description Safe failure category from the startup check. */ + /** @description Safe failure category when the plugin is not loaded. */ error: string | null; family: string | null; binding_name: string | null; @@ -3527,6 +3569,19 @@ export interface components { /** @description Source types delivered to the plugin; null when it consumes no Sources. */ source_types: string[] | null; triggers: components["schemas"]["PluginTrigger"][]; + /** @description Content digest of the plugin version in use on this instance; null when none loaded. */ + version_digest: string | null; + /** @description Digest of the declaration in use. */ + declaration_digest: string | null; + /** + * Format: date-time + * @description When this instance switched to the version in use. + */ + activated_at: string | null; + /** @description Safe category of the latest failed check of a newer version; the version in use stays active. Cleared when a version activates. */ + last_check_failure: string | null; + routes: components["schemas"]["PluginRoute"][]; + pages: components["schemas"]["PluginPagesState"] | null; }; PluginPage: { items: components["schemas"]["PluginState"][]; @@ -8184,7 +8239,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Plugins found in the plugin directory at startup. */ + /** @description Plugins found in the plugin directory by this instance. */ 200: { headers: { [name: string]: unknown; @@ -8209,7 +8264,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The plugin's startup check result and declaration. */ + /** @description The plugin's check result, version in use, and declaration on this instance. */ 200: { headers: { [name: string]: unknown; @@ -8224,6 +8279,32 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + get_plugin_openapi: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The plugin's route description. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PluginOpenApiDocument"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 503: components["responses"]["Unavailable"]; + }; + }; list_plugin_scopes: { parameters: { query?: { diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 855f18c627..e3403c7d8a 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 = "6e532edd31a2d4ebb632c1a1aae87826a4013f0c92d6fda1d1c53b08a8c1b70b"; +export const contractSha256 = "b94282cdbd0a0458d0e1502983e58a6db6da929a857beafbe52c0e40b9817a55"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/develop/plugins.md b/docs/en/docs/develop/plugins.md index 7552216d61..2931c4a795 100644 --- a/docs/en/docs/develop/plugins.md +++ b/docs/en/docs/develop/plugins.md @@ -22,7 +22,7 @@ only plugins you trust. ## Enable the plugin directory -Set the plugin directory and restart Server: +Set the plugin directory and start Server: ```bash export POWERCONTEXT_SERVER_PLUGINS_DIR=/srv/powercontext/plugins @@ -33,8 +33,7 @@ powercontext server run lowercase letters, digits, and `-`). - Install the third-party libraries a plugin needs into the Server's Python environment, for example `uv pip install pypdf`. A `requirements.txt` in the plugin directory only informs deployers; Server does not install it. -- Restart Server after changing a plugin. Server records a content fingerprint of the plugin directory when it loads and - refuses to run plugin files that changed afterward, so old and new code never mix. +- Adding, changing, or removing a plugin directory needs no Server restart; see "Update plugins". Check what loaded with administrator credentials: @@ -127,6 +126,7 @@ This plugin has the basic parts of a plugin: | `timeout_seconds`, `transaction_timeout_seconds` | Time limits for one generation run and its write transaction | | `change_timeout_seconds` | Time limit for one user change, presentation, or search | | `host_processes` | The most host processes of this plugin in each Server process, 1 by default; deployers can override it | +| `upgrade`, `upgrade_timeout_seconds` | A data upgrade function called before `setup` every time this version loads, and its time limit (600 seconds by default); see "Update plugins" | | `definitions` | The plugin's own Source Definitions; see "Define Source types" | | `api` | The plugin's APIs; see "Provide APIs" | | `pages` | Dashboard pages; see "Dashboard pages" | @@ -378,6 +378,12 @@ plugin = ArtifactPlugin( never exceeds the deployer's `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES`. Core returns 413 without calling the plugin when a body is too large. - Each call is bounded by `timeout_seconds` (30 seconds by default) and returns 504 when it runs over. +- A route declared with `mcp=True` (it needs a `description` and a JSON or empty body) is also a tool on the Server's MCP + endpoint, named `__`, whose arguments are the path parameters, `query`, and `body`. Calling the + tool is calling the route as the MCP session's caller; `GET` routes whose `access` is `scope.read` or `authenticated` + are marked read-only. +- `GET /v1/plugins//openapi.json` returns an OpenAPI document core generates from the declared routes. The + plugin owns its routes' compatibility; for an incompatible change, put a version in the path, such as `/v2/notes`. APIs, presentation, search, and user-change methods run in host processes. Each plugin has its own group of host processes and shares none with other plugins: the first starts once the plugin loads and always stays; another starts @@ -450,6 +456,47 @@ plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html - Pages are static files only (HTML, JavaScript, CSS, images, fonts), up to 5 MiB each; scripts may come only from the plugin's own directory and `/dashboard/plugins/_client.js`. +## Update plugins + +Each Server process scans the plugin directory every `POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS` (60 by default; 0 +turns it off) and compares file contents to decide whether a plugin changed: + +- Once two scans in a row see the same content, different from the version in use, Server copies the directory into a + read-only snapshot in its data directory and switches to it after the check passes. A change therefore takes effect + within two scan intervals, and a half-written directory never loads. Added directories load the same way; removed ones + unload at the next scan. +- Plugin code is imported only from snapshots. New requests go to the new version once its host process is ready; the old + version's host processes finish the requests they hold and stop, and background generation already started finishes on + the old version. +- If the new version fails its check or its `upgrade`, the old version stays in use and `last_check_failure` in + `GET /v1/plugins/` gives the failure category; `version_digest` and `activated_at` describe the version in + use and when it took effect. +- Server instances scan on their own, so until every instance has switched, instances may use different versions. + +`upgrade(ctx)` upgrades plugin data. It is called every time a plugin version loads, including at Server startup, and the +plugin decides whether anything needs doing: + +```python +from sqlalchemy import text + + +async def upgrade(ctx): + async with ctx.storage.engine().begin() as connection: + await connection.execute(text("CREATE TABLE IF NOT EXISTS note_tags (note_id TEXT, tag TEXT)")) + +plugin = ArtifactPlugin(family="notes", setup=setup, upgrade=upgrade) +``` + +- Every Server instance that loads the new version runs it, so it must be safe to repeat and to run on several instances + at once; take a lock in dedicated storage when it needs mutual exclusion. +- Keep it fast, such as schema changes in dedicated storage. Migrate large data formats lazily as they are read, or ship a + separate migration script for deployers to run before upgrading. +- `ctx.reads(scope_id)` and `ctx.writer(scope_id)` act as the plugin itself, and one `upgrade` opens at most one write + transaction block; `ctx.caller`, `ctx.require`, `ctx.sources`, and `ctx.scopes` are unavailable. +- The old version may still run on other instances during an upgrade. Make changes both versions can use first, and clean + up in a later version once the old one no longer runs. Core does not support downgrades; old code that cannot read new + data is the plugin's concern. + ## Troubleshooting | Symptom | What to do | @@ -458,12 +505,11 @@ plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html | APIs return 503 | The host process is starting or restarting, or was terminated because its event loop was blocked; check the Server log for plugin errors | | APIs return 504 `plugin_timeout` | The call ran past its time limit; move slow work into `asyncio.to_thread` or `generate`, or raise the limit | | APIs return 500 `plugin_error` | A handler raised an unhandled exception; the Server log records its type | -| Workers stop running after a plugin change | Plugin files changed while Server was running; restart Server | +| A plugin change does not take effect | Wait two scan intervals; check `last_check_failure` in `GET /v1/plugins/` and `plugin.changed` in the Server log | | Generated Artifacts do not appear | Check `GET /v1/plugins//scopes` for Scopes with pending Sources | ## Current limits -- Updating a plugin requires a Server restart; hot reload is not available yet. - Dedicated plugin storage supports only SQLite deployments for now. - Deployers install plugin dependencies into the Server environment by hand. - Plugin code has the Server's permissions; host processes and Workers isolate failures, not untrusted code. diff --git a/docs/en/docs/operate/configuration.md b/docs/en/docs/operate/configuration.md index 30daec691b..03ee0d7140 100644 --- a/docs/en/docs/operate/configuration.md +++ b/docs/en/docs/operate/configuration.md @@ -118,6 +118,8 @@ still occupies an HTTP slot until it finishes; reserve other slots for concurren | `POWERCONTEXT_SERVER_HTTP_UDS` | unset | HTTP Unix socket path for `server run` on Linux/macOS; selects UDS only, with no TCP listener; absolute path, at most 103 filesystem-encoded bytes, without `..` or NUL | | `POWERCONTEXT_SERVER_HTTP_UDS_EXTERNAL_SCHEME` | unset | Operator-declared external `http` or `https` scheme for a trusted proxy on the private UDS; requires `HTTP_UDS`. Set `https` only with trusted TLS termination. Forwarded scheme and client-IP headers remain ignored | | `POWERCONTEXT_SERVER_HTTP_MAX_REQUEST_BODY_BYTES` | `16777216` | Maximum external HTTP body bytes before parsing, including chunked uploads | +| `POWERCONTEXT_SERVER_PLUGINS_DIR` | unset | Plugin directory; each subdirectory is a plugin. See [Write a plugin](../develop/plugins.md) | +| `POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS` | `60` | Seconds between each Server process's scans of the plugin directory; added, changed, and removed plugins take effect within two intervals. `0` turns scanning off, so plugins load only at startup | | `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` | `33554432` | Request and response body limit of plugin routes (`/v1/plugins/{name}/api/...` and their Dashboard page forwarding), in place of the previous setting; a plugin route may declare a lower limit, never a higher one | | `POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES` | `{}` | JSON object overriding plugins' declared `host_processes` by plugin name, such as `{"knowledge-base": 4}`; each value is the most host processes of that plugin in each Server process | | `POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES` | unset | The most host processes of all plugins in each Server process; at the limit, no process is added for load and requests queue on existing ones. Each plugin's first, always-running process is exempt | diff --git a/docs/en/docs/operate/observability.md b/docs/en/docs/operate/observability.md index 8b730fed6f..aa859938b4 100644 --- a/docs/en/docs/operate/observability.md +++ b/docs/en/docs/operate/observability.md @@ -161,6 +161,12 @@ they are not per-request allocations or simultaneous process-tree totals, and mu Failed code stages retain the diagnostic `error_code` and, for domain errors, `status_code`; logs and spans use these values to distinguish timeouts, conflicts, and invalid requests from internal failures. +Plugin directory changes are recorded as `plugin.changed` events: `plugin_name` is the plugin, `plugin_event` is +`loaded`, `updated`, `removed`, `check_failed`, or `upgrade_failed`, and `version_digest` and `declaration_digest` are +the content and declaration digests of the version that took effect (or that stays in use after a failed check). Failed +checks record only a known failure category, never the exception message. With reloading on, each scan records a +`DEBUG` `plugin.scanned` event whose `scan_ms` is the scan's duration in milliseconds. + Server-created workers use the configured log level and format. Importing or running the Runtime as an SDK does not configure global logging; an embedding application owns its logging configuration. Logging bootstrap failure does not fail processing. The Server handler emits at most one content-free `observability.log_failed` notification to stderr diff --git a/docs/zh/docs/develop/plugins.md b/docs/zh/docs/develop/plugins.md index 2cf0e00e2b..1269b8f866 100644 --- a/docs/zh/docs/develop/plugins.md +++ b/docs/zh/docs/develop/plugins.md @@ -21,7 +21,7 @@ description: 用一个目录和 plugin.py 扩展 PowerContext:生成、展示 ## 启用插件目录 -设置插件目录,然后重启 Server: +设置插件目录并启动 Server: ```bash export POWERCONTEXT_SERVER_PLUGINS_DIR=/srv/powercontext/plugins @@ -31,7 +31,7 @@ powercontext server run - 目录下每个子目录是一个插件,子目录名就是插件名(小写字母开头,只含小写字母、数字和 `-`)。 - 插件依赖的第三方库需要装进 Server 的 Python 环境,例如 `uv pip install pypdf`。插件目录中的 `requirements.txt` 只是给 部署者看的说明,Server 不会自动安装。 -- 修改插件后需要重启 Server。Server 在加载时记录插件目录的内容指纹,运行中发现插件文件变了会拒绝执行,避免新旧代码混用。 +- 新增、修改或删除插件目录不需要重启 Server,见"更新插件"。 用管理员凭据查看加载结果: @@ -121,6 +121,7 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/jso | `timeout_seconds`、`transaction_timeout_seconds` | 一次生成和其中写事务的时限 | | `change_timeout_seconds` | 一次用户修改、展示或检索的时限 | | `host_processes` | 每个 Server 进程中本插件宿主进程数的上限,默认 1;部署者可以覆盖 | +| `upgrade`、`upgrade_timeout_seconds` | 每次加载本版本时在 `setup` 之前调用的数据升级函数及其时限(默认 600 秒),见"更新插件" | | `definitions` | 插件自己的 Source 定义,见"定义 Source 类型" | | `api` | 插件的接口,见"提供接口" | | `pages` | Dashboard 页面,见"Dashboard 页面" | @@ -351,6 +352,11 @@ plugin = ArtifactPlugin( - 请求体上限:JSON 默认 1 MiB,二进制默认 32 MiB,可以用 `max_body_bytes` 为单个路由声明,但不会超过部署者设置的 `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES`。超出时 core 返回 413,不调用插件。 - 每次调用受 `timeout_seconds`(默认 30 秒)约束,超时返回 504。 +- 路由声明 `mcp=True`(需要填写 `description`,请求体只能是 JSON 或没有)时,同时作为 Server MCP 端点上的工具,工具名为 + `<插件名>__`,参数由路径参数、`query` 和 `body` 组成。调用工具等同于以 MCP 会话的身份调用该路由;`GET` 且 + `access` 为 `scope.read` 或 `authenticated` 的路由标为只读工具。 +- `GET /v1/plugins/<插件名>/openapi.json` 返回 core 根据路由声明生成的 OpenAPI 文档。接口的兼容性由插件自己负责,需要不兼容 + 的变更时可以在路径中带上版本,例如 `/v2/notes`。 接口、展示、检索和用户修改方法在宿主进程中执行。每个插件有自己的一组宿主进程,不与其他插件共用:插件加载后启动第一个并 一直保留;所有进程都忙、且有请求排队时再启动一个,直到 `host_processes`;多出的进程空闲 5 分钟后停止。工厂 `setup(ctx)` @@ -416,6 +422,40 @@ plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html - 只提供静态文件(HTML、JavaScript、CSS、图片、字体),单个文件不超过 5 MiB;脚本只能来自插件自己的目录和 `/dashboard/plugins/_client.js`。 +## 更新插件 + +每个 Server 进程每隔 `POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS`(默认 60 秒,0 关闭)扫描一次插件目录,按文件内容判断 +插件是否变化: + +- 目录内容连续两次扫描都相同、且与正在使用的版本不同时,Server 把它复制成数据目录下的只读快照,检查通过后切换到新版本。 + 所以修改最晚在两个扫描间隔之后生效,写到一半的目录不会被加载。新增的目录同样处理;删除的目录在下一次扫描时卸载。 +- 插件代码只从快照导入。新版本的宿主进程就绪后,新请求才发往新版本;旧版本的宿主进程完成手头的请求后停止,已经开始的 + 后台生成用旧版本完成。 +- 新版本检查失败或 `upgrade` 失败时继续使用旧版本,`GET /v1/plugins/<插件名>` 的 `last_check_failure` 给出失败分类; + `version_digest` 和 `activated_at` 是正在使用的版本及其生效时间。 +- 多个 Server 实例各自扫描,在所有实例完成切换之前,不同实例可能使用不同版本。 + +`upgrade(ctx)` 用于插件数据的升级,每次加载一个插件版本都会调用(包括 Server 启动时),插件自己判断是否需要做什么: + +```python +from sqlalchemy import text + + +async def upgrade(ctx): + async with ctx.storage.engine().begin() as connection: + await connection.execute(text("CREATE TABLE IF NOT EXISTS note_tags (note_id TEXT, tag TEXT)")) + +plugin = ArtifactPlugin(family="notes", setup=setup, upgrade=upgrade) +``` + +- 每个加载新版本的 Server 实例都会执行,必须可以重复执行,也可以在多个实例上同时执行;需要互斥时在专属存储中自己加锁。 +- 只做快速的变更,例如专属存储的 schema 变更。大量数据的格式变化在读取时惰性迁移,或者提供单独的迁移脚本由部署者在 + 升级前执行。 +- `ctx.reads(scope_id)` 和 `ctx.writer(scope_id)` 以插件自身的身份执行,一次 `upgrade` 最多开启一个写事务块; + `ctx.caller`、`ctx.require`、`ctx.sources`、`ctx.scopes` 不可用。 +- 升级期间旧版本可能仍在其他实例上运行,先做新旧版本都能使用的变更,等旧版本不再运行后再在后续版本中清理。core 不支持 + 降级,旧代码读不了新数据由插件负责。 + ## 排错 | 现象 | 处理 | @@ -424,12 +464,11 @@ plugin = ArtifactPlugin(..., pages=PluginPages(directory="ui", entry="index.html | 接口返回 503 | 宿主进程正在启动或重启,或者因事件循环被阻塞而被终止;查看 Server 日志中的插件错误 | | 接口返回 504 `plugin_timeout` | 调用超过了时限;耗时的工作放进 `asyncio.to_thread` 或交给 `generate`,或者调大时限 | | 接口返回 500 `plugin_error` | 处理函数抛出了未处理的异常;Server 日志记录了异常类型 | -| 修改插件后 Worker 不再执行 | 插件文件在运行中被修改,重启 Server | +| 修改插件后没有生效 | 等待两个扫描间隔;查看 `GET /v1/plugins/<插件名>` 的 `last_check_failure` 和 Server 日志中的 `plugin.changed` | | 生成的 Artifact 没有出现 | 用 `GET /v1/plugins/<插件名>/scopes` 查看各 Scope 是否还有待处理的 Source | ## 当前限制 -- 插件更新需要重启 Server,还不支持热加载。 - 插件专属存储目前只支持 SQLite 部署。 - 插件依赖由部署者手动安装到 Server 环境。 - 插件代码与 Server 有相同的权限,宿主进程和 Worker 隔离的是故障,不是不可信代码。 diff --git a/docs/zh/docs/operate/configuration.md b/docs/zh/docs/operate/configuration.md index 2bdadf7711..23c426e1de 100644 --- a/docs/zh/docs/operate/configuration.md +++ b/docs/zh/docs/operate/configuration.md @@ -104,6 +104,8 @@ MCP 使用 stateless Streamable HTTP,每次工具执行都采用当前请求 | `POWERCONTEXT_SERVER_HTTP_UDS` | 未设置 | Linux/macOS 上 `server run` 的 HTTP Unix socket 路径;仅监听 UDS,不创建 TCP listener;必须是绝对路径,不含 `..` 或 NUL,按文件系统编码计算不超过 103 字节 | | `POWERCONTEXT_SERVER_HTTP_UDS_EXTERNAL_SCHEME` | 未设置 | 私有 UDS 上可信代理的外部协议,只接受 `http` 或 `https`,必须同时设置 `HTTP_UDS`;仅在可信代理终止 TLS 时设置 `https`。仍忽略转发的协议与客户端 IP header | | `POWERCONTEXT_SERVER_HTTP_MAX_REQUEST_BODY_BYTES` | `16777216` | 解析前允许的外部 HTTP 请求体字节数,包括分块上传 | +| `POWERCONTEXT_SERVER_PLUGINS_DIR` | 未设置 | 插件目录,每个子目录是一个插件;见[编写插件](../develop/plugins.md) | +| `POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS` | `60` | 每个 Server 进程扫描插件目录的间隔秒数,新增、修改和删除的插件在两个间隔内生效;`0` 关闭扫描,插件只在启动时加载 | | `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` | `33554432` | 插件接口(`/v1/plugins/{name}/api/...` 及 Dashboard 插件页面的转发)的请求体和响应体上限,替代上一项;插件路由可以声明更小的上限,不能超过此值 | | `POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES` | `{}` | 按插件名覆盖插件声明的 `host_processes`,JSON 对象,例如 `{"knowledge-base": 4}`;值是每个 Server 进程中该插件宿主进程数的上限 | | `POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES` | 未设置 | 每个 Server 进程中所有插件的宿主进程总数上限;达到上限时不再为负载增加进程,请求在已有进程上排队。每个插件常驻的第一个进程不受此限制 | diff --git a/docs/zh/docs/operate/observability.md b/docs/zh/docs/operate/observability.md index 306194e443..02155d25ee 100644 --- a/docs/zh/docs/operate/observability.md +++ b/docs/zh/docs/operate/observability.md @@ -153,6 +153,11 @@ Child 取消时,supervisor 以 `INFO` 级别输出 `artifact_processing.failed 失败的 code stage 保留诊断 `error_code`;领域错误还保留 `status_code`。日志与 span 使用这些值区分超时、冲突、 无效请求和内部故障。 +插件目录的变化记录为 `plugin.changed` event:`plugin_name` 为插件名,`plugin_event` 为 `loaded`、`updated`、`removed`、 +`check_failed` 或 `upgrade_failed`,`version_digest` 与 `declaration_digest` 为本次生效(或检查失败时仍在使用)的版本内容 +摘要和声明摘要。检查失败只记录已知的失败分类,不记录异常消息。开启热加载时,每次扫描在 `DEBUG` 级别记录一条 +`plugin.scanned` event,`scan_ms` 为该次扫描的耗时,单位为毫秒。 + Server 创建的 workers 使用配置的 log level 和 format。通过 SDK 导入或运行 Runtime 时不配置 global logging, 由 embedding application 管理 logging。Logging bootstrap 失败不会使 processing 失败。 Formatting 或写入失败时,Server handler 向 stderr 输出无内容的 `observability.log_failed` 通知,每个 handler diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index f07db20a78..266b9bdcf6 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -68,6 +68,7 @@ export const OPERATIONS = { flush_topic_memory: { method: 'POST', path: '/v1/topic-memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { method: 'GET', path: '/v1/plugins/{name}/openapi.json', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index f07db20a78..266b9bdcf6 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -68,6 +68,7 @@ export const OPERATIONS = { flush_topic_memory: { method: 'POST', path: '/v1/topic-memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { method: 'GET', path: '/v1/plugins/{name}/openapi.json', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index f07db20a78..266b9bdcf6 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -68,6 +68,7 @@ export const OPERATIONS = { flush_topic_memory: { method: 'POST', path: '/v1/topic-memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { method: 'GET', path: '/v1/plugins/{name}/openapi.json', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index f352948333..3aa6a7efb7 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1931,13 +1931,14 @@ paths: tags: [plugins] summary: List loaded plugins description: >- - Lists the plugins checked at Server startup, whether each loaded or failed, and what each one declares. - Plugins have no persisted lifecycle state; change the plugin directory and restart Server. + Lists the plugins in the plugin directory as this Server instance sees them: whether each loaded or failed, the + version in use, and what it declares. Plugins have no persisted lifecycle state. Each instance rescans the + directory on its own, so instances may briefly differ after a plugin changes. operationId: list_plugins x-powercontext-access: {action: server.admin, resource: {type: server}} responses: "200": - description: Plugins found in the plugin directory at startup. + description: Plugins found in the plugin directory by this instance. content: application/json: schema: {$ref: '#/components/schemas/PluginPage'} @@ -1954,7 +1955,7 @@ paths: - {name: name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'}} responses: "200": - description: The plugin's startup check result and declaration. + description: The plugin's check result, version in use, and declaration on this instance. content: application/json: schema: {$ref: '#/components/schemas/PluginState'} @@ -1962,6 +1963,28 @@ paths: "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "503": {$ref: '#/components/responses/Unavailable'} + /v1/plugins/{name}/openapi.json: + get: + tags: [plugins] + summary: Describe a plugin's own routes + description: >- + Returns an OpenAPI 3.1 document that core generates from the routes the loaded plugin version declares: paths, + methods, the access each route requires, request and response schemas, and the shared error responses. The + plugin owns the compatibility of its routes. + operationId: get_plugin_openapi + x-powercontext-access: {action: access.self, resource: {type: server}} + parameters: + - {name: name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'}} + responses: + "200": + description: The plugin's route description. + content: + application/json: + schema: {$ref: '#/components/schemas/PluginOpenApiDocument'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "503": {$ref: '#/components/responses/Unavailable'} /v1/plugins/{name}/scopes: get: tags: [plugins] @@ -8197,15 +8220,36 @@ components: PluginKind: {type: string, enum: [artifact]} PluginLoadStatus: {type: string, enum: [loaded, failed]} PluginTrigger: {type: string, enum: [schedule, sources]} + PluginRoute: + type: object + additionalProperties: false + required: [method, path, access, mcp] + properties: + method: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} + path: {type: string, description: 'Route template under /v1/plugins/{name}/api.'} + access: {type: string, enum: [authenticated, scope.read, scope.contribute]} + mcp: {type: boolean, description: The route is also an MCP tool.} + PluginOpenApiDocument: + type: object + additionalProperties: true + description: An OpenAPI 3.1 document describing one plugin's routes. + PluginPagesState: + type: object + additionalProperties: false + required: [title, path] + properties: + title: {type: object, additionalProperties: {type: string}, description: Navigation title by language.} + path: {type: string, description: Dashboard path of the plugin's page.} PluginState: type: object additionalProperties: false - required: [name, kind, status, error, family, binding_name, scopes, source_types, triggers] + required: [name, kind, status, error, family, binding_name, scopes, source_types, triggers, version_digest, + declaration_digest, activated_at, last_check_failure, routes, pages] properties: name: {type: string} kind: {$ref: '#/components/schemas/PluginKind'} status: {$ref: '#/components/schemas/PluginLoadStatus'} - error: {type: string, nullable: true, description: Safe failure category from the startup check.} + error: {type: string, nullable: true, description: Safe failure category when the plugin is not loaded.} family: {type: string, nullable: true} binding_name: {type: string, nullable: true} scopes: {type: array, nullable: true, items: {type: string}} @@ -8215,6 +8259,22 @@ components: items: {type: string} description: Source types delivered to the plugin; null when it consumes no Sources. triggers: {type: array, items: {$ref: '#/components/schemas/PluginTrigger'}} + version_digest: + type: string + nullable: true + description: Content digest of the plugin version in use on this instance; null when none loaded. + declaration_digest: {type: string, nullable: true, description: Digest of the declaration in use.} + activated_at: {type: string, format: date-time, nullable: true, description: When this instance switched to the version in use.} + last_check_failure: + type: string + nullable: true + description: >- + Safe category of the latest failed check of a newer version; the version in use stays active. Cleared when + a version activates. + routes: {type: array, items: {$ref: '#/components/schemas/PluginRoute'}} + pages: + nullable: true + allOf: [{$ref: '#/components/schemas/PluginPagesState'}] PluginPage: type: object additionalProperties: false diff --git a/src/powercontext/builtin/authz/profiles.py b/src/powercontext/builtin/authz/profiles.py index 68d2151a63..c1434c60cc 100644 --- a/src/powercontext/builtin/authz/profiles.py +++ b/src/powercontext/builtin/authz/profiles.py @@ -140,10 +140,21 @@ def subject_compatibility(self) -> dict[AccessRole, frozenset[str]]: } +_PLUGIN_PROFILES: set[str] = set() + + +def unregister_plugin_artifact_family(family: str) -> None: + # Only profiles a plugin registered are removable; built-in families keep theirs. + if family in _PLUGIN_PROFILES: + _PLUGIN_PROFILES.discard(family) + ARTIFACT_FAMILY_PROFILES.pop(family, None) + + def register_plugin_artifact_family(family: str) -> None: """Give a loaded plugin family the framework's read and write semantics.""" if family not in ARTIFACT_FAMILY_PROFILES: + _PLUGIN_PROFILES.add(family) ARTIFACT_FAMILY_PROFILES[family] = ArtifactFamilyAccessProfile( family=family, enabled=True, diff --git a/src/powercontext/builtin/plugins/executor.py b/src/powercontext/builtin/plugins/executor.py index 87716253f9..082f1baa26 100644 --- a/src/powercontext/builtin/plugins/executor.py +++ b/src/powercontext/builtin/plugins/executor.py @@ -169,7 +169,7 @@ async def complete(connection) -> None: call = bool(window) or inputs is None if call: directory = Path(spec.directory) - loaded, declaration = load_plugin(directory) + loaded, declaration = load_plugin(directory, manifest.name) if loaded != manifest: raise ValueError("the plugin changed after the Server checked it; restart the Server") # noqa: TRY003 async with AsyncExitStack() as plugin_resources: diff --git a/src/powercontext/builtin/plugins/families.py b/src/powercontext/builtin/plugins/families.py index 3b9a743ef1..20cc2324c8 100644 --- a/src/powercontext/builtin/plugins/families.py +++ b/src/powercontext/builtin/plugins/families.py @@ -58,12 +58,40 @@ def register_plugin_family(family: str) -> None: register_plugin_artifact_family(family) +def unregister_plugin_family(family: str) -> None: + """A removed plugin's family is no longer readable: its content usually lives in the plugin's storage.""" + + from powercontext.server.authz.profiles import unregister_plugin_artifact_family + + _PLUGIN_FAMILIES.discard(family) + unregister_plugin_artifact_family(family) + + def register_source_binding(binding_name: str, scopes: frozenset[str] | None, source_types: frozenset[str]) -> None: """Let Source writes in this process mark the plugin's Scope invocation dirty.""" _SOURCE_BINDINGS[binding_name] = (scopes, source_types) +def unregister_source_binding(binding_name: str) -> None: + _SOURCE_BINDINGS.pop(binding_name, None) + + +def replace_registrations( + families: tuple[str, ...], bindings: tuple[tuple[str, tuple[str, ...] | None, tuple[str, ...]], ...] +) -> None: + """Make this process's plugin families and Source bindings exactly the given ones.""" + + for family in _PLUGIN_FAMILIES - set(families): + unregister_plugin_family(family) + for family in families: + register_plugin_family(family) + for binding in set(_SOURCE_BINDINGS) - {name for name, _, _ in bindings}: + unregister_source_binding(binding) + for binding, scopes, source_types in bindings: + register_source_binding(binding, None if scopes is None else frozenset(scopes), frozenset(source_types)) + + def source_bindings(scope_id: str) -> tuple[str, ...]: return tuple(sorted(name for name, (scopes, _) in _SOURCE_BINDINGS.items() if scopes is None or scope_id in scopes)) diff --git a/src/powercontext/builtin/plugins/host.py b/src/powercontext/builtin/plugins/host.py index f50e87c490..d1e9badafe 100644 --- a/src/powercontext/builtin/plugins/host.py +++ b/src/powercontext/builtin/plugins/host.py @@ -38,12 +38,13 @@ from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.plugins.executor import open_plugin_database -from powercontext.builtin.plugins.families import register_plugin_family, register_source_binding, revision_metadata +from powercontext.builtin.plugins.families import replace_registrations, revision_metadata from powercontext.builtin.plugins.host_protocol import ( HostCancel, HostFailed, HostHeartbeat, HostReady, + HostRegistry, HostRequest, HostResponse, HostShutdown, @@ -102,12 +103,42 @@ class _Call: _CURRENT: contextvars.ContextVar[_Call] = contextvars.ContextVar("powercontext_plugin_call") +def _control(message: object, tasks: dict[str, asyncio.Task[None]]) -> None: + if isinstance(message, HostCancel): + running = tasks.get(message.id) + if running is not None: + running.cancel() + elif isinstance(message, HostRegistry): + replace_registrations(message.plugin_families, message.source_bindings) + + +class _UpgradeFailed(Exception): + """The version's upgrade raised or ran past upgrade_timeout_seconds.""" + + def run_plugin_host(connection: Any, spec: PluginHostSpec) -> None: """Spawn target: serve requests from the Server until the Pipe closes or a shutdown arrives.""" asyncio.run(_Host(connection, spec).serve()) +def run_plugin_upgrade(spec: PluginHostSpec) -> None: + """Spawn target on instances without host processes: run only the version's upgrade, then exit. + + The exit code tells the Server whether the new version may be used for background calls. + """ + + async def upgrade() -> None: + async with AsyncExitStack() as resources: + await _Host(None, spec)._start(resources, serve=False) + + try: + asyncio.run(upgrade()) + except Exception as error: + logger.warning("Plugin upgrade failed (%s)", type(error).__name__) + raise SystemExit(1) from None + + class _Host: def __init__(self, connection: Any, spec: PluginHostSpec) -> None: self.connection = connection @@ -120,6 +151,10 @@ def __init__(self, connection: Any, spec: PluginHostSpec) -> None: self.plugin: Any = None self.routes: dict[tuple[str, str], Any] = {} self.definitions: Any = None + self.security: Any = None + # While upgrade runs, reads and writes act as the plugin itself and no caller exists. + self.upgrading = False + self.upgrade_writer: PluginArtifactWriter | None = None async def _send(self, message: object) -> None: async with self.send_lock: @@ -132,6 +167,9 @@ async def serve(self) -> None: except PluginConfigError as error: await self._send(HostFailed(error=str(error), permanent=True)) return + except _UpgradeFailed as error: + await self._send(HostFailed(error=str(error), category="upgrade_failed")) + return except Exception as error: await self._send( HostFailed(error=str(error) if isinstance(error, ValueError) else type(error).__name__) @@ -147,10 +185,8 @@ async def serve(self) -> None: break if isinstance(message, HostShutdown): break - if isinstance(message, HostCancel): - running = tasks.get(message.id) - if running is not None: - running.cancel() + if not isinstance(message, HostRequest): + _control(message, tasks) continue task = asyncio.create_task(self._handle(message)) tasks[message.id] = task @@ -167,20 +203,18 @@ async def _beat(self) -> None: return await asyncio.sleep(HEARTBEAT_SECONDS) - async def _start(self, resources: AsyncExitStack) -> tuple[str, ...]: + async def _start(self, resources: AsyncExitStack, *, serve: bool = True) -> tuple[str, ...]: spec = self.spec directory = Path(spec.directory) - loaded, declaration = load_plugin(directory) + loaded, declaration = load_plugin(directory, self.manifest.name) if loaded != self.manifest: raise PluginConfigError("the plugin changed after the Server checked it; restart the Server") # noqa: TRY003 - for family in {*spec.plugin_families, self.family}: - register_plugin_family(family) - for binding, scopes, source_types in spec.source_bindings: - register_source_binding(binding, None if scopes is None else frozenset(scopes), frozenset(source_types)) + replace_registrations(tuple({*spec.plugin_families, self.family}), spec.source_bindings) self.database = await resources.enter_async_context(open_plugin_database(spec.database)) from powercontext.builtin.authz.worker import open_worker_security security = await resources.enter_async_context(open_worker_security(spec.security, self.database)) + self.security = security if security is not None: self.access = security.access if spec.security is not None: @@ -204,6 +238,10 @@ async def _start(self, resources: AsyncExitStack) -> tuple[str, ...]: models=models, host=self, ) + if declaration.upgrade is not None: + await self._upgrade(declaration.upgrade, context) + if not serve: + return () self.plugin = await declaration.setup(context) declared = self.manifest.spec background = declared.schedule is not None or declared.on_sources @@ -213,6 +251,44 @@ async def _start(self, resources: AsyncExitStack) -> tuple[str, ...]: raise PluginConfigError("setup returned an object without " + ", ".join(missing)) return tuple(name for name in USER_CHANGE_METHODS if callable(getattr(self.plugin, name, None))) + async def _upgrade(self, upgrade: Any, context: PluginContext) -> None: + self.upgrading = True + try: + async with asyncio.timeout(self.manifest.spec.upgrade_timeout_seconds): + await upgrade(context) + except Exception as error: + raise _UpgradeFailed(type(error).__name__) from error + finally: + self.upgrading = False + self.upgrade_writer = None + + def _plugin_writer(self, scope_id: str) -> PluginArtifactWriter: + """A writer acting as the plugin, like a Worker's, with no invocation cursor.""" + + spec = self.manifest.spec + security = self.security + + async def authorize(connection: Any) -> None: + if spec.scopes is not None and scope_id not in spec.scopes: + raise PluginWriteError("the Scope is outside the plugin's scopes") # noqa: TRY003 + if security is not None: + await security.authorize_transaction(connection, scope_id) + + return PluginArtifactWriter( + database=self.database, + scope_id=scope_id, + family=self.family, + sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), + metadata=revision_metadata(self.manifest.name, self.manifest.fingerprint), + begin=authorize, + transaction_timeout_seconds=spec.transaction_timeout_seconds, + security=security, + ) + + def _no_caller(self) -> None: + if self.upgrading: + raise RuntimeError("upgrade runs as the plugin itself; there is no caller") # noqa: TRY003 + # Request handling async def _handle(self, request: HostRequest) -> None: # noqa: C901 - maps every plugin outcome to one response @@ -477,10 +553,12 @@ async def _api(self, request: HostRequest) -> HostResponse: # Request helpers behind PluginContext def caller(self) -> Caller: + self._no_caller() principal = _CURRENT.get().request.caller return Caller(principal_id="local" if principal is None else f"{principal.type}:{principal.id}") async def require(self, action: str, scope_id: str | None) -> None: + self._no_caller() call = _CURRENT.get() if action in {"scope.read", "scope.contribute"} and scope_id is None: raise ValueError("Scope permissions need a scope_id") # noqa: TRY003 @@ -504,6 +582,8 @@ async def require(self, action: str, scope_id: str | None) -> None: call.contribute_scopes.add(scope_id) def reads(self, scope_id: str) -> Any: + if self.upgrading: + return self._plugin_writer(scope_id) return _CheckedReads(self, scope_id) async def store_source(self, scope_id: str, definition: str, value: BaseModel) -> SourceRef: @@ -530,14 +610,23 @@ async def store_source(self, scope_id: str, definition: str, value: BaseModel) - return stored.ref def writer(self, scope_id: str) -> PluginArtifactWriter: + if self.upgrading: + # Like a Worker invocation, one upgrade opens at most one write transaction. + if self.upgrade_writer is None: + self.upgrade_writer = self._plugin_writer(scope_id) + elif self.upgrade_writer.scope_id != scope_id: + raise PluginWriteError("an upgrade opens at most one write transaction") # noqa: TRY003 + return self.upgrade_writer if scope_id not in _CURRENT.get().contribute_scopes: raise AccessDenied("call ctx.require('scope.contribute', scope_id) before writing") # noqa: TRY003 return self._writer(scope_id) def sources(self) -> _Sources: + self._no_caller() return _Sources(self) def scopes(self) -> _Scopes: + self._no_caller() return _Scopes(self) diff --git a/src/powercontext/builtin/plugins/host_protocol.py b/src/powercontext/builtin/plugins/host_protocol.py index b3363bb919..0052c9b3ee 100644 --- a/src/powercontext/builtin/plugins/host_protocol.py +++ b/src/powercontext/builtin/plugins/host_protocol.py @@ -70,6 +70,8 @@ class HostFailed: error: str # The plugin breaks its contract, for example it lacks a required method; restarting cannot help. permanent: bool = False + # "upgrade_failed" when the version's upgrade raised or timed out. + category: str | None = None @dataclass(frozen=True) @@ -107,6 +109,14 @@ class HostCancel: id: str +@dataclass(frozen=True) +class HostRegistry: + """The plugin families and Source bindings loaded in the Server now, after a plugin was added or removed.""" + + plugin_families: tuple[str, ...] + source_bindings: tuple[tuple[str, tuple[str, ...] | None, tuple[str, ...]], ...] + + @dataclass(frozen=True) class HostHeartbeat: """Sent every second by a task on the host's event loop, so a blocked loop stops sending it.""" diff --git a/src/powercontext/builtin/plugins/manifest.py b/src/powercontext/builtin/plugins/manifest.py index b6d3c777c2..826e7cd3c7 100644 --- a/src/powercontext/builtin/plugins/manifest.py +++ b/src/powercontext/builtin/plugins/manifest.py @@ -24,6 +24,7 @@ import hashlib import importlib.util import inspect +import json import re import sys from pathlib import Path @@ -35,6 +36,8 @@ ENTRY_FILE = "plugin.py" _NAME = re.compile(r"^[a-z][a-z0-9-]{0,63}$") +# MCP clients commonly reject longer tool names. +MCP_TOOL_NAME_LIMIT = 64 class PluginConfigError(ValueError): @@ -52,12 +55,20 @@ class PluginManifest(BaseModel): fingerprint: str = "" # Declarative manifests of the Source Definitions the plugin submits through its routes. source_manifests: tuple[dict[str, JsonValue], ...] = () + # Digest of everything above except the fingerprint: whether a new version changes what core registers. + declaration_digest: str = "" + upgrade: bool = False @property def binding_name(self) -> str: return f"plugin:{self.name}" +def mcp_tool_name(plugin: str, handler: str) -> str: + # Plugin names have no underscores, so the first double underscore always ends the plugin name. + return f"{plugin}__{handler}" + + def code_fingerprint(directory: Path) -> str: """SHA-256 over relative paths and contents, so any content change shows, whatever the timestamps.""" @@ -74,12 +85,12 @@ def code_fingerprint(directory: Path) -> str: return digest.hexdigest() -def load_module(directory: Path) -> ModuleType: +def load_module(directory: Path, name: str) -> ModuleType: root = directory.resolve() path = root / ENTRY_FILE if not path.is_file(): raise PluginConfigError(f"the plugin directory has no {ENTRY_FILE}") # noqa: TRY003 - module_name = f"powercontext_plugin_{root.name.replace('-', '_')}" + module_name = f"powercontext_plugin_{name.replace('-', '_')}" spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: raise PluginConfigError("plugin entry is unavailable") # noqa: TRY003 @@ -92,26 +103,28 @@ def load_module(directory: Path) -> ModuleType: return module -def load_plugin(directory: Path) -> tuple[PluginManifest, ArtifactPlugin]: - """Import ``plugin.py`` and validate its declaration without calling the factory.""" +def load_plugin(directory: Path, name: str | None = None) -> tuple[PluginManifest, ArtifactPlugin]: + """Import ``plugin.py`` and validate its declaration without calling the factory. - name = directory.name + ``name`` defaults to the directory name; a version snapshot passes the plugin's name. + """ + + name = directory.name if name is None else name if _NAME.fullmatch(name) is None: raise PluginConfigError("the directory name is not a valid plugin name") # noqa: TRY003 - declaration = getattr(load_module(directory), "plugin", None) + declaration = getattr(load_module(directory, name), "plugin", None) if not isinstance(declaration, ArtifactPlugin): raise PluginConfigError(f"{ENTRY_FILE} must assign an ArtifactPlugin to plugin") # noqa: TRY003 if not inspect.iscoroutinefunction(declaration.setup): raise PluginConfigError("setup must be an async function") # noqa: TRY003 + if declaration.upgrade is not None and not inspect.iscoroutinefunction(declaration.upgrade): + raise PluginConfigError("upgrade must be an async function") # noqa: TRY003 try: spec = ArtifactPluginSpec.model_validate(declaration.model_dump(mode="json")) except ValidationError as error: fields = ", ".join(".".join(str(part) for part in item["loc"]) or "plugin" for item in error.errors()) raise PluginConfigError(f"invalid declaration fields: {fields}") from error # noqa: TRY003 - if spec.pages is not None: - pages = (directory.resolve() / spec.pages.directory).resolve() - if not pages.is_relative_to(directory.resolve()) or not (pages / spec.pages.entry).is_file(): - raise PluginConfigError("pages.entry must be a file inside pages.directory") # noqa: TRY003 + _check_routes_and_pages(directory, name, spec) from powercontext.sources.observations import manifest_for_definition try: @@ -121,21 +134,40 @@ def load_plugin(directory: Path) -> tuple[PluginManifest, ArtifactPlugin]: ) except Exception as error: raise PluginConfigError("invalid Source Definitions") from error # noqa: TRY003 + declared = { + "name": name, + "spec": spec.model_dump(mode="json"), + "source_manifests": source_manifests, + "upgrade": declaration.upgrade is not None, + } return ( PluginManifest( - name=name, spec=spec, fingerprint=code_fingerprint(directory), source_manifests=source_manifests + name=name, + spec=spec, + fingerprint=code_fingerprint(directory), + source_manifests=source_manifests, + declaration_digest=hashlib.sha256(json.dumps(declared, sort_keys=True).encode()).hexdigest(), + upgrade=declaration.upgrade is not None, ), declaration, ) +def _check_routes_and_pages(directory: Path, name: str, spec: ArtifactPluginSpec) -> None: + for route in () if spec.api is None else spec.api.routes: + if route.mcp and len(mcp_tool_name(name, route.handler)) > MCP_TOOL_NAME_LIMIT: + raise PluginConfigError(f"MCP tool names are at most {MCP_TOOL_NAME_LIMIT} characters") # noqa: TRY003 + if spec.pages is not None: + pages = (directory.resolve() / spec.pages.directory).resolve() + if not pages.is_relative_to(directory.resolve()) or not (pages / spec.pages.entry).is_file(): + raise PluginConfigError("pages.entry must be a file inside pages.directory") # noqa: TRY003 + + def main() -> None: """Check one plugin directory in a child process and report the outcome as JSON.""" - import json - try: - manifest, _ = load_plugin(Path(sys.argv[1])) + manifest, _ = load_plugin(Path(sys.argv[1]), sys.argv[2] if len(sys.argv) > 2 else None) except Exception as error: message = ( str(error) diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 77072929ea..c744f8de82 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -4043,6 +4043,11 @@ def register(self, service: Any) -> None: self._application.register(service) + def unregister(self, family: str) -> None: + """Forget a plugin family that was removed or replaced.""" + + self._application.unregister(family) + def content_type(self, family: str) -> type[BaseModel]: """The content model a registered Family's merge results validate against.""" diff --git a/src/powercontext/builtin/runtime/artifact_merge.py b/src/powercontext/builtin/runtime/artifact_merge.py index 2622722d42..81d970ccd9 100644 --- a/src/powercontext/builtin/runtime/artifact_merge.py +++ b/src/powercontext/builtin/runtime/artifact_merge.py @@ -158,6 +158,11 @@ def register(self, service: ArtifactMergeService) -> None: raise ValueError(f"Artifact merge Family is already registered: {service.family}") # noqa: TRY003 self._services[service.family] = service + def unregister(self, family: str) -> None: + """Forget a Family whose plugin was removed or changed; built-in Families are never unregistered.""" + + self._services.pop(family, None) + def _service(self, family: str) -> ArtifactMergeService: try: return self._services[family] diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 01a78630d8..7d34289a1d 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -329,7 +329,10 @@ Omission, OpenQuestion, PlanAccessOwnershipRequest, + PluginOpenApiDocument, PluginPage, + PluginPagesState, + PluginRoute, PluginScopePage, PluginScopeState, PluginState, @@ -842,7 +845,10 @@ "Omission", "OpenQuestion", "PlanAccessOwnershipRequest", + "PluginOpenApiDocument", "PluginPage", + "PluginPagesState", + "PluginRoute", "PluginScopePage", "PluginScopeState", "PluginState", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 88fdb10dbf..0ad4302edd 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -695,6 +695,44 @@ class PluginTrigger(StrEnum): SOURCES = "sources" +class Method(StrEnum): + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" + + +class Access(StrEnum): + AUTHENTICATED = "authenticated" + SCOPE_READ = "scope.read" + SCOPE_CONTRIBUTE = "scope.contribute" + + +class PluginRoute(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + method: Method + path: Annotated[StrictStr, Field(description="Route template under /v1/plugins/{name}/api.")] + access: Access + mcp: Annotated[StrictBool, Field(description="The route is also an MCP tool.")] + + +class PluginOpenApiDocument(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + + +class PluginPagesState(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + title: Annotated[dict[str, StrictStr], Field(description="Navigation title by language.")] + path: Annotated[StrictStr, Field(description="Dashboard path of the plugin's page.")] + + class PluginState(BaseModel): model_config = ConfigDict( extra="forbid", @@ -702,7 +740,7 @@ class PluginState(BaseModel): name: StrictStr kind: PluginKind status: PluginLoadStatus - error: Annotated[StrictStr | None, Field(description="Safe failure category from the startup check.")] + error: Annotated[StrictStr | None, Field(description="Safe failure category when the plugin is not loaded.")] family: Annotated[StrictStr | None, Field(...)] binding_name: Annotated[StrictStr | None, Field(...)] scopes: Annotated[list[StrictStr] | None, Field(...)] @@ -711,6 +749,22 @@ class PluginState(BaseModel): Field(description="Source types delivered to the plugin; null when it consumes no Sources."), ] triggers: list[PluginTrigger] + version_digest: Annotated[ + StrictStr | None, + Field(description="Content digest of the plugin version in use on this instance; null when none loaded."), + ] + declaration_digest: Annotated[StrictStr | None, Field(description="Digest of the declaration in use.")] + activated_at: Annotated[ + AwareDatetime | None, Field(description="When this instance switched to the version in use.") + ] + last_check_failure: Annotated[ + StrictStr | None, + Field( + description="Safe category of the latest failed check of a newer version; the version in use stays active. Cleared when a version activates." + ), + ] + routes: list[PluginRoute] + pages: Annotated[PluginPagesState | None, Field(...)] class PluginPage(BaseModel): diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 0d24932312..e0071ade63 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -140,6 +140,7 @@ MergeArtifactsRequest, MergeAtomicMemoryRequest, PlanAccessOwnershipRequest, + PluginOpenApiDocument, PluginPage, PluginScopePage, PluginState, @@ -1587,7 +1588,7 @@ class AccessRequirement(BaseModel): tags=("plugins",), scope_mode="none", responses={ - 200: {"description": "Plugins found in the plugin directory at startup."}, + 200: {"description": "Plugins found in the plugin directory by this instance."}, 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 503: {"$ref": "#/components/responses/Unavailable"}, @@ -1608,7 +1609,7 @@ class AccessRequirement(BaseModel): tags=("plugins",), scope_mode="none", responses={ - 200: {"description": "The plugin's startup check result and declaration."}, + 200: {"description": "The plugin's check result, version in use, and declaration on this instance."}, 401: {"$ref": "#/components/responses/Unauthorized"}, 403: {"$ref": "#/components/responses/Forbidden"}, 404: {"$ref": "#/components/responses/NotFound"}, @@ -1617,6 +1618,28 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), ) +GET_PLUGIN_OPENAPI = Operation[None, PluginOpenApiDocument]( + method="GET", + path="/v1/plugins/{name}/openapi.json", + operation_id="get_plugin_openapi", + request_type=None, + request_location=None, + path_parameters=("name",), + response_type=PluginOpenApiDocument, + success_status=200, + summary="Describe a plugin's own routes", + tags=("plugins",), + scope_mode="none", + responses={ + 200: {"description": "The plugin's route description."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + LIST_PLUGIN_SCOPES = Operation[ListPluginScopesRequest, PluginScopePage]( method="GET", path="/v1/plugins/{name}/scopes", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index d9adc33b46..f86d1237ff 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1984,15 +1984,17 @@ "get": { "tags": ["plugins"], "summary": "List loaded plugins", - "description": "Lists the plugins checked at Server startup, " - "whether each loaded or failed, and what each " - "one declares. Plugins have no persisted " - "lifecycle state; change the plugin directory " - "and restart Server.", + "description": "Lists the plugins in the plugin directory as " + "this Server instance sees them: whether each " + "loaded or failed, the version in use, and what " + "it declares. Plugins have no persisted " + "lifecycle state. Each instance rescans the " + "directory on its own, so instances may briefly " + "differ after a plugin changes.", "operationId": "list_plugins", "responses": { "200": { - "description": "Plugins found in the plugin directory at startup.", + "description": "Plugins found in the plugin directory by this instance.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PluginPage"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, @@ -2018,7 +2020,7 @@ ], "responses": { "200": { - "description": "The plugin's startup check result and declaration.", + "description": "The plugin's check result, version in use, and declaration on this instance.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PluginState"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, @@ -2028,6 +2030,46 @@ }, } }, + "/v1/plugins/{name}/openapi.json": { + "get": { + "tags": ["plugins"], + "summary": "Describe a plugin's own routes", + "description": "Returns an OpenAPI 3.1 " + "document that core " + "generates from the routes " + "the loaded plugin version " + "declares: paths, methods, " + "the access each route " + "requires, request and " + "response schemas, and the " + "shared error responses. The " + "plugin owns the " + "compatibility of its " + "routes.", + "operationId": "get_plugin_openapi", + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + "parameters": [ + { + "name": "name", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z][a-z0-9-]*$"}, + } + ], + "responses": { + "200": { + "description": "The plugin's route description.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PluginOpenApiDocument"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, "/v1/plugins/{name}/scopes": { "get": { "tags": ["plugins"], @@ -8371,6 +8413,35 @@ "PluginKind": {"type": "string", "enum": ["artifact"]}, "PluginLoadStatus": {"type": "string", "enum": ["loaded", "failed"]}, "PluginTrigger": {"type": "string", "enum": ["schedule", "sources"]}, + "PluginRoute": { + "properties": { + "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, + "path": {"type": "string", "description": "Route template under /v1/plugins/{name}/api."}, + "access": {"type": "string", "enum": ["authenticated", "scope.read", "scope.contribute"]}, + "mcp": {"type": "boolean", "description": "The route is also an MCP tool."}, + }, + "additionalProperties": False, + "type": "object", + "required": ["method", "path", "access", "mcp"], + }, + "PluginOpenApiDocument": { + "additionalProperties": True, + "type": "object", + "description": "An OpenAPI 3.1 document describing one plugin's routes.", + }, + "PluginPagesState": { + "properties": { + "title": { + "additionalProperties": {"type": "string"}, + "type": "object", + "description": "Navigation title by language.", + }, + "path": {"type": "string", "description": "Dashboard path of the plugin's page."}, + }, + "additionalProperties": False, + "type": "object", + "required": ["title", "path"], + }, "PluginState": { "properties": { "name": {"type": "string"}, @@ -8378,7 +8449,7 @@ "status": {"$ref": "#/components/schemas/PluginLoadStatus"}, "error": { "type": "string", - "description": "Safe failure category from the startup check.", + "description": "Safe failure category when the plugin is not loaded.", "nullable": True, }, "family": {"type": "string", "nullable": True}, @@ -8391,6 +8462,64 @@ "nullable": True, }, "triggers": {"items": {"$ref": "#/components/schemas/PluginTrigger"}, "type": "array"}, + "version_digest": { + "type": "string", + "description": "Content " + "digest " + "of " + "the " + "plugin " + "version " + "in " + "use " + "on " + "this " + "instance; " + "null " + "when " + "none " + "loaded.", + "nullable": True, + }, + "declaration_digest": { + "type": "string", + "description": "Digest of the declaration in use.", + "nullable": True, + }, + "activated_at": { + "type": "string", + "format": "date-time", + "description": "When this instance switched to the version in use.", + "nullable": True, + }, + "last_check_failure": { + "type": "string", + "description": "Safe " + "category " + "of " + "the " + "latest " + "failed " + "check " + "of " + "a " + "newer " + "version; " + "the " + "version " + "in " + "use " + "stays " + "active. " + "Cleared " + "when " + "a " + "version " + "activates.", + "nullable": True, + }, + "routes": {"items": {"$ref": "#/components/schemas/PluginRoute"}, "type": "array"}, + "pages": {"allOf": [{"$ref": "#/components/schemas/PluginPagesState"}], "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -8404,6 +8533,12 @@ "scopes", "source_types", "triggers", + "version_digest", + "declaration_digest", + "activated_at", + "last_check_failure", + "routes", + "pages", ], }, "PluginPage": { diff --git a/src/powercontext/plugins.py b/src/powercontext/plugins.py index 75e3c8c52b..42c326101b 100644 --- a/src/powercontext/plugins.py +++ b/src/powercontext/plugins.py @@ -233,6 +233,8 @@ class ArtifactPluginSpec(BaseModel): change_timeout_seconds: float = Field(default=30, gt=0) # Suggested ceiling on this plugin's host processes in each Server process; deployers may override it. host_processes: int = Field(default=1, ge=1) + # Time limit of ``upgrade``, which runs every time a version of the plugin loads. + upgrade_timeout_seconds: float = Field(default=600, gt=0) api: PluginApiSpec | None = None pages: PluginPages | None = None @@ -732,6 +734,8 @@ class ArtifactPlugin(ArtifactPluginSpec): # Source Definitions this plugin's own routes submit; their manifests register when the plugin loads. definitions: tuple[Any, ...] = Field(default=(), exclude=True) api: PluginApi | None = None + # Called every time a version of the plugin loads, before setup; it must be fast and safe to repeat. + upgrade: Callable[[PluginContext], Awaitable[None]] | None = Field(default=None, exclude=True) def _path_parameters(path: str) -> tuple[str, ...]: diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index f87ca5ffc0..a46ad64075 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -604,6 +604,7 @@ MemoryMutationResponse, MergeArtifactsRequest, PlanAccessOwnershipRequest, + PluginOpenApiDocument, PluginPage, PluginScopePage, PluginState, @@ -840,6 +841,7 @@ GET_MEMORY_ENTRY, GET_MEMORY_ENTRY_TAGS, GET_PLUGIN, + GET_PLUGIN_OPENAPI, GET_PROFILE_POLICY, GET_PROMPT_CONFIGURATION, GET_READINESS, @@ -1583,6 +1585,7 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_CAPABILITIES, get_capabilities) _add_route(app, LIST_PLUGINS, list_plugins) _add_route(app, GET_PLUGIN, get_plugin) + _add_route(app, GET_PLUGIN_OPENAPI, get_plugin_openapi) # Plugin routes are outside the main contract; core authenticates, the plugin host authorizes. app.router.add_api_route( "/v1/plugins/{name}/api/{path:path}", @@ -1838,6 +1841,12 @@ async def get_plugin( return _require_plugin_manager(http_request).get(name) +async def get_plugin_openapi( + name: Annotated[str, Path(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9-]*$")], http_request: Request +) -> PluginOpenApiDocument: + return PluginOpenApiDocument.model_validate(_require_plugin_manager(http_request).openapi(name)) + + def _list_plugin_scopes_query( http_request: Request, limit: Annotated[int, Query(ge=1, le=200)] = 50, diff --git a/src/powercontext/server/dashboard/plugin_pages.py b/src/powercontext/server/dashboard/plugin_pages.py index bfbd0a7f4c..488706a401 100644 --- a/src/powercontext/server/dashboard/plugin_pages.py +++ b/src/powercontext/server/dashboard/plugin_pages.py @@ -108,7 +108,7 @@ async def plugin_asset(request: Request, name: str, path: str) -> Response: if loaded is None or suffix not in ASSET_TYPES or not path or ".." in path.split("/"): return Response(status_code=404) plugin, manifest = loaded - root = (plugin.directory / manifest.spec.pages.directory).resolve() + root = (plugin.code_directory / manifest.spec.pages.directory).resolve() target = (root / path).resolve() # Symbolic links may not lead outside the pages directory. if not target.is_relative_to(root) or not target.is_file() or target.stat().st_size > MAX_ASSET_BYTES: diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index aaf4de9a6b..288db8b3bd 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -361,9 +361,12 @@ def bind_supervisor(supervisors) -> None: on_supervisor=bind_supervisor, plugins_dir=resolved.plugins_dir, register_merge=None if runtime.artifact_merge is None else runtime.artifact_merge.register, + unregister_merge=None if runtime.artifact_merge is None else runtime.artifact_merge.unregister, dispatch=dispatch_plugins, inference_config=config.inference, storage_root=powercontext_data_dir() / "plugins", + snapshot_root=powercontext_data_dir() / "plugin-versions", + rescan_seconds=resolved.plugins_rescan_seconds, # Host processes authorize callers in every role that serves requests. host_security_builder=lambda: build_worker_security( resolved, diff --git a/src/powercontext/server/logging.py b/src/powercontext/server/logging.py index da21cc13bb..6d9f377c1d 100644 --- a/src/powercontext/server/logging.py +++ b/src/powercontext/server/logging.py @@ -151,6 +151,13 @@ "limitations", "reason", ) +_PLUGIN_FIELDS = ( + "plugin_name", + "plugin_event", + "version_digest", + "declaration_digest", + "scan_ms", +) _MISSING_OTLP_LOG_EXPORTER = ( "OpenTelemetry log export requires the 'tracing-otlp' extra; " "install 'powercontext[server,tracing-otlp]' before enabling OTLP logs" @@ -168,8 +175,13 @@ def _operational_payload(record: logging.LogRecord) -> dict[str, Any]: """Return only the documented structured fields, never arbitrary record extras.""" payload = {field: value for field in _OPERATIONAL_FIELDS if (value := getattr(record, field, None)) is not None} - if str(getattr(record, "event", "")).startswith("code."): + event = str(getattr(record, "event", "")) + if event.startswith("code."): payload.update((field, value) for field in _CODE_FIELDS if (value := getattr(record, field, None)) is not None) + elif event.startswith("plugin."): + payload.update( + (field, value) for field in _PLUGIN_FIELDS if (value := getattr(record, field, None)) is not None + ) return payload diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index 3a108f350e..d947ab9470 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json from collections.abc import Mapping from contextvars import ContextVar from copy import deepcopy @@ -28,6 +29,7 @@ from fastmcp import FastMCP from fastmcp.exceptions import ToolError from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.server.providers import Provider from fastmcp.server.providers.openapi import ( MCPType, OpenAPIProvider, @@ -35,6 +37,7 @@ OpenAPIResourceTemplate, OpenAPITool, ) +from fastmcp.tools import Tool from fastmcp.tools.base import ToolResult from fastmcp.utilities.lifespan import combine_lifespans from fastmcp.utilities.openapi import HTTPRoute @@ -567,7 +570,11 @@ def create_mcp_server( # pass rejects valid OpenAPI 3.0 nullable references in empty results. validate_output=False, ) - server = FastMCP(name=MCP_SERVER_NAME, instructions=MCP_GUIDANCE, providers=[provider]) + server = FastMCP( + name=MCP_SERVER_NAME, + instructions=MCP_GUIDANCE, + providers=[provider, _PluginToolProvider(server_app, client)], + ) server.add_middleware(McpTracingMiddleware(resolved_tracing)) if access_log: server.add_middleware(McpAccessLogMiddleware()) @@ -579,6 +586,107 @@ def create_mcp_server( return server +class _PluginToolProvider(Provider): + """Plugin routes declared with ``mcp=True``, read from the plugins loaded now. + + Tools follow plugin reloads at the next ``tools/list``. A call is the same HTTP request + as calling the route, so authentication, access, time limits, and errors match it. + """ + + def __init__(self, server_app: FastAPI, client: httpx.AsyncClient) -> None: + super().__init__() + self._app = server_app + self._client = client + + @override + async def _list_tools(self) -> list[Tool]: + from powercontext.builtin.plugins.manifest import mcp_tool_name + + manager = getattr(self._app.state, "plugin_manager", None) + if manager is None: + return [] + tools: list[Tool] = [] + for name in sorted(manager.plugins): + manifest = manager.loaded_manifest(name) + api = None if manifest is None else manifest.spec.api + for route in () if api is None else api.routes: + if route.mcp: + tools.append( + _PluginRouteTool.for_route(mcp_tool_name(name, route.handler), name, route, self._client) + ) + return tools + + +class _PluginRouteTool(Tool): + plugin: str + method: str + path: str + path_parameters: tuple[str, ...] + has_body: bool + client: Any + + @classmethod + def for_route(cls, tool_name: str, plugin: str, route: Any, client: httpx.AsyncClient) -> _PluginRouteTool: + import re + + path_parameters = tuple(re.findall(r"{([A-Za-z_][A-Za-z0-9_]*)}", route.path)) + properties: dict[str, Any] = {name: {"type": "string"} for name in path_parameters} + properties["query"] = {"type": "object", "additionalProperties": {"type": "string"}} + required = list(path_parameters) + definitions: dict[str, Any] = {} + has_body = route.body == "json" + if has_body: + body: dict[str, Any] = dict(route.request_schema or {"type": "object"}) + # Keep the model's references resolvable from the top of the tool's input schema. + definitions = body.pop("$defs", {}) + properties["body"] = body + required.append("body") + parameters = {"type": "object", "properties": properties, "required": required} + if definitions: + parameters["$defs"] = definitions + read_only = route.method == "GET" and route.access in {"scope.read", "authenticated"} + response = route.response_schema + return cls( + name=tool_name, + description=route.description, + parameters=parameters, + output_schema=response if response is not None and response.get("type") == "object" else None, + annotations=ToolAnnotations(readOnlyHint=read_only, idempotentHint=read_only, openWorldHint=False), + plugin=plugin, + method=route.method, + path=route.path, + path_parameters=path_parameters, + has_body=has_body, + client=client, + ) + + @override + async def run(self, arguments: dict[str, Any]) -> ToolResult: + from urllib.parse import quote + + path = self.path + for name in self.path_parameters: + path = path.replace("{" + name + "}", quote(str(arguments[name]), safe="")) + response = await self.client.request( + self.method, + f"/v1/plugins/{self.plugin}/api{path}", + params=arguments.get("query") or None, + json=arguments.get("body") if self.has_body else None, + ) + if response.status_code == 204: + return ToolResult(content=[]) + try: + payload = response.json() + except ValueError: + raise ToolError(f"HTTP {response.status_code}: the plugin returned a non-JSON response") from None # noqa: TRY003 + if response.status_code >= 400: + error = payload.get("error", {}) if isinstance(payload, dict) else {} + raise ToolError(f"HTTP {response.status_code} {error.get('code', '')}: {error.get('message', '')}") # noqa: TRY003 + if self.output_schema is not None and isinstance(payload, dict): + return ToolResult(structured_content=payload) + return ToolResult(content=json.dumps(payload, ensure_ascii=False)) + + class _InternalBridgeTransport(httpx.ASGITransport): @override async def handle_async_request(self, request: httpx.Request) -> httpx.Response: diff --git a/src/powercontext/server/plugin_hosts.py b/src/powercontext/server/plugin_hosts.py index 5b256f9a7a..34c446854c 100644 --- a/src/powercontext/server/plugin_hosts.py +++ b/src/powercontext/server/plugin_hosts.py @@ -29,6 +29,7 @@ import multiprocessing import time from contextlib import suppress +from dataclasses import replace from typing import Any from uuid import uuid4 @@ -40,6 +41,7 @@ HostHeartbeat, HostMethod, HostReady, + HostRegistry, HostRequest, HostResponse, HostShutdown, @@ -127,6 +129,7 @@ async def run(self) -> None: group.notify() elif isinstance(message, HostFailed): log_safely(logger, logging.WARNING, f"Plugin host {group.name} failed to start ({message.error})") + group.start_failure = message.category or "start_failed" if message.permanent: group.failure = message.error break @@ -227,6 +230,9 @@ def __init__( self._backoff = 0.0 self._not_before = 0.0 self._closing = False + self._waiting = 0 + # Why the last process exited before it was ready, such as "upgrade_failed". + self.start_failure: str | None = None async def start(self) -> None: """Start the process the plugin always keeps; callers wait for readiness within their own time limit.""" @@ -257,6 +263,8 @@ def notify(self) -> None: self._changed = asyncio.Event() def exited(self, process: _HostProcess) -> None: + if not process.ready and process.reason == "crash" and self.start_failure is None: + self.start_failure = "start_failed" self._processes.remove(process) self._budget.give_back() self.notify() @@ -316,6 +324,7 @@ async def _acquire(self, deadline: float) -> _HostProcess: elif not serving and not self._launch(resident=True): raise PluginHostUnavailable(self.name) changed = self._changed + self._waiting += 1 try: await asyncio.wait_for(changed.wait(), max(0.0, deadline - time.monotonic())) except TimeoutError: @@ -323,6 +332,8 @@ async def _acquire(self, deadline: float) -> _HostProcess: if any(process.ready and process.accepting for process in self._processes): raise PluginHostTimeout(self.name) from None raise PluginHostUnavailable(self.name) from None + finally: + self._waiting -= 1 async def call( self, @@ -356,6 +367,40 @@ async def call( process.idle_since = time.monotonic() self.notify() + async def wait_ready(self, timeout: float) -> str | None: # noqa: ASYNC109 - bounds a new version's start + """Wait for the first process of a new version; return why it failed, or None once one is ready.""" + + deadline = time.monotonic() + timeout + while True: + if any(process.ready for process in self._processes): + return None + if self.failure is not None or not self._processes: + return self.start_failure or "start_failed" + changed = self._changed + try: + await asyncio.wait_for(changed.wait(), max(0.0, deadline - time.monotonic())) + except TimeoutError: + return "start_timeout" + + def update_registry( + self, families: tuple[str, ...], bindings: tuple[tuple[str, tuple[str, ...] | None, tuple[str, ...]], ...] + ) -> None: + """New processes start with these registrations; running ones replace theirs.""" + + self.spec = replace(self.spec, plugin_families=families, source_bindings=bindings) + message = HostRegistry(plugin_families=families, source_bindings=bindings) + for process in self._processes: + if process.ready: + self.spawn_task(_send_quietly(process, message)) + + async def retire(self) -> None: + """Stop taking new processes, let accepted and queued calls finish within their limits, then close.""" + + self._closing = True + while any(process.in_flight for process in self._processes) or self._waiting: + await self._changed.wait() + await self.close() + async def close(self) -> None: self._closing = True if self._watcher is not None: @@ -367,6 +412,11 @@ async def close(self) -> None: await asyncio.wait(list(self._tasks), timeout=_SHUTDOWN_SECONDS) +async def _send_quietly(process: _HostProcess, message: object) -> None: + with suppress(OSError): + await process.send(message) + + def _stop_process(process: Any) -> None: if process.is_alive(): process.terminate() diff --git a/src/powercontext/server/plugin_versions.py b/src/powercontext/server/plugin_versions.py new file mode 100644 index 0000000000..cad90e3131 --- /dev/null +++ b/src/powercontext/server/plugin_versions.py @@ -0,0 +1,95 @@ +# 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. + +"""Read-only version snapshots of plugin directories. + +Deployers may change a plugin directory at any time, so no process imports from it. +Once a directory's content digest is stable, core copies it to +``///`` and every check, host process, and Worker imports from +that read-only copy, so one call never mixes two versions of the code. +""" + +from __future__ import annotations + +import shutil +import stat +from pathlib import Path +from uuid import uuid4 + +from powercontext.builtin.plugins.manifest import code_fingerprint + +_FILE_MODE = 0o444 +_DIRECTORY_MODE = 0o555 + + +def content_digest(directory: Path) -> str: + return code_fingerprint(directory) + + +def make_snapshot(source: Path, root: Path, name: str, digest: str) -> Path | None: + """Copy ``source`` into a read-only snapshot named by ``digest``. + + Returns None when the directory changed while it was copied; the next scan retries. + """ + + target = root / name / digest + if target.is_dir(): + return target + target.parent.mkdir(parents=True, exist_ok=True) + staging = target.parent / f".tmp-{uuid4().hex}" + # Symbolic links stay links: page serving still refuses those that lead outside the plugin. + shutil.copytree(source, staging, ignore=shutil.ignore_patterns("__pycache__"), symlinks=True) + if code_fingerprint(staging) != digest: + remove_snapshot(staging) + return None + for path in sorted(staging.rglob("*"), reverse=True): + # chmod follows links, which may lead outside the snapshot. + if not path.is_symlink(): + path.chmod(_DIRECTORY_MODE if path.is_dir() else _FILE_MODE) + staging.chmod(_DIRECTORY_MODE) + try: + staging.rename(target) + except OSError: + # Another Server process on this machine made the same snapshot first. + remove_snapshot(staging) + if not target.is_dir(): + raise + return target + + +def remove_snapshot(path: Path) -> None: + if not path.exists(): + return + # Deleting an entry needs a writable parent; read-only files themselves can be unlinked. + for directory in (path, *(item for item in path.rglob("*") if item.is_dir() and not item.is_symlink())): + directory.chmod(stat.S_IRWXU) + shutil.rmtree(path) + + +def remove_stale_snapshots(root: Path, *, older_than: float, keep: frozenset[Path]) -> None: + """At startup, remove snapshots and leftovers older than this process that are no current version.""" + + if not root.is_dir(): + return + for plugin in root.iterdir(): + if not plugin.is_dir(): + continue + for snapshot in plugin.iterdir(): + if snapshot.resolve() in keep: + continue + try: + if snapshot.stat().st_mtime < older_than: + remove_snapshot(snapshot) + except OSError: + continue diff --git a/src/powercontext/server/plugins.py b/src/powercontext/server/plugins.py index 909da1449a..0fb064458c 100644 --- a/src/powercontext/server/plugins.py +++ b/src/powercontext/server/plugins.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Load plugins at startup and hand their calls to the Artifact processing Supervisor. +"""Load plugins, follow changes to the plugin directory, and hand calls to Workers and host processes. -Nothing about a plugin is persisted: the directory as it is at startup decides which -plugins exist. Plugin code runs only in check, Worker, and host child processes. User +Nothing about a plugin is persisted: each Server process scans the plugin directory and +keeps its own view of which plugin versions are loaded. Code is imported only from +read-only version snapshots, and only in check, Worker, and host child processes. User changes to plugin Artifacts and the plugin's own routes go to its host process, which authorizes, prechecks, and writes as the caller; this process only authenticates and forwards. @@ -26,11 +27,14 @@ import asyncio import json import logging +import multiprocessing +import re import sys +import time from collections.abc import Callable, Mapping from contextlib import AsyncExitStack from dataclasses import dataclass, field -from datetime import timedelta +from datetime import UTC, datetime, timedelta from functools import partial from pathlib import Path from typing import Any @@ -46,7 +50,14 @@ from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.tables import ARTIFACT_PROCESSING_INTENTS_TABLE from powercontext.builtin.plugins.executor import PluginWorkerSpec, run_plugin_worker -from powercontext.builtin.plugins.families import register_plugin_family, register_source_binding, revision_metadata +from powercontext.builtin.plugins.families import ( + register_plugin_family, + register_source_binding, + revision_metadata, + unregister_plugin_family, + unregister_source_binding, +) +from powercontext.builtin.plugins.host import run_plugin_upgrade from powercontext.builtin.plugins.host_protocol import PluginHostSpec from powercontext.builtin.plugins.manifest import PluginManifest from powercontext.builtin.plugins.writer import plugin_merge_service @@ -63,29 +74,71 @@ from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY from powercontext.http._generated.models import PluginPage, PluginScopePage, PluginScopeState, PluginState from powercontext.plugins import Cron, Interval -from powercontext.server.plugin_hosts import HostProcessBudget, PluginHost +from powercontext.server.plugin_hosts import READY_TIMEOUT_SECONDS, HostProcessBudget, PluginHost +from powercontext.server.plugin_versions import ( + content_digest, + make_snapshot, + remove_snapshot, + remove_stale_snapshots, +) logger = logging.getLogger(__name__) CHECK_TIMEOUT_SECONDS = 60 # How soon a dirty Scope is admitted when the plugin asks to run on new Sources. ON_SOURCES_INTERVAL_SECONDS = 2.0 +# A replaced snapshot outlives the Worker time limit by this much before it is deleted. +_WORKER_GRACE_SECONDS = 60.0 + + +class _VersionFailed(Exception): + def __init__(self, category: str) -> None: + super().__init__(category) + self.category = category + + +async def _run_upgrade(spec: PluginHostSpec, timeout: float) -> bool: # noqa: ASYNC109 - the child enforces it too + """Run a new version's upgrade in a short-lived child on an instance without host processes.""" + + process = multiprocessing.get_context("spawn").Process( + target=run_plugin_upgrade, args=(spec,), name="powercontext-plugin-upgrade" + ) + await asyncio.to_thread(process.start) + await asyncio.to_thread(process.join, timeout + READY_TIMEOUT_SECONDS) + if process.is_alive(): + process.kill() + await asyncio.to_thread(process.join) + return False + return process.exitcode == 0 @dataclass class _Plugin: name: str + # The deployer's directory; it is only scanned, never imported from. directory: Path manifest: PluginManifest | None = None + # Why no version of the plugin is loaded. error: str | None = None tasks: list[asyncio.Task[None]] = field(default_factory=list) host: PluginHost | None = None + snapshot: Path | None = None + activated_at: datetime | None = None + # Why the latest newer version was not activated; the version in use stays. + last_check_failure: str | None = None + last_digest: str | None = None + failed_digest: str | None = None @property def failure(self) -> str | None: return self.error if self.error is not None or self.host is None else self.host.failure + @property + def code_directory(self) -> Path: + return self.directory if self.snapshot is None else self.snapshot + def state(self) -> PluginState: - spec = None if self.manifest is None else self.manifest.spec + manifest = self.manifest + spec = None if manifest is None else manifest.spec triggers = [] if spec is not None: triggers = [ @@ -99,14 +152,25 @@ def state(self) -> PluginState: "status": "failed" if self.failure is not None else "loaded", "error": self.failure, "family": None if spec is None else spec.family, - "binding_name": None if self.manifest is None else self.manifest.binding_name, + "binding_name": None if manifest is None else manifest.binding_name, "scopes": None if spec is None or spec.scopes is None else list(spec.scopes), "source_types": None if spec is None or spec.inputs is None else list(spec.inputs.source_types), "triggers": triggers, + "version_digest": None if manifest is None else manifest.fingerprint, + "declaration_digest": None if manifest is None else manifest.declaration_digest, + "activated_at": self.activated_at, + "last_check_failure": self.last_check_failure, + "routes": [ + {"method": route.method, "path": route.path, "access": route.access, "mcp": route.mcp} + for route in (() if spec is None or spec.api is None else spec.api.routes) + ], + "pages": None + if spec is None or spec.pages is None + else {"title": spec.pages.title, "path": f"/dashboard/plugins/{self.name}"}, }) -async def check_plugin_directory(directory: Path) -> PluginManifest: +async def check_plugin_directory(directory: Path, name: str | None = None) -> PluginManifest: """Import user code only in a time-limited child process.""" process = await asyncio.create_subprocess_exec( @@ -114,6 +178,7 @@ async def check_plugin_directory(directory: Path) -> PluginManifest: "-m", "powercontext.builtin.plugins.manifest", str(directory), + *(() if name is None else (name,)), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) @@ -133,6 +198,42 @@ async def check_plugin_directory(directory: Path) -> PluginManifest: return PluginManifest.model_validate(result["manifest"]) +def _binding_fields(manifest: PluginManifest) -> dict[str, Any]: + """The declaration fields that decide the plugin's Supervisor binding and schedule.""" + + return manifest.spec.model_dump( + include={"family", "inputs", "schedule", "on_sources", "scopes", "max_workers", "timeout_seconds"} + ) + + +class _VersionLauncher: + """Launch each Worker from the plugin version current at dispatch. + + Workers already running finish on the snapshot they started with, so a new version + with the same binding needs no binding replacement, which would cancel them. + """ + + def __init__(self, manager: PluginManager, name: str) -> None: + self._manager = manager + self._name = name + + async def start(self, assignment: Any) -> Any: + plugin = self._manager.plugins[self._name] + manifest = plugin.manifest + if manifest is None: + raise RuntimeError("the plugin is no longer loaded") # noqa: TRY003 + self._manager.retain(plugin.snapshot, manifest.spec.timeout_seconds) + spec = PluginWorkerSpec( + database=self._manager.config, + directory=str(plugin.code_directory), + manifest=manifest.model_dump(mode="json"), + security=self._manager.security_builder(), + inference=self._manager.inference_config, + storage_root=self._manager.storage_root, + ) + return await SpawnArtifactProcessingWorkerLauncher(partial(run_plugin_worker, spec)).start(assignment) + + class PluginManager: def __init__( self, @@ -144,9 +245,12 @@ def __init__( plugins_dir: Path | None, *, register_merge: Callable[[Any], None] | None = None, + unregister_merge: Callable[[str], None] | None = None, dispatch: bool = True, inference_config: Any = None, storage_root: Path | None = None, + snapshot_root: Path | None = None, + rescan_seconds: float = 0, host_security_builder: Callable[[], dict[str, Any] | None] | None = None, serve_requests: bool = True, host_processes: Mapping[str, int] | None = None, @@ -154,6 +258,7 @@ def __init__( ) -> None: self.database = database self.register_merge = register_merge + self.unregister_merge = unregister_merge # API-only instances register families for reads but never dispatch plugin Workers. self.dispatch = dispatch self.config = config @@ -165,89 +270,343 @@ def __init__( self._resources = AsyncExitStack() self.inference_config = inference_config self.storage_root = None if storage_root is None else str(storage_root) + self.snapshot_root = snapshot_root + # 0 turns off rescanning: plugins load once at startup. + self.rescan_seconds = rescan_seconds self.host_security_builder = host_security_builder or security_builder # Instances that receive no requests (the background role) start no host processes. self.serve_requests = serve_requests self.host_processes = dict(host_processes or {}) self.host_budget = HostProcessBudget(host_processes_total) + # Snapshots this process made; it deletes only these, once nothing here uses them. + self._created: set[Path] = set() + # Snapshots kept after they stopped being current: Workers or retiring host processes may still use them. + self._kept_until: dict[Path, float] = {} + self._retiring: dict[Path, int] = {} + self._background: set[asyncio.Task[Any]] = set() + self._scanner: asyncio.Task[None] | None = None async def __aenter__(self) -> PluginManager: + if self.plugins_dir is None: + return self + started = time.time() + await self.scan(initial=True) + if self.snapshot_root is not None: + current = frozenset(plugin.snapshot for plugin in self.plugins.values() if plugin.snapshot is not None) + await asyncio.to_thread(remove_stale_snapshots, self.snapshot_root, older_than=started, keep=current) + if self.rescan_seconds > 0: + self._scanner = asyncio.create_task(self._rescan(), name="plugin-rescan") + return self + + async def __aexit__(self, *_: object) -> None: + if self._scanner is not None: + self._scanner.cancel() + await asyncio.gather(self._scanner, return_exceptions=True) + tasks = [*self._background, *(task for plugin in self.plugins.values() for task in plugin.tasks)] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await asyncio.gather( + *(plugin.host.close() for plugin in self.plugins.values() if plugin.host is not None), + return_exceptions=True, + ) + await self._resources.aclose() + + # Scanning + + async def _rescan(self) -> None: + while True: + await asyncio.sleep(self.rescan_seconds) + try: + await self.scan() + except Exception as error: + log_safely(logger, logging.WARNING, f"Plugin scan failed ({type(error).__name__})") + + async def scan(self, *, initial: bool = False) -> None: + """Compare each plugin directory with the version in use and add, update, or remove plugins. + + A changed directory is checked only once two scans in a row see the same content, so a + half-written directory is never loaded; the first scan at startup does not wait. + """ + directory = self.plugins_dir if directory is None: - return self + return + started = time.perf_counter() try: entries = await asyncio.to_thread(lambda: sorted(path for path in directory.iterdir() if path.is_dir())) except OSError as error: log_safely(logger, logging.WARNING, f"Plugin directory unavailable ({type(error).__name__})") - return self - families: set[str] = set() + return + names = {entry.name for entry in entries} for entry in entries: - plugin = _Plugin(name=entry.name, directory=entry.resolve()) - self.plugins[entry.name] = plugin + plugin = self.plugins.get(entry.name) + if plugin is None: + plugin = self.plugins[entry.name] = _Plugin(name=entry.name, directory=entry.resolve()) + try: + digest = await asyncio.to_thread(content_digest, plugin.directory) + except OSError: + continue + stable = initial or digest == plugin.last_digest + plugin.last_digest = digest + current = None if plugin.manifest is None else plugin.manifest.fingerprint + if digest in {current, plugin.failed_digest} or not stable: + continue + await self._activate(plugin, digest) + for name in set(self.plugins) - names: + await self._remove(self.plugins.pop(name)) + await asyncio.to_thread(self._collect_snapshots) + if not initial or self.rescan_seconds > 0: + log_safely( + logger, + logging.DEBUG, + "Plugin directory scanned", + extra={"event": "plugin.scanned", "scan_ms": round((time.perf_counter() - started) * 1000, 3)}, + ) + + def retain(self, snapshot: Path | None, seconds: float) -> None: + """Keep a snapshot at least ``seconds`` past a Worker dispatched from it.""" + + if snapshot is not None: + deadline = time.monotonic() + seconds + _WORKER_GRACE_SECONDS + self._kept_until[snapshot] = max(deadline, self._kept_until.get(snapshot, 0.0)) + + def _collect_snapshots(self) -> None: + current = {plugin.snapshot for plugin in self.plugins.values()} + now = time.monotonic() + for snapshot in list(self._created): + if snapshot in current or self._retiring.get(snapshot) or self._kept_until.get(snapshot, 0.0) > now: + continue try: - manifest = await check_plugin_directory(plugin.directory) - if manifest.spec.family in families: - raise ValueError("family is already provided by another plugin") # noqa: TRY003, TRY301 - plugin.manifest = manifest - families.add(manifest.spec.family) + remove_snapshot(snapshot) + except OSError: + continue + self._created.discard(snapshot) + self._kept_until.pop(snapshot, None) + + # Activation + + async def _activate(self, plugin: _Plugin, digest: str) -> None: + if self.snapshot_root is None: + snapshot = None + else: + snapshot = await asyncio.to_thread(make_snapshot, plugin.directory, self.snapshot_root, plugin.name, digest) + if snapshot is None: + # The directory changed while it was copied; wait for it to settle again. + plugin.last_digest = None + return + self._created.add(snapshot) + try: + manifest = await check_plugin_directory(snapshot or plugin.directory, plugin.name) + if any( + other is not plugin + and other.manifest is not None + and other.manifest.spec.family == manifest.spec.family + for other in self.plugins.values() + ): + raise ValueError("family is already provided by another plugin") # noqa: TRY003, TRY301 + except Exception as error: + self._check_failed(plugin, digest, str(error) if isinstance(error, ValueError) else type(error).__name__) + return + if plugin.manifest is None: + await self._add(plugin, manifest, snapshot, digest) + else: + await self._update(plugin, manifest, snapshot, digest) + + def _check_failed(self, plugin: _Plugin, digest: str, category: str, *, event: str = "check_failed") -> None: + plugin.failed_digest = digest + plugin.last_check_failure = category + if plugin.manifest is None: + plugin.error = category + self._event(plugin, event) + + def _event(self, plugin: _Plugin, event: str) -> None: + manifest = plugin.manifest + log_safely( + logger, + logging.WARNING if event in {"check_failed", "upgrade_failed"} else logging.INFO, + f"Plugin {plugin.name} {event.replace('_', ' ')}", + extra={ + "event": "plugin.changed", + "plugin_name": plugin.name, + "plugin_event": event, + "version_digest": None if manifest is None else manifest.fingerprint, + "declaration_digest": None if manifest is None else manifest.declaration_digest, + }, + ) + + async def _add(self, plugin: _Plugin, manifest: PluginManifest, snapshot: Path | None, digest: str) -> None: + plugin.manifest, plugin.snapshot, plugin.error = manifest, snapshot, None + host = None + try: + await self._register(plugin) + failure = await self._prepare_version(plugin, manifest, snapshot) + if isinstance(failure, str): + raise _VersionFailed(failure) # noqa: TRY301 + host = failure + plugin.host = host + self._sync_registry() + await self._bind(plugin) + except Exception as error: + await self._unregister(plugin, manifest) + if host is not None: + await host.close() + plugin.manifest = plugin.snapshot = plugin.host = None + category = error.category if isinstance(error, _VersionFailed) else None + message = str(error) if isinstance(error, ValueError) else type(error).__name__ + self._check_failed( + plugin, + digest, + category or message, + event="upgrade_failed" if category == "upgrade_failed" else "check_failed", + ) + self._sync_registry() + return + plugin.activated_at = datetime.now(UTC) + plugin.last_check_failure = plugin.failed_digest = None + self._event(plugin, "loaded") + + async def _prepare_version( + self, plugin: _Plugin, manifest: PluginManifest, snapshot: Path | None + ) -> PluginHost | str | None: + """Start the version's host process, or run its upgrade where no host runs. + + Returns the host, None when this instance runs none, or why the version cannot be used. + A version that declares upgrade is used only after its upgrade finished here. + """ + + spec = self._host_spec(manifest, snapshot or plugin.directory) + if not self.serve_requests: + if not manifest.upgrade: + return None + return None if await _run_upgrade(spec, manifest.spec.upgrade_timeout_seconds) else "upgrade_failed" + host = PluginHost( + plugin.name, + spec, + limit=self.host_processes.get(plugin.name, manifest.spec.host_processes), + budget=self.host_budget, + ) + await host.start() + # Without upgrade the first process starts in the background, so a slow start delays no request + # path; an upgrade must finish before the version is used. + if manifest.upgrade or plugin.host is not None: + failure = await host.wait_ready(READY_TIMEOUT_SECONDS + manifest.spec.upgrade_timeout_seconds) + if failure is not None: + await host.close() + return failure + return host + + async def _update(self, plugin: _Plugin, manifest: PluginManifest, snapshot: Path | None, digest: str) -> None: + old = plugin.manifest + assert old is not None # noqa: S101 - updates replace a loaded version + prepared = await self._prepare_version(plugin, manifest, snapshot) + if isinstance(prepared, str): + self._check_failed( + plugin, digest, prepared, event="upgrade_failed" if prepared == "upgrade_failed" else "check_failed" + ) + return + old_snapshot, old_host = plugin.snapshot, plugin.host + family_changed = old.spec.family != manifest.spec.family + rebind = family_changed or _binding_fields(old) != _binding_fields(manifest) + try: + if rebind: + await self._unbind(plugin) + if family_changed: + await self._unregister(plugin, old) + plugin.manifest, plugin.snapshot, plugin.host = manifest, snapshot, prepared + if old.declaration_digest != manifest.declaration_digest: await self._register(plugin) - except Exception as error: - plugin.error = str(error) if isinstance(error, ValueError) else type(error).__name__ - log_safely(logger, logging.WARNING, f"Plugin {entry.name!r} failed to load") - if self.serve_requests: - self._prepare_hosts() - return self + self._sync_registry() + if rebind: + await self._bind(plugin) + except Exception as error: + await self._abandon(plugin, manifest, prepared, digest, error) + if old_host is not None: + self._spawn(self._retire(old_host, old_snapshot)) + return + if old_snapshot is not None: + self.retain(old_snapshot, old.spec.timeout_seconds) + if old_host is not None: + self._spawn(self._retire(old_host, old_snapshot)) + plugin.activated_at = datetime.now(UTC) + plugin.last_check_failure = plugin.failed_digest = None + self._event(plugin, "updated") + + async def _abandon( + self, plugin: _Plugin, manifest: PluginManifest, prepared: PluginHost | None, digest: str, error: Exception + ) -> None: + """Registering the new declaration failed after the old one was released: unload the plugin.""" - def _prepare_hosts(self) -> None: - loaded = [ - (plugin, plugin.manifest) - for plugin in self.plugins.values() - if plugin.error is None and plugin.manifest is not None - ] - families = tuple(sorted(manifest.spec.family for _, manifest in loaded)) + if prepared is not None: + await prepared.close() + await self._unregister(plugin, manifest) + plugin.manifest = plugin.snapshot = plugin.host = None + self._check_failed(plugin, digest, str(error) if isinstance(error, ValueError) else type(error).__name__) + self._sync_registry() + + async def _remove(self, plugin: _Plugin) -> None: + manifest = plugin.manifest + if manifest is None: + return + await self._unbind(plugin) + await self._unregister(plugin, manifest) + host, snapshot = plugin.host, plugin.snapshot + plugin.manifest = plugin.host = None + self._sync_registry() + if snapshot is not None: + self.retain(snapshot, manifest.spec.timeout_seconds) + if host is not None: + self._spawn(self._retire(host, snapshot)) + self._event(plugin, "removed") + + async def _retire(self, host: PluginHost, snapshot: Path | None) -> None: + """Let the old version's calls finish within their limits, then stop its processes.""" + + if snapshot is not None: + self._retiring[snapshot] = self._retiring.get(snapshot, 0) + 1 + try: + await host.retire() + finally: + if snapshot is not None: + self._retiring[snapshot] -= 1 + + def _spawn(self, coroutine: Any) -> None: + task = asyncio.create_task(coroutine) + self._background.add(task) + task.add_done_callback(self._background.discard) + + def _registry(self) -> tuple[tuple[str, ...], tuple[tuple[str, tuple[str, ...] | None, tuple[str, ...]], ...]]: + loaded = [plugin.manifest for plugin in self.plugins.values() if plugin.manifest is not None] + families = tuple(sorted(manifest.spec.family for manifest in loaded)) bindings = tuple( (manifest.binding_name, manifest.spec.scopes, manifest.spec.inputs.source_types) - for _, manifest in loaded + for manifest in loaded if manifest.spec.inputs is not None ) - security = self.host_security_builder() - for plugin, manifest in loaded: - plugin.host = PluginHost( - plugin.name, - PluginHostSpec( - database=self.config, - directory=str(plugin.directory), - manifest=manifest.model_dump(mode="json"), - security=security, - inference=self.inference_config, - plugin_families=families, - source_bindings=bindings, - storage_root=self.storage_root, - ), - limit=self.host_processes.get(plugin.name, manifest.spec.host_processes), - budget=self.host_budget, - ) - # Every loaded plugin's host starts now, so the first request does not wait for a spawn. - task = asyncio.create_task(self._start_host(plugin.host), name=f"plugin-host-start-{plugin.name}") - plugin.tasks.append(task) + return families, bindings - @staticmethod - async def _start_host(host: PluginHost) -> None: - try: - await host.start() - except Exception as error: - log_safely(logger, logging.WARNING, f"Plugin host {host.name} did not start ({type(error).__name__})") + def _sync_registry(self) -> None: + """Host processes read and write Sources like this process; tell them which plugins are loaded now.""" - async def __aexit__(self, *_: object) -> None: + families, bindings = self._registry() for plugin in self.plugins.values(): - for task in plugin.tasks: - task.cancel() - await asyncio.gather(*plugin.tasks, return_exceptions=True) - await asyncio.gather( - *(plugin.host.close() for plugin in self.plugins.values() if plugin.host is not None), - return_exceptions=True, + if plugin.host is not None: + plugin.host.update_registry(families, bindings) + + def _host_spec(self, manifest: PluginManifest, directory: Path) -> PluginHostSpec: + families, bindings = self._registry() + if manifest.spec.family not in families: + families = tuple(sorted((*families, manifest.spec.family))) + return PluginHostSpec( + database=self.config, + directory=str(directory), + manifest=manifest.model_dump(mode="json"), + security=self.host_security_builder() if self.serve_requests else self.security_builder(), + inference=self.inference_config, + plugin_families=families, + source_bindings=bindings, + storage_root=self.storage_root, ) - await self._resources.aclose() async def _ensure_supervisor(self) -> ArtifactProcessingSupervisors: if self.supervisor is None: @@ -263,6 +622,8 @@ async def _ensure_supervisor(self) -> ArtifactProcessingSupervisors: return self.supervisor async def _register(self, plugin: _Plugin) -> None: + """Make the plugin's family, merges, Source types, and Source binding known in this process.""" + manifest = plugin.manifest if manifest is None: raise ValueError("plugin manifest is unavailable") # noqa: TRY003 @@ -270,6 +631,8 @@ async def _register(self, plugin: _Plugin) -> None: await self._register_source_definitions(manifest) register_plugin_family(generate.family) if self.register_merge is not None: + if self.unregister_merge is not None: + self.unregister_merge(generate.family) self.register_merge( plugin_merge_service( generate.family, @@ -278,10 +641,26 @@ async def _register(self, plugin: _Plugin) -> None: metadata=revision_metadata(manifest.name, manifest.fingerprint), ) ) + unregister_source_binding(manifest.binding_name) + if generate.inputs is not None: + scopes = None if generate.scopes is None else frozenset(generate.scopes) + register_source_binding(manifest.binding_name, scopes, frozenset(generate.inputs.source_types)) + + async def _unregister(self, plugin: _Plugin, manifest: PluginManifest) -> None: + # Source Definition manifests stay registered: stored Sources refer to them. + unregister_plugin_family(manifest.spec.family) + if self.unregister_merge is not None: + self.unregister_merge(manifest.spec.family) + unregister_source_binding(manifest.binding_name) + + async def _bind(self, plugin: _Plugin) -> None: + """Register the plugin's background generation with the Supervisor.""" + + manifest = plugin.manifest + assert manifest is not None # noqa: S101 - only loaded plugins bind + generate = manifest.spec scopes = None if generate.scopes is None else frozenset(generate.scopes) inputs = generate.inputs - if inputs is not None: - register_source_binding(manifest.binding_name, scopes, frozenset(inputs.source_types)) interval = None cron = None if inputs is not None: @@ -299,21 +678,13 @@ async def in_scope(connection, candidates): # Without a schedule or on_sources the plugin has no background generation and no binding. if not self.dispatch or (generate.schedule is None and not generate.on_sources): return - spec = PluginWorkerSpec( - database=self.config, - directory=str(plugin.directory), - manifest=manifest.model_dump(mode="json"), - security=self.security_builder(), - inference=self.inference_config, - storage_root=self.storage_root, - ) supervisor = await self._ensure_supervisor() await supervisor.replace_binding( manifest.binding_name, ArtifactProcessingBinding( binding_name=manifest.binding_name, artifact_family=generate.family, - launcher=SpawnArtifactProcessingWorkerLauncher(partial(run_plugin_worker, spec)), + launcher=_VersionLauncher(self, plugin.name), max_workers=generate.max_workers, worker_timeout_seconds=generate.timeout_seconds, automatic_processing_interval=interval, @@ -333,6 +704,16 @@ async def in_scope(connection, candidates): asyncio.create_task(self._tick(manifest, generate.schedule.seconds), name=f"plugin-{plugin.name}") ) + async def _unbind(self, plugin: _Plugin) -> None: + for task in plugin.tasks: + task.cancel() + await asyncio.gather(*plugin.tasks, return_exceptions=True) + plugin.tasks.clear() + manifest = plugin.manifest + if manifest is not None and self.dispatch and self.supervisor is not None: + # Removing a binding cancels its running Workers; their windows run again later. + await self.supervisor.replace_binding(manifest.binding_name, None) + async def _register_source_definitions(self, manifest: PluginManifest) -> None: """Register the manifests of the plugin's Source Definitions; identical manifests are reused.""" @@ -377,6 +758,12 @@ def _plugin(self, name: str) -> _Plugin: def get(self, name: str) -> PluginState: return self._plugin(name).state() + def openapi(self, name: str) -> dict[str, Any]: + manifest = self.loaded_manifest(name) + if manifest is None: + raise BaseValueNotFoundError("plugin", name) + return plugin_openapi(manifest) + async def scopes(self, name: str, *, cursor: str | None, limit: int) -> PluginScopePage: """Per-Scope progress, read from the shared intent and cursor tables by any Server role.""" @@ -458,3 +845,82 @@ def host_for_family(self, family: str) -> tuple[PluginManifest, PluginHost] | No if plugin.error is None and manifest is not None and manifest.spec.family == family: return (manifest, plugin.host) if plugin.host is not None else None return None + + +_ERRORS = { + "401": "The caller is not authenticated.", + "403": "The caller lacks the access the route requires.", + "404": "The plugin, route, or resource was not found.", + "413": "The request body exceeds the route's limit.", + "422": "The request or its content is invalid.", + "500": "The plugin failed.", + "503": "The plugin is temporarily unavailable.", + "504": "The plugin did not finish within the route's time limit.", +} + + +def plugin_openapi(manifest: PluginManifest) -> dict[str, Any]: + """Describe the routes one plugin version declares; the plugin owns their compatibility.""" + + from powercontext.builtin.plugins.manifest import mcp_tool_name + + error = {"$ref": "#/components/schemas/Error"} + paths: dict[str, dict[str, Any]] = {} + api = manifest.spec.api + for route in () if api is None else api.routes: + parameters = [ + {"name": parameter, "in": "path", "required": True, "schema": {"type": "string"}} + for parameter in re.findall(r"{([A-Za-z_][A-Za-z0-9_]*)}", route.path) + ] + operation: dict[str, Any] = { + "operationId": mcp_tool_name(manifest.name, route.handler), + "parameters": parameters, + "x-powercontext-access": { + "access": route.access, + **({} if route.scope_param is None else {"scope_param": route.scope_param}), + }, + "responses": { + "200": { + "description": "The handler's result.", + "content": {"application/json": {"schema": route.response_schema or {}}}, + }, + "204": {"description": "The handler returned nothing."}, + **{ + status: {"description": text, "content": {"application/json": {"schema": error}}} + for status, text in _ERRORS.items() + }, + }, + } + if route.description is not None: + operation["description"] = route.description + if route.body == "json": + schema = route.request_schema or {} + operation["requestBody"] = {"required": True, "content": {"application/json": {"schema": schema}}} + elif route.body == "binary": + content = {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}} + operation["requestBody"] = {"required": True, "content": content} + paths.setdefault(f"/v1/plugins/{manifest.name}/api{route.path}", {})[route.method.lower()] = operation + return { + "openapi": "3.1.0", + "info": {"title": f"{manifest.name} plugin", "version": manifest.fingerprint[:12]}, + "paths": paths, + "components": { + "schemas": { + "Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message", "details"], + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "details": {"type": ["object", "null"]}, + }, + } + }, + } + } + }, + } diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 3e2a1e7dc9..341afbfd55 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -267,6 +267,8 @@ class ServerSettings(BaseSettings): code: CodeConfig = Field(default_factory=CodeConfig) runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) plugins_dir: Path | None = None + # How often each Server process rescans plugins_dir for added, changed, and removed plugins; 0 turns it off. + plugins_rescan_seconds: float = Field(default=60, ge=0) # Ceiling on plugin route request and response bodies; a route may declare a lower limit. plugin_api_max_body_bytes: int = Field(default=32 * 1024 * 1024, ge=1024) # Per-plugin ceilings on host processes, by plugin name, replacing the plugin's declared host_processes. diff --git a/tests/e2e/test_plugin_reload.py b/tests/e2e/test_plugin_reload.py new file mode 100644 index 0000000000..4fce16665e --- /dev/null +++ b/tests/e2e/test_plugin_reload.py @@ -0,0 +1,146 @@ +"""A running Server follows changes to its plugin directory without a restart.""" + +import asyncio +import shutil + +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport + +from tests.e2e.plugin_server import ADMIN_AUTHORIZATION_VALUE, running_server, wait_for + +PLUGIN = """ +import os +from pathlib import Path + +from pydantic import BaseModel + +from powercontext.plugins import ApiRequest, ArtifactPlugin, PluginApi, Rendering, Route + + +class Version(BaseModel): + version: str + + +class Notes: + async def render_artifact(self, request): + return Rendering(text="") + + async def version(self, request: ApiRequest) -> Version: + return Version(version="{version}") + + +async def setup(ctx): + return Notes() + + +async def upgrade(ctx): + {upgrade} + + +plugin = ArtifactPlugin( + family="{family}", + setup=setup, + upgrade=upgrade, + upgrade_timeout_seconds=20, + api=PluginApi( + routes=( + Route( + method="GET", + path="/version", + handler="version", + access="authenticated", + response_model=Version, + description="The plugin version that answered.", + mcp=True, + ), + ) + ), +) +""" + + +def write(directory, *, version, upgrade="pass", family="reload-notes"): + directory.mkdir(parents=True, exist_ok=True) + source = PLUGIN.replace("{version}", version).replace("{upgrade}", upgrade).replace("{family}", family) + (directory / "plugin.py").write_text(source, encoding="utf-8") + + +def record(marker): + # The upgrade appends to a file the test reads, so each load of a version is visible. + return f'Path({str(marker)!r}).open("a").write("{{}}\\n".format(os.getpid()))' + + +def test_server_reloads_added_updated_and_removed_plugins(tmp_path): + plugins = tmp_path / "plugins" + notes = plugins / "notes" + marker = tmp_path / "upgrades.txt" + write(notes, version="v1", upgrade=record(marker)) + log = tmp_path / "server.log" + env = {"POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS": "0.5", "POWERCONTEXT_SERVER_MCP_ENABLED": "true"} + + async def scenario(): + async with running_server(tmp_path, plugins, extra_env=env) as client: + + async def version(): + response = await client.get("/v1/plugins/notes/api/version") + return response.json().get("version") if response.status_code == 200 else response.status_code + + assert await wait_for(version, lambda value: value == "v1", log=log) == "v1" + first = (await client.get("/v1/plugins/notes")).json() + assert first["status"] == "loaded" + assert first["routes"] == [{"method": "GET", "path": "/version", "access": "authenticated", "mcp": True}] + assert first["version_digest"] and first["activated_at"] and first["last_check_failure"] is None + # The version's upgrade ran before it served requests. + assert len(marker.read_text().splitlines()) == 1 + + document = (await client.get("/v1/plugins/notes/openapi.json")).json() + operation = document["paths"]["/v1/plugins/notes/api/version"]["get"] + assert operation["operationId"] == "notes__version" + assert operation["responses"]["200"]["content"]["application/json"]["schema"]["title"] == "Version" + + # A changed directory takes effect without a restart; the new version upgrades first. + write(notes, version="v2", upgrade=record(marker)) + assert await wait_for(version, lambda value: value == "v2", log=log) == "v2" + second = (await client.get("/v1/plugins/notes")).json() + assert second["version_digest"] != first["version_digest"] + assert len(marker.read_text().splitlines()) == 2 + + # A version whose upgrade fails is not used; the previous version keeps serving. + write(notes, version="v3", upgrade='raise RuntimeError("cannot upgrade")') + failed = await wait_for( + lambda: client.get("/v1/plugins/notes"), + lambda response: response.json()["last_check_failure"] is not None, + log=log, + ) + assert failed.json()["last_check_failure"] == "upgrade_failed" + assert failed.json()["version_digest"] == second["version_digest"] + assert await version() == "v2" + + # MCP lists the route as a tool of the loaded version and calls it as the caller. + transport = StreamableHttpTransport( + f"{client.base_url}/mcp/", headers={"Authorization": f"Bearer {ADMIN_AUTHORIZATION_VALUE}"} + ) + async with Client(transport) as mcp: + tools = {tool.name: tool for tool in await mcp.list_tools()} + assert tools["notes__version"].annotations.readOnlyHint is True + result = await mcp.call_tool("notes__version", {}) + assert result.structured_content == {"version": "v2"} + + # Added and removed directories load and unload; a removed plugin's routes and tools disappear. + write(plugins / "extra", version="x1", family="extra-notes") + await wait_for( + lambda: client.get("/v1/plugins/extra/api/version"), + lambda response: response.status_code == 200, + log=log, + ) + shutil.rmtree(plugins / "extra") + await wait_for( + lambda: client.get("/v1/plugins/extra"), lambda response: response.status_code == 404, log=log + ) + assert (await client.get("/v1/plugins/extra/api/version")).status_code == 404 + async with Client(transport) as mcp: + names = {tool.name for tool in await mcp.list_tools()} + assert "notes__version" in names + assert "extra__version" not in names + + asyncio.run(scenario()) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 68d3850c19..4c53aecfe7 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -81,6 +81,8 @@ async def inspect_components() -> tuple[list[str], int, int]: tool_names, resource_count, prompt_count = run_async(inspect_components) + # Plugin tools are named __; no built-in name may take that form. + assert not [name for name in tool_names if "__" in name] assert set(tool_names) == { "pc_source_add", "pc_source_capabilities", From 7f6cd1e9cfae66ca5802013bca6cf61daadc26e2 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 13:58:46 -0800 Subject: [PATCH 20/38] chore(release): review the plugin profile unregistration for upgrade support Co-Authored-By: Claude Opus 5.5 --- release/upgrade-support.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/upgrade-support.json b/release/upgrade-support.json index a17a6784f8..be37fa2adc 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -4,7 +4,7 @@ "mode": "upgrade-required", "schema_review": { "persistence_sha256": "d222849d2532b0ce0d845360052f28923fc171ec76e51cea24f5da201694485e", - "rationale": "Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." + "rationale": "Plugin reloading can unregister, in the Server process, the Artifact access profile a plugin family registered when that plugin is removed or renames its family; built-in family profiles are never removed, and no table layout, migration resource, frozen checksum or persisted encoding changes. Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." }, "cases": [], "automatic_sources": { From 8b24d7c84ffbbe41bf15e4a0fc04264bc5891d64 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 14:16:41 -0800 Subject: [PATCH 21/38] fix(plugins): keep version snapshot directories deletable Read-only directories made the data directory impossible to remove with ordinary tools; snapshot files stay read-only. Co-Authored-By: Claude Opus 5.5 --- src/powercontext/server/plugin_versions.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/powercontext/server/plugin_versions.py b/src/powercontext/server/plugin_versions.py index cad90e3131..f718ddec0a 100644 --- a/src/powercontext/server/plugin_versions.py +++ b/src/powercontext/server/plugin_versions.py @@ -23,14 +23,15 @@ from __future__ import annotations import shutil -import stat from pathlib import Path from uuid import uuid4 from powercontext.builtin.plugins.manifest import code_fingerprint +# Files are read-only so a snapshot never changes; directories stay writable by the owner so +# deleting the data directory with ordinary tools still works. _FILE_MODE = 0o444 -_DIRECTORY_MODE = 0o555 +_DIRECTORY_MODE = 0o755 def content_digest(directory: Path) -> str: @@ -69,12 +70,8 @@ def make_snapshot(source: Path, root: Path, name: str, digest: str) -> Path | No def remove_snapshot(path: Path) -> None: - if not path.exists(): - return - # Deleting an entry needs a writable parent; read-only files themselves can be unlinked. - for directory in (path, *(item for item in path.rglob("*") if item.is_dir() and not item.is_symlink())): - directory.chmod(stat.S_IRWXU) - shutil.rmtree(path) + if path.exists(): + shutil.rmtree(path) def remove_stale_snapshots(root: Path, *, older_than: float, keep: frozenset[Path]) -> None: From e747e941b27facdddf3062231aa93ee8110e5ce3 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 14:50:49 -0800 Subject: [PATCH 22/38] build(integrations): rebuild plugin bundles with the plugin route description operation Co-Authored-By: Claude Opus 5.5 --- .../cindy/plugins/powercontext-hooks/node/worker.cjs | 11 +++++++++++ integrations/dsh/plugins/powercontext/lib/index.js | 11 +++++++++++ .../opencode/plugins/powercontext/lib/index.js | 11 +++++++++++ integrations/opencode/plugins/powercontext/lib/tui.js | 11 +++++++++++ 4 files changed, 44 insertions(+) diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index a5001d5dd6..4c2d092487 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -671,6 +671,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { + method: "GET", + path: "/v1/plugins/{name}/openapi.json", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index cdbd312f18..5f0496b9df 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -739,6 +739,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { + method: "GET", + path: "/v1/plugins/{name}/openapi.json", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 751187f0ec..75e52bf1f3 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -639,6 +639,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { + method: "GET", + path: "/v1/plugins/{name}/openapi.json", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index 9ebda3d3b1..dac8e8845c 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -639,6 +639,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { + method: "GET", + path: "/v1/plugins/{name}/openapi.json", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", From 7022449041e063ff473c9e0822e01074d6843a07 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 20:41:18 -0800 Subject: [PATCH 23/38] build(desktop): regenerate the contract identity after rebasing onto the review fixes Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b8871f5f23..7e65e63afe 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "b94282cdbd0a0458d0e1502983e58a6db6da929a857beafbe52c0e40b9817a55", + "contractSha256": "b22355a14d4154cb58d55ec325ab101e7d9c1531310e456ed1c622de1694faea", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index e3403c7d8a..06a6520ca9 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 = "b94282cdbd0a0458d0e1502983e58a6db6da929a857beafbe52c0e40b9817a55"; +export const contractSha256 = "b22355a14d4154cb58d55ec325ab101e7d9c1531310e456ed1c622de1694faea"; export const operations = { "get_liveness": { "method": "GET", From 069edcacfede0a261fc266ae91e799c0434d9e07 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 22:32:30 -0800 Subject: [PATCH 24/38] test(bub): classify the plugin route description operation in the access audit get_plugin_openapi is authorized for any authenticated caller, as the Access API is, but returns only a plugin's route descriptions and no Scope content. Co-Authored-By: Claude Opus 5.5 --- e2e/bub/tests/test_access_audit.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/bub/tests/test_access_audit.py b/e2e/bub/tests/test_access_audit.py index 986bf982ad..4f1f8bf54d 100644 --- a/e2e/bub/tests/test_access_audit.py +++ b/e2e/bub/tests/test_access_audit.py @@ -274,6 +274,10 @@ def test_a_handoff_report_over_every_scope_reaches_them_although_it_is_authorize }) +# Authorized for any authenticated caller like the Access API, but returning only a plugin's route descriptions. +_AUTHENTICATED_WITHOUT_CONTENT = frozenset({"get_plugin_openapi"}) + + def test_every_server_wide_operation_is_classified_by_whether_it_returns_scope_content() -> None: contract = yaml.safe_load((_REPOSITORY / "openapi" / "powercontext.yaml").read_text(encoding="utf-8")) server_wide, access_api = set(), set() @@ -291,7 +295,7 @@ def test_every_server_wide_operation_is_classified_by_whether_it_returns_scope_c assert server_wide == SERVER_WIDE_CONTENT_OPERATIONS | _SERVER_WIDE_WITHOUT_CONTENT # The Access API authorizes the caller itself and then decides about any resource it names, returning no content. - assert access_api == ACCESS_OPERATIONS + assert access_api == ACCESS_OPERATIONS | _AUTHENTICATED_WITHOUT_CONTENT class _AuditClient: From 983924a4006b4fa806f77d381a0fb8762d84c1ac Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:17:40 -0800 Subject: [PATCH 25/38] fix(plugins): import the plugin access profile helpers from the built-in runtime Co-Authored-By: Claude Opus 5.5 --- src/powercontext/builtin/plugins/families.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/powercontext/builtin/plugins/families.py b/src/powercontext/builtin/plugins/families.py index 20cc2324c8..40f770be24 100644 --- a/src/powercontext/builtin/plugins/families.py +++ b/src/powercontext/builtin/plugins/families.py @@ -61,7 +61,7 @@ def register_plugin_family(family: str) -> None: def unregister_plugin_family(family: str) -> None: """A removed plugin's family is no longer readable: its content usually lives in the plugin's storage.""" - from powercontext.server.authz.profiles import unregister_plugin_artifact_family + from powercontext.builtin.authz.profiles import unregister_plugin_artifact_family _PLUGIN_FAMILIES.discard(family) unregister_plugin_artifact_family(family) From dd75d91181dffd127e350574a0970cdc0af53447 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:18:24 -0800 Subject: [PATCH 26/38] build: regenerate the Desktop contract identity and review persistence after rebasing onto master Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- release/upgrade-support.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 7e65e63afe..2b117a2df8 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "b22355a14d4154cb58d55ec325ab101e7d9c1531310e456ed1c622de1694faea", + "contractSha256": "ad0fa0e1209f2dc60935416ae8d54826335b82b35b81c999e3ae77a21325f0ef", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 06a6520ca9..a100cb0cfc 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 = "b22355a14d4154cb58d55ec325ab101e7d9c1531310e456ed1c622de1694faea"; +export const contractSha256 = "ad0fa0e1209f2dc60935416ae8d54826335b82b35b81c999e3ae77a21325f0ef"; export const operations = { "get_liveness": { "method": "GET", diff --git a/release/upgrade-support.json b/release/upgrade-support.json index be37fa2adc..dc55269a0e 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,7 +3,7 @@ "target_version": "1.5.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "d222849d2532b0ce0d845360052f28923fc171ec76e51cea24f5da201694485e", + "persistence_sha256": "08098928cff34642895f967549c5759a1c5ed47c842823551537e7045668f2ce", "rationale": "Plugin reloading can unregister, in the Server process, the Artifact access profile a plugin family registered when that plugin is removed or renames its family; built-in family profiles are never removed, and no table layout, migration resource, frozen checksum or persisted encoding changes. Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." }, "cases": [], From 8effceddefa0c431554288d1b7f439868686c3fb Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:19:04 -0800 Subject: [PATCH 27/38] build(integrations): rebuild the DSH pet bundle with the plugin route description operation Co-Authored-By: Claude Opus 5.5 --- integrations/dsh/plugins/dsh-pet/lib/index.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/integrations/dsh/plugins/dsh-pet/lib/index.js b/integrations/dsh/plugins/dsh-pet/lib/index.js index 878b897281..33d9489c35 100644 --- a/integrations/dsh/plugins/dsh-pet/lib/index.js +++ b/integrations/dsh/plugins/dsh-pet/lib/index.js @@ -672,6 +672,17 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_plugin_openapi: { + method: "GET", + path: "/v1/plugins/{name}/openapi.json", + location: null, + scopeMode: "none", + pathParameters: ["name"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", From ab7b36d28fbd473990a903c988658ee3e1d5d946 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:20:41 -0800 Subject: [PATCH 28/38] test(desktop): qualify the plugin reload contract Co-Authored-By: Claude Opus 5.5 --- desktop/VALIDATION.md | 31 +++++++++++++++++++ .../src/connections/compatibility.json | 22 +++++++++++++ 2 files changed, 53 insertions(+) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 8c548848b3..8c2c221cca 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -3464,3 +3464,34 @@ uv run --no-sync python desktop/tests/real_server.py \ --product-qualification sqlite-plugin-interfaces-3427cee7-v1 \ --wheel desktop/.artifacts/plugin-interfaces/powercontext-1.5.2.dev163+g3427cee71-py3-none-any.whl ``` + + +## Server 8effcedd plugin reload protocol qualification + +`sqlite-plugin-reload-8effcedd-v1` qualifies all thirteen Desktop protocol operations against +Server source `8effceddefa0c431554288d1b7f439868686c3fb`, which reloads plugin directories, describes +plugin routes, and adds version fields to the plugin listing. + +- Normalized OpenAPI SHA-256: `ad0fa0e1209f2dc60935416ae8d54826335b82b35b81c999e3ae77a21325f0ef`. +- Wheel: `powercontext-1.5.2.dev173+g8effcedde-py3-none-any.whl`. +- Wheel SHA-256: `605e1d795c7ec2779ccf83070f9478a1e8c2f45bedffb078b974ed03b1d835d7`. +- Native debug probe SHA-256 (built without debug info): `1e0302e3e55c68059eb3fdbe60ad4638b61319bc8ef0e825d4f94302e89ca566`. + +On 2026-11-14, Linux 7.0 x86_64 (Ubuntu 24.04, glibc 2.39) and Python 3.12.3 passed anonymous loopback, +static Bearer, HTTPS with explicit CA/base path, and enforced Provider modes of the unchanged +`desktop/tests/real_server.py` through the ordinary product ConnectionManager. The probe was built with +the product Cargo lock without `ci-fixtures` and selected this bundled qualification; the client and +qualified contract digests are identical. `bundled_product_qualification_covers_every_desktop_operation` +passed against the same records. The wheel and report are retained under ignored +`desktop/.artifacts/plugin-reload/` and `desktop/.artifacts/real-server.json`. +This qualifies the observed local SQLite protocol; it does not establish installed WebView, native +credential persistence, other operating systems, native database upgrades or release acceptance. + +Reproduce against the recorded artifact: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-plugin-reload-8effcedd-v1 \ + --wheel desktop/.artifacts/plugin-reload/powercontext-1.5.2.dev173+g8effcedde-py3-none-any.whl +``` diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 8fb4afa6cf..758330a8c5 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1706,5 +1706,27 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-3427cee7-plugin-interfaces-protocol-qualification" + }, + { + "id": "sqlite-plugin-reload-8effcedd-v1", + "serverCommit": "8effceddefa0c431554288d1b7f439868686c3fb", + "contractSha256": "ad0fa0e1209f2dc60935416ae8d54826335b82b35b81c999e3ae77a21325f0ef", + "artifactSha256": "605e1d795c7ec2779ccf83070f9478a1e8c2f45bedffb078b974ed03b1d835d7", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-8effcedd-plugin-reload-protocol-qualification" } ] From 1255054afebb7015902a5b5d42e5e0f1d51cab8a Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 14:49:10 -0800 Subject: [PATCH 29/38] feat(sources): collect with Source plugins through the Server's runner and device runners Implements RFC #2617 and replaces the previous Source plugin framework. - `SourcePlugin` (version, min_compatible_version, definitions, collect, target, setup, api, pages, upgrade) loads from the plugin directory alongside Artifact plugins. A plugin with only definitions extends the Source write API; one with collect runs through runners. - Revision v1_5_0_006 adds pc_source_runners and pc_source_collect_targets and drops pc_source_write_plugins, refusing the upgrade while it still lists plugins. Portable bundles carry runners (name and bound subject) and targets without runner status. - Target API and MCP tools: pc_source_runners, pc_source_add, pc_source_status, pc_source_target_set_enabled, pc_source_target_delete; runners sync through POST /v1/source-runners/{runner}/sync. Targets of the server runner need server.admin; identity parameters are declared, normalized, and immutable. - The server runner collects in Supervisor Workers and commits Sources, checkpoints, and target status in one transaction; CollectResult.fail reports one target. - `powercontext source-agent` collects on devices: it submits Sources and then checkpoints with status. Runner requests carry the plugin version, accepted within the Server plugin's compatible range; the Server's plugin resolves and projects them. - Removed: /v1/source-management/*, `powercontext source load`, the source management settings, and the old examples, which are rewritten as Source plugins. Co-Authored-By: Claude Opus 5.5 --- .../src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/api.d.ts | 1678 +++++------- desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/develop/plugins.md | 5 + docs/en/docs/operate/deploy-server.md | 8 +- docs/en/docs/workflows/source-plugins.md | 270 +- docs/zh/docs/develop/plugins.md | 4 + docs/zh/docs/operate/deploy-server.md | 7 +- docs/zh/docs/workflows/source-plugins.md | 238 +- examples/source_plugins/README.zh.md | 67 +- examples/source_plugins/images/__init__.py | 33 - .../images/{definition.py => plugin.py} | 11 +- examples/source_plugins/images/write.yml | 16 - .../events.json => ticket-chat/chats.json} | 19 - examples/source_plugins/ticket-chat/plugin.py | 144 + .../source_plugins/ticket_chat/__init__.py | 148 - .../source_plugins/ticket_chat/definition.py | 122 - .../ticket_chat/powercontext.yml | 27 - examples/source_plugins/tickets/__init__.py | 137 - .../tickets/{definition.py => plugin.py} | 77 +- .../source_plugins/tickets/powercontext.yml | 20 - integrations/capabilities.toml | 21 +- .../powercontext-hooks/node/worker.cjs | 231 +- .../dsh/plugins/powercontext/lib/index.js | 693 +---- .../src/mcp-operations.generated.ts | 11 +- .../dsh/plugins/powercontext/src/mcp.ts | 3 - .../powercontext/src/operations.generated.ts | 21 +- .../dsh/plugins/powercontext/src/secrets.ts | 3 +- .../plugins/powercontext/tests/mcp.spec.ts | 12 +- .../plugins/powercontext/profiles.json | 10 +- .../plugins/powercontext/lib/index.js | 231 +- .../opencode/plugins/powercontext/lib/tui.js | 231 +- .../powercontext/src/operations.generated.ts | 21 +- .../powercontext/src/operations.generated.ts | 21 +- openapi/powercontext.yaml | 2421 ++++++----------- release/upgrade-support.json | 2 +- .../migrations/server_resources/manifest.json | 41 +- .../mysql/versions/v1_5_0_006.py | 12 +- .../schemas/mysql-v1_5_0_006.json | 563 ++++ .../schemas/sqlite-v1_5_0_006.json | 8 + .../source_collection_readiness.py | 55 + .../versions/v1_5_0_006_source_collection.py} | 16 +- .../server_sources/1.4.0/manifest.json | 41 +- .../builtin/persistence/source_collection.py | 366 +++ .../builtin/persistence/tables.py | 45 +- src/powercontext/builtin/plugins/collector.py | 189 ++ src/powercontext/builtin/plugins/executor.py | 8 +- src/powercontext/builtin/plugins/host.py | 66 +- .../builtin/plugins/host_protocol.py | 1 + src/powercontext/builtin/plugins/manifest.py | 31 +- .../builtin/plugins/observations.py | 78 + .../builtin/portability/bundle.py | 36 + src/powercontext/cli/app.py | 5 +- src/powercontext/cli/system.py | 5 +- src/powercontext/http/__init__.py | 30 + src/powercontext/http/_generated/models.py | 437 ++- .../http/_generated/operations.py | 547 ++-- src/powercontext/http/_generated/schema.py | 1635 +++++------ src/powercontext/plugins.py | 244 +- src/powercontext/server/app.py | 375 ++- src/powercontext/server/factory.py | 29 +- src/powercontext/server/mcp.py | 8 +- src/powercontext/server/plugins.py | 187 +- src/powercontext/server/settings.py | 20 - src/powercontext/source_agent.py | 356 +++ .../source_management/background.py | 232 -- .../source_management/bootstrap.py | 81 - src/powercontext/source_management/channel.py | 143 - src/powercontext/source_management/cli.py | 241 -- src/powercontext/source_management/config.py | 116 - src/powercontext/source_management/context.py | 149 - .../source_management/deployment.py | 406 --- src/powercontext/source_management/durable.py | 99 - src/powercontext/source_management/errors.py | 22 - src/powercontext/source_management/gateway.py | 437 --- .../source_management/inspection.py | 38 - src/powercontext/source_management/loading.py | 106 - src/powercontext/source_management/manager.py | 720 ----- src/powercontext/source_management/models.py | 135 - .../source_management/remote_deployment.py | 161 -- src/powercontext/source_management/runtime.py | 354 --- src/powercontext/source_management/service.py | 191 -- src/powercontext/source_management/storage.py | 88 - src/powercontext/source_management/targets.py | 120 - src/powercontext/source_management/write.py | 268 -- src/powercontext/sources/collection.py | 66 + .../test_atomic_description_vector_layout.py | 7 +- .../test_atomic_evidence_upgrade_history.py | 4 +- .../test_full_server_migrations.py | 3 +- .../test_mysql_initialization_preflight.py | 2 +- .../test_mysql_migration_runner.py | 16 +- .../test_owner_recovery_migration.py | 9 +- .../test_plugin_storage_migration.py | 29 +- .../test_portable_storage_migration.py | 21 +- .../test_release140_server_migrations.py | 3 +- tests/builtin/portability/test_bundle.py | 52 + tests/e2e/plugin_server.py | 47 +- tests/e2e/source_collect_helpers.py | 109 - tests/e2e/test_access_control_http.py | 11 +- .../test_atomic_memory_description_upgrade.py | 4 +- tests/e2e/test_mcp_transport.py | 5 +- tests/e2e/test_source_citation_locators.py | 13 +- tests/e2e/test_source_collect.py | 440 --- tests/e2e/test_source_deployment.py | 321 --- tests/e2e/test_source_plugins.py | 179 ++ tests/e2e/test_source_write.py | 364 --- tests/plugin_support.py | 11 +- tests/source_helpers.py | 195 -- tests/test_api_contract.py | 3 +- tests/test_managed_atomic_maintenance.py | 4 +- tests/test_managed_processing_maintenance.py | 2 +- tests/test_mcp.py | 5 +- tests/test_migration_revision_names.py | 6 +- .../test_registered_source_maintenance_cli.py | 2 +- tests/test_server_uds.py | 100 - tests/test_source_management.py | 950 ------- 116 files changed, 6514 insertions(+), 12977 deletions(-) delete mode 100644 examples/source_plugins/images/__init__.py rename examples/source_plugins/images/{definition.py => plugin.py} (87%) delete mode 100644 examples/source_plugins/images/write.yml rename examples/source_plugins/{ticket_chat/events.json => ticket-chat/chats.json} (59%) create mode 100644 examples/source_plugins/ticket-chat/plugin.py delete mode 100644 examples/source_plugins/ticket_chat/__init__.py delete mode 100644 examples/source_plugins/ticket_chat/definition.py delete mode 100644 examples/source_plugins/ticket_chat/powercontext.yml delete mode 100644 examples/source_plugins/tickets/__init__.py rename examples/source_plugins/tickets/{definition.py => plugin.py} (51%) delete mode 100644 examples/source_plugins/tickets/powercontext.yml rename examples/source_plugins/tickets/write.yml => src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_006.py (69%) create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_006.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_006.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/source_collection_readiness.py rename src/powercontext/{source_management/__init__.py => builtin/persistence/migrations/server_resources/versions/v1_5_0_006_source_collection.py} (56%) create mode 100644 src/powercontext/builtin/persistence/source_collection.py create mode 100644 src/powercontext/builtin/plugins/collector.py create mode 100644 src/powercontext/builtin/plugins/observations.py create mode 100644 src/powercontext/source_agent.py delete mode 100644 src/powercontext/source_management/background.py delete mode 100644 src/powercontext/source_management/bootstrap.py delete mode 100644 src/powercontext/source_management/channel.py delete mode 100644 src/powercontext/source_management/cli.py delete mode 100644 src/powercontext/source_management/config.py delete mode 100644 src/powercontext/source_management/context.py delete mode 100644 src/powercontext/source_management/deployment.py delete mode 100644 src/powercontext/source_management/durable.py delete mode 100644 src/powercontext/source_management/errors.py delete mode 100644 src/powercontext/source_management/gateway.py delete mode 100644 src/powercontext/source_management/inspection.py delete mode 100644 src/powercontext/source_management/loading.py delete mode 100644 src/powercontext/source_management/manager.py delete mode 100644 src/powercontext/source_management/models.py delete mode 100644 src/powercontext/source_management/remote_deployment.py delete mode 100644 src/powercontext/source_management/runtime.py delete mode 100644 src/powercontext/source_management/service.py delete mode 100644 src/powercontext/source_management/storage.py delete mode 100644 src/powercontext/source_management/targets.py delete mode 100644 src/powercontext/source_management/write.py create mode 100644 src/powercontext/sources/collection.py delete mode 100644 tests/e2e/source_collect_helpers.py delete mode 100644 tests/e2e/test_source_collect.py delete mode 100644 tests/e2e/test_source_deployment.py create mode 100644 tests/e2e/test_source_plugins.py delete mode 100644 tests/e2e/test_source_write.py delete mode 100644 tests/source_helpers.py delete mode 100644 tests/test_source_management.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 2b117a2df8..14a4573db8 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "ad0fa0e1209f2dc60935416ae8d54826335b82b35b81c999e3ae77a21325f0ef", + "contractSha256": "e12490d40a86f30b8540727d3fd9c4ce50a9edcb9c8a17d8b22634f06d942073", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index 44e845e697..a21af43019 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -935,6 +935,114 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/source-runners": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List collection runners + * @description Lists the Server's own runner and every runner that has synced, with the Source plugins each loaded, whether each plugin version is compatible with the version this Server loaded, and when each runner last synced. A capability's description, parameter schema, and identity come from the Source plugin this Server loaded. + */ + get: operations["pc_source_runners"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-runners/{runner}/sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Sync a collection runner + * @description Records the runner's loaded Source plugins and last-seen time and returns every enabled target assigned to it across Scopes, with each target's checkpoint. Only the runner itself may sync: the caller's identifier equals the runner name, or the caller is the subject that first synced with this name. Targets of plugins whose version this Server does not accept are withheld and listed as rejected. + */ + post: operations["sync_source_runner"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/source-runners/{runner}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete a collection runner record + * @description Deletes a runner's record once it has no targets. + */ + delete: operations["delete_source_runner"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/source-targets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List a Scope's collection targets + * @description Returns each target's configuration and the status its runner last reported. + */ + get: operations["pc_source_status"]; + put?: never; + /** + * Add a collection target + * @description Assigns a target of a Source plugin capability to a runner. Parameters are validated against the parameter schema of the plugin version this Server loaded, and the declared identity parameters form the target key. A target with the same Scope, runner, capability, and key is returned unchanged when its parameters match and is a conflict otherwise. Targets of the Server's own runner, named server, require server.admin. + */ + post: operations["pc_source_add"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/scopes/{scope_id}/source-targets/{target_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete a collection target + * @description Deletes the target and its checkpoint. Sources it collected stay. Targets of the server runner require server.admin. + */ + delete: operations["pc_source_target_delete"]; + options?: never; + head?: never; + /** + * Change or pause a collection target + * @description Enables or disables a target, or replaces its parameters. Identity parameters cannot change: to collect another object, add a target. A disabled target keeps its checkpoint and resumes from it. Targets of the server runner require server.admin. + */ + patch: operations["pc_source_target_set_enabled"]; + trace?: never; + }; "/v1/plugins/{name}/scopes": { parameters: { query?: never; @@ -2520,24 +2628,7 @@ export interface paths { patch?: never; trace?: never; }; - "/v1/source-management/capabilities": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** pc source capabilities */ - post: operations["pc_source_capabilities"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/add": { + "/v1/access/ownership/plan": { parameters: { query?: never; header?: never; @@ -2547,17 +2638,17 @@ export interface paths { get?: never; put?: never; /** - * pc source add - * @description Use the discovered write or collect capability and version. Collect capabilities require an explicit instance_id. Write runs in the Server using loaded Definition and Projection implementations, without an instance or management service. Write waits for Source references or an error in the original call; no durable request or status query is created. Collect establishes or reuses a persistent target and never resumes a stopped target. No mode or idempotency_key is accepted; write retries use the underlying Source identity rules. + * Inventory ownership data missing after switching Access enforcement on + * @description Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet protect anything. A background Principal check requires an available Access store; otherwise it reports 503 relationship_management_unavailable. Use the offline ownership command to check the proposed background Principal before enforcement is enabled. */ - post: operations["pc_source_add"]; + post: operations["plan_access_ownership"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/v1/source-management/status": { + "/v1/access/ownership/apply": { parameters: { query?: never; header?: never; @@ -2567,315 +2658,73 @@ export interface paths { get?: never; put?: never; /** - * pc source status - * @description Query a collect target by scope_id and target_id. Result view requires scope.read and returns received Source references and submission time, not per-round completion. Control view requires scope.contribute and returns enabled and tracking without input or results. + * Backfill missing Artifact owners and Candidate owner attestations + * @description Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when they disagree with the named Principal. Identities with recorded ownership recovery evidence are reported as conflicts and must retain their original attribution. Every item is one idempotent write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. */ - post: operations["pc_source_status"]; + post: operations["apply_access_ownership"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/v1/source-management/target-set-enabled": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @enum {string} */ + GatewayLifecycleDecision: "deadline_exceeded" | "gateway_shutdown" | "request_cancelled"; /** - * 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. + * @description Sending facts known at the stop boundary. Possibly sent never means safe to resend; response received does not authorize release or establish client receipt. + * @enum {string} */ - post: operations["pc_source_target_set_enabled"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/instances": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + GatewayModelSend: "no_send" | "definitely_not_sent" | "possibly_sent" | "response_received"; + /** @description Lifecycle control result, independent of Core allow/block/withhold and local delivery. */ + GatewayLifecycleError: { + decision: components["schemas"]["GatewayLifecycleDecision"]; + model_send: components["schemas"]["GatewayModelSend"]; }; - get?: never; - put?: never; - /** - * pc source instances - * @description Discover instances with scope_id from the Server capability catalog. Control view requires instance_id and server.admin, and returns enabled, actual and availability without business configuration. - */ - post: operations["pc_source_instances"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/instance-set-enabled": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + /** @description A major/minor compatibility version. A major increment may remove or incompatibly change the governed contract. A minor increment only adds backward-compatible behavior or fields. */ + ContractVersion: { + major: number; + minor: number; }; - get?: never; - put?: never; - /** - * pc source instance set enabled - * @description Set instance business enabled state using server.admin. Repeating the same state does not restart apply. Restarting an ended run requires disable, confirmed stopped, then enable. The Runtime process stays online. - */ - post: operations["pc_source_instance_set_enabled"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/configure": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + /** @description Compatibility version for exactly the listed OpenAPI operation IDs. Adding operations or compatible semantics increments minor; removing, renaming or incompatibly changing a listed operation increments major. */ + FeatureContract: { + version: components["schemas"]["ContractVersion"]; + operations: string[]; }; - get?: never; - put?: never; - /** source configure */ - post: operations["source_configure"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/write/load": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + /** @description Stable deployment identity and protocol compatibility metadata. Compatible clients must ignore unknown optional fields added by a future schema minor version. This contract intentionally excludes runtime capabilities, health, limits, inventory and authorization-principal identity. */ + ServerInfo: { + /** @description Compatibility version for this response shape and field semantics. */ + schema_version: components["schemas"]["ContractVersion"]; + /** @enum {string} */ + product: "powercontext"; + /** @description Opaque identity of the durable Server deployment. It is unrelated to Access deployment_id and remains stable across restarts, upgrades, backup restore and replicas sharing the same primary database. */ + server_id: string; + package_version: string; + /** @description Major/minor projection of the OpenAPI info.version served by this package. */ + api_contract_version: components["schemas"]["ContractVersion"]; + /** @description Stable feature groups keyed by contract name. */ + feature_contracts: { + [key: string]: components["schemas"]["FeatureContract"]; + }; }; - get?: never; - put?: never; - /** - * Load a trusted Server-local Source write plugin - * @description Requires server.admin. The Server imports the local directory or module, validates and registers its Source Definitions, and persists its entry for restart. An identical deployment is reusable; conflicting configurations fail. Existing code is replaced only through controlled Server restart. - */ - post: operations["load_source_write_plugin"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/channel/open": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + RecallManifestRequest: { + scope_id: string; + kind?: string | null; + tags?: string[]; + tag_match?: components["schemas"]["TagMatch"]; + /** @default 50 */ + limit: number; + cursor?: string | null; }; - get?: never; - put?: never; - /** Source manager reverse channel open */ - post: operations["source_channel_open"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/channel/catalog": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Source manager reverse channel catalog */ - post: operations["source_channel_catalog"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/channel/poll": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Source manager reverse channel poll */ - post: operations["source_channel_poll"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/channel/reply": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Source manager reverse channel reply */ - post: operations["source_channel_reply"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/channel/close": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Source manager reverse channel close */ - post: operations["source_channel_close"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/source-management/instances/{instance_id}/{operation}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Authenticated instance control, permission checks and durable result exchange - * @description Served by the optional standalone Source manager. Instances initiate every exchange; this is not a Server or public MCP endpoint. The operation path chooses owner/connect/poll/reply/sync/execution/results/stopped-targets/close and the applicable fields. Source observations continue to go directly to the PC Server. - */ - post: operations["source_instance_exchange"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/access/ownership/plan": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Inventory ownership data missing after switching Access enforcement on - * @description Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending Review Candidates without an owner attestation, and, when named, the background Principal's per-Scope write authority. Produces no change. In enforced mode completes as a deployment administrator; in disabled mode completes without authentication because the Server cannot yet protect anything. A background Principal check requires an available Access store; otherwise it reports 503 relationship_management_unavailable. Use the offline ownership command to check the proposed background Principal before enforcement is enabled. - */ - post: operations["plan_access_ownership"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/v1/access/ownership/apply": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Backfill missing Artifact owners and Candidate owner attestations - * @description Grant the missing ownership data to the explicitly named Principal; the Server never derives it. Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when they disagree with the named Principal. Identities with recorded ownership recovery evidence are reported as conflicts and must retain their original attribution. Every item is one idempotent write, so interrupted or repeated runs refill only still-missing items. Set dry_run first to preview the exact items. Requires a deployment administrator; while Access is disabled the Server has no ownership store and reports 503 ownership_migration_unavailable. - */ - post: operations["apply_access_ownership"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - /** @enum {string} */ - GatewayLifecycleDecision: "deadline_exceeded" | "gateway_shutdown" | "request_cancelled"; - /** - * @description Sending facts known at the stop boundary. Possibly sent never means safe to resend; response received does not authorize release or establish client receipt. - * @enum {string} - */ - GatewayModelSend: "no_send" | "definitely_not_sent" | "possibly_sent" | "response_received"; - /** @description Lifecycle control result, independent of Core allow/block/withhold and local delivery. */ - GatewayLifecycleError: { - decision: components["schemas"]["GatewayLifecycleDecision"]; - model_send: components["schemas"]["GatewayModelSend"]; - }; - /** @description A major/minor compatibility version. A major increment may remove or incompatibly change the governed contract. A minor increment only adds backward-compatible behavior or fields. */ - ContractVersion: { - major: number; - minor: number; - }; - /** @description Compatibility version for exactly the listed OpenAPI operation IDs. Adding operations or compatible semantics increments minor; removing, renaming or incompatibly changing a listed operation increments major. */ - FeatureContract: { - version: components["schemas"]["ContractVersion"]; - operations: string[]; - }; - /** @description Stable deployment identity and protocol compatibility metadata. Compatible clients must ignore unknown optional fields added by a future schema minor version. This contract intentionally excludes runtime capabilities, health, limits, inventory and authorization-principal identity. */ - ServerInfo: { - /** @description Compatibility version for this response shape and field semantics. */ - schema_version: components["schemas"]["ContractVersion"]; - /** @enum {string} */ - product: "powercontext"; - /** @description Opaque identity of the durable Server deployment. It is unrelated to Access deployment_id and remains stable across restarts, upgrades, backup restore and replicas sharing the same primary database. */ - server_id: string; - package_version: string; - /** @description Major/minor projection of the OpenAPI info.version served by this package. */ - api_contract_version: components["schemas"]["ContractVersion"]; - /** @description Stable feature groups keyed by contract name. */ - feature_contracts: { - [key: string]: components["schemas"]["FeatureContract"]; - }; - }; - RecallManifestRequest: { - scope_id: string; - kind?: string | null; - tags?: string[]; - tag_match?: components["schemas"]["TagMatch"]; - /** @default 50 */ - limit: number; - cursor?: string | null; - }; - RecallManifestEntry: { - ref: components["schemas"]["ArtifactReference"]; - kind: string; - /** @description Stored summary, or null for historical records awaiting explicit summary completion. No body is read to synthesize it. */ - description: string | null; - state_version: number; + RecallManifestEntry: { + ref: components["schemas"]["ArtifactReference"]; + kind: string; + /** @description Stored summary, or null for historical records awaiting explicit summary completion. No body is read to synthesize it. */ + description: string | null; + state_version: number; }; RecallManifestResponse: { items: components["schemas"]["RecallManifestEntry"][]; @@ -3530,7 +3379,7 @@ export interface components { effect: "review_then_publish" | "review_then_publish_configuration" | "review_then_replace_tags"; }; /** @enum {string} */ - PluginKind: "artifact"; + PluginKind: "artifact" | "source"; /** @enum {string} */ PluginLoadStatus: "loaded" | "failed"; /** @enum {string} */ @@ -3545,6 +3394,115 @@ export interface components { /** @description The route is also an MCP tool. */ mcp: boolean; }; + /** @description The Source plugin and version a runner used; the Server accepts it only within the loaded plugin's compatible range. */ + PluginVersionRef: { + name: string; + version: string; + }; + SourceRunnerPluginReport: { + name: string; + /** @description Null when the plugin failed to load on the runner. */ + version: string | null; + /** @description Safe load failure category on the runner. */ + error: string | null; + }; + SourceRunnerSyncRequest: { + plugins: components["schemas"]["SourceRunnerPluginReport"][]; + }; + SourceRunnerTarget: { + scope_id: string; + target_id: string; + capability: string; + parameters: { + [key: string]: unknown; + }; + /** @description The target's last committed checkpoint; null before the first commit. */ + checkpoint: unknown; + }; + SourceRunnerRejection: { + name: string; + /** @enum {string} */ + code: "plugin_version_incompatible" | "plugin_not_loaded"; + }; + SourceRunnerSyncResult: { + targets: components["schemas"]["SourceRunnerTarget"][]; + rejected: components["schemas"]["SourceRunnerRejection"][]; + }; + SourceRunnerCapability: { + name: string; + /** @description The version the runner reported. */ + version: string | null; + error: string | null; + /** @description This Server loaded the plugin and accepts the runner's version. */ + compatible: boolean; + /** @description From the plugin this Server loaded; null when it loaded none. */ + description: string | null; + parameters_schema: { + [key: string]: unknown; + } | null; + /** @description Identity parameters and their normalization steps. */ + identity: { + [key: string]: string[]; + } | null; + }; + SourceRunner: { + runner: string; + /** + * Format: date-time + * @description Null for the Server's own runner. + */ + last_seen_at: string | null; + capabilities: components["schemas"]["SourceRunnerCapability"][]; + }; + SourceRunnerPage: { + items: components["schemas"]["SourceRunner"][]; + }; + SourceTargetCreateRequest: { + runner: string; + capability: string; + parameters: { + [key: string]: unknown; + }; + }; + SourceTargetUpdateRequest: { + enabled?: boolean; + parameters?: { + [key: string]: unknown; + }; + }; + SourceTargetStatus: { + /** Format: date-time */ + last_attempt_at: string | null; + /** Format: date-time */ + last_success_at: string | null; + consecutive_failures: number; + last_error_code: string | null; + last_error_message: string | null; + }; + /** @description The outcome of the collection attempt whose checkpoint this commits. */ + SourceTargetResultStatus: { + succeeded: boolean; + error_code?: string | null; + error_message?: string | null; + }; + SourceTarget: { + scope_id: string; + target_id: string; + runner: string; + capability: string; + target_key: string; + parameters: { + [key: string]: unknown; + }; + enabled: boolean; + /** Format: date-time */ + updated_at: string; + updated_by: string; + status: components["schemas"]["SourceTargetStatus"]; + }; + SourceTargetPage: { + items: components["schemas"]["SourceTarget"][]; + }; /** @description An OpenAPI 3.1 document describing one plugin's routes. */ PluginOpenApiDocument: { [key: string]: unknown; @@ -4196,6 +4154,8 @@ export interface components { SubmitSourceObservationRequest: { scope_id: string; observation: components["schemas"]["SourceObservation"]; + /** @description Set by collection runners. The Server checks the version against the Source plugin it loaded and has that plugin resolve and project the observation; unsupported versions return plugin_version_incompatible. */ + plugin?: components["schemas"]["PluginVersionRef"]; }; SourceObservationReceipt: { source: components["schemas"]["SourceReference"]; @@ -4205,6 +4165,10 @@ export interface components { binding: components["schemas"]["ConnectorBinding"]; expected: unknown; checkpoint: unknown; + /** @description Set by collection runners for a target's checkpoint; checked like a runner's observations. */ + plugin?: components["schemas"]["PluginVersionRef"]; + /** @description The collection attempt's outcome, recorded on the target together with the checkpoint. */ + status?: components["schemas"]["SourceTargetResultStatus"]; }; CommitHandoffRequest: { scope_id: string; @@ -6327,222 +6291,45 @@ export interface components { /** @default [] */ versions: components["schemas"]["Candidate"][]; }; - SourceCapabilityQuery: { - scope_id: string; - capability?: string; - instance_id?: string; - input?: { - [key: string]: unknown; - }; - }; - SourceWritePluginLoadRequest: { - id: string; - entry: string; + PlanAccessOwnershipRequest: { + /** @description Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. */ + scope_ids?: string[]; + /** @description Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required. */ + background_principal?: components["schemas"]["AccessPrincipal"] | null; }; - SourceWritePluginState: { - id: string; - entry: string; - capabilities: { - [key: string]: unknown; - }[]; + /** @enum {string} */ + OwnershipGapKind: "artifact_owner" | "candidate_attestation"; + OwnershipGapItem: { + kind: components["schemas"]["OwnershipGapKind"]; + scope_id: string; + family: string; + artifact_id?: string | null; + entry_id?: string | null; + candidate_id?: string | null; + version?: number | null; + conflict_code?: string | null; }; - SourceAddRequest: { + AccessOwnershipScopePlan: { scope_id: string; - capability: string; - capability_version: string; - instance_id?: string; - input: { - [key: string]: unknown; - }; + artifact_gaps: components["schemas"]["OwnershipGapItem"][]; + candidate_gaps: components["schemas"]["OwnershipGapItem"][]; }; - SourceStatusRequest: { + BackgroundPermissionGap: { scope_id: string; - target_id: string; - /** - * @default result - * @enum {string} - */ - view: "result" | "control"; + principal_id: string; + /** @enum {boolean} */ + allowed: false; + reason_code: string | null; }; - SourceInstanceSetEnabledRequest: { - instance_id: string; - enabled: boolean; + AccessOwnershipPlan: { + mode: components["schemas"]["AccessControlMode"]; + scopes: components["schemas"]["AccessOwnershipScopePlan"][]; + background: components["schemas"]["BackgroundPermissionGap"][]; }; - SourceConfigureRequest: { - /** @default false */ - update: boolean; - registration: { - [key: string]: unknown; - }; - }; - SourceCapabilityResponse: { - capabilities: { - [key: string]: unknown; - }[]; - /** @enum {string} */ - management: "not-configured" | "online" | "unavailable"; - }; - SourceInstancesResponse: { - instances: { - [key: string]: unknown; - }[]; - capabilities?: { - [key: string]: unknown; - }[]; - /** @enum {string} */ - management?: "not-configured" | "online" | "unavailable"; - }; - SourceCollectionResult: { - /** @enum {string} */ - outcome: "saved" | "unchanged"; - source_refs: components["schemas"]["SourceTypeReference"][]; - }; - SourceManagementReceipt: { - target_id?: string; - /** @enum {string} */ - tracking?: "enabled" | "stopping" | "stopped"; - result?: components["schemas"]["SourceCollectionResult"]; - error?: string; - instance_id?: string; - capability?: string; - capability_version?: string; - /** @enum {string} */ - availability?: "online" | "offline" | "unavailable"; - updated_at?: string; - /** @enum {string} */ - kind?: "collect" | "write"; - enabled?: boolean; - /** @description Default collect status only. The single Source reference from the submission report most recently received by the manager, or empty before any report. Omitted from collect add responses; not a batch result or submission history. */ - source_refs?: components["schemas"]["SourceTypeReference"][]; - /** @description Default collect status only. Runtime-reported submission time paired with source_refs, or null before any report. Selection follows manager receipt order, not timestamp order. Omitted from collect add responses. */ - last_submitted_at?: string | null; - }; - SourceInstanceStatus: { - targets_initialized?: boolean; - target_id?: string; - instance_id: string; - enabled?: boolean; - actual?: string; - run_id?: string | null; - availability?: string; - reused?: boolean; - }; - SourceChannelOpen: { - protocol: string; - deployment_id: string; - manager_id: string; - }; - SourceChannelRequest: { - generation: string; - message_id?: string; - response?: { - [key: string]: unknown; - }; - catalog?: { - [key: string]: unknown; - }; - }; - SourceChannelResponse: { - generation?: string; - protocol?: string; - confirmed?: boolean; - messages?: { - [key: string]: unknown; - }[]; - }; - SourceInstanceExchangeRequest: { - generation?: string; - protocol?: string; - message_id?: string; - execution_location?: string; - configuration_digest?: string; - run_id?: string; - execution?: string; - target_id?: string; - capabilities?: { - [key: string]: unknown; - }[]; - response?: { - [key: string]: unknown; - }; - owner?: { - [key: string]: unknown; - }; - /** @enum {string} */ - status?: "running" | "complete" | "completed" | "pending" | "failed" | "stopped" | "cleanup-failed"; - /** @description One confirmed Source submission reported by the Runtime; not a batch or historical reference list. */ - source_refs?: components["schemas"]["SourceTypeReference"][]; - error?: string; - targets?: { - [key: string]: unknown; - }[]; - cursor?: string; - owner_exited?: boolean; - previous_owner?: { - [key: string]: unknown; - }; - submitted_at?: string; - }; - SourceInstanceExchangeResponse: { - [key: string]: unknown; - }; - SourceTargetSetEnabledRequest: { - scope_id: string; - target_id: string; - enabled: boolean; - }; - SourceInstancesQuery: { - scope_id?: string; - capability?: string; - instance_id?: string; - input?: { - [key: string]: unknown; - }; - /** - * @default discovery - * @enum {string} - */ - view: "discovery" | "control"; - }; - PlanAccessOwnershipRequest: { - /** @description Scopes to inspect; every Scope when omitted. Pass Scopes incrementally for large deployments. */ - scope_ids?: string[]; - /** @description Optional background processing Principal, such as ACCESS_BACKGROUND_PRINCIPAL_ID; each selected Scope is checked for scope.contribute and write permission on an existing Profile, including after all ownership gaps have been filled. An available Access store is required. */ - background_principal?: components["schemas"]["AccessPrincipal"] | null; - }; - /** @enum {string} */ - OwnershipGapKind: "artifact_owner" | "candidate_attestation"; - OwnershipGapItem: { - kind: components["schemas"]["OwnershipGapKind"]; - scope_id: string; - family: string; - artifact_id?: string | null; - entry_id?: string | null; - candidate_id?: string | null; - version?: number | null; - conflict_code?: string | null; - }; - AccessOwnershipScopePlan: { - scope_id: string; - artifact_gaps: components["schemas"]["OwnershipGapItem"][]; - candidate_gaps: components["schemas"]["OwnershipGapItem"][]; - }; - BackgroundPermissionGap: { - scope_id: string; - principal_id: string; - /** @enum {boolean} */ - allowed: false; - reason_code: string | null; - }; - AccessOwnershipPlan: { - mode: components["schemas"]["AccessControlMode"]; - scopes: components["schemas"]["AccessOwnershipScopePlan"][]; - background: components["schemas"]["BackgroundPermissionGap"][]; - }; - ApplyAccessOwnershipRequest: { - owner: components["schemas"]["AccessPrincipal"]; - /** @description Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments. */ - scope_ids?: string[]; + ApplyAccessOwnershipRequest: { + owner: components["schemas"]["AccessPrincipal"]; + /** @description Scopes to migrate; every Scope when omitted. Pass Scopes incrementally for large deployments. */ + scope_ids?: string[]; /** @default false */ dry_run: boolean; }; @@ -8305,280 +8092,259 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; - list_plugin_scopes: { + pc_source_runners: { parameters: { - query?: { - cursor?: string; - limit?: number; - }; + query?: never; header?: never; - path: { - name: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Scopes the plugin has been asked to process, ordered by Scope ID. */ + /** @description Runners ordered by name. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PluginScopePage"]; + "application/json": components["schemas"]["SourceRunnerPage"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; 503: components["responses"]["Unavailable"]; }; }; - merge_artifacts: { + sync_source_runner: { parameters: { query?: never; header?: never; path: { - scope_id: string; - family: components["schemas"]["ArtifactReadFamily"]; + runner: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["MergeArtifactsRequest"]; + "application/json": components["schemas"]["SourceRunnerSyncRequest"]; }; }; responses: { - /** @description The merge result and the frozen inputs. */ + /** @description The runner's current targets. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ArtifactGovernanceResponse"]; + "application/json": components["schemas"]["SourceRunnerSyncResult"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; 409: components["responses"]["Conflict"]; 422: components["responses"]["InvalidRequest"]; 503: components["responses"]["Unavailable"]; }; }; - get_artifact_governance: { + delete_source_runner: { parameters: { query?: never; header?: never; path: { - scope_id: string; - family: components["schemas"]["ArtifactReadFamily"]; - artifact_id: string; + runner: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The current governance state. */ - 200: { + /** @description The runner record is deleted. */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["ArtifactGovernanceRecord"]; - }; + content?: never; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"]; - 422: components["responses"]["InvalidRequest"]; + 409: components["responses"]["Conflict"]; 503: components["responses"]["Unavailable"]; }; }; - forget_artifact: { + pc_source_status: { parameters: { query?: never; header?: never; path: { scope_id: string; - family: components["schemas"]["ArtifactReadFamily"]; - artifact_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ForgetArtifactRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description The Artifact's governance state after forgetting. */ + /** @description Targets ordered by ID. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ArtifactGovernanceResponse"]; + "application/json": components["schemas"]["SourceTargetPage"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; 503: components["responses"]["Unavailable"]; }; }; - restore_artifact: { + pc_source_add: { parameters: { query?: never; header?: never; path: { scope_id: string; - family: components["schemas"]["ArtifactReadFamily"]; - artifact_id: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["RestoreArtifactRequest"]; + "application/json": components["schemas"]["SourceTargetCreateRequest"]; }; }; responses: { - /** @description The revisions restored and retired by the operation. */ + /** @description The same target already exists. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ArtifactRestorationResult"]; + "application/json": components["schemas"]["SourceTarget"]; + }; + }; + /** @description The target was added. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SourceTarget"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; 409: components["responses"]["Conflict"]; 422: components["responses"]["InvalidRequest"]; 503: components["responses"]["Unavailable"]; }; }; - search_artifacts: { + pc_source_target_delete: { parameters: { query?: never; header?: never; path: { scope_id: string; - family: string; + target_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SearchArtifactsRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Complete matching Artifact revisions in retrieval order. */ - 200: { + /** @description The target is deleted. */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["SearchArtifactsResponse"]; - }; + content?: never; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"]; - 422: components["responses"]["InvalidRequest"]; - 500: components["responses"]["InternalError"]; 503: components["responses"]["Unavailable"]; }; }; - get_topic_memory_processing_status: { + pc_source_target_set_enabled: { parameters: { query?: never; header?: never; - path?: never; + path: { + scope_id: string; + target_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["TopicMemoryProcessingStatusRequest"]; + "application/json": components["schemas"]["SourceTargetUpdateRequest"]; }; }; responses: { - /** @description Current durable processing state. */ + /** @description The changed target. */ 200: { headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicMemoryProcessingStatus"]; + "application/json": components["schemas"]["SourceTarget"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; 422: components["responses"]["InvalidRequest"]; - 500: components["responses"]["InternalError"]; 503: components["responses"]["Unavailable"]; }; }; - get_topic_memory_processing_block: { + list_plugin_scopes: { parameters: { - query?: never; + query?: { + cursor?: string; + limit?: number; + }; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["GetTopicMemoryProcessingBlockRequest"]; + path: { + name: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The exact retained block. */ + /** @description Scopes the plugin has been asked to process, ordered by Scope ID. */ 200: { headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TopicMemoryProcessingBlock"]; + "application/json": components["schemas"]["PluginScopePage"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"]; - 422: components["responses"]["InvalidRequest"]; - 500: components["responses"]["InternalError"]; 503: components["responses"]["Unavailable"]; }; }; - retry_topic_memory_processing: { + merge_artifacts: { parameters: { query?: never; header?: never; - path?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["RetryTopicMemoryProcessingRequest"]; + "application/json": components["schemas"]["MergeArtifactsRequest"]; }; }; responses: { - /** @description One retry was durably accepted, including duplicate acceptance. */ + /** @description The merge result and the frozen inputs. */ 200: { headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RetryTopicMemoryProcessingResponse"]; + "application/json": components["schemas"]["ArtifactGovernanceResponse"]; }; }; 401: components["responses"]["Unauthorized"]; @@ -8586,50 +8352,272 @@ export interface operations { 404: components["responses"]["NotFound"]; 409: components["responses"]["Conflict"]; 422: components["responses"]["InvalidRequest"]; - 500: components["responses"]["InternalError"]; 503: components["responses"]["Unavailable"]; }; }; - search_topic_memory: { + get_artifact_governance: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SearchTopicMemoryRequest"]; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Matching current Topic Memory revisions, including the actual mode used. */ + /** @description The current governance state. */ 200: { headers: { - "X-PowerContext-Request-ID": components["headers"]["RequestId"]; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SearchTopicMemoryResponse"]; + "application/json": components["schemas"]["ArtifactGovernanceRecord"]; }; }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; 422: components["responses"]["InvalidRequest"]; - 500: components["responses"]["InternalError"]; 503: components["responses"]["Unavailable"]; }; }; - get_topic_memory: { + forget_artifact: { parameters: { query?: never; header?: never; - path?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["GetTopicMemoryRequest"]; + "application/json": components["schemas"]["ForgetArtifactRequest"]; + }; + }; + responses: { + /** @description The Artifact's governance state after forgetting. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactGovernanceResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + restore_artifact: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: components["schemas"]["ArtifactReadFamily"]; + artifact_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RestoreArtifactRequest"]; + }; + }; + responses: { + /** @description The revisions restored and retired by the operation. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArtifactRestorationResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + search_artifacts: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + family: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SearchArtifactsRequest"]; + }; + }; + responses: { + /** @description Complete matching Artifact revisions in retrieval order. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchArtifactsResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + get_topic_memory_processing_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TopicMemoryProcessingStatusRequest"]; + }; + }; + responses: { + /** @description Current durable processing state. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TopicMemoryProcessingStatus"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + get_topic_memory_processing_block: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GetTopicMemoryProcessingBlockRequest"]; + }; + }; + responses: { + /** @description The exact retained block. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TopicMemoryProcessingBlock"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + retry_topic_memory_processing: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RetryTopicMemoryProcessingRequest"]; + }; + }; + responses: { + /** @description One retry was durably accepted, including duplicate acceptance. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RetryTopicMemoryProcessingResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + search_topic_memory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SearchTopicMemoryRequest"]; + }; + }; + responses: { + /** @description Matching current Topic Memory revisions, including the actual mode used. */ + 200: { + headers: { + "X-PowerContext-Request-ID": components["headers"]["RequestId"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchTopicMemoryResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 422: components["responses"]["InvalidRequest"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + }; + }; + get_topic_memory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GetTopicMemoryRequest"]; }; }; responses: { @@ -11065,436 +11053,6 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; - pc_source_capabilities: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceCapabilityQuery"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceCapabilityResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - pc_source_add: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceAddRequest"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceManagementReceipt"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - pc_source_status: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceStatusRequest"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceManagementReceipt"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - pc_source_target_set_enabled: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceTargetSetEnabledRequest"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceManagementReceipt"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - pc_source_instances: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceInstancesQuery"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceInstancesResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - pc_source_instance_set_enabled: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceInstanceSetEnabledRequest"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceInstanceStatus"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_configure: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceConfigureRequest"]; - }; - }; - responses: { - /** @description Confirmed Source management result. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceInstanceStatus"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - load_source_write_plugin: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceWritePluginLoadRequest"]; - }; - }; - responses: { - /** @description The Server has loaded and persisted the executable write capabilities. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceWritePluginState"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_channel_open: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceChannelOpen"]; - }; - }; - responses: { - /** @description Channel exchange. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceChannelResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_channel_catalog: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceChannelRequest"]; - }; - }; - responses: { - /** @description Channel exchange. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceChannelResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_channel_poll: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceChannelRequest"]; - }; - }; - responses: { - /** @description Channel exchange. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceChannelResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_channel_reply: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceChannelRequest"]; - }; - }; - responses: { - /** @description Channel exchange. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceChannelResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_channel_close: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceChannelRequest"]; - }; - }; - responses: { - /** @description Channel exchange. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceChannelResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; - source_instance_exchange: { - parameters: { - query?: never; - header?: never; - path: { - instance_id: string; - operation: "owner" | "connect" | "poll" | "reply" | "sync" | "execution" | "results" | "stopped-targets" | "close"; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SourceInstanceExchangeRequest"]; - }; - }; - responses: { - /** @description Instance control exchange. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SourceInstanceExchangeResponse"]; - }; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; - }; - }; plan_access_ownership: { parameters: { query?: never; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index a100cb0cfc..d69f4aa283 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 = "ad0fa0e1209f2dc60935416ae8d54826335b82b35b81c999e3ae77a21325f0ef"; +export const contractSha256 = "e12490d40a86f30b8540727d3fd9c4ce50a9edcb9c8a17d8b22634f06d942073"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/develop/plugins.md b/docs/en/docs/develop/plugins.md index 2931c4a795..632f7a7629 100644 --- a/docs/en/docs/develop/plugins.md +++ b/docs/en/docs/develop/plugins.md @@ -13,6 +13,7 @@ A plugin is a folder in the Server's plugin directory, with `plugin.py` as its e | Present and search | Give each revision's default presentation, and optionally search, so the generic search API and Prepared Context can use the plugin's data | | Support user changes | Implement create, update, merge, forget, and restore methods so the generic Artifact APIs work on the plugin's family | | Define Source types | Declare the plugin's own Source types and Projections to record what users asked for, such as an upload | +| Collect Sources | Collect continuously from external systems with a `SourcePlugin`, run by the Server's runner or runners on devices for assigned targets; see [Source plugins](../workflows/source-plugins.md) | | Provide APIs | Declare HTTP routes under `/v1/plugins//api/`; core authenticates callers and forwards requests to the plugin | | Provide Dashboard pages | Ship static pages that the Dashboard shows in a sandboxed iframe | | Use dedicated storage and models | Keep the plugin's own data in separate storage that core allocates, and call the embedding and generation models Server configures | @@ -319,6 +320,10 @@ plugin = ArtifactPlugin( In `generate`, branch on `source.ref.source_type`. +To provide only Source types without generating Artifacts, or to collect continuously from external systems, assign a +`SourcePlugin` in `plugin.py` instead; see [Source plugins](../workflows/source-plugins.md). Source plugins can declare +`api`, `pages`, and `upgrade` too. + ## Provide APIs A plugin can declare its own HTTP routes; handlers are async methods of the plugin object: diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index 0856520490..434586510a 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -198,11 +198,9 @@ UDS configurations. The configuration wizard also produces TCP configurations. A HTTP(S) URL when the Core uses UDS. Remote Skill Receiver endpoints retain their HTTPS or loopback-peer requirement; direct UDS does not supply a loopback peer and does not waive that check. -Automatic Source deployment with `POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR` or -`POWERCONTEXT_SERVER_SOURCE_MANAGEMENT_AUTOSTART` requires `POWERCONTEXT_SERVER_PUBLIC_URL` pointing to the proxy -when the Server uses UDS. The collect manager and runtimes connect through that URL; missing configuration is rejected -before deployment credentials or processes are created. `source load --kind write` accepts the same setting or an explicit -`--server-url`. These commands do not infer a TCP endpoint from `HTTP_PORT` in UDS mode. +When the Server uses UDS, `powercontext source-agent` on a device connects to it through the proxy's HTTP(S) URL given +with `--server`; it does not infer a TCP endpoint from `HTTP_PORT`. The Server's own collection runner runs inside the +Server and needs no address. This listener provides the Core transport for the [Rust Gateway design (RFC #2199)](https://github.com/oceanbase/powercontext/pull/2199). The Rust executable, joint launcher, gateway settings, private channel authentication, private routes, and gateway state diff --git a/docs/en/docs/workflows/source-plugins.md b/docs/en/docs/workflows/source-plugins.md index 87eca81725..9c34bd5057 100644 --- a/docs/en/docs/workflows/source-plugins.md +++ b/docs/en/docs/workflows/source-plugins.md @@ -1,175 +1,175 @@ --- title: Source plugins -description: Load trusted local plugins for synchronous writes or ongoing Source collection through the public MCP. +description: Provide your own Source types with Source plugins, and collect continuously through the Server's runner or runners on devices, by assigned targets. --- # 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. +Source plugins live in the same plugin directory as Artifact plugins (`POWERCONTEXT_SERVER_PLUGINS_DIR`): `plugin.py` +assigns a `SourcePlugin` to `plugin`, and the directory name is the plugin name. A Source plugin can: -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`). +- **Only provide Source types**: it declares `definitions`; callers submit content through the existing Source write API, + and the Server runs `resolve` and the Projections before storing it. +- **Collect**: it also declares `collect`, `target`, and `setup`. Runners collect: the Server has its own runner named + `server`, and devices run `powercontext source-agent`. Users assign collection targets to a runner, and the runner + fetches its targets on a schedule and submits the results. -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. +Source plugins load, update, and unload like Artifact plugins; see [Write a plugin](../develop/plugins.md). Runnable +examples are in `examples/source_plugins/` in the repository. -| 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. +## Permissions -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"} -}) -``` +| Operation | Required permission | +| --- | --- | +| List runners and their capabilities (`pc_source_runners`) | Authenticated | +| Read a Scope's targets and status (`pc_source_status`) | `scope.read` on the Scope | +| Add, change, disable, or delete targets of a device runner | `scope.contribute` on the Scope | +| Add, change, disable, or delete targets of the `server` runner | `server.admin` | +| Sync a device runner and submit Sources and checkpoints | The runner itself, and `scope.contribute` on the target Scope | +| Delete a runner record | `server.admin` | -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. +The `server` runner reaches external systems with credentials from the Server environment, so deciding what it collects +is an operator decision and requires `server.admin`. Plugins read their own credentials for external systems (for +example from environment variables); those do not replace PowerContext permissions. -## Load collect on a running Server +## Only provide Source types -Enable authentication and PC Access, and configure a trusted instance directory in the Server environment file: +`examples/source_plugins/images` declares one Source type and nothing else: -```dotenv -POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR=/srv/powercontext/source-plugins -POWERCONTEXT_SERVER_ACCESS='{"mode":"enforced"}' +```python +plugin = SourcePlugin(version="1.0.0", definitions=(ImageDefinition(),)) ``` -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: +Once the directory is in the plugin directory, callers submit through the generic Source API: -```yaml -id: my-tickets -plugin: . -scope_ids: [SCOPE_ID] -config: - file: tickets.json - poll_interval: 10 +```bash +curl -X POST "$SERVER/v1/scopes/$SCOPE/sources" -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"source_type": "example-image", "content": {"media_type": "image/png", "data_base64": "..."}}' ``` -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 +The Server validates the input, runs `resolve` and the Projections in the plugin's host process, and deduplicates by +Source identity. + +## Collect on the Server + +`examples/source_plugins/tickets` collects tickets from a JSON file on the runner's machine: + +```python +plugin = SourcePlugin( + version="1.0.0", + definitions=(TicketDefinition(),), + collect=Collect(schedule=Interval(seconds=10)), + target=TargetSpec( + description="Collect tickets from a JSON file on the runner", + parameters=TicketFile, + identity={"file": ("strip",)}, + ), + setup=setup, +) ``` -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: +An administrator assigns a target to the `server` runner (MCP examples use the `tool(arguments)` shorthand): ```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"}) +pc_source_runners() +pc_source_add({"scope_id": "SCOPE_ID", "runner": "server", "capability": "tickets", + "parameters": {"file": "/srv/data/tickets.json"}}) ``` -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. +A new target is collected right away, and then on the plugin's `schedule`. Each collection runs in a Supervisor Worker +for the enabled `server` targets of one Scope; the returned Sources, each target's new checkpoint, and its status are +stored in one transaction. -## Remote collect Runtimes +## Collect on a device -Prepare the code on its execution machine and add this deployment block: +Put a copy of the same plugin on the device and run the runner with a credential that has `scope.contribute` on the +target Scopes: -```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 +```bash +export POWERCONTEXT_SOURCE_AGENT_TOKEN=... # the runner's credential +powercontext source-agent --server https://memory.example.com --plugins /opt/robot/plugins ``` -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. +- The runner is named after its credential's subject unless `--name` gives a name; devices sharing a credential must + each set one. The name `server` is reserved for the Server's own runner. A name is bound to the credential subject of + its first sync, and other subjects cannot sync with it. +- The runner syncs every 30 seconds (`--interval`): it reports the plugins and versions it loaded and fetches every + target assigned to it across Scopes. New targets are collected right away; the others follow the plugin's `schedule`. +- For each target the runner submits the Sources first and then the new checkpoint and status, with the old checkpoint + as the expected value. If anything fails in between, the checkpoint does not advance; the next collection repeats the + same content, and the Server deduplicates it by Source identity. +- Every Source plugin used on devices must also be in the Server's plugin directory: capability descriptions, parameter + schemas, and Projections follow the version the Server loaded. -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: +Assign a target to a device: ```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 +pc_source_add({"scope_id": "SCOPE_ID", "runner": "robot-7", "capability": "ticket-chat", + "parameters": {"file": "/var/lib/robot/chats.json"}}) ``` -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: +## Manage targets ```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"}) +pc_source_status({"scope_id": "SCOPE_ID"}) +pc_source_target_set_enabled({"scope_id": "SCOPE_ID", "target_id": "...", "enabled": false}) +pc_source_target_delete({"scope_id": "SCOPE_ID", "target_id": "..."}) ``` -`stopping` means the stop intent is saved; `stopped` means Runtime has confirmed the stop. Set `enabled` to `true` to resume; this does not enable a disabled instance. A disconnected manager does not prove that collection stopped. Disabling preserves Sources, targets, and checkpoints. Source saving, checkpoint updates, and submission reports are separate operations. - -The current implementation has no complete unload command that deletes an instance registration and its targets. `runtime stop` stops execution while preserving management records and saved data. Stopping Server or deleting a plugin directory does not uninstall the independent services. - -## Troubleshooting - -| Symptom | Check or action | -| --- | --- | -| Startup logs report `background-unsupported` or `service-unavailable` | Local collect deployment requires Linux; `local-service` also requires a working `systemctl --user`. Check the execution platform and selected process mode. | -| Manager disconnected | Collect can continue with synchronized targets; new management calls may fail. Built-in and loaded write capabilities do not depend on the manager. | -| Runtime restarts while the manager is unreachable | Wait for target synchronization; do not fall back to initial targets in the file. | -| A target produces no new Sources | Check that collection logic is still running, its schedule, external credentials, and Scope permissions. A live process does not prove successful collection. | -| State remains stopping | Check the original Runtime and management connection; disconnection does not confirm a stop. | -| A saved Source is absent from target status | Submission reports may be lost. Target status is not a complete inventory of Server data; do not rewind checkpoints on that basis. | -| An agent does not use saved content | Check that collection and host Scopes match, then check downstream Projection, Artifact, and indexing processing. | - -An ongoing collector maintains its own loop in `apply`. After `apply` exits, new targets or reconnection do not restart it. To run it again, explicitly disable the instance, confirm it has stopped, then enable it. - -## Runnable examples - -The repository's `examples/source_plugins` groups examples by source: tickets, ticket/chat and images. Each directory contains its code and loading configuration. The tickets example shares one Definition and Projection implementation across write and collect: use `write.yml` for Server-local synchronous writes or `powercontext.yml` for managed collection. Loading and MCP usage are documented in `examples/source_plugins/README.zh.md`. +- **Deduplication**: the Server validates `parameters` against the plugin version it loaded and computes the target key + from the `identity` parameters after their declared normalization steps. When a target with the same key exists for + the same Scope, runner, and capability, identical parameters return the existing target and different ones a + conflict. +- **Changes**: parameters outside the identity and enablement can change; identity parameters cannot + (`identity_immutable`). To collect another object, add a target, so a checkpoint always belongs to one external object. +- **Disabling and deleting**: a disabled target is no longer synced to its runner and keeps its checkpoint, resuming + where it left off; deleting also deletes the checkpoint, and collected Sources stay. +- **Status**: each target records the latest attempt, the latest success, consecutive failures, and the latest error. A + runner's `last_seen_at` later than a target's `updated_at` means the runner has fetched the latest configuration. + +## The collection contract + +```python +async def collect(self, request: CollectRequest) -> CollectResult: + result = CollectResult() + for target in request.targets: + ... + result.add(target, items, checkpoint=new_checkpoint, has_more=False) + # When only this target fails: result.fail(target, code="...", message="...") + return result +``` -Keep generated credentials, databases, logs, and process state on the deployment machine. Commit plugin code, configuration templates without sensitive values, and synthetic example data. +- One call covers one Scope and at most `max_targets` enabled targets; with more targets, they rotate. +- Returning more items than `max_items` in total, or a target that was not requested, fails the whole call without + truncation. Targets missing from the result keep their checkpoints. +- `fail` fails only that target: its checkpoint stays and its status records the error, while the other targets in the + call still commit. A `collect` that raises fails the whole call. +- Put blocking reads in `await asyncio.to_thread(...)`. On a device `ctx.storage` is unavailable; `ctx.center` exists + only on a device, and `ctx.center.call_plugin(method, path, json=..., content=...)` calls the plugin's own routes on + the Server, for example to upload a large file before submitting a Source that refers to it. + +## Versions and upgrades + +`SourcePlugin` declares `version` (PEP 440) and an optional `min_compatible_version` (default: `version`). When a +device's plugin version satisfies `Server min_compatible_version <= device version <= Server version`, the Server accepts +its syncs, Sources, checkpoints, and `call_plugin` requests; otherwise it returns `plugin_version_incompatible`, sync +withholds that plugin's targets, and `pc_source_runners` shows it as incompatible. + +- **Upgrade the Server first, then devices.** Device versions newer than the Server's are rejected too. With several + Server instances, finish upgrading all of them first. +- Target parameters follow the Server version's schema and may carry fields older devices do not know, so parameter + models must not set `extra="forbid"`. When new parameters must take effect on every device, raise + `min_compatible_version`. +- The Server calls `upgrade(ctx)` every time it loads a plugin version (see [Write a plugin](../develop/plugins.md)); + devices do not call `upgrade`, and plugins check and migrate their own local files in `setup`. + +## Migrating from the previous Source plugin framework + +`/v1/source-management/*`, `powercontext source load`, `powercontext.yml`, and `write.yml` are removed: + +- Rewrite a write plugin as a `SourcePlugin` that declares `definitions`, and put it in the plugin directory. +- Rewrite a collect plugin as a `SourcePlugin` that declares `collect`, `target`, and `setup`; target identity computed + on the device becomes the `identity` declaration. +- Before upgrading, unload every loaded write plugin on the previous version; the migration refuses to upgrade while the + database still lists write plugins. diff --git a/docs/zh/docs/develop/plugins.md b/docs/zh/docs/develop/plugins.md index 1269b8f866..54dbbea526 100644 --- a/docs/zh/docs/develop/plugins.md +++ b/docs/zh/docs/develop/plugins.md @@ -13,6 +13,7 @@ description: 用一个目录和 plugin.py 扩展 PowerContext:生成、展示 | 展示和检索 | 给出每个版本的默认展示;按需实现检索,让通用检索接口和 Prepared Context 能用上插件的数据 | | 支持用户修改 | 实现新建、修改、合并、遗忘、恢复方法,让通用 Artifact 接口作用于插件的 family | | 定义 Source 类型 | 声明插件自己的 Source 类型和 Projection,记录用户要求做的事,例如一次上传 | +| 采集 Source | 用 `SourcePlugin` 从外部系统持续采集,由 Server 或设备上的 runner 按分配的目标执行,见 [Source 插件](../workflows/source-plugins.md) | | 提供接口 | 声明 HTTP 路由,挂在 `/v1/plugins/<插件名>/api/` 下,由 core 认证并转发给插件 | | 提供 Dashboard 页面 | 随插件提供静态页面,在 Dashboard 中以沙箱 iframe 展示 | | 使用专属存储和模型 | 在 core 分配的独立存储中保存自己的数据,调用 Server 配置的 embedding 和生成模型 | @@ -299,6 +300,9 @@ plugin = ArtifactPlugin( `generate` 中按 `source.ref.source_type` 区分。 +不生成 Artifact、只提供 Source 类型,或者需要从外部系统持续采集时,`plugin.py` 改为赋值一个 `SourcePlugin`,见 +[Source 插件](../workflows/source-plugins.md)。Source 插件同样可以声明 `api`、`pages` 和 `upgrade`。 + ## 提供接口 插件可以声明自己的 HTTP 路由,处理函数是插件对象上的 async 方法: diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index 4e6f367ad8..e4b0ab23ca 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -182,11 +182,8 @@ asyncio.run(main()) 配置。Core 使用 UDS 时,Agent 宿主接入需要显式提供代理的 HTTP(S) URL。远端 Skill Receiver endpoint 仍要求 HTTPS 或 loopback peer;直接 UDS 连接没有 loopback peer,不会豁免该检查。 -Server 使用 UDS 时,通过 `POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR` 或 -`POWERCONTEXT_SERVER_SOURCE_MANAGEMENT_AUTOSTART` 启用 Source 自动部署,还需将 -`POWERCONTEXT_SERVER_PUBLIC_URL` 配置为代理地址。collect manager 和 runtime 通过该 URL 连接;未配置时会在创建 -部署凭据或进程前拒绝启动。`source load --kind write` 使用同一配置,也可显式传入 `--server-url`。UDS 模式下, -这些命令不会根据 `HTTP_PORT` 推断 TCP 地址。 +Server 使用 UDS 时,设备上的 `powercontext source-agent` 通过 `--server` 传入代理的 HTTP(S) URL 连接中心; +它不会根据 `HTTP_PORT` 推断 TCP 地址。Server 自带的采集 runner 在 Server 内部运行,不需要额外的地址。 这一监听入口提供 [Rust 网关设计(RFC #2199)](https://github.com/oceanbase/powercontext/pull/2199)所需的 Core 传输能力。 Rust 可执行程序、联合 launcher、网关配置、私有通道认证、私有路由和网关状态表仍需单独实现。设计中的网关负责原有 diff --git a/docs/zh/docs/workflows/source-plugins.md b/docs/zh/docs/workflows/source-plugins.md index b3633e1037..e7e2bcb8f1 100644 --- a/docs/zh/docs/workflows/source-plugins.md +++ b/docs/zh/docs/workflows/source-plugins.md @@ -1,173 +1,163 @@ --- title: Source 插件 -description: 加载可信本地插件,通过公共 MCP 同步写入或持续采集 Source。 +description: 用 Source 插件提供自己的 Source 类型,并由 Server 或设备上的 runner 按分配的目标持续采集。 --- # Source 插件 -宿主始终连接 PowerContext Server 的公共 `/mcp`。`write` 在 Server 内同步保存宿主提供的内容;`collect` 由独立 Runtime 采集外部资料,共享管理服务负责目标与启停状态。 +Source 插件与 Artifact 插件放在同一个插件目录中(`POWERCONTEXT_SERVER_PLUGINS_DIR`),`plugin.py` 把一个 +`SourcePlugin` 赋给 `plugin`,目录名就是插件名。它可以: -加载代码是拥有 `server.admin` 权限的部署操作。插件代码和依赖须在执行它的 Python 环境可用:write 在 Server 机器,collect 在 Runtime 机器。只加载受信任的代码。凭据文件须为普通文件,不能是符号链接;Linux 下将权限设为 `600`(`chmod 600 /path/to/token`)。 +- **只提供 Source 类型**:声明 `definitions`,调用方通过现有的 Source 写入接口提交内容,Server 执行 `resolve` 和 + Projection 后保存。 +- **采集**:再声明 `collect`、`target` 和 `setup`。采集由 runner 执行:Server 自带名为 `server` 的 runner,设备上运行 + `powercontext source-agent`。用户把采集目标分配给某个 runner,runner 定时取回自己的目标并提交结果。 -开始前,准备已启用鉴权和 PC Access 的 Server、已有 Scope,以及分开的部署管理员和普通宿主凭据。本文示例中的路径、地址和 `SCOPE_ID` 需替换为实际值;MCP 示例采用 `工具名(参数)` 简写,对应 `/mcp` 的 `tools/call` 中的 `name` 和 `arguments`。能力名称、版本和输入字段以发现结果为准。 +插件的加载、更新和移除与 Artifact 插件相同,见[编写插件](../develop/plugins.md)。可运行的示例在仓库的 +`examples/source_plugins/` 中。 -| 操作 | 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`。管理员在该目录执行: +| 操作 | 需要的权限 | +| --- | --- | +| 列出 runner 和它们的能力(`pc_source_runners`) | 已登录 | +| 查看 Scope 的目标与状态(`pc_source_status`) | 该 Scope 的 `scope.read` | +| 给设备 runner 新增、修改、停用、删除目标 | 该 Scope 的 `scope.contribute` | +| 给 `server` runner 新增、修改、停用、删除目标 | `server.admin` | +| 设备 runner 同步、提交 Source 和 checkpoint | runner 本身,以及目标 Scope 的 `scope.contribute` | +| 删除 runner 记录 | `server.admin` | -```sh -powercontext source load --kind write --config ./write.yml \ - --server-url http://127.0.0.1:8000 --credential-file /run/secrets/pc-admin -``` +`server` runner 用 Server 环境中的凭据访问外部系统,决定它采集什么等同于运维决定,所以需要 `server.admin`。插件访问 +外部系统的凭据由插件自己读取(例如环境变量),不替代 PowerContext 的权限。 -Server 实际导入代码、校验输入模型和 Definition/Projection 契约,注册成功后公开能力。它持久保存入口,重启重新导入。一致配置重复加载可复用。当前仅支持保持入口、能力和 Definition/Projection 声明不变的代码更新,通过受控重启 Server 生效;不做进程内 Python 热替换。修改声明中的版本、输入或输出 schema 后,重启会因持久契约不一致而恢复失败,不能用重启完成契约升级。 +## 只提供 Source 类型 -宿主使用具有目标 Scope 的 `scope.read` 和 `scope.contribute` 权限的身份调用: +`examples/source_plugins/images` 只声明了一个 Source 类型: -```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":"服务已恢复"} -}) +```python +plugin = SourcePlugin(version="1.0.0", definitions=(ImageDefinition(),)) ``` -write 不传 `instance_id`。返回 `result.source_refs` 表示真实 Source/Projection 已保存。write 不创建持久请求,也不使用 collect 目标或 checkpoint。相同身份和相同内容复用,同身份不同内容冲突;工单示例按完整快照计算身份。超时表示结果未确认,不能认定未保存。完整 add 参数 JSON 上限为 1 MiB,物化 observation 上限为 4 MiB,包含 Base64 膨胀;超限报错,不截断。 - -## 在运行中的 Server 上加载 collect - -Server 的环境文件中启用鉴权、PC Access,并指定可信实例目录: +把目录放进插件目录后,调用方用通用 Source 接口提交: -```dotenv -POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR=/srv/powercontext/source-plugins -POWERCONTEXT_SERVER_ACCESS='{"mode":"enforced"}' +```bash +curl -X POST "$SERVER/v1/scopes/$SCOPE/sources" -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"source_type": "example-image", "content": {"media_type": "image/png", "data_base64": "..."}}' ``` -首次配置后重启 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 插件。 +Server 在插件的宿主进程中校验输入、执行 `resolve` 和 Projection,按 Source 身份去重。 -实例目录就近放置 `__init__.py`、`definition.py`、资料文件和 `powercontext.yml`,例如: +## 由 Server 采集 -```yaml -id: my-tickets -plugin: . -scope_ids: [SCOPE_ID] -config: - file: tickets.json - poll_interval: 10 -``` - -Scope 须已存在,配置里的范围不替代 PC 权限。业务参数由插件的 `Config` 校验,采集节奏由插件决定。 +`examples/source_plugins/tickets` 从 runner 本机的 JSON 文件采集工单: -```sh -powercontext source load --config /srv/powercontext/source-plugins/my-tickets/powercontext.yml \ - --env-file ./pc.env +```python +plugin = SourcePlugin( + version="1.0.0", + definitions=(TicketDefinition(),), + collect=Collect(schedule=Interval(seconds=10)), + target=TargetSpec( + description="Collect tickets from a JSON file on the runner", + parameters=TicketFile, + identity={"file": ("strip",)}, + ), + setup=setup, +) ``` -命令在 Server 所在机器使用相同环境配置执行,完成 Definition 注册、Scope 授权、实例登记和 Runtime 启动。重复加载一致配置保留启停状态和进程;修改代码入口或业务配置须显式更新。已部署实例的 Scope 范围与目标以管理服务记录为准,重复 load 不用文件覆盖它们。 - -宿主通过公共 MCP 建立持续采集目标: +管理员给 `server` runner 分配一个目标(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"}) +pc_source_runners() +pc_source_add({"scope_id": "SCOPE_ID", "runner": "server", "capability": "tickets", + "parameters": {"file": "/srv/data/tickets.json"}}) ``` -add 和重复 add 返回目标身份及控制状态,不携带 Source 引用和提交时间。状态查询只携带管理服务最近收到的一次成功提交报告:`source_refs` 包含一个引用,`last_submitted_at` 是同一报告的提交时间;尚未收到报告时,引用为空数组,也没有提交时间。报告按接收顺序覆盖,这既不表示一批已完成,也不是已采集 Source 的历史清单。管理服务不累计历史引用,报告丢失不撤销已保存的 Source。插件仍从每次真实提交回执取得 SourceRef,并用 checkpoint 管理采集进度。相同目标重复 add 复用,不重启采集,也不恢复已停用目标。可选 `targets` 清单只用于首次初始化,后续以管理服务记录为准。 +新目标立即采集一次,之后按插件的 `schedule` 采集。每次采集在 Supervisor Worker 中执行,处理一个 Scope 中分配给 +`server` 的已启用目标;返回的 Source、各目标新的 checkpoint 和状态在一个事务中保存。 -## 远端 collect Runtime +## 由设备采集 -在执行机器准备插件代码,并在其清单中增加: +设备上放一份同一插件,用一个对目标 Scope 有 `scope.contribute` 的凭据运行 runner: -```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 +```bash +export POWERCONTEXT_SOURCE_AGENT_TOKEN=... # runner 的凭据 +powercontext source-agent --server https://memory.example.com --plugins /opt/robot/plugins ``` -在该机器执行 `powercontext source load --config ./powercontext.yml`。Server 和管理服务须已可达;非本机连接要求 HTTPS。管理员和 Runtime 使用不同身份,Runtime 身份不能具有 `server.admin`。命令启动本机 Runtime,不分发代码或执行远程 shell。显式设置 `deployment.start: local-service` 可使用 systemd 用户服务。 +- runner 的名字默认取自凭据的主体,也可以用 `--name` 指定;多台设备共用一个凭据时必须分别指定。名字 `server` 保留给 + Server 自带的 runner;一个名字第一次同步时与当时的凭据主体绑定,其他主体不能再以这个名字同步。 +- runner 每 30 秒(`--interval`)同步一次:上报加载的插件和版本,取回分配给自己的全部目标(跨 Scope)。新目标立即 + 采集,其余按插件的 `schedule` 采集。 +- 每个目标先提交 Source,再以旧 checkpoint 为期望值提交新的 checkpoint 和状态。中途失败时 checkpoint 不前进,下次 + 重新采集同样的内容,Server 按 Source 身份去重。 +- 设备上使用的每个 Source 插件都要同时部署在 Server 的插件目录中:能力的说明、参数格式和 Projection 都以 Server 加载的 + 版本为准。 -普通后台进程不会在机器重启后自动拉起。已部署实例可使用 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"}) +pc_source_add({"scope_id": "SCOPE_ID", "runner": "robot-7", "capability": "ticket-chat", + "parameters": {"file": "/var/lib/robot/chats.json"}}) ``` -等到 `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"}) +pc_source_status({"scope_id": "SCOPE_ID"}) +pc_source_target_set_enabled({"scope_id": "SCOPE_ID", "target_id": "...", "enabled": false}) +pc_source_target_delete({"scope_id": "SCOPE_ID", "target_id": "..."}) ``` -`stopping` 表示停止意图已保存,`stopped` 表示 Runtime 已确认停止。恢复时将 `enabled` 设为 `true`;这不会自动启用已停用的实例。管理失联不等于业务停止。Source、目标和 checkpoint 不随停用删除;Source 保存和 checkpoint/报告提交是独立动作。 +- **去重**:Server 按自己加载的插件版本校验 `parameters`,并用 `identity` 中的参数(经过声明的规范化步骤)计算目标键。 + 同一 Scope、runner、能力下键相同的目标已存在时,参数相同返回已有目标,参数不同返回冲突。 +- **修改**:可以修改不参与唯一性的参数和启停;唯一性参数不能修改(返回 `identity_immutable`),要采集另一个对象就新增 + 目标,这样 checkpoint 总是对应同一个外部对象。 +- **停用与删除**:停用的目标不再同步给 runner,checkpoint 保留,恢复后从原位置继续;删除同时删除 checkpoint,已采集的 + Source 保留。 +- **状态**:每个目标记录最近尝试时间、最近成功时间、连续失败次数和最近错误。runner 的 `last_seen_at` 晚于目标的 + `updated_at`,说明 runner 已经取到最新配置。 + +## 插件的采集契约 + +```python +async def collect(self, request: CollectRequest) -> CollectResult: + result = CollectResult() + for target in request.targets: + ... + result.add(target, items, checkpoint=new_checkpoint, has_more=False) + # 只有这个目标失败时:result.fail(target, code="...", message="...") + return result +``` -当前实现尚无删除实例登记及所属目标的完整卸载命令。`runtime stop` 只停止执行进程,保留管理记录和已保存数据;停止 Server 或删除插件目录也不等于卸载独立服务。 +- 一次调用对应一个 Scope,最多 `max_targets` 个已启用目标;目标较多时轮转。 +- 返回的条目总数超过 `max_items`,或返回了请求中没有的目标,整次调用失败,不截断。没有出现在结果中的目标 checkpoint + 不变。 +- `fail` 只让这个目标失败:它的 checkpoint 不变、状态记录错误,同一次调用中其他目标照常提交。`collect` 抛出异常时整次 + 调用失败。 +- 阻塞的读取放进 `await asyncio.to_thread(...)`。在设备上 `ctx.storage` 不可用;`ctx.center` 只在设备上存在,可以用 + `ctx.center.call_plugin(method, path, json=..., content=...)` 调用插件自己在 Server 上的路由,例如先上传大文件,再 + 提交引用它的 Source。 -## 常见问题 +## 版本与升级 -| 现象 | 检查和处理 | -| --- | --- | -| 启动日志出现 `background-unsupported` 或 `service-unavailable` | 本地 collect 部署要求 Linux;`local-service` 还需要可用的 `systemctl --user`。检查执行平台和进程模式。 | -| 管理服务断线 | 已同步的 collect 可继续;新管理调用可能失败。内置及已加载的 write 不依赖管理服务。 | -| Runtime 重启后管理服务不可达 | 等待重新同步目标,不从文件初始 targets 回退采集。 | -| 目标长期没有新 Source | 检查插件采集逻辑是否仍运行、采集节奏、外部凭据和 Scope 权限;进程在线不保证采集成功。 | -| 一直处于 stopping | 检查原 Runtime 与管理连接,不能把断线当成停止完成。 | -| Source 已保存但目标状态没有引用 | 提交报告可能丢失;目标状态不是 Server 全部数据的清单,不应因此回退 checkpoint。 | -| Agent 没使用已保存的资料 | 核对采集 Scope 与宿主当前 Scope,以及后续 Projection、Artifact 和索引处理。 | +`SourcePlugin` 声明 `version`(PEP 440)和可选的 `min_compatible_version`(默认等于 `version`)。设备上的插件版本满足 +`Server 的 min_compatible_version <= 设备版本 <= Server 的 version` 时,Server 接受它的同步、Source、checkpoint 和 +`call_plugin` 请求;否则返回 `plugin_version_incompatible`,同步时不下发该插件的目标,`pc_source_runners` 中显示为 +不兼容。 -持续采集插件应在 `apply` 中保持自己的循环。`apply` 结束后,新目标或通信重连不会自动重跑它;需要重新运行时,显式停用并确认停止,再启用。 +- **先升级 Server,再升级设备**。比 Server 新的设备版本同样被拒绝。Server 有多个实例时,先让所有实例完成升级。 +- 目标参数按 Server 版本的格式填写,可能带有旧设备不认识的字段,所以参数模型不能设置 `extra="forbid"`。新参数必须在 + 所有设备上生效时,提高 `min_compatible_version`。 +- Server 每次加载插件版本时调用 `upgrade(ctx)`(见[编写插件](../develop/plugins.md));设备不调用 `upgrade`,插件自己的 + 本地文件在 `setup` 中检查和迁移。 -## 可运行示例 +## 从旧的 Source 插件框架迁移 -仓库 `examples/source_plugins` 按工单、工单/聊天、图片组织示例,每个目录就近放置代码和加载配置。工单示例共用一份 Definition 和 Projection:`write.yml` 用于 Server 内同步写入,`powercontext.yml` 用于托管采集。加载和 MCP 调用步骤见 `examples/source_plugins/README.zh.md`。 +`/v1/source-management/*`、`powercontext source load`、`powercontext.yml` 和 `write.yml` 已移除: -凭据、数据库、日志和生成的进程状态留在部署机器;只提交插件代码、无敏感信息的配置模板和合成示例资料。 +- write 插件改写为只声明 `definitions` 的 `SourcePlugin`,放进插件目录。 +- collect 插件改写为声明 `collect`、`target` 和 `setup` 的 `SourcePlugin`;原来在设备上计算的目标身份改为 `identity` + 声明。 +- 升级到本版本前,在旧版本上卸载所有已加载的 write 插件;数据库中仍登记着 write 插件时,迁移会拒绝升级。 diff --git a/examples/source_plugins/README.zh.md b/examples/source_plugins/README.zh.md index 21f0e18cce..503929368b 100644 --- a/examples/source_plugins/README.zh.md +++ b/examples/source_plugins/README.zh.md @@ -1,67 +1,30 @@ # Source 插件示例 -本目录按业务场景组织可复制的插件,每个目录就近放置代码、加载配置和样例数据。Server 部署、权限、启停与升级步骤见 [Source 插件指南](../../docs/zh/docs/workflows/source-plugins.md)。 +每个目录是一个 Source 插件:`plugin.py` 把 `SourcePlugin` 赋给 `plugin`,目录名就是插件名。把目录复制到 Server 的 +插件目录(`POWERCONTEXT_SERVER_PLUGINS_DIR`)即可加载;设备上采集时,再把同一目录放进 `powercontext source-agent +--plugins` 指向的目录。部署、权限、目标管理和升级见 [Source 插件指南](../../docs/zh/docs/workflows/source-plugins.md)。 -| 目录 | 能力 | 配置及准备 | +| 目录 | Source 类型 | 采集 | | --- | --- | --- | -| [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]` | +| [tickets](tickets/) | `example-ticket`,带文本和结构化 Projection | 从 runner 本机的 JSON 文件采集工单,样例数据 `tickets.json` | +| [ticket-chat](ticket-chat/) | `example-chat`,带消息列表 Projection | 从 runner 本机的 JSON 文件采集聊天室,样例数据 `chats.json` | +| [images](images/) | `example-image`,校验 PNG/JPEG | 不采集,通过 Source 写入接口提交;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 发现能力并提交完整工单: +给 Server 自带的 runner 分配工单目标(需要 `server.admin`): ```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} - } -}) +pc_source_add({"scope_id": "SCOPE_ID", "runner": "server", "capability": "tickets", + "parameters": {"file": "/srv/data/tickets.json"}}) ``` -返回 `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 +export POWERCONTEXT_SOURCE_AGENT_TOKEN=... +powercontext source-agent --server https://memory.example.com --plugins ./plugins --name robot-7 ``` -宿主添加文件中 ID 为 A 的工单作为持续采集目标: - ```javascript -pc_source_add({"scope_id":"SCOPE_ID","instance_id":"my-tickets","capability":"tickets.item","capability_version":"1","input":{"id":"A"}}) +pc_source_add({"scope_id": "SCOPE_ID", "runner": "robot-7", "capability": "ticket-chat", + "parameters": {"file": "/var/lib/robot/chats.json"}}) ``` - -插件按 `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 deleted file mode 100644 index 2c9f2c9636..0000000000 --- a/examples/source_plugins/images/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Server-local PNG/JPEG write capabilities; install powercontext[source-images].""" - -from powercontext.source_management import Capability - -from .definition import ImageCapture, ImageDefinition - -image_definition = ImageDefinition() -definitions = (image_definition,) -capabilities = ( - Capability( - name="images.write", - version="1", - kind="write", - description="Save a supplied PNG or JPEG image", - input_schema=ImageCapture.model_json_schema(), - definition_name=image_definition.name, - definition_version=image_definition.version, - ), -) diff --git a/examples/source_plugins/images/definition.py b/examples/source_plugins/images/plugin.py similarity index 87% rename from examples/source_plugins/images/definition.py rename to examples/source_plugins/images/plugin.py index eacf1f4452..d233f4c6da 100644 --- a/examples/source_plugins/images/definition.py +++ b/examples/source_plugins/images/plugin.py @@ -12,7 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Captured PNG/JPEG content with validated bytes and immutable identity.""" +"""A Source type for PNG and JPEG images with validated bytes and an immutable identity. + +The plugin only provides the type: callers write images through the generic Source API +with ``source_type`` ``example-image``. The Server needs Pillow +(``powercontext[source-images]``). +""" from __future__ import annotations @@ -24,6 +29,7 @@ from PIL import Image, UnidentifiedImageError from pydantic import BaseModel, ConfigDict, Field, model_validator +from powercontext.plugins import SourcePlugin from powercontext.sources import Source, SourceMaterialization @@ -76,3 +82,6 @@ async def resolve(self, value: ImageCapture, /) -> ImageSource: async def read(self, source: ImageSource, /) -> ImageCapture: return source.capture + + +plugin = SourcePlugin(version="1.0.0", definitions=(ImageDefinition(),)) diff --git a/examples/source_plugins/images/write.yml b/examples/source_plugins/images/write.yml deleted file mode 100644 index fc3bb9e77b..0000000000 --- a/examples/source_plugins/images/write.yml +++ /dev/null @@ -1,16 +0,0 @@ -# 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/events.json b/examples/source_plugins/ticket-chat/chats.json similarity index 59% rename from examples/source_plugins/ticket_chat/events.json rename to examples/source_plugins/ticket-chat/chats.json index 71e5af7e5e..5ab420c6d0 100644 --- a/examples/source_plugins/ticket_chat/events.json +++ b/examples/source_plugins/ticket-chat/chats.json @@ -1,23 +1,4 @@ { - "tickets": [ - { - "ticket_id": "42", - "title": "部署失败", - "body": "检查发布任务的构建日志。", - "labels": [ - "发布", - "数据库" - ], - "metadata": { - "priority": 3, - "active": true, - "owner": null, - "details": { - "attempts": 2 - } - } - } - ], "chats": [ { "chat_id": "release-42", diff --git a/examples/source_plugins/ticket-chat/plugin.py b/examples/source_plugins/ticket-chat/plugin.py new file mode 100644 index 0000000000..4ab73b809c --- /dev/null +++ b/examples/source_plugins/ticket-chat/plugin.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Collect chat rooms from a JSON file on the runner; each room becomes one Source of its messages. + +A room is submitted again only when its messages change. The ``example.chat-messages`` +projection delivers the messages to Artifact plugins. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from pathlib import Path +from typing import cast + +import rfc8785 +from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel + +from powercontext.plugins import ( + Collect, + CollectedItem, + CollectRequest, + CollectResult, + Interval, + SourcePlugin, + TargetSpec, +) +from powercontext.sources import Source, SourceMaterialization, SourceProjection + + +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 ChatSource(Source): + capture: ChatCapture + + +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 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 + + +class ChatFile(BaseModel): + file: str = Field(min_length=1, description="Path of a JSON file with a chats array on the runner") + namespace: str = Field(default="chats", min_length=1) + + +class Checkpoint(BaseModel): + # A digest per item already submitted from this target. + seen: dict[str, str] = Field(default_factory=dict) + + +class ChatCollector: + async def collect(self, request: CollectRequest) -> CollectResult: + result = CollectResult() + for target in request.targets: + settings = ChatFile.model_validate(target.parameters) + try: + chats = json.loads(await asyncio.to_thread(Path(settings.file).read_text, encoding="utf-8"))["chats"] + except (OSError, ValueError, KeyError) as error: + result.fail(target, code="file_unreadable", message=type(error).__name__) + continue + seen = Checkpoint.model_validate(target.checkpoint or {}).seen + items = [] + for chat in chats: + capture = ChatCapture(namespace=settings.namespace, **chat) + digest = hashlib.sha256(rfc8785.dumps(capture.model_dump(mode="json"))).hexdigest() + if seen.get(capture.chat_id) != digest and len(items) < request.max_items: + items.append(CollectedItem(definition=ChatDefinition.name, value=capture)) + seen[capture.chat_id] = digest + result.add(target, items, checkpoint=Checkpoint(seen=seen).model_dump()) + return result + + +async def setup(ctx): + return ChatCollector() + + +plugin = SourcePlugin( + version="1.0.0", + definitions=(ChatDefinition(),), + collect=Collect(schedule=Interval(seconds=10)), + target=TargetSpec( + description="Collect chat rooms from a JSON file on the runner", + parameters=ChatFile, + identity={"file": ("strip",)}, + ), + setup=setup, +) diff --git a/examples/source_plugins/ticket_chat/__init__.py b/examples/source_plugins/ticket_chat/__init__.py deleted file mode 100644 index 70e634408b..0000000000 --- a/examples/source_plugins/ticket_chat/__init__.py +++ /dev/null @@ -1,148 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Collect ticket and chat snapshots through the managed Runtime.""" - -import asyncio -import hashlib -import json -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter - -from powercontext.errors import InvalidConnectorRunError -from powercontext.source_management import Capability, TargetIdentity -from powercontext.source_management.context import PluginContext -from powercontext.source_management.errors import PluginError -from powercontext.source_management.models import ManagementError -from powercontext.sources import ConnectorRunCompletion, ConnectorRunSession, ConnectorRunStatus - -from .definition import ChatCapture, ChatDefinition, TicketDefinition, TicketDetails - -plugin_api_version = "2" -connector_name = "example-ticket-chat" -connector_version = "1" -definitions = (TicketDefinition(), ChatDefinition()) - - -class Config(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - namespace: str = Field(min_length=1) - file: str = "events.json" - batch_size: int = Field(default=100, ge=1, le=1000) - interval_seconds: float = Field(default=5, gt=0) - - -capabilities = ( - Capability( - name="ticket-chat.dataset", - version="1", - description="Collect ticket and chat snapshots from the configured JSON dataset", - input_schema={"type": "object", "properties": {}, "additionalProperties": False}, - ), -) - - -async def identify_target(request, config): - return TargetIdentity(target_key=config.namespace, normalized_input={}) - - -class Checkpoint(BaseModel): - model_config = ConfigDict(extra="forbid") - - version: Literal["1"] = "1" - namespace: str - seen: dict[str, str] = Field(default_factory=dict) - - -class TicketChatConnector: - name = connector_name - version = connector_version - source_definitions = frozenset({"example-ticket", "example-chat"}) - - def __init__(self, ctx: PluginContext, config: Config) -> None: - self.ctx, self.config = ctx, config - self.path = ctx.plugin_dir / config.file - self.target = None - - async def run(self, session: ConnectorRunSession, /) -> ConnectorRunCompletion: - checkpoint = ( - Checkpoint.model_validate(session.checkpoint) - if session.checkpoint is not None - else Checkpoint(namespace=self.config.namespace) - ) - if checkpoint.namespace != self.config.namespace: - raise InvalidConnectorRunError("checkpoint", "namespace changed; use a new Binding ID") - rows = TypeAdapter(dict[str, list[dict[str, JsonValue]]]).validate_python( - json.loads(self.path.read_text(encoding="utf-8")) - ) - items = [ - ("example-ticket", TicketDetails.model_validate({**row, "namespace": self.config.namespace})) - for row in rows.get("tickets", []) - ] + [ - ("example-chat", ChatCapture.model_validate({**row, "namespace": self.config.namespace})) - for row in rows.get("chats", []) - ] - identities = [ - (kind, value.ticket_id if isinstance(value, TicketDetails) else value.chat_id) for kind, value in items - ] - if len(set(identities)) != len(identities): - raise InvalidConnectorRunError("duplicate-item", "fixture contains duplicate identities") - accepted = 0 - for (kind, value), (_, identity) in zip(items, identities, strict=True): - item_key = f"{kind}:{identity}" - fingerprint = hashlib.sha256( - json.dumps(value.model_dump(mode="json"), sort_keys=True, ensure_ascii=False).encode() - ).hexdigest() - if checkpoint.seen.get(item_key) != fingerprint and accepted < self.config.batch_size: - await self.ctx.targets.submit( - self.target, session=session, item_id=item_key, definition_name=kind, value=value - ) - checkpoint.seen[item_key] = fingerprint - accepted += 1 - return ConnectorRunCompletion(status=ConnectorRunStatus.COMPLETE, checkpoint=checkpoint.model_dump(mode="json")) - - -async def apply(ctx: PluginContext, config: Config) -> None: - connector = TicketChatConnector(ctx, config) - ctx.connect(connector) - while True: - for target in await ctx.targets.list(): - connector.target = target - scope_id = target.scope_id - try: - result = await ctx.run_batch(scope_id=scope_id, targets=(target,)) - except ManagementError as error: - if error.code != "stale-target": - raise - # A target can be stopped while another Scope's batch is running. - break - with (ctx.plugin_dir / "receipts.jsonl").open("a", encoding="utf-8") as receipts: - for item in result.items: - if item.source_ref is not None: - receipts.write( - json.dumps( - { - "scope_id": scope_id, - "item_id": item.item_id, - "source": item.source_ref.model_dump(mode="json"), - }, - ensure_ascii=False, - ) - + "\n" - ) - if result.status is not ConnectorRunStatus.COMPLETE: - raise PluginError("batch-incomplete", "ticket/chat batch did not complete") - await asyncio.sleep(config.interval_seconds) diff --git a/examples/source_plugins/ticket_chat/definition.py b/examples/source_plugins/ticket_chat/definition.py deleted file mode 100644 index 91f071be7a..0000000000 --- a/examples/source_plugins/ticket_chat/definition.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Two captured Source types with independent structured projection contracts.""" - -import hashlib -from typing import cast - -import rfc8785 -from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel - -from powercontext.sources import Source, SourceMaterialization, SourceProjection - - -class TicketDetails(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - namespace: str = Field(min_length=1) - ticket_id: str = Field(min_length=1) - title: str - body: str - labels: list[str] = Field(default_factory=list) - metadata: dict[str, JsonValue] = Field(default_factory=dict) - - -class ChatMessage(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - author: str - text: str - metadata: dict[str, JsonValue] = Field(default_factory=dict) - - -class ChatCapture(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - namespace: str = Field(min_length=1) - chat_id: str = Field(min_length=1) - room: str - messages: list[ChatMessage] - - -class ChatMessages(RootModel[list[ChatMessage]]): - pass - - -class TicketSource(Source): - capture: TicketDetails - - -class ChatSource(Source): - capture: ChatCapture - - -class TicketDetailsProjection: - name = "example.ticket-details" - version = "1" - source_class = TicketSource - output_class = TicketDetails - - def project(self, source: TicketSource, /) -> TicketDetails: - return source.capture - - -class ChatMessagesProjection: - name = "example.chat-messages" - version = "2" - source_class = ChatSource - output_class = ChatMessages - - def project(self, source: ChatSource, /) -> ChatMessages: - return ChatMessages(source.capture.messages) - - -class TicketDefinition: - input_class = TicketDetails - source_class = TicketSource - name = "example-ticket" - version = "2" - projections = (cast(SourceProjection[TicketSource], TicketDetailsProjection()),) - - async def resolve(self, value: TicketDetails, /) -> TicketSource: - return TicketSource( - name="ticket-" + hashlib.sha256(rfc8785.dumps(value.model_dump(mode="json"))).hexdigest(), - definition_version=self.version, - materialization=SourceMaterialization.CAPTURED, - description=value.title, - capture=value, - ) - - async def read(self, source: TicketSource, /) -> TicketDetails: - return source.capture - - -class ChatDefinition: - input_class = ChatCapture - source_class = ChatSource - name = "example-chat" - version = "1" - projections = (cast(SourceProjection[ChatSource], ChatMessagesProjection()),) - - async def resolve(self, value: ChatCapture, /) -> ChatSource: - return ChatSource( - name="chat-" + hashlib.sha256(rfc8785.dumps(value.model_dump(mode="json"))).hexdigest(), - materialization=SourceMaterialization.CAPTURED, - description=value.room, - capture=value, - ) - - async def read(self, source: ChatSource, /) -> ChatCapture: - return source.capture diff --git a/examples/source_plugins/ticket_chat/powercontext.yml b/examples/source_plugins/ticket_chat/powercontext.yml deleted file mode 100644 index a5c3ff0c3c..0000000000 --- a/examples/source_plugins/ticket_chat/powercontext.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -id: ticket-chat-local -plugin: . -scope_ids: [SCOPE_ID] -config: - namespace: example-company - file: events.json - batch_size: 100 - interval_seconds: 5 -targets: - - scope_id: SCOPE_ID - capability: ticket-chat.dataset - capability_version: "1" - input: {} diff --git a/examples/source_plugins/tickets/__init__.py b/examples/source_plugins/tickets/__init__.py deleted file mode 100644 index a5f32bfc23..0000000000 --- a/examples/source_plugins/tickets/__init__.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""A runnable local-file collector for dynamic ticket targets.""" - -from __future__ import annotations - -import asyncio -import json - -from pydantic import BaseModel, Field - -from powercontext.source_management import Capability, TargetIdentity -from powercontext.source_management.models import ManagementError -from powercontext.sources import ConnectorRunCompletion, ConnectorRunStatus, ConnectorSubmissionStatus - -from .definition import TicketCapture, TicketDefinition - -plugin_api_version = "2" -connector_name = "tickets" -connector_version = "1" -ticket_definition = TicketDefinition() -definitions = (ticket_definition,) -capabilities = ( - Capability( - name="tickets.item", - version="1", - description="Collect a ticket from the local JSON file", - input_schema={ - "type": "object", - "properties": {"id": {"type": "string", "minLength": 1}}, - "required": ["id"], - "additionalProperties": False, - }, - ), - Capability( - name="ticket.write", - version="1", - kind="write", - description="Save complete ticket content supplied by the host", - input_schema=TicketCapture.model_json_schema(), - definition_name=ticket_definition.name, - definition_version=ticket_definition.version, - ), -) - - -class Config(BaseModel): - file: str = "tickets.json" - namespace: str = "tickets" - project: str = "default" - poll_interval: float = Field(default=1, gt=0) - - -async def identify_target(request, config): - identifier = request.input["id"].strip() - if not identifier: - raise ValueError("Ticket ID must not be blank") # noqa: TRY003 - return TargetIdentity( - target_key=f"{config.namespace}:{config.project}:{identifier}", normalized_input={"id": identifier} - ) - - -class TicketConnector: - name = connector_name - version = connector_version - source_definitions = frozenset({ticket_definition.name}) - - def __init__(self, ctx, config): - self.ctx, self.config, self.targets = ctx, config, () - - async def run(self, session): - records = json.loads((self.ctx.plugin_dir / self.config.file).read_text(encoding="utf-8")) - values = {str(record["id"]): record for record in records} - seen = dict((session.checkpoint or {}).get("seen", {})) - complete = True - for target in self.targets: - ticket = values.get(target.input["id"]) - if ticket is None: - continue - content = ticket.get("body", "") - capture = TicketCapture( - namespace=self.config.namespace, - project=self.config.project, - ticket_id=target.input["id"], - title=ticket.get("title", ""), - body=content, - labels=ticket.get("labels", []), - metadata=ticket.get("metadata", {}), - ) - digest = capture.snapshot_id() - if seen.get(target.target_id) == digest: - continue - receipt = await self.ctx.targets.submit( - target, - session=session, - item_id=target.target_id, - definition_name=ticket_definition.name, - value=capture, - ) - accepted = receipt.status == ConnectorSubmissionStatus.ACCEPTED - - complete = complete and accepted - if accepted: - seen[target.target_id] = digest - return ConnectorRunCompletion( - status=ConnectorRunStatus.COMPLETE if complete else ConnectorRunStatus.INCOMPLETE, checkpoint={"seen": seen} - ) - - -async def apply(ctx, config): - connector = TicketConnector(ctx, config) - ctx.connect(connector) - while True: - targets = await ctx.targets.list() - for scope_id in sorted({target.scope_id for target in targets}): - selected = tuple(target for target in targets if target.scope_id == scope_id) - connector.targets = selected - try: - await ctx.run_batch(scope_id=scope_id, targets=selected) - except ManagementError as error: - if error.code != "stale-target": - raise - # Another Scope's batch may have outlived this target snapshot. - break - await asyncio.sleep(config.poll_interval) diff --git a/examples/source_plugins/tickets/definition.py b/examples/source_plugins/tickets/plugin.py similarity index 51% rename from examples/source_plugins/tickets/definition.py rename to examples/source_plugins/tickets/plugin.py index 1f98a0eaa6..e674bbabef 100644 --- a/examples/source_plugins/tickets/definition.py +++ b/examples/source_plugins/tickets/plugin.py @@ -12,16 +12,32 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Capture immutable ticket snapshots with text and structured projections.""" +"""Collect tickets from a JSON file on the runner, with text and structured projections. + +Each target names one file. A ticket becomes a new Source whenever its content changes; +the checkpoint keeps a digest per ticket, so unchanged tickets are not submitted again. +""" from __future__ import annotations +import asyncio import hashlib +import json +from pathlib import Path from typing import cast import rfc8785 from pydantic import BaseModel, ConfigDict, Field, JsonValue +from powercontext.plugins import ( + Collect, + CollectedItem, + CollectRequest, + CollectResult, + Interval, + SourcePlugin, + TargetSpec, +) from powercontext.sources import ( TEXT_EVIDENCE_PROJECTION_KEY, Source, @@ -96,3 +112,62 @@ async def resolve(self, value: TicketCapture, /) -> TicketSource: async def read(self, source: TicketSource, /) -> TicketCapture: return source.capture + + +class TicketFile(BaseModel): + file: str = Field(min_length=1, description="Path of a JSON array of tickets on the runner") + namespace: str = Field(default="tickets", min_length=1) + project: str = Field(default="default", min_length=1) + + +class Checkpoint(BaseModel): + # A digest per item already submitted from this target. + seen: dict[str, str] = Field(default_factory=dict) + + +class TicketCollector: + async def collect(self, request: CollectRequest) -> CollectResult: + result = CollectResult() + for target in request.targets: + settings = TicketFile.model_validate(target.parameters) + try: + tickets = json.loads(await asyncio.to_thread(Path(settings.file).read_text, encoding="utf-8")) + except (OSError, ValueError) as error: + # Only this target fails; the others in the call still commit. + result.fail(target, code="file_unreadable", message=type(error).__name__) + continue + seen = Checkpoint.model_validate(target.checkpoint or {}).seen + items = [] + for ticket in tickets: + capture = TicketCapture( + namespace=settings.namespace, + project=settings.project, + ticket_id=str(ticket["id"]), + title=ticket["title"], + body=ticket["body"], + labels=ticket.get("labels", []), + metadata=ticket.get("metadata", {}), + ) + digest = capture.snapshot_id() + if seen.get(capture.ticket_id) != digest and len(items) < request.max_items: + items.append(CollectedItem(definition=TicketDefinition.name, value=capture)) + seen[capture.ticket_id] = digest + result.add(target, items, checkpoint=Checkpoint(seen=seen).model_dump()) + return result + + +async def setup(ctx): + return TicketCollector() + + +plugin = SourcePlugin( + version="1.0.0", + definitions=(TicketDefinition(),), + collect=Collect(schedule=Interval(seconds=10)), + target=TargetSpec( + description="Collect tickets from a JSON file on the runner", + parameters=TicketFile, + identity={"file": ("strip",)}, + ), + setup=setup, +) diff --git a/examples/source_plugins/tickets/powercontext.yml b/examples/source_plugins/tickets/powercontext.yml deleted file mode 100644 index 05dd9e46a7..0000000000 --- a/examples/source_plugins/tickets/powercontext.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -id: my-tickets -plugin: . -scope_ids: [SCOPE_ID] -config: - file: tickets.json - poll_interval: 10 diff --git a/integrations/capabilities.toml b/integrations/capabilities.toml index 103240bae1..43acf8e892 100644 --- a/integrations/capabilities.toml +++ b/integrations/capabilities.toml @@ -104,7 +104,7 @@ capabilities = ["memory_write"] id = "capture_content_source" capabilities = ["source_capture"] [[toolsets.tools]] -id = "pc_source_capabilities" +id = "pc_source_runners" capabilities = ["source_capture"] [[toolsets.tools]] id = "pc_source_add" @@ -116,10 +116,7 @@ capabilities = ["source_capture"] 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" +id = "pc_source_target_delete" capabilities = ["source_capture"] [[toolsets.tools]] id = "capture_working_note" @@ -337,7 +334,7 @@ capabilities = ["memory_write"] id = "mcp__powercontext__capture_content_source" capabilities = ["source_capture"] [[toolsets.tools]] -id = "mcp__powercontext__pc_source_capabilities" +id = "mcp__powercontext__pc_source_runners" capabilities = ["source_capture"] [[toolsets.tools]] id = "mcp__powercontext__pc_source_add" @@ -349,10 +346,7 @@ capabilities = ["source_capture"] id = "mcp__powercontext__pc_source_target_set_enabled" capabilities = ["source_capture"] [[toolsets.tools]] -id = "mcp__powercontext__pc_source_instances" -capabilities = ["source_capture"] -[[toolsets.tools]] -id = "mcp__powercontext__pc_source_instance_set_enabled" +id = "mcp__powercontext__pc_source_target_delete" capabilities = ["source_capture"] [[toolsets.tools]] id = "mcp__powercontext__capture_working_note" @@ -1187,7 +1181,7 @@ capabilities = ["memory_write"] id = "capture_content_source" capabilities = ["source_capture"] [[toolsets.tools]] -id = "pc_source_capabilities" +id = "pc_source_runners" capabilities = ["source_capture"] [[toolsets.tools]] id = "pc_source_add" @@ -1199,10 +1193,7 @@ capabilities = ["source_capture"] 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" +id = "pc_source_target_delete" capabilities = ["source_capture"] [[toolsets.tools]] id = "capture_working_note" diff --git a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs index 4c2d092487..426706eb34 100644 --- a/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs +++ b/integrations/cindy/plugins/powercontext-hooks/node/worker.cjs @@ -682,6 +682,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { + method: "GET", + path: "/v1/source-runners", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + sync_source_runner: { + method: "POST", + path: "/v1/source-runners/{runner}/sync", + location: "body", + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + delete_source_runner: { + method: "DELETE", + path: "/v1/source-runners/{runner}", + location: null, + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, + pc_source_status: { + method: "GET", + path: "/v1/scopes/{scope_id}/source-targets", + location: null, + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/scopes/{scope_id}/source-targets", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201, 200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "PATCH", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_delete: { + method: "DELETE", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", @@ -1699,160 +1776,6 @@ const OPERATIONS = { 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: [] - }, plan_access_ownership: { method: "POST", path: "/v1/access/ownership/plan", diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 5f0496b9df..1033fe4bae 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -570,7 +570,7 @@ const OPERATIONS = { scopeMode: "current", pathParameters: [], queryParams: [], - headerParams: ["X-PowerContext-Resolve-Target"], + headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -750,6 +750,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { + method: "GET", + path: "/v1/source-runners", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + sync_source_runner: { + method: "POST", + path: "/v1/source-runners/{runner}/sync", + location: "body", + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + delete_source_runner: { + method: "DELETE", + path: "/v1/source-runners/{runner}", + location: null, + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, + pc_source_status: { + method: "GET", + path: "/v1/scopes/{scope_id}/source-targets", + location: null, + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/scopes/{scope_id}/source-targets", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201, 200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "PATCH", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_delete: { + method: "DELETE", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", @@ -1767,160 +1844,6 @@ const OPERATIONS = { 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: [] - }, plan_access_ownership: { method: "POST", path: "/v1/access/ownership/plan", @@ -2116,9 +2039,9 @@ function isPlainObject(value) { } function queryString(payload) { const params = new URLSearchParams(); - for (const [key$1, value] of Object.entries(payload ?? {})) { + for (const [key, value] of Object.entries(payload ?? {})) { if (value === void 0 || value === null) continue; - for (const item of Array.isArray(value) ? value : [value]) params.append(key$1, String(item)); + for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)); } const encoded = params.toString(); return encoded ? `?${encoded}` : ""; @@ -2514,11 +2437,11 @@ function createDiagnosticEmitter(write, now = Date.now, cooldownMs = 6e4) { "empty", "skipped" ].includes(outcome)) { - const key$1 = outcome; + const key = outcome; const timestamp = now(); - const previous = lastEmitted.get(key$1); + const previous = lastEmitted.get(key); if (previous !== void 0 && timestamp - previous < cooldownMs) return; - lastEmitted.set(key$1, timestamp); + lastEmitted.set(key, timestamp); } return write(JSON.stringify(normalized)); }; @@ -2539,7 +2462,7 @@ function isRecord$1(value) { function validatePreparedContext(response, path = "/v1/context/prepare", maxBytes = MAX_CONTEXT_BYTES) { if (!isRecord$1(response)) throw new InvalidResponseError(path, void 0, void 0, "prepared_object"); const keys = Object.keys(response); - if (keys.length !== PREPARED_FIELDS.size || keys.some((key$1) => !PREPARED_FIELDS.has(key$1))) throw new InvalidResponseError(path, void 0, void 0, "prepared_fields"); + if (keys.length !== PREPARED_FIELDS.size || keys.some((key) => !PREPARED_FIELDS.has(key))) throw new InvalidResponseError(path, void 0, void 0, "prepared_fields"); if (response.schema !== PREPARED_CONTEXT_SCHEMA) throw new InvalidResponseError(path, void 0, void 0, "prepared_schema"); const status = response.status; const content = response.content; @@ -2822,7 +2745,7 @@ function capabilities(response) { "artifact_families", "search_modes", "context_versions" - ].every((key$1) => Array.isArray(body[key$1]) && body[key$1].every((value) => typeof value === "string"))) invalid(operation, response, "capabilities"); + ].every((key) => Array.isArray(body[key]) && body[key].every((value) => typeof value === "string"))) invalid(operation, response, "capabilities"); if (!body.context_versions.includes(PREPARED_CONTEXT_SCHEMA)) return { ...check(operation, "unsupported_context_schema", "The Server does not advertise the PreparedContext schema required by this plugin.", "Install Server and plugin from the same supported release tag or checkout commit."), http_status: response.status, @@ -3038,7 +2961,8 @@ const PAYLOAD_WRITES = new Set([ "change_atomic_memory_lifecycle" ]); const CONTENT_FIELDS = new Map([ - ["pc_source_add", ["input"]], + ["pc_source_add", ["parameters"]], + ["pc_source_target_set_enabled", ["parameters"]], ["create_scope", [ "title", "summary", @@ -3061,28 +2985,11 @@ const CONTENT_FIELDS = new Map([ ["finalize_handoff", ["draft"]], ["commit_handoff", ["handoff"]] ]); -const SECRET_PATTERNS = [ - /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, - /(? scrubbed.replace(pattern, "[REDACTED]"), text); -} -/** Detect secrets without rejecting ordinary words that merely contain a marker. */ -function containsSecret(text, authorization) { - return scrubSecrets(text, authorization) !== text; -} -function containsSecretMarker(text) { +function containsSecret(text) { return SECRET_MARKERS.some((marker) => text.includes(marker)); } function containsSecretValue(value) { - if (typeof value === "string") return containsSecretMarker(value); + if (typeof value === "string") return containsSecret(value); if (!value || typeof value !== "object") return false; return Object.values(value).some(containsSecretValue); } @@ -3455,18 +3362,18 @@ var RuntimeStatus = class { this.now = now; } begin(sessionId, cwd, turn) { - const key$1 = sessionKey(sessionId, cwd); + const key = sessionKey(sessionId, cwd); const attempt = { attempt: ++this.sequence, turn: /^\d{1,20}$/.test(turn) ? turn : "(unavailable)", started_at: this.now(), stages: initialStages() }; - this.sessions.delete(key$1); - this.sessions.set(key$1, attempt); + this.sessions.delete(key); + this.sessions.set(key, attempt); if (this.sessions.size > STATUS_SESSION_LIMIT) this.sessions.delete(this.sessions.keys().next().value); const record$3 = (stage, result) => { - if (this.sessions.get(key$1) !== attempt) return; + if (this.sessions.get(key) !== attempt) return; attempt.stages[stage] = { ...result, operation: OPERATIONS$1[stage], @@ -3475,7 +3382,7 @@ var RuntimeStatus = class { }; return { scope: (scopeId) => { - if (this.sessions.get(key$1) !== attempt) return; + if (this.sessions.get(key) !== attempt) return; attempt.scope_key = fingerprint(scopeId); attempt.scope_id = /^[a-zA-Z0-9_-]{1,128}$/.test(scopeId) ? scopeId : "(redacted)"; record$3("scope", { state: "resolved" }); @@ -3747,8 +3654,6 @@ function storedAuthorization(env, baseUrl) { } function resolveConfig(config = {}, env = process.env) { const transport = resolveTransport("dsh", env, config.baseUrl, config.allowInsecureHttp, DEFAULTS.baseUrl); - if (config.updateChannel !== void 0 && config.updateChannel !== "stable" && config.updateChannel !== "preview") throw new Error("updateChannel must be stable or preview"); - if (config.updateHold !== void 0 && typeof config.updateHold !== "boolean") throw new Error("updateHold must be a boolean"); const maxBytes = config.maxBytes ?? DEFAULTS.maxBytes; if (maxBytes < 512 || maxBytes > 32768) throw new Error("maxBytes must be between 512 and 32768"); return { @@ -3768,9 +3673,7 @@ function resolveConfig(config = {}, env = process.env) { capturePrompts: envBoolean(env, "POWERCONTEXT_DSH_CAPTURE_PROMPTS") ?? config.capturePrompts ?? DEFAULTS.capturePrompts, flushOnCapture: envBoolean(env, "POWERCONTEXT_DSH_FLUSH_ON_CAPTURE") ?? config.flushOnCapture ?? DEFAULTS.flushOnCapture, flushMaxCalls: config.flushMaxCalls ?? DEFAULTS.flushMaxCalls, - ownerLocal: ownerLocal(env, config.ownerLocal), - updateChannel: config.updateChannel, - updateHold: config.updateHold + ownerLocal: ownerLocal(env, config.ownerLocal) }; } @@ -3816,10 +3719,9 @@ const MCP_OPERATIONS = { "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_runners": { "readOnly": true }, "pc_source_status": { "readOnly": true }, + "pc_source_target_delete": { "readOnly": false }, "pc_source_target_set_enabled": { "readOnly": false }, "prepare_handoff_hint": { "readOnly": true }, "preview_atomic_memory_restoration": { "readOnly": true }, @@ -3977,7 +3879,7 @@ async function loadPeer(specifier) { const POWERCONTEXT_MCP_SERVER_NAME = "powercontext"; const MCP_TOOL_PREFIX = `mcp__${POWERCONTEXT_MCP_SERVER_NAME}__`; const MCP_STARTUP_WAIT_MS = 5e3; -const MUTATING_MCP_OPERATIONS = new Set(Object.entries(MCP_OPERATIONS).filter(([, metadata$1]) => !metadata$1.readOnly).map(([operation]) => operation)); +const MUTATING_MCP_OPERATIONS = new Set(Object.entries(MCP_OPERATIONS).filter(([, metadata]) => !metadata.readOnly).map(([operation]) => operation)); function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -3987,16 +3889,9 @@ function rawOperation(name$1) { return Object.prototype.hasOwnProperty.call(MCP_OPERATIONS, operation) ? operation : void 0; } 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$1 = OPERATIONS[operation]; - if (metadata$1.pathParameters.some((parameter) => parameter === "scope_id")) return "current"; - return metadata$1.scopeMode; + const metadata = OPERATIONS[operation]; + if (metadata.pathParameters.some((parameter) => parameter === "scope_id")) return "current"; + return metadata.scopeMode; } function matchesScope(argumentsValue, mode, scopeId) { if (!isRecord(argumentsValue)) return false; @@ -4118,7 +4013,7 @@ async function captureUserPrompt(input) { observation?.skip("capture", "capture_disabled"); return; } - if (input.prompt.length > MAX_SOURCE_LENGTH || containsSecret(input.prompt, input.config.authorization)) { + if (input.prompt.length > MAX_SOURCE_LENGTH || containsSecret(input.prompt)) { observation?.skip("capture", input.prompt.length > MAX_SOURCE_LENGTH ? "source_too_long" : "sensitive_content"); logSafely(input.log, { event: "capture_content_source", @@ -4234,7 +4129,7 @@ async function recallContent(input, query, scopeId, observation) { if (input.signal?.aborted) throw new TransportError("", input.signal.reason); const result = await input.client.request("prepare_context", { scope_id: scopeId, - query: boundedRecallQuery(scrubSecrets(query, input.config.authorization)), + query: boundedRecallQuery(query), max_bytes: input.config.maxBytes, ...input.config.contextAssembly === void 0 ? {} : { assembly: input.config.contextAssembly } }, input.signal); @@ -4882,8 +4777,8 @@ var StatusBridge = class { }; } async resolveScope(session, now) { - const key$1 = `${session.id}\u0000${session.cwd ?? ""}`; - const cached = this.scopes.get(key$1); + const key = `${session.id}\u0000${session.cwd ?? ""}`; + const cached = this.scopes.get(key); if (cached !== void 0 && now - cached.observed_at < SCOPE_TTL_MS) return cached; try { const valid = safeScopeId(await this.options.resolveScope(session.cwd, this.abort.signal)); @@ -4898,7 +4793,7 @@ var StatusBridge = class { state: "resolved", observed_at: now }; - this.scopes.set(key$1, observation); + this.scopes.set(key, observation); return observation; } catch (error) { const failure = operationFailure("resolve_scope_binding", error); @@ -4909,7 +4804,7 @@ var StatusBridge = class { ...failure.message ? { message: failure.message } : {}, observed_at: now }; - this.scopes.set(key$1, observation); + this.scopes.set(key, observation); return observation; } } @@ -4986,8 +4881,8 @@ var StatusBridge = class { } async readPackageVersion() { try { - const version = await readServerPackageVersion(this.options.client, this.abort.signal); - if (!this.disposed) this.packageVersion = version; + const response = await this.options.client.request("get_server_info", {}, combineSignals([this.abort.signal, AbortSignal.timeout(1e3)])); + if (!this.disposed) this.packageVersion = response.status === 200 && record(response.value) ? packageVersion(response.value.package_version) : null; } catch { this.packageVersion = null; } @@ -5039,7 +4934,7 @@ var StatusBridge = class { "artifact_families", "search_modes", "context_versions" - ].every((key$1) => Array.isArray(value[key$1]) && value[key$1].every((item) => typeof item === "string"))) throw new TypeError("Capabilities response does not match its schema"); + ].every((key) => Array.isArray(value[key]) && value[key].every((item) => typeof item === "string"))) throw new TypeError("Capabilities response does not match its schema"); const contextVersions = value.context_versions.slice(0, 32); const observedAt = this.now(); if (!contextVersions.includes(PREPARED_CONTEXT_SCHEMA)) return { @@ -5063,11 +4958,6 @@ var StatusBridge = class { } } }; -/** Read the running Server package identity without treating metadata as readiness. */ -async function readServerPackageVersion(client, signal) { - const response = await client.request("get_server_info", {}, combineSignals(signal ? [signal, AbortSignal.timeout(1e3)] : [AbortSignal.timeout(1e3)])); - return response.status === 200 && record(response.value) ? packageVersion(response.value.package_version) : null; -} function sendJson(response, status, payload) { if (response.writableEnded) return; response.statusCode = status; @@ -5153,360 +5043,6 @@ function registerStatusBridge(ctx, runtime) { }); } -//#endregion -//#region src/update-contract.ts -const UPDATE_ROUTE = "/powercontext/updates"; -const UPDATE_ACTION_HEADER = "x-powercontext-update-action"; -const UPDATE_PLAN_HEADER = "x-powercontext-update-plan"; -/** Releases use the same numeric version in Python and npm, with different RC spellings. */ -function releaseVersion(value) { - if (typeof value !== "string" || value.length > 64) return null; - const parts = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})(?:(?:(a|b|rc)|-(alpha|beta|rc)\.)(0|[1-9]\d{0,8}))?$/.exec(value); - if (!parts) return null; - const stage = parts[4] ?? { - alpha: "a", - beta: "b", - rc: "rc" - }[parts[5]]; - const core = parts.slice(1, 4).join("."); - const npmStage = stage === "a" ? "alpha" : stage === "b" ? "beta" : stage; - return { - python: stage ? core + stage + parts[6] : core, - npm: stage ? core + "-" + npmStage + "." + parts[6] : core, - prerelease: Boolean(stage), - order: [ - ...parts.slice(1, 4).map(Number), - stage === "a" ? 0 : stage === "b" ? 1 : stage === "rc" ? 2 : 3, - Number(parts[6] ?? 0) - ] - }; -} -function compareVersions(a, b) { - return a.order.map((part, index) => part - b.order[index]).find((part) => part !== 0) ?? 0; -} - -//#endregion -//#region src/release-updates.ts -const RELEASES = "https://api.github.com/repos/oceanbase/powercontext/releases?per_page=30"; -const MAX_METADATA_BYTES = 2 * 1024 * 1024; -function object(value) { - return value !== null && typeof value === "object" ? value : {}; -} -async function metadata(url, fetcher) { - const response = await fetcher(url, { - redirect: "error", - signal: AbortSignal.timeout(6e3), - headers: { - accept: "application/json", - "user-agent": "powercontext-dsh-update-check" - } - }); - if (!response.ok || !response.body) { - await response.body?.cancel(); - throw new Error("release metadata unavailable"); - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let json = ""; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) return JSON.parse(json + decoder.decode()); - size += value.byteLength; - if (size > MAX_METADATA_BYTES) throw new Error("release metadata too large"); - json += decoder.decode(value, { stream: true }); - } - } finally { - await reader.cancel(); - } -} -async function officialRelease(channel, fetcher = fetch) { - const releases = await metadata(RELEASES, fetcher); - if (!Array.isArray(releases)) throw new Error("invalid release listing"); - const candidates = releases.flatMap((raw) => { - const release = object(raw); - const tag = release.tag_name; - if (typeof tag !== "string" || !tag.startsWith("powercontext-v") || release.draft !== false || typeof release.published_at !== "string" || typeof release.prerelease !== "boolean") return []; - const version = releaseVersion(tag.slice(14)); - if (!version || tag !== "powercontext-v" + version.python || channel === "stable" && (version.prerelease || release.prerelease)) return []; - const assets = Array.isArray(release.assets) ? release.assets.map(object).filter((asset) => asset.state === "uploaded") : []; - const plugin = assets.find((asset) => asset.name === "powercontext-dsh-" + version.npm + ".tgz"); - const wheel = assets.find((asset) => asset.name === "powercontext-" + version.python + "-py3-none-any.whl"); - const digest = typeof wheel?.digest === "string" && /^sha256:[a-f0-9]{64}$/.test(wheel.digest) ? wheel.digest.slice(7) : null; - return [{ - tag, - version, - pluginPublished: plugin !== void 0, - wheelDigest: digest - }]; - }).sort((a, b) => compareVersions(b.version, a.version)); - if (!candidates[0]) throw new Error("no published PowerContext release found"); - return candidates[0]; -} -async function serverDistributionAvailable(release, fetcher = fetch) { - if (release.wheelDigest === null) return false; - try { - const value = object(await metadata("https://pypi.org/pypi/powercontext/" + release.version.python + "/json", fetcher)); - return Array.isArray(value.urls) && value.urls.map(object).some((file) => file.filename === "powercontext-" + release.version.python + "-py3-none-any.whl" && file.yanked === false && object(file.digests).sha256 === release.wheelDigest); - } catch { - return false; - } -} - -//#endregion -//#region src/update-bridge.ts -function supportsRegistryUpdate(bundle) { - return bundle.update === void 0 ? bundle.updatable === true : bundle.update === "registry"; -} -const PUBLIC_REGISTRIES = new Set(["https://registry.npmjs.org/", "https://registry.npmmirror.com/"]); -const UPDATE_CHECK_INTERVAL_MS = 60 * 6e4; -const UPDATE_STARTUP_DELAY_MS = 15e3; -const UPDATE_BUSY_RETRY_MS = 6e4; -const PLAN_TTL_MS = UPDATE_CHECK_INTERVAL_MS + 5 * 6e4; -const CHECK_COOLDOWN_MS = 5e3; -function publicRegistry(registry) { - if (registry === null) return false; - try { - return PUBLIC_REGISTRIES.has(new URL(registry).href); - } catch { - return false; - } -} -function updateLedger() { - return { - snapshot: { - phase: "idle", - plugin_version: null, - plugin_target: null, - server_target: null, - server_state: "unknown", - plan: null, - release_tag: null - }, - plan: null, - checkedAt: Number.NEGATIVE_INFINITY - }; -} -const key = Symbol.for("powercontext.dsh-update-ledgers.v1"); -const global = globalThis; -const ledgers = global[key] ??= /* @__PURE__ */ new WeakMap(); -var DshUpdates = class { - options; - constructor(options) { - this.options = options; - } - get ledger() { - return this.options.ledger; - } - now() { - return this.options.now?.() ?? Date.now(); - } - read() { - const { snapshot, plan } = this.ledger; - const plugin_version = packageVersion(this.options.pluginVersion); - if (plan && (plan.expires <= this.now() || plan.from !== plugin_version || plan.channel !== this.options.channel || this.options.held)) { - this.ledger.plan = null; - this.ledger.snapshot = { - ...snapshot, - phase: "idle", - plan: null - }; - } - if (snapshot.phase === "restart_required" && snapshot.plugin_target === plugin_version) this.ledger.snapshot = { - ...snapshot, - phase: "updated" - }; - return { - ...this.ledger.snapshot, - plugin_version - }; - } - check() { - const current = this.read(); - if ([ - "checking", - "installing", - "restart_required" - ].includes(current.phase) || this.now() - this.ledger.checkedAt < CHECK_COOLDOWN_MS) return current; - this.ledger.checkedAt = this.now(); - this.ledger.plan = null; - this.ledger.snapshot = { - ...current, - phase: "checking", - plan: null - }; - this.resolveCheck().catch(() => this.phase("check_failed")); - return this.read(); - } - /** Schedule the same read-only check once per hour, independent of the panel. */ - scheduleChecks() { - let timer; - const remaining = () => this.ledger.checkedAt + UPDATE_CHECK_INTERVAL_MS - this.now(); - const schedule = (delay) => { - timer = setTimeout(tick, delay); - timer.unref(); - }; - const tick = () => { - if (remaining() <= 0) this.check(); - const delay = remaining(); - schedule(delay > 0 ? delay : UPDATE_BUSY_RETRY_MS); - }; - schedule(Math.max(UPDATE_STARTUP_DELAY_MS, remaining())); - return () => clearTimeout(timer); - } - install(id) { - if (this.read().phase !== "available" || this.ledger.plan?.id !== id) return null; - const plan = this.ledger.plan; - this.ledger.plan = null; - this.phase("installing"); - this.apply(plan).catch(() => this.phase("unconfirmed")); - return this.read(); - } - phase(phase) { - this.ledger.snapshot = { - ...this.ledger.snapshot, - phase, - plan: null - }; - } - async resolveCheck() { - const { fetch: fetcher, channel } = this.options; - const [release, server] = await Promise.all([officialRelease(channel, fetcher), this.options.serverVersion()]); - const installedServer = releaseVersion(server); - const serverHasUpdate = server === null || installedServer !== null && compareVersions(release.version, installedServer) > 0; - const serverAvailable = serverHasUpdate && await serverDistributionAvailable(release, fetcher); - const server_state = serverHasUpdate ? serverAvailable ? "available" : "artifact_unavailable" : installedServer ? "current" : "unknown"; - this.ledger.snapshot = { - ...this.ledger.snapshot, - server_state, - server_target: serverHasUpdate ? release.version.python : null, - release_tag: release.tag, - plugin_target: release.version.npm - }; - const running = releaseVersion(this.options.pluginVersion); - const manager = this.options.manager(); - const bundle = (await manager?.listBundles())?.find((bundle$1) => bundle$1.name === PLUGIN_NAME); - if (!running || !manager || !bundle?.installed || !bundle.enabled || !supportsRegistryUpdate(bundle) || !bundle.source || bundle.version !== running.npm) return this.phase("manual"); - if (compareVersions(release.version, running) <= 0) return this.phase("current"); - if (this.options.held) return this.phase("held"); - if (!release.pluginPublished) return this.phase("artifact_unavailable"); - const registries = await manager.registries(); - if (!publicRegistry(registries.registry ?? registries.resolved) || registries.fallbackRegistries.some((registry$1) => !publicRegistry(registry$1))) return this.phase("manual"); - const versions = await manager.listVersions(PLUGIN_NAME, void 0, AbortSignal.timeout(12e3)); - if (versions.status !== "listed") return this.phase(versions.problem === "not-found" ? "artifact_unavailable" : "check_failed"); - const registry = versions.registry ?? registries.resolved; - if (!publicRegistry(registry) || versions.installed !== running.npm) return this.phase("manual"); - const target = versions.versions.find((version) => version.version === release.version.npm); - if (!target) return this.phase("artifact_unavailable"); - if (!target.bundle || target.deprecated !== void 0 || target.incompatible) return this.phase("incompatible"); - if (installedServer?.python !== release.version.python) return this.phase("server_first"); - const plan = { - id: randomUUID(), - expires: this.now() + PLAN_TTL_MS, - from: running.npm, - source: bundle.source, - to: target.version, - server: release.version.python, - registry, - channel - }; - this.ledger.plan = plan; - this.ledger.snapshot = { - ...this.ledger.snapshot, - phase: "available", - plan: plan.id - }; - } - async apply(plan) { - const manager = this.options.manager(); - if (!manager || this.options.held) return this.phase("manual"); - const [bundles, server, registries] = await Promise.all([ - manager.listBundles(), - this.options.serverVersion(), - manager.registries() - ]); - const current = bundles.find((bundle) => bundle.name === PLUGIN_NAME); - if (current?.version !== plan.from || current.source !== plan.source || !current.enabled || !current.installed || !supportsRegistryUpdate(current) || !publicRegistry(registries.registry ?? registries.resolved) || !publicRegistry(plan.registry) || registries.fallbackRegistries.some((registry) => !publicRegistry(registry))) return this.phase("manual"); - if (releaseVersion(server)?.python !== plan.server) return this.phase("server_first"); - if (!this.options.canInstall()) return this.phase("busy"); - const { result, restore } = await manager.updateBundle(PLUGIN_NAME, plan.to, { registry: plan.registry }); - if (result.application === "applied" || result.application === "restart-required") { - const installed = (await manager.listBundles()).find((bundle) => bundle.name === PLUGIN_NAME); - if (installed?.version !== plan.to || !installed.enabled || !installed.installed) return this.phase("unconfirmed"); - return this.phase("restart_required"); - } - if (restore?.application === "applied" || restore?.application === "restart-required") { - const installed = (await manager.listBundles()).find((bundle) => bundle.name === PLUGIN_NAME); - if (installed?.version === plan.from && installed.enabled && installed.installed) return this.phase("restored"); - } - return this.phase("install_failed"); - } -}; -function createUpdateRouteHandler(updates, connection, facts) { - return (request$2, response) => { - const rejection = connection.requestRejection(request$2); - const deployment = deploymentRejection(request$2, facts); - if (rejection !== void 0 || deployment) return sendJson(response, rejection ?? 403, { code: "owner_required" }); - if (request$2.method === "GET") return sendJson(response, 200, updates.read()); - if (request$2.method !== "POST") { - response.setHeader("allow", "GET, POST"); - return sendJson(response, 405, { code: "method_not_allowed" }); - } - const action = request$2.headers[UPDATE_ACTION_HEADER]; - if (action === "check") return sendJson(response, 202, updates.check()); - if (action !== "install") return sendJson(response, 400, { code: "unknown_action" }); - if (connection.admit?.(request$2).peer?.principal?.kind !== "owner") return sendJson(response, 403, { code: "owner_required" }); - const id = request$2.headers[UPDATE_PLAN_HEADER]; - const snapshot = typeof id === "string" && updates.install(id); - if (!snapshot) return sendJson(response, 409, { code: "update_plan_changed" }); - return sendJson(response, 202, snapshot); - }; -} -function hostUpdates(ctx, runtime, facts) { - let ledger = ledgers.get(ctx.root); - if (!ledger) ledgers.set(ctx.root, ledger = updateLedger()); - return new DshUpdates({ - ledger, - pluginVersion: PLUGIN_VERSION, - channel: runtime.config.updateChannel ?? "stable", - held: runtime.config.updateHold === true, - manager: () => { - if (typeof ctx.connection.admit !== "function") return void 0; - if (ctx.get("profileContext")?.name !== (facts.desktop ? "desktop" : "web")) return void 0; - const manager = ctx.root.get("pluginManager") ?? ctx.get("pluginManager"); - return manager && typeof manager.listVersions === "function" && typeof manager.updateBundle === "function" ? manager : void 0; - }, - serverVersion: async () => { - try { - return await readServerPackageVersion(runtime.client); - } catch { - return null; - } - }, - canInstall: () => { - const sessions = ctx.get("sessions"); - const agents = ctx.get("agents"); - return typeof sessions?.list === "function" && typeof agents?.turnBoundaryOf === "function" && sessions.list().every((session) => agents.turnBoundaryOf(session).openTurnStartSeq === null); - } - }); -} -function registerUpdateBridge(ctx, runtime) { - if (typeof ctx.inject !== "function") return; - ctx.inject(["webServer", "connection"], (host) => { - const ui = host; - const facts = localDeploymentFacts(ui, runtime.config.ownerLocal); - const updates = hostUpdates(ui, runtime, facts); - host.effect(() => ui.webServer.register({ - kind: "exact", - path: UPDATE_ROUTE, - handler: createUpdateRouteHandler(updates, ui.connection, facts) - }), "powercontext: update route"); - const profile = ui.get("profileContext"); - if (resolveDeployment(facts) && profile?.name === (facts.desktop ? "desktop" : "web")) host.effect(() => updates.scheduleChecks(), "powercontext: hourly update check"); - }); -} - //#endregion //#region src/index.ts const name = PLUGIN_NAME; @@ -5561,12 +5097,12 @@ function createSessionScopeResolver(runtime) { const cache = /* @__PURE__ */ new Map(); const keyFor = (sessionId, cwd) => sessionId ? `session:${sessionId}` : `cwd:${cwd ?? ""}`; const resolve$1 = async (request$2, refresh) => { - const key$1 = keyFor(request$2.sessionId, request$2.cwd); - const cached = cache.get(key$1); + const key = keyFor(request$2.sessionId, request$2.cwd); + const cached = cache.get(key); if (!refresh && cached && cached.cwd === request$2.cwd) return cached.scopeId; - cache.delete(key$1); + cache.delete(key); const scopeId = await runtime.resolveScope(request$2.cwd, request$2.signal); - if (scopeId) cache.set(key$1, { + if (scopeId) cache.set(key, { cwd: request$2.cwd, scopeId }); @@ -5652,7 +5188,6 @@ async function apply(ctx, config) { registerRecall(ctx, runtime, llmMod.createUserMessage, sessionScope.forPreStep); registerCommands(ctx, runtime); registerStatusBridge(ctx, runtime); - registerUpdateBridge(ctx, runtime); await registerMcp(ctx, runtime.config); } diff --git a/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts b/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts index 69ce14fd33..066466dca0 100644 --- a/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/mcp-operations.generated.ts @@ -134,18 +134,15 @@ export const MCP_OPERATIONS = { "pc_source_add": { "readOnly": false }, - "pc_source_capabilities": { - "readOnly": true - }, - "pc_source_instance_set_enabled": { - "readOnly": false - }, - "pc_source_instances": { + "pc_source_runners": { "readOnly": true }, "pc_source_status": { "readOnly": true }, + "pc_source_target_delete": { + "readOnly": false + }, "pc_source_target_set_enabled": { "readOnly": false }, diff --git a/integrations/dsh/plugins/powercontext/src/mcp.ts b/integrations/dsh/plugins/powercontext/src/mcp.ts index 3cb36fbd00..3765cb13a0 100644 --- a/integrations/dsh/plugins/powercontext/src/mcp.ts +++ b/integrations/dsh/plugins/powercontext/src/mcp.ts @@ -72,9 +72,6 @@ function rawOperation(name: string): string | undefined { } 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' diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 266b9bdcf6..b259eb0962 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -69,6 +69,13 @@ export const OPERATIONS = { list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin_openapi: { method: 'GET', path: '/v1/plugins/{name}/openapi.json', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { method: 'GET', path: '/v1/source-runners', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + sync_source_runner: { method: 'POST', path: '/v1/source-runners/{runner}/sync', location: "body", scopeMode: 'none', pathParameters: ['runner'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + delete_source_runner: { method: 'DELETE', path: '/v1/source-runners/{runner}', location: null, scopeMode: 'none', pathParameters: ['runner'], queryParams: [], headerParams: [], successStatuses: [204], emptyStatuses: [204] }, + pc_source_status: { method: 'GET', path: '/v1/scopes/{scope_id}/source-targets', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_add: { method: 'POST', path: '/v1/scopes/{scope_id}/source-targets', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201,200], emptyStatuses: [] }, + pc_source_target_set_enabled: { method: 'PATCH', path: '/v1/scopes/{scope_id}/source-targets/{target_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'target_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_target_delete: { method: 'DELETE', path: '/v1/scopes/{scope_id}/source-targets/{target_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'target_id'], queryParams: [], headerParams: [], successStatuses: [204], emptyStatuses: [204] }, list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -155,20 +162,6 @@ 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: [] }, plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const diff --git a/integrations/dsh/plugins/powercontext/src/secrets.ts b/integrations/dsh/plugins/powercontext/src/secrets.ts index 6a52a4e08a..b69d1d91d7 100644 --- a/integrations/dsh/plugins/powercontext/src/secrets.ts +++ b/integrations/dsh/plugins/powercontext/src/secrets.ts @@ -21,7 +21,8 @@ const PAYLOAD_WRITES = new Set([ 'replace_artifact', 'change_atomic_memory_lifecycle', ]) const CONTENT_FIELDS = new Map([ - ['pc_source_add', ['input']], + ['pc_source_add', ['parameters']], + ['pc_source_target_set_enabled', ['parameters']], ['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 76f5796e88..312dbf8ae8 100644 --- a/integrations/dsh/plugins/powercontext/tests/mcp.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/mcp.spec.ts @@ -125,8 +125,7 @@ describe('PowerContext MCP bridge', () => { ) it.each([ - 'pc_source_capabilities', 'pc_source_status', 'pc_source_add', - 'pc_source_target_set_enabled', 'pc_source_instances', + 'pc_source_status', 'pc_source_add', 'pc_source_target_set_enabled', 'pc_source_target_delete', ])('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 })) @@ -153,9 +152,8 @@ describe('PowerContext MCP bridge', () => { }) 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) => { + ['pc_source_runners', {}, 'allow'], + ])('keeps Server-wide 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}`, @@ -179,8 +177,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'] } } }], + ['pc_source_add', { runner: 'server', capability: 'tickets', parameters: { file: 'sk-review-fixture-only' } }], + ['pc_source_add', { runner: 'robot-7', capability: 'tickets', parameters: { notes: ['api_key=FAKE_REVIEW_CANARY'] } }], ['remember_memory', { text: 'api_key=FAKE_REVIEW_MARKER' }], ['replace_artifact', { content: { kind: 'decision', text: 'api_key=FAKE_REVIEW_MARKER' } }], ['merge_atomic_memories', { content: { kind: 'decision', text: 'api_key=FAKE_REVIEW_MARKER' } }], diff --git a/integrations/kimi-code/plugins/powercontext/profiles.json b/integrations/kimi-code/plugins/powercontext/profiles.json index 2fb8623e83..9682dc9500 100644 --- a/integrations/kimi-code/plugins/powercontext/profiles.json +++ b/integrations/kimi-code/plugins/powercontext/profiles.json @@ -27,10 +27,9 @@ "list_memory_entries", "merge_atomic_memories", "pc_source_add", - "pc_source_capabilities", - "pc_source_instance_set_enabled", - "pc_source_instances", + "pc_source_runners", "pc_source_status", + "pc_source_target_delete", "pc_source_target_set_enabled", "prepare_handoff_hint", "preview_atomic_memory_restoration", @@ -74,10 +73,9 @@ "list_memory_entries", "merge_atomic_memories", "pc_source_add", - "pc_source_capabilities", - "pc_source_instance_set_enabled", - "pc_source_instances", + "pc_source_runners", "pc_source_status", + "pc_source_target_delete", "pc_source_target_set_enabled", "prepare_handoff_hint", "preview_atomic_memory_restoration", diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index 75e52bf1f3..db24da3140 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -650,6 +650,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { + method: "GET", + path: "/v1/source-runners", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + sync_source_runner: { + method: "POST", + path: "/v1/source-runners/{runner}/sync", + location: "body", + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + delete_source_runner: { + method: "DELETE", + path: "/v1/source-runners/{runner}", + location: null, + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, + pc_source_status: { + method: "GET", + path: "/v1/scopes/{scope_id}/source-targets", + location: null, + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/scopes/{scope_id}/source-targets", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201, 200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "PATCH", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_delete: { + method: "DELETE", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", @@ -1667,160 +1744,6 @@ const OPERATIONS = { 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: [] - }, plan_access_ownership: { method: "POST", path: "/v1/access/ownership/plan", diff --git a/integrations/opencode/plugins/powercontext/lib/tui.js b/integrations/opencode/plugins/powercontext/lib/tui.js index dac8e8845c..c7be52a861 100644 --- a/integrations/opencode/plugins/powercontext/lib/tui.js +++ b/integrations/opencode/plugins/powercontext/lib/tui.js @@ -650,6 +650,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { + method: "GET", + path: "/v1/source-runners", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + sync_source_runner: { + method: "POST", + path: "/v1/source-runners/{runner}/sync", + location: "body", + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + delete_source_runner: { + method: "DELETE", + path: "/v1/source-runners/{runner}", + location: null, + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, + pc_source_status: { + method: "GET", + path: "/v1/scopes/{scope_id}/source-targets", + location: null, + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/scopes/{scope_id}/source-targets", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201, 200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "PATCH", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_delete: { + method: "DELETE", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", @@ -1667,160 +1744,6 @@ const OPERATIONS = { 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: [] - }, plan_access_ownership: { method: "POST", path: "/v1/access/ownership/plan", diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 266b9bdcf6..b259eb0962 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -69,6 +69,13 @@ export const OPERATIONS = { list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin_openapi: { method: 'GET', path: '/v1/plugins/{name}/openapi.json', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { method: 'GET', path: '/v1/source-runners', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + sync_source_runner: { method: 'POST', path: '/v1/source-runners/{runner}/sync', location: "body", scopeMode: 'none', pathParameters: ['runner'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + delete_source_runner: { method: 'DELETE', path: '/v1/source-runners/{runner}', location: null, scopeMode: 'none', pathParameters: ['runner'], queryParams: [], headerParams: [], successStatuses: [204], emptyStatuses: [204] }, + pc_source_status: { method: 'GET', path: '/v1/scopes/{scope_id}/source-targets', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_add: { method: 'POST', path: '/v1/scopes/{scope_id}/source-targets', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201,200], emptyStatuses: [] }, + pc_source_target_set_enabled: { method: 'PATCH', path: '/v1/scopes/{scope_id}/source-targets/{target_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'target_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_target_delete: { method: 'DELETE', path: '/v1/scopes/{scope_id}/source-targets/{target_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'target_id'], queryParams: [], headerParams: [], successStatuses: [204], emptyStatuses: [204] }, list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -155,20 +162,6 @@ 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: [] }, plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 266b9bdcf6..b259eb0962 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -69,6 +69,13 @@ export const OPERATIONS = { list_plugins: { method: 'GET', path: '/v1/plugins', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin: { method: 'GET', path: '/v1/plugins/{name}', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_plugin_openapi: { method: 'GET', path: '/v1/plugins/{name}/openapi.json', location: null, scopeMode: 'none', pathParameters: ['name'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { method: 'GET', path: '/v1/source-runners', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + sync_source_runner: { method: 'POST', path: '/v1/source-runners/{runner}/sync', location: "body", scopeMode: 'none', pathParameters: ['runner'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + delete_source_runner: { method: 'DELETE', path: '/v1/source-runners/{runner}', location: null, scopeMode: 'none', pathParameters: ['runner'], queryParams: [], headerParams: [], successStatuses: [204], emptyStatuses: [204] }, + pc_source_status: { method: 'GET', path: '/v1/scopes/{scope_id}/source-targets', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_add: { method: 'POST', path: '/v1/scopes/{scope_id}/source-targets', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201,200], emptyStatuses: [] }, + pc_source_target_set_enabled: { method: 'PATCH', path: '/v1/scopes/{scope_id}/source-targets/{target_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'target_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + pc_source_target_delete: { method: 'DELETE', path: '/v1/scopes/{scope_id}/source-targets/{target_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'target_id'], queryParams: [], headerParams: [], successStatuses: [204], emptyStatuses: [204] }, list_plugin_scopes: { method: 'GET', path: '/v1/plugins/{name}/scopes', location: "query", scopeMode: 'none', pathParameters: ['name'], queryParams: ['cursor','limit'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, merge_artifacts: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts/{family}/merges', location: "body", scopeMode: 'current', pathParameters: ['scope_id', 'family'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_governance: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/governance', location: null, scopeMode: 'current', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, @@ -155,20 +162,6 @@ 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: [] }, plan_access_ownership: { method: 'POST', path: '/v1/access/ownership/plan', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, apply_access_ownership: { method: 'POST', path: '/v1/access/ownership/apply', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 3aa6a7efb7..e247681449 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1985,6 +1985,169 @@ paths: "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "503": {$ref: '#/components/responses/Unavailable'} + /v1/source-runners: + get: + tags: [source-collection] + summary: List collection runners + description: >- + Lists the Server's own runner and every runner that has synced, with the Source plugins each loaded, whether + each plugin version is compatible with the version this Server loaded, and when each runner last synced. A + capability's description, parameter schema, and identity come from the Source plugin this Server loaded. + operationId: pc_source_runners + x-powercontext-access: {action: access.self, resource: {type: server}} + responses: + "200": + description: Runners ordered by name. + content: + application/json: + schema: {$ref: '#/components/schemas/SourceRunnerPage'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/source-runners/{runner}/sync: + post: + tags: [source-collection] + summary: Sync a collection runner + description: >- + Records the runner's loaded Source plugins and last-seen time and returns every enabled target assigned to it + across Scopes, with each target's checkpoint. Only the runner itself may sync: the caller's identifier equals + the runner name, or the caller is the subject that first synced with this name. Targets of plugins whose version + this Server does not accept are withheld and listed as rejected. + operationId: sync_source_runner + x-powercontext-access: {action: access.self, resource: {type: server}} + parameters: + - {name: runner, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:@-]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SourceRunnerSyncRequest'} + responses: + "200": + description: The runner's current targets. + content: + application/json: + schema: {$ref: '#/components/schemas/SourceRunnerSyncResult'} + "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-runners/{runner}: + delete: + tags: [source-collection] + summary: Delete a collection runner record + description: Deletes a runner's record once it has no targets. + operationId: delete_source_runner + x-powercontext-access: {action: server.admin, resource: {type: server}} + parameters: + - {name: runner, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:@-]*$'}} + responses: + "204": {description: The runner record is deleted.} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "409": {$ref: '#/components/responses/Conflict'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/source-targets: + post: + tags: [source-collection] + summary: Add a collection target + description: >- + Assigns a target of a Source plugin capability to a runner. Parameters are validated against the parameter + schema of the plugin version this Server loaded, and the declared identity parameters form the target key. A + target with the same Scope, runner, capability, and key is returned unchanged when its parameters match and is a + conflict otherwise. Targets of the Server's own runner, named server, require server.admin. + operationId: pc_source_add + x-powercontext-scope-mode: none + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SourceTargetCreateRequest'} + responses: + "201": + description: The target was added. + content: + application/json: + schema: {$ref: '#/components/schemas/SourceTarget'} + "200": + description: The same target already exists. + content: + application/json: + schema: {$ref: '#/components/schemas/SourceTarget'} + "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'} + get: + tags: [source-collection] + summary: List a Scope's collection targets + description: Returns each target's configuration and the status its runner last reported. + operationId: pc_source_status + x-powercontext-scope-mode: none + x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + responses: + "200": + description: Targets ordered by ID. + content: + application/json: + schema: {$ref: '#/components/schemas/SourceTargetPage'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "503": {$ref: '#/components/responses/Unavailable'} + /v1/scopes/{scope_id}/source-targets/{target_id}: + patch: + tags: [source-collection] + summary: Change or pause a collection target + description: >- + Enables or disables a target, or replaces its parameters. Identity parameters cannot change: to collect another + object, add a target. A disabled target keeps its checkpoint and resumes from it. Targets of the server runner + require server.admin. + operationId: pc_source_target_set_enabled + x-powercontext-scope-mode: none + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + - {name: target_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 64, pattern: '^[0-9a-f]{1,64}$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SourceTargetUpdateRequest'} + responses: + "200": + description: The changed target. + content: + application/json: + schema: {$ref: '#/components/schemas/SourceTarget'} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "422": {$ref: '#/components/responses/InvalidRequest'} + "503": {$ref: '#/components/responses/Unavailable'} + delete: + tags: [source-collection] + summary: Delete a collection target + description: Deletes the target and its checkpoint. Sources it collected stay. Targets of the server runner require server.admin. + operationId: pc_source_target_delete + x-powercontext-scope-mode: none + x-powercontext-access: {action: scope.contribute, resource: {type: scope, scope-id-from: scope_id}} + parameters: + - {name: scope_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'}} + - {name: target_id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 64, pattern: '^[0-9a-f]{1,64}$'}} + responses: + "204": {description: The target is deleted.} + "401": {$ref: '#/components/responses/Unauthorized'} + "403": {$ref: '#/components/responses/Forbidden'} + "404": {$ref: '#/components/responses/NotFound'} + "503": {$ref: '#/components/responses/Unavailable'} /v1/plugins/{name}/scopes: get: tags: [plugins] @@ -5463,653 +5626,108 @@ 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: + /v1/access/ownership/plan: post: - tags: - - source-management - summary: pc source add - operationId: pc_source_add + tags: [access] + summary: Inventory ownership data missing after switching Access enforcement on + operationId: plan_access_ownership 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 + Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending + Review Candidates without an owner attestation, and, when named, the background Principal's + per-Scope write authority. Produces no change. In enforced mode completes as a deployment + administrator; in disabled mode completes without authentication because the Server cannot yet + protect anything. A background Principal check requires an available Access store; otherwise it + reports 503 relationship_management_unavailable. Use the offline ownership command to check the + proposed background Principal before enforcement is enabled. + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/SourceAddRequest' + $ref: "#/components/schemas/PlanAccessOwnershipRequest" responses: - '200': - description: Confirmed Source management result. + "200": + description: The missing-attribution inventory for the requested Scopes. 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: + $ref: "#/components/schemas/AccessOwnershipPlan" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + /v1/access/ownership/apply: post: - tags: - - source-management - summary: pc source status - operationId: pc_source_status + tags: [access] + summary: Backfill missing Artifact owners and Candidate owner attestations + operationId: apply_access_ownership 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 + Grant the missing ownership data to the explicitly named Principal; the Server never derives it. + Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when + they disagree with the named Principal. Identities with recorded ownership recovery evidence + are reported as conflicts and must retain their original attribution. Every item is one idempotent + write, so interrupted or + repeated runs refill only still-missing items. Set dry_run first to preview the exact items. + Requires a deployment administrator; while Access is disabled the Server has no ownership store + and reports 503 ownership_migration_unavailable. + x-powercontext-access: {action: access.self, resource: {type: server}} requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/SourceStatusRequest' + $ref: "#/components/schemas/ApplyAccessOwnershipRequest" responses: - '200': - description: Confirmed Source management result. + "200": + description: The applied, or, in dry-run, previewed ownership backfill. 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' - /v1/access/ownership/plan: - post: - tags: [access] - summary: Inventory ownership data missing after switching Access enforcement on - operationId: plan_access_ownership - description: >- - Inspect the Scopes that were written while Access was disabled: missing Artifact owners, pending - Review Candidates without an owner attestation, and, when named, the background Principal's - per-Scope write authority. Produces no change. In enforced mode completes as a deployment - administrator; in disabled mode completes without authentication because the Server cannot yet - protect anything. A background Principal check requires an available Access store; otherwise it - reports 503 relationship_management_unavailable. Use the offline ownership command to check the - proposed background Principal before enforcement is enabled. - x-powercontext-access: {action: access.self, resource: {type: server}} - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PlanAccessOwnershipRequest" - responses: - "200": - description: The missing-attribution inventory for the requested Scopes. - content: - application/json: - schema: - $ref: "#/components/schemas/AccessOwnershipPlan" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "422": - $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" - /v1/access/ownership/apply: - post: - tags: [access] - summary: Backfill missing Artifact owners and Candidate owner attestations - operationId: apply_access_ownership - description: >- - Grant the missing ownership data to the explicitly named Principal; the Server never derives it. - Existing Artifact owners and Candidate attestations are preserved and reported as conflicts when - they disagree with the named Principal. Identities with recorded ownership recovery evidence - are reported as conflicts and must retain their original attribution. Every item is one idempotent - write, so interrupted or - repeated runs refill only still-missing items. Set dry_run first to preview the exact items. - Requires a deployment administrator; while Access is disabled the Server has no ownership store - and reports 503 ownership_migration_unavailable. - x-powercontext-access: {action: access.self, resource: {type: server}} - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ApplyAccessOwnershipRequest" - responses: - "200": - description: The applied, or, in dry-run, previewed ownership backfill. - content: - application/json: - schema: - $ref: "#/components/schemas/AccessOwnershipApplyResult" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "422": - $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - description: Bearer credential resolved to an opaque authenticated Principal by the Server deployment. - TargetBearerAuth: - type: http - scheme: bearer - description: Per-target credential issued once during remote Receiver enrollment. - headers: - ArtifactETag: - description: Opaque strong validator for the current Artifact head. Clients must replay it verbatim. - schema: - type: string - minLength: 1 - BearerChallenge: - description: Authentication scheme required by the Server. - schema: - type: string - example: Bearer - RequestId: - description: Opaque identifier for correlating one request. - schema: - type: string - Location: - description: URI of the newly created Source or Artifact head. - schema: - type: string - responses: - GatewayDeadlineExceeded: + $ref: "#/components/schemas/AccessOwnershipApplyResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: Bearer credential resolved to an opaque authenticated Principal by the Server deployment. + TargetBearerAuth: + type: http + scheme: bearer + description: Per-target credential issued once during remote Receiver enrollment. + headers: + ArtifactETag: + description: Opaque strong validator for the current Artifact head. Clients must replay it verbatim. + schema: + type: string + minLength: 1 + BearerChallenge: + description: Authentication scheme required by the Server. + schema: + type: string + example: Bearer + RequestId: + description: Opaque identifier for correlating one request. + schema: + type: string + Location: + description: URI of the newly created Source or Artifact head. + schema: + type: string + responses: + GatewayDeadlineExceeded: description: >- Total monotonic work deadline exceeded. Best effort only while HTTP status can still be sent; an expired deadline or unavailable connection may prevent delivery. Does not imply zero cost or permit retry. @@ -8217,18 +7835,148 @@ components: effect: type: string enum: [review_then_publish, review_then_publish_configuration, review_then_replace_tags] - PluginKind: {type: string, enum: [artifact]} + PluginKind: {type: string, enum: [artifact, source]} PluginLoadStatus: {type: string, enum: [loaded, failed]} PluginTrigger: {type: string, enum: [schedule, sources]} PluginRoute: type: object additionalProperties: false - required: [method, path, access, mcp] + required: [method, path, access, mcp] + properties: + method: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} + path: {type: string, description: 'Route template under /v1/plugins/{name}/api.'} + access: {type: string, enum: [authenticated, scope.read, scope.contribute]} + mcp: {type: boolean, description: The route is also an MCP tool.} + PluginVersionRef: + type: object + additionalProperties: false + required: [name, version] + description: The Source plugin and version a runner used; the Server accepts it only within the loaded plugin's compatible range. + properties: + name: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'} + version: {type: string, minLength: 1, maxLength: 64} + SourceRunnerPluginReport: + type: object + additionalProperties: false + required: [name, version, error] + properties: + name: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'} + version: {type: string, nullable: true, maxLength: 64, description: Null when the plugin failed to load on the runner.} + error: {type: string, nullable: true, maxLength: 1024, description: Safe load failure category on the runner.} + SourceRunnerSyncRequest: + type: object + additionalProperties: false + required: [plugins] + properties: + plugins: {type: array, maxItems: 200, items: {$ref: '#/components/schemas/SourceRunnerPluginReport'}} + SourceRunnerTarget: + type: object + additionalProperties: false + required: [scope_id, target_id, capability, parameters, checkpoint] + properties: + scope_id: {type: string} + target_id: {type: string} + capability: {type: string} + parameters: {type: object, additionalProperties: true} + checkpoint: {nullable: true, description: The target's last committed checkpoint; null before the first commit.} + SourceRunnerRejection: + type: object + additionalProperties: false + required: [name, code] + properties: + name: {type: string} + code: {type: string, enum: [plugin_version_incompatible, plugin_not_loaded]} + SourceRunnerSyncResult: + type: object + additionalProperties: false + required: [targets, rejected] + properties: + targets: {type: array, items: {$ref: '#/components/schemas/SourceRunnerTarget'}} + rejected: {type: array, items: {$ref: '#/components/schemas/SourceRunnerRejection'}} + SourceRunnerCapability: + type: object + additionalProperties: false + required: [name, version, error, compatible, description, parameters_schema, identity] + properties: + name: {type: string} + version: {type: string, nullable: true, description: The version the runner reported.} + error: {type: string, nullable: true} + compatible: {type: boolean, description: This Server loaded the plugin and accepts the runner's version.} + description: {type: string, nullable: true, description: From the plugin this Server loaded; null when it loaded none.} + parameters_schema: {type: object, nullable: true, additionalProperties: true} + identity: + type: object + nullable: true + additionalProperties: {type: array, items: {type: string}} + description: Identity parameters and their normalization steps. + SourceRunner: + type: object + additionalProperties: false + required: [runner, last_seen_at, capabilities] + properties: + runner: {type: string} + last_seen_at: {type: string, format: date-time, nullable: true, description: Null for the Server's own runner.} + capabilities: {type: array, items: {$ref: '#/components/schemas/SourceRunnerCapability'}} + SourceRunnerPage: + type: object + additionalProperties: false + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/SourceRunner'}} + SourceTargetCreateRequest: + type: object + additionalProperties: false + required: [runner, capability, parameters] + properties: + runner: {type: string, minLength: 1, maxLength: 128} + capability: {type: string, minLength: 1, maxLength: 64, pattern: '^[a-z][a-z0-9-]*$'} + parameters: {type: object, additionalProperties: true} + SourceTargetUpdateRequest: + type: object + additionalProperties: false + properties: + enabled: {type: boolean} + parameters: {type: object, additionalProperties: true} + SourceTargetStatus: + type: object + additionalProperties: false + required: [last_attempt_at, last_success_at, consecutive_failures, last_error_code, last_error_message] + properties: + last_attempt_at: {type: string, format: date-time, nullable: true} + last_success_at: {type: string, format: date-time, nullable: true} + consecutive_failures: {type: integer, minimum: 0} + last_error_code: {type: string, nullable: true} + last_error_message: {type: string, nullable: true} + SourceTargetResultStatus: + type: object + additionalProperties: false + required: [succeeded] + description: The outcome of the collection attempt whose checkpoint this commits. + properties: + succeeded: {type: boolean} + error_code: {type: string, nullable: true, maxLength: 128} + error_message: {type: string, nullable: true, maxLength: 1024} + SourceTarget: + type: object + additionalProperties: false + required: [scope_id, target_id, runner, capability, target_key, parameters, enabled, updated_at, updated_by, status] properties: - method: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]} - path: {type: string, description: 'Route template under /v1/plugins/{name}/api.'} - access: {type: string, enum: [authenticated, scope.read, scope.contribute]} - mcp: {type: boolean, description: The route is also an MCP tool.} + scope_id: {type: string} + target_id: {type: string} + runner: {type: string} + capability: {type: string} + target_key: {type: string} + parameters: {type: object, additionalProperties: true} + enabled: {type: boolean} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + status: {$ref: '#/components/schemas/SourceTargetStatus'} + SourceTargetPage: + type: object + additionalProperties: false + required: [items] + properties: + items: {type: array, items: {$ref: '#/components/schemas/SourceTarget'}} PluginOpenApiDocument: type: object additionalProperties: true @@ -9675,6 +9423,11 @@ components: pattern: '.*\S.*' observation: $ref: "#/components/schemas/SourceObservation" + plugin: + allOf: [{$ref: '#/components/schemas/PluginVersionRef'}] + description: >- + Set by collection runners. The Server checks the version against the Source plugin it loaded and has that + plugin resolve and project the observation; unsupported versions return plugin_version_incompatible. SourceObservationReceipt: type: object additionalProperties: false @@ -9696,6 +9449,12 @@ components: nullable: true checkpoint: nullable: true + plugin: + allOf: [{$ref: '#/components/schemas/PluginVersionRef'}] + description: Set by collection runners for a target's checkpoint; checked like a runner's observations. + status: + allOf: [{$ref: '#/components/schemas/SourceTargetResultStatus'}] + description: The collection attempt's outcome, recorded on the target together with the checkpoint. CommitHandoffRequest: type: object additionalProperties: false @@ -14083,1047 +13842,651 @@ components: type: string enum: [available, unavailable] HandoffResolutionStatus: - type: string - enum: [empty, resolved] - HandoffRolloverReason: - type: string - enum: [user_requested, host_context_budget, host_compaction, context_quality, delegation, manual_checkpoint] - HandoffSchema: - type: string - enum: [powercontext.handoff.v1] - HandoffSelection: - type: string - enum: [prepared, exact, latest] - PreparedHandoffSchema: - type: string - enum: [powercontext.prepared-handoff.v1] - AccessPrincipal: - type: object - additionalProperties: false - required: [type, id] - properties: - type: {type: string, enum: [user, service]} - id: {type: string, minLength: 1, maxLength: 255} - description: {type: string, minLength: 1, maxLength: 255, nullable: true} - AccessGroup: - type: object - additionalProperties: false - required: [type, id] - properties: - type: {type: string, enum: [group]} - id: {type: string, minLength: 1, maxLength: 255} - description: {type: string, minLength: 1, maxLength: 255, nullable: true} - AccessSubject: - oneOf: - - $ref: "#/components/schemas/AccessPrincipal" - - $ref: "#/components/schemas/AccessGroup" - discriminator: - propertyName: type - mapping: - user: "#/components/schemas/AccessPrincipal" - service: "#/components/schemas/AccessPrincipal" - group: "#/components/schemas/AccessGroup" - AccessControlMode: - type: string - enum: [disabled, enforced] - AccessProviderCapabilities: - type: object - additionalProperties: false - required: - - safe_resource_filtering - - multi_requirement_check - - relationship_management - - group_subjects - - multi_principal - - max_direct_resource_keys - properties: - safe_resource_filtering: {type: boolean} - multi_requirement_check: {type: boolean} - relationship_management: {type: boolean} - group_subjects: {type: boolean} - multi_principal: {type: boolean} - max_direct_resource_keys: {type: integer, minimum: 1, maximum: 10000} - ArtifactFamilyAccessCapability: - type: object - additionalProperties: false - required: [family, enabled, share_unit, actions, grantable_roles] - properties: - family: {type: string, minLength: 1, maxLength: 128} - enabled: {type: boolean} - share_unit: - type: string - enum: [artifact, memory_entry] - actions: - type: array - items: - $ref: "#/components/schemas/AccessAction" - grantable_roles: - type: array - items: - $ref: "#/components/schemas/AccessRole" - ConnectionAuthentication: - type: string - enum: [disabled, authenticated] - ConnectionIdentity: - type: object - additionalProperties: false - required: [server_id, authentication, access_mode, principal] - properties: - server_id: - type: string - minLength: 1 - maxLength: 128 - authentication: - $ref: '#/components/schemas/ConnectionAuthentication' - access_mode: - $ref: '#/components/schemas/AccessControlMode' - principal: - nullable: true - allOf: - - $ref: '#/components/schemas/AccessPrincipal' - AccessMeResponse: - type: object - additionalProperties: false - required: - - principal - - mode - - resource_kinds - - provider_capabilities - - artifact_families - properties: - principal: - $ref: "#/components/schemas/AccessPrincipal" - mode: - $ref: "#/components/schemas/AccessControlMode" - resource_kinds: - type: array - items: - $ref: "#/components/schemas/AccessResourceType" - provider_capabilities: - $ref: "#/components/schemas/AccessProviderCapabilities" - artifact_families: - type: array - items: - $ref: "#/components/schemas/ArtifactFamilyAccessCapability" - AccessAction: - type: string - enum: - - server.observe - - server.admin - - scope.read - - scope.contribute - - scope.review - - scope.delegate - - scope.admin - - gateway.invoke - - artifact.read - - artifact.write - - artifact.share - - handoff.evidence.inspect - - handoff.acknowledge - - prompt.use - AccessResourceType: - type: string - enum: [server, scope, artifact] - ServerAccessResource: - type: object - additionalProperties: false - required: [type, deployment_id] - properties: - type: {type: string, enum: [server]} - deployment_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} - ScopeAccessResource: - type: object - additionalProperties: false - required: [type, scope_id] - properties: - type: {type: string, enum: [scope]} - scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} - MemoryEntryAccessSelector: - type: object - additionalProperties: false - description: Logical Memory entry selector; the Binding covers the entry's existing and future versions. - required: [type, entry_id] - properties: - type: {type: string, enum: [memory_entry]} - entry_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} - AccessArtifactIdentity: - type: object - additionalProperties: false - description: Logical Artifact identity; the Binding covers existing and future Revisions of the same Artifact. - required: [family, artifact_id] - properties: - family: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} - artifact_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} - ArtifactAccessResource: - type: object - additionalProperties: false - required: [type, scope_id, identity] - properties: - type: {type: string, enum: [artifact]} - scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} - identity: - $ref: "#/components/schemas/AccessArtifactIdentity" - selector: - allOf: - - $ref: "#/components/schemas/MemoryEntryAccessSelector" - nullable: true - AccessResource: - oneOf: - - $ref: "#/components/schemas/ServerAccessResource" - - $ref: "#/components/schemas/ScopeAccessResource" - - $ref: "#/components/schemas/ArtifactAccessResource" - discriminator: - propertyName: type - mapping: - server: "#/components/schemas/ServerAccessResource" - scope: "#/components/schemas/ScopeAccessResource" - artifact: "#/components/schemas/ArtifactAccessResource" - AccessDecision: - type: object - additionalProperties: false - required: [allowed, reason_code] - properties: - allowed: {type: boolean} - reason_code: {type: string, minLength: 1, maxLength: 64} - AccessRequirementMatch: - type: string - enum: [all, any] - AccessCheckRequirement: - type: object - additionalProperties: false - required: [action, resource] - properties: - action: - $ref: "#/components/schemas/AccessAction" - resource: - $ref: "#/components/schemas/AccessResource" - AccessCheckRequest: - type: object - additionalProperties: false - required: [match, requirements] - properties: - match: - $ref: "#/components/schemas/AccessRequirementMatch" - requirements: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: "#/components/schemas/AccessCheckRequirement" - AccessCheckResponse: - type: object - additionalProperties: false - required: [allowed, decisions] - properties: - allowed: {type: boolean} - decisions: - type: array - minItems: 1 - maxItems: 100 - items: - $ref: "#/components/schemas/AccessDecision" - ListAccessResourcesRequest: + type: string + enum: [empty, resolved] + HandoffRolloverReason: + type: string + enum: [user_requested, host_context_budget, host_compaction, context_quality, delegation, manual_checkpoint] + HandoffSchema: + type: string + enum: [powercontext.handoff.v1] + HandoffSelection: + type: string + enum: [prepared, exact, latest] + PreparedHandoffSchema: + type: string + enum: [powercontext.prepared-handoff.v1] + AccessPrincipal: type: object additionalProperties: false - required: [action, resource_type] + required: [type, id] properties: - action: - $ref: "#/components/schemas/AccessAction" - resource_type: - $ref: "#/components/schemas/AccessResourceType" - family: {type: string, minLength: 1, maxLength: 128, nullable: true} - cursor: {type: string, nullable: true} - limit: {type: integer, minimum: 1, maximum: 500, default: 100} - AccessResourcePage: + type: {type: string, enum: [user, service]} + id: {type: string, minLength: 1, maxLength: 255} + description: {type: string, minLength: 1, maxLength: 255, nullable: true} + AccessGroup: type: object additionalProperties: false - required: [items, total, next_cursor] + required: [type, id] properties: - items: - type: array - maxItems: 500 - items: - $ref: "#/components/schemas/AccessResource" - total: {type: integer, minimum: 0} - next_cursor: {type: string, nullable: true} - AccessRole: - type: string - enum: - - handoff.viewer - - handoff.receiver - - artifact.viewer - - prompt.user - - artifact.owner - - scope.viewer - - scope.contributor - - scope.reviewer - - scope.delegator - - scope.admin - - gateway.user - - server.observer - - server.admin - AccessRoleCardinality: + type: {type: string, enum: [group]} + id: {type: string, minLength: 1, maxLength: 255} + description: {type: string, minLength: 1, maxLength: 255, nullable: true} + AccessSubject: + oneOf: + - $ref: "#/components/schemas/AccessPrincipal" + - $ref: "#/components/schemas/AccessGroup" + discriminator: + propertyName: type + mapping: + user: "#/components/schemas/AccessPrincipal" + service: "#/components/schemas/AccessPrincipal" + group: "#/components/schemas/AccessGroup" + AccessControlMode: type: string - enum: [many_per_resource, one_per_resource] - ListAccessRolesRequest: + enum: [disabled, enforced] + AccessProviderCapabilities: type: object additionalProperties: false + required: + - safe_resource_filtering + - multi_requirement_check + - relationship_management + - group_subjects + - multi_principal + - max_direct_resource_keys properties: - resource_type: - allOf: - - $ref: "#/components/schemas/AccessResourceType" - nullable: true - family: {type: string, minLength: 1, maxLength: 128, nullable: true} - AccessRoleDescriptor: + safe_resource_filtering: {type: boolean} + multi_requirement_check: {type: boolean} + relationship_management: {type: boolean} + group_subjects: {type: boolean} + multi_principal: {type: boolean} + max_direct_resource_keys: {type: integer, minimum: 1, maximum: 10000} + ArtifactFamilyAccessCapability: type: object additionalProperties: false - required: - - role - - resource_type - - cardinality - - actions - - artifact_families - - assignable_subject_types - - system_managed + required: [family, enabled, share_unit, actions, grantable_roles] properties: - role: - $ref: "#/components/schemas/AccessRole" - resource_type: - $ref: "#/components/schemas/AccessResourceType" - cardinality: - $ref: "#/components/schemas/AccessRoleCardinality" + family: {type: string, minLength: 1, maxLength: 128} + enabled: {type: boolean} + share_unit: + type: string + enum: [artifact, memory_entry] actions: type: array items: $ref: "#/components/schemas/AccessAction" - artifact_families: - type: array - items: {type: string, minLength: 1, maxLength: 128} - assignable_subject_types: - type: array - items: {type: string, enum: [user, service, group]} - system_managed: {type: boolean} - AccessRolePage: - type: object - additionalProperties: false - required: [items] - properties: - items: + grantable_roles: type: array - maxItems: 16 items: - $ref: "#/components/schemas/AccessRoleDescriptor" - AccessBindingState: + $ref: "#/components/schemas/AccessRole" + ConnectionAuthentication: type: string - enum: [active, revoked] - AccessBinding: - type: object - additionalProperties: false - required: - - binding_id - - subject - - resource - - role - - granted_by - - reason - - created_at - - expires_at - - state - - version - - policy_revision - - idempotency_key - - revoked_at - - revoked_by - properties: - binding_id: {type: string, minLength: 1, maxLength: 64} - subject: - $ref: "#/components/schemas/AccessSubject" - resource: - $ref: "#/components/schemas/AccessResource" - role: - $ref: "#/components/schemas/AccessRole" - granted_by: - $ref: "#/components/schemas/AccessPrincipal" - reason: {type: string, maxLength: 1024, nullable: true} - created_at: {type: string, format: date-time} - expires_at: {type: string, format: date-time, nullable: true} - state: - $ref: "#/components/schemas/AccessBindingState" - version: {type: integer, minimum: 1} - policy_revision: {type: string, minLength: 1, maxLength: 64} - idempotency_key: {type: string, minLength: 1, maxLength: 255} - revoked_at: {type: string, format: date-time, nullable: true} - revoked_by: - allOf: - - $ref: "#/components/schemas/AccessPrincipal" - nullable: true - ListAccessBindingsRequest: + enum: [disabled, authenticated] + ConnectionIdentity: type: object additionalProperties: false - required: [management_resource] + required: [server_id, authentication, access_mode, principal] properties: - management_resource: - $ref: "#/components/schemas/AccessResource" - subject: - allOf: - - $ref: "#/components/schemas/AccessSubject" - nullable: true - role: - allOf: - - $ref: "#/components/schemas/AccessRole" + server_id: + type: string + minLength: 1 + maxLength: 128 + authentication: + $ref: '#/components/schemas/ConnectionAuthentication' + access_mode: + $ref: '#/components/schemas/AccessControlMode' + principal: nullable: true - state: allOf: - - $ref: "#/components/schemas/AccessBindingState" - nullable: true - cursor: {type: string, maxLength: 2048, nullable: true} - limit: {type: integer, minimum: 1, maximum: 500, default: 100} - AccessBindingPage: + - $ref: '#/components/schemas/AccessPrincipal' + AccessMeResponse: type: object additionalProperties: false - required: [items, next_cursor] + required: + - principal + - mode + - resource_kinds + - provider_capabilities + - artifact_families properties: - items: + principal: + $ref: "#/components/schemas/AccessPrincipal" + mode: + $ref: "#/components/schemas/AccessControlMode" + resource_kinds: type: array - maxItems: 500 items: - $ref: "#/components/schemas/AccessBinding" - next_cursor: {type: string, maxLength: 2048, nullable: true} - CreateAccessBindingRequest: - type: object - additionalProperties: false - required: [subject, resource, role, idempotency_key] - properties: - subject: - $ref: "#/components/schemas/AccessSubject" - resource: - $ref: "#/components/schemas/AccessResource" - role: - $ref: "#/components/schemas/AccessRole" - idempotency_key: {type: string, minLength: 1, maxLength: 255} - reason: {type: string, maxLength: 1024, nullable: true} - expires_at: {type: string, format: date-time, nullable: true} - RevokeAccessBindingRequest: + $ref: "#/components/schemas/AccessResourceType" + provider_capabilities: + $ref: "#/components/schemas/AccessProviderCapabilities" + artifact_families: + type: array + items: + $ref: "#/components/schemas/ArtifactFamilyAccessCapability" + AccessAction: + type: string + enum: + - server.observe + - server.admin + - scope.read + - scope.contribute + - scope.review + - scope.delegate + - scope.admin + - gateway.invoke + - artifact.read + - artifact.write + - artifact.share + - handoff.evidence.inspect + - handoff.acknowledge + - prompt.use + AccessResourceType: + type: string + enum: [server, scope, artifact] + ServerAccessResource: type: object additionalProperties: false - required: [binding_id, expected_version, idempotency_key] + required: [type, deployment_id] properties: - binding_id: {type: string, minLength: 1, maxLength: 64} - expected_version: {type: integer, minimum: 1} - idempotency_key: {type: string, minLength: 1, maxLength: 255} - AccessBindingReplacementInput: + type: {type: string, enum: [server]} + deployment_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + ScopeAccessResource: type: object additionalProperties: false - required: [subject] + required: [type, scope_id] properties: - subject: - $ref: "#/components/schemas/AccessSubject" - reason: {type: string, maxLength: 1024, nullable: true} - expires_at: {type: string, format: date-time, nullable: true} - ReplaceAccessBindingRequest: + type: {type: string, enum: [scope]} + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + MemoryEntryAccessSelector: type: object additionalProperties: false - required: [binding_id, expected_version, replacement, idempotency_key] + description: Logical Memory entry selector; the Binding covers the entry's existing and future versions. + required: [type, entry_id] properties: - binding_id: {type: string, minLength: 1, maxLength: 64} - expected_version: {type: integer, minimum: 1} - replacement: - $ref: "#/components/schemas/AccessBindingReplacementInput" - idempotency_key: {type: string, minLength: 1, maxLength: 255} - AccessBindingReplacement: + type: {type: string, enum: [memory_entry]} + entry_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + AccessArtifactIdentity: type: object additionalProperties: false - required: [previous, current] + description: Logical Artifact identity; the Binding covers existing and future Revisions of the same Artifact. + required: [family, artifact_id] properties: - previous: - $ref: "#/components/schemas/AccessBinding" - current: - $ref: "#/components/schemas/AccessBinding" - ListAccessAuditRequest: + family: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + artifact_id: {type: string, minLength: 1, maxLength: 128, pattern: '^[\x21-\x7E]+$'} + ArtifactAccessResource: type: object additionalProperties: false - required: [resource] + required: [type, scope_id, identity] properties: - resource: - oneOf: - - $ref: "#/components/schemas/ServerAccessResource" - - $ref: "#/components/schemas/ScopeAccessResource" - discriminator: - propertyName: type - action: - allOf: - - $ref: "#/components/schemas/AccessAction" - nullable: true - subject: - allOf: - - $ref: "#/components/schemas/AccessSubject" - nullable: true - result: {type: string, enum: [allowed, denied], nullable: true} - time_range: + type: {type: string, enum: [artifact]} + scope_id: {type: string, minLength: 1, maxLength: 256, pattern: '.*\S.*'} + identity: + $ref: "#/components/schemas/AccessArtifactIdentity" + selector: allOf: - - $ref: "#/components/schemas/AccessAuditTimeRange" + - $ref: "#/components/schemas/MemoryEntryAccessSelector" nullable: true - cursor: {type: string, maxLength: 2048, nullable: true} - limit: {type: integer, minimum: 1, maximum: 500, default: 100} - AccessAuditTimeRange: + AccessResource: + oneOf: + - $ref: "#/components/schemas/ServerAccessResource" + - $ref: "#/components/schemas/ScopeAccessResource" + - $ref: "#/components/schemas/ArtifactAccessResource" + discriminator: + propertyName: type + mapping: + server: "#/components/schemas/ServerAccessResource" + scope: "#/components/schemas/ScopeAccessResource" + artifact: "#/components/schemas/ArtifactAccessResource" + AccessDecision: type: object additionalProperties: false - required: [start, end] + required: [allowed, reason_code] properties: - start: {type: string, format: date-time} - end: {type: string, format: date-time} - AccessAuditEvent: + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + AccessRequirementMatch: + type: string + enum: [all, any] + AccessCheckRequirement: type: object additionalProperties: false - required: - - cursor - - event_id - - occurred_at - - request_id - - transport - - operation - - principal - - actor - - action - - resource - - allowed - - reason_code - - policy_revision - - matched_subject - - binding_id - - target - - role - - expected_version - - result_version + required: [action, resource] properties: - cursor: {type: integer, minimum: 1} - event_id: {type: string, minLength: 1, maxLength: 64} - occurred_at: {type: string, format: date-time} - request_id: {type: string, maxLength: 128, nullable: true} - transport: {type: string, minLength: 1, maxLength: 16} - operation: {type: string, minLength: 1, maxLength: 128} - principal: - $ref: "#/components/schemas/AccessPrincipal" - actor: - allOf: - - $ref: "#/components/schemas/AccessPrincipal" - nullable: true action: $ref: "#/components/schemas/AccessAction" resource: $ref: "#/components/schemas/AccessResource" - allowed: {type: boolean} - reason_code: {type: string, minLength: 1, maxLength: 64} - policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} - matched_subject: - allOf: - - $ref: "#/components/schemas/AccessSubject" - nullable: true - binding_id: {type: string, maxLength: 64, nullable: true} - target: - allOf: - - $ref: "#/components/schemas/AccessSubject" - nullable: true - role: - allOf: - - $ref: "#/components/schemas/AccessRole" - nullable: true - expected_version: {type: integer, minimum: 1, nullable: true} - result_version: {type: integer, minimum: 1, nullable: true} - AccessAuditPage: + AccessCheckRequest: type: object additionalProperties: false - required: [items, next_cursor] + required: [match, requirements] properties: - items: + match: + $ref: "#/components/schemas/AccessRequirementMatch" + requirements: type: array - maxItems: 500 + minItems: 1 + maxItems: 100 items: - $ref: "#/components/schemas/AccessAuditEvent" - next_cursor: {type: string, maxLength: 2048, nullable: true} - TagDreamTarget: + $ref: "#/components/schemas/AccessCheckRequirement" + AccessCheckResponse: type: object additionalProperties: false - required: - - target - - expected_etag - - basis_ref + required: [allowed, decisions] properties: - target: &id002 - $ref: '#/components/schemas/TagTarget' - expected_etag: &id001 - type: string - minLength: 1 - basis_ref: &id003 - $ref: '#/components/schemas/ArtifactReference' - CandidateAudit: + allowed: {type: boolean} + decisions: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/AccessDecision" + ListAccessResourcesRequest: type: object additionalProperties: false - required: - - origin - - operation - - dream_run_id - - spec_version - - proposal_digest - - evidence_manifest_ref - - validation_policy_digest - - proposal_fingerprint + required: [action, resource_type] properties: - origin: *id001 - operation: *id001 - dream_run_id: *id001 - spec_version: *id001 - proposal_digest: *id001 - evidence_manifest_ref: *id001 - validation_policy_digest: *id001 - proposal_fingerprint: - type: string + action: + $ref: "#/components/schemas/AccessAction" + resource_type: + $ref: "#/components/schemas/AccessResourceType" + family: {type: string, minLength: 1, maxLength: 128, nullable: true} + cursor: {type: string, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessResourcePage: + type: object + additionalProperties: false + required: [items, total, next_cursor] + properties: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessResource" + total: {type: integer, minimum: 0} + next_cursor: {type: string, nullable: true} + AccessRole: + type: string + enum: + - handoff.viewer + - handoff.receiver + - artifact.viewer + - prompt.user + - artifact.owner + - scope.viewer + - scope.contributor + - scope.reviewer + - scope.delegator + - scope.admin + - gateway.user + - server.observer + - server.admin + AccessRoleCardinality: + type: string + enum: [many_per_resource, one_per_resource] + ListAccessRolesRequest: + type: object + additionalProperties: false + properties: + resource_type: + allOf: + - $ref: "#/components/schemas/AccessResourceType" nullable: true - CatalogChangeProposal: + family: {type: string, minLength: 1, maxLength: 128, nullable: true} + AccessRoleDescriptor: type: object additionalProperties: false required: - - target - - expected_etag - - basis_ref - - before_tags - - after_tags + - role + - resource_type + - cardinality + - actions + - artifact_families + - assignable_subject_types + - system_managed properties: - target: *id002 - expected_etag: *id001 - basis_ref: *id003 - before_tags: &id005 + role: + $ref: "#/components/schemas/AccessRole" + resource_type: + $ref: "#/components/schemas/AccessResourceType" + cardinality: + $ref: "#/components/schemas/AccessRoleCardinality" + actions: type: array items: - type: string - minLength: 1 - maxLength: 64 - maxItems: 32 - after_tags: *id005 - CatalogTagResult: - type: object - additionalProperties: false - required: - - scope_id - - target - - tags - - tag_digest - - etag - properties: - scope_id: *id001 - target: - $ref: '#/components/schemas/TagTarget' - tags: *id005 - tag_digest: *id001 - etag: *id001 - CandidateHistory: + $ref: "#/components/schemas/AccessAction" + artifact_families: + type: array + items: {type: string, minLength: 1, maxLength: 128} + assignable_subject_types: + type: array + items: {type: string, enum: [user, service, group]} + system_managed: {type: boolean} + AccessRolePage: type: object additionalProperties: false - required: - - versions + required: [items] properties: - versions: + items: type: array + maxItems: 16 items: - $ref: '#/components/schemas/Candidate' - default: [] - SourceCapabilityQuery: + $ref: "#/components/schemas/AccessRoleDescriptor" + AccessBindingState: + type: string + enum: [active, revoked] + AccessBinding: type: object additionalProperties: false required: - - scope_id + - binding_id + - subject + - resource + - role + - granted_by + - reason + - created_at + - expires_at + - state + - version + - policy_revision + - idempotency_key + - revoked_at + - revoked_by properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - capability: - type: string - minLength: 1 - maxLength: 256 - instance_id: - type: string - minLength: 1 - maxLength: 256 - input: &source_mgmt_001 - type: object - additionalProperties: true - SourceWritePluginLoadRequest: + binding_id: {type: string, minLength: 1, maxLength: 64} + subject: + $ref: "#/components/schemas/AccessSubject" + resource: + $ref: "#/components/schemas/AccessResource" + role: + $ref: "#/components/schemas/AccessRole" + granted_by: + $ref: "#/components/schemas/AccessPrincipal" + reason: {type: string, maxLength: 1024, nullable: true} + created_at: {type: string, format: date-time} + expires_at: {type: string, format: date-time, nullable: true} + state: + $ref: "#/components/schemas/AccessBindingState" + version: {type: integer, minimum: 1} + policy_revision: {type: string, minLength: 1, maxLength: 64} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + revoked_at: {type: string, format: date-time, nullable: true} + revoked_by: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + nullable: true + ListAccessBindingsRequest: type: object additionalProperties: false - required: [id, entry] + required: [management_resource] 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: + management_resource: + $ref: "#/components/schemas/AccessResource" + subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + state: + allOf: + - $ref: "#/components/schemas/AccessBindingState" + nullable: true + cursor: {type: string, maxLength: 2048, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessBindingPage: type: object additionalProperties: false - required: [id, entry, capabilities] + required: [items, next_cursor] properties: - id: {type: string} - entry: {type: string} - capabilities: + items: 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: + maxItems: 500 + items: + $ref: "#/components/schemas/AccessBinding" + next_cursor: {type: string, maxLength: 2048, nullable: true} + CreateAccessBindingRequest: type: object additionalProperties: false - required: - - scope_id - - target_id + required: [subject, resource, role, idempotency_key] 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: + subject: + $ref: "#/components/schemas/AccessSubject" + resource: + $ref: "#/components/schemas/AccessResource" + role: + $ref: "#/components/schemas/AccessRole" + idempotency_key: {type: string, minLength: 1, maxLength: 255} + reason: {type: string, maxLength: 1024, nullable: true} + expires_at: {type: string, format: date-time, nullable: true} + RevokeAccessBindingRequest: type: object additionalProperties: false - required: - - instance_id - - enabled + required: [binding_id, expected_version, idempotency_key] properties: - instance_id: - type: string - minLength: 1 - maxLength: 256 - enabled: - type: boolean - SourceConfigureRequest: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + idempotency_key: {type: string, minLength: 1, maxLength: 255} + AccessBindingReplacementInput: type: object additionalProperties: false - required: - - registration + required: [subject] properties: - update: - type: boolean - default: false - registration: *source_mgmt_001 - SourceCapabilityResponse: + subject: + $ref: "#/components/schemas/AccessSubject" + reason: {type: string, maxLength: 1024, nullable: true} + expires_at: {type: string, format: date-time, nullable: true} + ReplaceAccessBindingRequest: type: object additionalProperties: false - required: - - capabilities - - management + required: [binding_id, expected_version, replacement, idempotency_key] properties: - capabilities: - type: array - items: *source_mgmt_001 - management: - type: string - enum: - - not-configured - - online - - unavailable - SourceInstancesResponse: + binding_id: {type: string, minLength: 1, maxLength: 64} + expected_version: {type: integer, minimum: 1} + replacement: + $ref: "#/components/schemas/AccessBindingReplacementInput" + idempotency_key: {type: string, minLength: 1, maxLength: 255} + AccessBindingReplacement: type: object additionalProperties: false - required: - - instances + required: [previous, current] properties: - instances: - type: array - items: *source_mgmt_001 - capabilities: - type: array - items: *source_mgmt_001 - management: - type: string - enum: - - not-configured - - online - - unavailable - SourceCollectionResult: + previous: + $ref: "#/components/schemas/AccessBinding" + current: + $ref: "#/components/schemas/AccessBinding" + ListAccessAuditRequest: type: object additionalProperties: false - required: - - outcome - - source_refs + required: [resource] properties: - outcome: - type: string - enum: - - saved - - unchanged - source_refs: - type: array - items: - $ref: '#/components/schemas/SourceTypeReference' - SourceManagementReceipt: + resource: + oneOf: + - $ref: "#/components/schemas/ServerAccessResource" + - $ref: "#/components/schemas/ScopeAccessResource" + discriminator: + propertyName: type + action: + allOf: + - $ref: "#/components/schemas/AccessAction" + nullable: true + subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + result: {type: string, enum: [allowed, denied], nullable: true} + time_range: + allOf: + - $ref: "#/components/schemas/AccessAuditTimeRange" + nullable: true + cursor: {type: string, maxLength: 2048, nullable: true} + limit: {type: integer, minimum: 1, maximum: 500, default: 100} + AccessAuditTimeRange: type: object additionalProperties: false + required: [start, end] 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: - 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: + start: {type: string, format: date-time} + end: {type: string, format: date-time} + AccessAuditEvent: type: object additionalProperties: false required: - - instance_id + - cursor + - event_id + - occurred_at + - request_id + - transport + - operation + - principal + - actor + - action + - resource + - allowed + - reason_code + - policy_revision + - matched_subject + - binding_id + - target + - role + - expected_version + - result_version properties: - 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 + cursor: {type: integer, minimum: 1} + event_id: {type: string, minLength: 1, maxLength: 64} + occurred_at: {type: string, format: date-time} + request_id: {type: string, maxLength: 128, nullable: true} + transport: {type: string, minLength: 1, maxLength: 16} + operation: {type: string, minLength: 1, maxLength: 128} + principal: + $ref: "#/components/schemas/AccessPrincipal" + actor: + allOf: + - $ref: "#/components/schemas/AccessPrincipal" nullable: true - availability: - type: string - reused: - type: boolean - SourceChannelOpen: + action: + $ref: "#/components/schemas/AccessAction" + resource: + $ref: "#/components/schemas/AccessResource" + allowed: {type: boolean} + reason_code: {type: string, minLength: 1, maxLength: 64} + policy_revision: {type: string, minLength: 1, maxLength: 64, nullable: true} + matched_subject: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + binding_id: {type: string, maxLength: 64, nullable: true} + target: + allOf: + - $ref: "#/components/schemas/AccessSubject" + nullable: true + role: + allOf: + - $ref: "#/components/schemas/AccessRole" + nullable: true + expected_version: {type: integer, minimum: 1, nullable: true} + result_version: {type: integer, minimum: 1, nullable: true} + AccessAuditPage: type: object additionalProperties: false - required: - - protocol - - deployment_id - - manager_id + required: [items, next_cursor] 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: + items: + type: array + maxItems: 500 + items: + $ref: "#/components/schemas/AccessAuditEvent" + next_cursor: {type: string, maxLength: 2048, nullable: true} + TagDreamTarget: type: object additionalProperties: false required: - - generation + - target + - expected_etag + - basis_ref properties: - generation: - type: string - minLength: 1 - maxLength: 256 - message_id: + target: &id002 + $ref: '#/components/schemas/TagTarget' + expected_etag: &id001 type: string minLength: 1 - maxLength: 256 - response: *source_mgmt_001 - catalog: *source_mgmt_001 - SourceChannelResponse: + basis_ref: &id003 + $ref: '#/components/schemas/ArtifactReference' + CandidateAudit: type: object additionalProperties: false + required: + - origin + - operation + - dream_run_id + - spec_version + - proposal_digest + - evidence_manifest_ref + - validation_policy_digest + - proposal_fingerprint properties: - generation: - type: string - minLength: 1 - maxLength: 256 - protocol: + origin: *id001 + operation: *id001 + dream_run_id: *id001 + spec_version: *id001 + proposal_digest: *id001 + evidence_manifest_ref: *id001 + validation_policy_digest: *id001 + proposal_fingerprint: type: string - minLength: 1 - maxLength: 256 - confirmed: - type: boolean - messages: - type: array - items: *source_mgmt_001 - SourceInstanceExchangeRequest: + nullable: true + CatalogChangeProposal: type: object additionalProperties: false + required: + - target + - expected_etag + - basis_ref + - before_tags + - after_tags properties: - generation: &source_mgmt_002 - type: string - minLength: 1 - maxLength: 256 - protocol: *source_mgmt_002 - message_id: *source_mgmt_002 - execution_location: *source_mgmt_002 - configuration_digest: *source_mgmt_002 - run_id: *source_mgmt_002 - execution: *source_mgmt_002 - target_id: *source_mgmt_002 - capabilities: - type: array - maxItems: 64 - items: &source_mgmt_003 - type: object - additionalProperties: true - response: *source_mgmt_003 - owner: *source_mgmt_003 - status: - type: string - enum: - - running - - complete - - completed - - pending - - failed - - stopped - - cleanup-failed - source_refs: + target: *id002 + expected_etag: *id001 + basis_ref: *id003 + before_tags: &id005 type: array - 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: - type: string - maxLength: 1024 - targets: - type: array - maxItems: 256 - items: *source_mgmt_003 - cursor: - type: string - maxLength: 256 - owner_exited: - type: boolean - previous_owner: - type: object - additionalProperties: true - submitted_at: - type: string - SourceInstanceExchangeResponse: - type: object - additionalProperties: true - SourceTargetSetEnabledRequest: + type: string + minLength: 1 + maxLength: 64 + maxItems: 32 + after_tags: *id005 + CatalogTagResult: type: object additionalProperties: false required: - scope_id - - target_id - - enabled + - target + - tags + - tag_digest + - etag properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - target_id: - type: string - minLength: 1 - maxLength: 256 - enabled: - type: boolean - SourceInstancesQuery: + scope_id: *id001 + target: + $ref: '#/components/schemas/TagTarget' + tags: *id005 + tag_digest: *id001 + etag: *id001 + CandidateHistory: type: object additionalProperties: false + required: + - versions 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 + versions: + type: array + items: + $ref: '#/components/schemas/Candidate' + default: [] PlanAccessOwnershipRequest: type: object additionalProperties: false diff --git a/release/upgrade-support.json b/release/upgrade-support.json index dc55269a0e..6d99fbbd0b 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -4,7 +4,7 @@ "mode": "upgrade-required", "schema_review": { "persistence_sha256": "08098928cff34642895f967549c5759a1c5ed47c842823551537e7045668f2ce", - "rationale": "Plugin reloading can unregister, in the Server process, the Artifact access profile a plugin family registered when that plugin is removed or renames its family; built-in family profiles are never removed, and no table layout, migration resource, frozen checksum or persisted encoding changes. Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." + "rationale": "Revision v1_5_0_006 follows v1_5_0_005 on both registered source chains and all vector/Code profiles. It adds pc_source_runners (runner, bound subject, reported capabilities and digest, nullable last_seen_at) and pc_source_collect_targets (Scope and target identity, runner, capability, target key unique per Scope, runner and capability, parameters, enabled, update audit, and runner-written status columns), and drops pc_source_write_plugins. The source_collection domain step refuses the upgrade while pc_source_write_plugins lists a plugin, so no loaded write plugin is lost silently; otherwise no existing record is rewritten. Earlier revision resources, frozen checksums and SQLite fingerprints are unchanged; the MySQL layout for the new tables is registered from the same SQLAlchemy DDL. Target checkpoints reuse pc_connector_checkpoints keyed by (scope_id, target_id) with a constant connector version. Portable bundles add source_runner (name and bound subject) and source_collect_target records; reported capabilities, last-seen time and target status restart empty. Plugin reloading can unregister, in the Server process, the Artifact access profile a plugin family registered when that plugin is removed or renames its family; built-in family profiles are never removed, and no table layout, migration resource, frozen checksum or persisted encoding changes. Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 201e266aad..fd2adafcbd 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -52,6 +52,10 @@ "v1_5_0_005": [ "schemas/sqlite-v1_5_0_005.json", "schemas/mysql-v1_5_0_005.json" + ], + "v1_5_0_006": [ + "schemas/sqlite-v1_5_0_006.json", + "schemas/mysql-v1_5_0_006.json" ] }, "sqlite_baselines": { @@ -93,6 +97,9 @@ ], "v1_5_0_005": [ "50bff3a823c596084264f6b8bfbe495677217aa7f419b342966d287387058741" + ], + "v1_5_0_006": [ + "9e49c26fffa97d8ceadd3150a6ff20882c0b6d5e9cb08468eb334ca2536da3f4" ] }, "affected_objects": [ @@ -210,7 +217,9 @@ "pc_topic_memory_topic_fts_docsize", "pc_topic_memory_topic_fts_idx", "pc_topic_memory_work_blocks", - "pc_topic_memory_work_budgets" + "pc_topic_memory_work_budgets", + "pc_source_runners", + "pc_source_collect_targets" ], "readiness_scope": "server", "retained_objects": [ @@ -321,6 +330,28 @@ "seekdb", "oceanbase" ] + }, + { + "id": "source_collection", + "revision": "v1_5_0_006", + "kind": "data", + "module": "source_collection_readiness.py", + "resources": [ + "server_domain.py", + "server_vector_domain.py", + "atomic_memory_readiness_1300.py", + "dream/upgrade.py", + "dream/schema.json", + "plugin_artifact_readiness.py" + ], + "depends_on": [ + "plugin_artifacts" + ], + "backends": [ + "sqlite", + "seekdb", + "oceanbase" + ] } ], "domain_readiness": { @@ -369,6 +400,11 @@ "plugin_artifacts", "portable_storage", "atomic_memory_evidence_projection" + ], + "v1_5_0_006": [ + "source_collection", + "portable_storage", + "atomic_memory_evidence_projection" ] }, "retained_fingerprints": { @@ -391,7 +427,8 @@ "schemas/mysql-v1_5_0_002.json", "schemas/mysql-v1_5_0_003.json", "schemas/mysql-v1_5_0_004.json", - "schemas/mysql-v1_5_0_005.json" + "schemas/mysql-v1_5_0_005.json", + "schemas/mysql-v1_5_0_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/examples/source_plugins/tickets/write.yml b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_006.py similarity index 69% rename from examples/source_plugins/tickets/write.yml rename to src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_006.py index c677a8557f..79c24c23ae 100644 --- a/examples/source_plugins/tickets/write.yml +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_5_0_006.py @@ -12,5 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -id: local-tickets -entry: . +"""Add Source collection runners and targets through the shared DDL executor.""" + +from alembic import context + +revision = "v1_5_0_006" +down_revision = "v1_5_0_005" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_006.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_006.json new file mode 100644 index 0000000000..1cfd12d24f --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_5_0_006.json @@ -0,0 +1,563 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_5_0_006": { + "statements": [ + "DROP TABLE pc_source_write_plugins", + "CREATE TABLE pc_source_runners (\n\trunner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tsubject VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tcapabilities MEDIUMBLOB NOT NULL, \n\tcapabilities_digest VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlast_seen_at DATETIME(6), \n\tPRIMARY KEY (runner)\n)", + "CREATE TABLE pc_source_collect_targets (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\ttarget_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\trunner VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tcapability VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\ttarget_key VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tparameters MEDIUMBLOB NOT NULL, \n\tenabled BOOL NOT NULL, \n\tupdated_at DATETIME(6) NOT NULL, \n\tupdated_by VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tlast_attempt_at DATETIME(6), \n\tlast_success_at DATETIME(6), \n\tconsecutive_failures BIGINT NOT NULL DEFAULT '0', \n\tlast_error_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tlast_error_message VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin, \n\tPRIMARY KEY (scope_id, target_id), \n\tCONSTRAINT uq_pc_source_collect_target_key UNIQUE (scope_id, runner, capability, target_key)\n)", + "CREATE INDEX ix_pc_source_collect_targets_runner ON pc_source_collect_targets (runner)" + ], + "changes": [ + { + "pc_source_write_plugins": null + }, + { + "pc_source_runners": { + "columns": { + "runner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "subject": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "capabilities": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "capabilities_digest": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "last_seen_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "runner", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": {}, + "indexes": {} + } + }, + { + "pc_source_collect_targets": { + "columns": { + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "target_id": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "runner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "capability": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "target_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "parameters": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "enabled": { + "type": [ + "tinyint", + [ + "1" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_by": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "last_attempt_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "last_success_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "consecutive_failures": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": [ + "number", + 0 + ], + "charset": null, + "collation": null, + "extra": [] + }, + "last_error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "last_error_message": { + "type": [ + "varchar", + [ + "1024" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + } + }, + "primary_key": [ + [ + "scope_id", + null, + "asc" + ], + [ + "target_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": {}, + "indexes": { + "uq_pc_source_collect_target_key": { + "columns": [ + [ + "scope_id", + null, + "asc" + ], + [ + "runner", + null, + "asc" + ], + [ + "capability", + null, + "asc" + ], + [ + "target_key", + null, + "asc" + ] + ], + "unique": true + } + } + } + }, + { + "pc_source_collect_targets": { + "columns": { + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "target_id": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "runner": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "capability": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "target_key": { + "type": [ + "varchar", + [ + "64" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "parameters": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "enabled": { + "type": [ + "tinyint", + [ + "1" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "updated_by": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "last_attempt_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "last_success_at": { + "type": [ + "datetime", + [ + "6" + ] + ], + "nullable": true, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "consecutive_failures": { + "type": [ + "bigint", + [] + ], + "nullable": false, + "default": [ + "number", + 0 + ], + "charset": null, + "collation": null, + "extra": [] + }, + "last_error_code": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "last_error_message": { + "type": [ + "varchar", + [ + "1024" + ] + ], + "nullable": true, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + } + }, + "primary_key": [ + [ + "scope_id", + null, + "asc" + ], + [ + "target_id", + null, + "asc" + ] + ], + "foreign_keys": [], + "checks": {}, + "indexes": { + "ix_pc_source_collect_targets_runner": { + "columns": [ + [ + "runner", + null, + "asc" + ] + ], + "unique": false + }, + "uq_pc_source_collect_target_key": { + "columns": [ + [ + "scope_id", + null, + "asc" + ], + [ + "runner", + null, + "asc" + ], + [ + "capability", + null, + "asc" + ], + [ + "target_key", + null, + "asc" + ] + ], + "unique": true + } + } + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_006.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_006.json new file mode 100644 index 0000000000..c68ca9b539 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_5_0_006.json @@ -0,0 +1,8 @@ +{ + "statements": [ + "DROP TABLE pc_source_write_plugins", + "CREATE TABLE pc_source_runners (\n\trunner VARCHAR(128) NOT NULL, \n\tsubject VARCHAR(256) NOT NULL, \n\tcapabilities BLOB NOT NULL, \n\tcapabilities_digest VARCHAR(64) NOT NULL, \n\tlast_seen_at DATETIME, \n\tPRIMARY KEY (runner)\n)", + "CREATE TABLE pc_source_collect_targets (\n\tscope_id VARCHAR(256) NOT NULL, \n\ttarget_id VARCHAR(64) NOT NULL, \n\trunner VARCHAR(128) NOT NULL, \n\tcapability VARCHAR(64) NOT NULL, \n\ttarget_key VARCHAR(64) NOT NULL, \n\tparameters BLOB NOT NULL, \n\tenabled BOOLEAN NOT NULL, \n\tupdated_at DATETIME NOT NULL, \n\tupdated_by VARCHAR(256) NOT NULL, \n\tlast_attempt_at DATETIME, \n\tlast_success_at DATETIME, \n\tconsecutive_failures BIGINT DEFAULT '0' NOT NULL, \n\tlast_error_code VARCHAR(128), \n\tlast_error_message VARCHAR(1024), \n\tPRIMARY KEY (scope_id, target_id), \n\tCONSTRAINT uq_pc_source_collect_target_key UNIQUE (scope_id, runner, capability, target_key)\n)", + "CREATE INDEX ix_pc_source_collect_targets_runner ON pc_source_collect_targets (runner)" + ] +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/source_collection_readiness.py b/src/powercontext/builtin/persistence/migrations/server_resources/source_collection_readiness.py new file mode 100644 index 0000000000..55897b6600 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/source_collection_readiness.py @@ -0,0 +1,55 @@ +# 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 readiness for Source collection runners and targets at v1.5.0.006. + +This revision adds the runner and target tables and drops the registry of the +removed Source write plugin framework. Collection bindings are named +"plugin:" like other plugin bindings, so the v1.5.0.005 readiness contract +applies unchanged. +""" + +import runpy +from pathlib import Path + +from powercontext.builtin.persistence.migrations.models import MigrationError + +previous = runpy.run_path(str(Path(__file__).parent / "plugin_artifact_readiness.py")) + + +def preflight(connection, *, configuration): + """Refuse to drop the write plugin registry while it still lists plugins.""" + from sqlalchemy import inspect + + if ( + "pc_source_write_plugins" in inspect(connection).get_table_names() + and connection.exec_driver_sql("SELECT 1 FROM pc_source_write_plugins LIMIT 1").first() + ): + raise MigrationError( + "unsupported_plugin_data", + "Source write plugins of the removed framework are still loaded; unload them on the previous version" + " and provide their Source types as Source plugins before upgrading.", + ) + + +def apply(connection, *, configuration): + """The schema transition drops only an empty registry and adds empty tables; no records are rewritten.""" + + +def verify(connection, *, configuration): + validate(connection, configuration=configuration) + + +def validate(connection, *, configuration): + previous["validate"](connection, configuration=configuration) diff --git a/src/powercontext/source_management/__init__.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_006_source_collection.py similarity index 56% rename from src/powercontext/source_management/__init__.py rename to src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_006_source_collection.py index 6db950956c..9087061637 100644 --- a/src/powercontext/source_management/__init__.py +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_5_0_006_source_collection.py @@ -12,8 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Source management contracts; plugins own their collection schedule.""" +"""Add Source collection runners and targets, and drop the empty write plugin registry they replace.""" -from .models import Capability, ManagementError, SourceTarget, TargetIdentity, TargetIdentityRequest +import json +from pathlib import Path -__all__ = ["Capability", "ManagementError", "SourceTarget", "TargetIdentity", "TargetIdentityRequest"] +from alembic import op + +revision = "v1_5_0_006" +down_revision = "v1_5_0_005" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_5_0_006.json" + for statement in json.loads(resource.read_text(encoding="utf-8"))["statements"]: + op.get_bind().exec_driver_sql(statement) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 32d714a6d2..bfe7e2a309 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -51,6 +51,10 @@ "v1_5_0_005": [ "schemas/sqlite-v1_5_0_005.json", "schemas/mysql-v1_5_0_005.json" + ], + "v1_5_0_006": [ + "schemas/sqlite-v1_5_0_006.json", + "schemas/mysql-v1_5_0_006.json" ] }, "sqlite_baselines": { @@ -92,6 +96,9 @@ ], "v1_5_0_005": [ "50bff3a823c596084264f6b8bfbe495677217aa7f419b342966d287387058741" + ], + "v1_5_0_006": [ + "9e49c26fffa97d8ceadd3150a6ff20882c0b6d5e9cb08468eb334ca2536da3f4" ] }, "affected_objects": [ @@ -209,7 +216,9 @@ "pc_topic_memory_topic_fts_docsize", "pc_topic_memory_topic_fts_idx", "pc_topic_memory_work_blocks", - "pc_topic_memory_work_budgets" + "pc_topic_memory_work_budgets", + "pc_source_runners", + "pc_source_collect_targets" ], "readiness_scope": "server", "retained_objects": [ @@ -325,6 +334,28 @@ "seekdb", "oceanbase" ] + }, + { + "id": "source_collection", + "revision": "v1_5_0_006", + "kind": "data", + "module": "source_collection_readiness.py", + "resources": [ + "server_domain.py", + "server_vector_domain.py", + "atomic_memory_readiness_1300.py", + "dream/upgrade.py", + "dream/schema.json", + "plugin_artifact_readiness.py" + ], + "depends_on": [ + "plugin_artifacts" + ], + "backends": [ + "sqlite", + "seekdb", + "oceanbase" + ] } ], "domain_readiness": { @@ -373,6 +404,11 @@ "plugin_artifacts", "portable_storage", "atomic_memory_evidence_projection" + ], + "v1_5_0_006": [ + "source_collection", + "portable_storage", + "atomic_memory_evidence_projection" ] }, "retained_fingerprints": { @@ -395,7 +431,8 @@ "schemas/mysql-v1_5_0_002.json", "schemas/mysql-v1_5_0_003.json", "schemas/mysql-v1_5_0_004.json", - "schemas/mysql-v1_5_0_005.json" + "schemas/mysql-v1_5_0_005.json", + "schemas/mysql-v1_5_0_006.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/source_collection.py b/src/powercontext/builtin/persistence/source_collection.py new file mode 100644 index 0000000000..f1d45def88 --- /dev/null +++ b/src/powercontext/builtin/persistence/source_collection.py @@ -0,0 +1,366 @@ +# 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. + +"""Collection runners and the targets users assign to them. + +Users write a target's configuration columns; the runner writes its status columns +together with the target's checkpoint. A runner pulls its whole target list at every +sync, so targets carry no revision: the last writer wins, and a runner has applied a +change once its ``last_seen_at`` is later than the target's ``updated_at``. +""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from pydantic import JsonValue +from sqlalchemy import and_, delete, func, insert, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.tables import ( + CONNECTOR_CHECKPOINTS_TABLE, + SOURCE_COLLECT_TARGETS_TABLE, + SOURCE_RUNNERS_TABLE, +) + +# The Server's own runner, which collects in Supervisor Workers. +SERVER_RUNNER = "server" +# Checkpoints of collection targets are keyed by target; the plugin version is not part of their identity. +TARGET_CHECKPOINT_VERSION = "target" + +_TARGETS = SOURCE_COLLECT_TARGETS_TABLE +_RUNNERS = SOURCE_RUNNERS_TABLE + + +class SourceCollectionError(Exception): + """A runner or target request that core refuses, with its HTTP status and stable code.""" + + def __init__(self, status: int, code: str, message: str) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +@dataclass(frozen=True) +class Runner: + runner: str + subject: str + capabilities: tuple[dict[str, JsonValue], ...] + last_seen_at: datetime | None + + +@dataclass(frozen=True) +class TargetStatus: + last_attempt_at: datetime | None + last_success_at: datetime | None + consecutive_failures: int + last_error_code: str | None + last_error_message: str | None + + +@dataclass(frozen=True) +class Target: + scope_id: str + target_id: str + runner: str + capability: str + target_key: str + parameters: dict[str, JsonValue] + enabled: bool + updated_at: datetime + updated_by: str + status: TargetStatus + + +def canonical(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode() + + +def now() -> datetime: + return datetime.now(UTC) + + +def _instant(value: datetime | None) -> datetime | None: + # SQLite returns naive values; every stored instant is UTC. + return None if value is None or value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _runner(row: Mapping[Any, Any]) -> Runner: + return Runner( + runner=row["runner"], + subject=row["subject"], + capabilities=tuple(json.loads(bytes(row["capabilities"]))), + last_seen_at=_instant(row["last_seen_at"]), + ) + + +def _target(row: Mapping[Any, Any]) -> Target: + return Target( + scope_id=row["scope_id"], + target_id=row["target_id"], + runner=row["runner"], + capability=row["capability"], + target_key=row["target_key"], + parameters=json.loads(bytes(row["parameters"])), + enabled=bool(row["enabled"]), + updated_at=_instant(row["updated_at"]) or now(), + updated_by=row["updated_by"], + status=TargetStatus( + last_attempt_at=_instant(row["last_attempt_at"]), + last_success_at=_instant(row["last_success_at"]), + consecutive_failures=int(row["consecutive_failures"]), + last_error_code=row["last_error_code"], + last_error_message=row["last_error_message"], + ), + ) + + +class SourceCollectionRepository: + async def runner(self, connection: AsyncConnection, runner: str) -> Runner | None: + row = (await connection.execute(select(_RUNNERS).where(_RUNNERS.c.runner == runner))).mappings().first() + return None if row is None else _runner(row) + + async def runners(self, connection: AsyncConnection) -> tuple[Runner, ...]: + rows = (await connection.execute(select(_RUNNERS).order_by(_RUNNERS.c.runner))).mappings().all() + return tuple(_runner(row) for row in rows) + + async def record_sync( + self, connection: AsyncConnection, runner: str, subject: str, capabilities: Sequence[Mapping[str, JsonValue]] + ) -> Runner: + """Record a sync: capabilities only when they changed, ``last_seen_at`` every time. + + A runner name is bound to the subject of its first sync; another subject may use it + only when its own identifier is the runner name. + """ + + encoded = canonical(list(capabilities)) + digest = hashlib.sha256(encoded).hexdigest() + existing = await self.runner(connection, runner) + seen = now() + if existing is None: + try: + await connection.execute( + insert(_RUNNERS).values( + runner=runner, + subject=subject, + capabilities=encoded, + capabilities_digest=digest, + last_seen_at=seen, + ) + ) + except IntegrityError: + raise SourceCollectionError(409, "conflict", "The runner synced concurrently; retry.") from None + else: + values: dict[str, Any] = {"last_seen_at": seen} + current = ( + await connection.execute(select(_RUNNERS.c.capabilities_digest).where(_RUNNERS.c.runner == runner)) + ).scalar_one() + if current != digest: + values.update(capabilities=encoded, capabilities_digest=digest) + await connection.execute(update(_RUNNERS).where(_RUNNERS.c.runner == runner).values(**values)) + loaded = await self.runner(connection, runner) + assert loaded is not None # noqa: S101 - written above in this transaction + return loaded + + async def delete_runner(self, connection: AsyncConnection, runner: str) -> None: + if await self.runner(connection, runner) is None: + raise SourceCollectionError(404, "not_found", "The runner was not found.") + remaining = ( + await connection.execute(select(func.count()).select_from(_TARGETS).where(_TARGETS.c.runner == runner)) + ).scalar_one() + if remaining: + raise SourceCollectionError(409, "conflict", "Delete the runner's targets first.") + await connection.execute(delete(_RUNNERS).where(_RUNNERS.c.runner == runner)) + + async def target(self, connection: AsyncConnection, scope_id: str, target_id: str) -> Target | None: + row = ( + ( + await connection.execute( + select(_TARGETS).where(_TARGETS.c.scope_id == scope_id, _TARGETS.c.target_id == target_id) + ) + ) + .mappings() + .first() + ) + return None if row is None else _target(row) + + async def targets(self, connection: AsyncConnection, scope_id: str) -> tuple[Target, ...]: + statement = select(_TARGETS).where(_TARGETS.c.scope_id == scope_id).order_by(_TARGETS.c.target_id) + return tuple(_target(row) for row in (await connection.execute(statement)).mappings().all()) + + async def add_target( + self, + connection: AsyncConnection, + *, + scope_id: str, + runner: str, + capability: str, + target_key: str, + parameters: Mapping[str, JsonValue], + actor: str, + ) -> tuple[Target, bool]: + """Add a target, or return the existing one with the same key; True when it was created.""" + + same_key = and_( + _TARGETS.c.scope_id == scope_id, + _TARGETS.c.runner == runner, + _TARGETS.c.capability == capability, + _TARGETS.c.target_key == target_key, + ) + row = (await connection.execute(select(_TARGETS).where(same_key))).mappings().first() + if row is not None: + existing = _target(row) + if canonical(existing.parameters) != canonical(dict(parameters)): + raise SourceCollectionError( + 409, "target_conflict", "A target with the same identity has other parameters." + ) + return existing, False + target_id = uuid.uuid4().hex + try: + await connection.execute( + insert(_TARGETS).values( + scope_id=scope_id, + target_id=target_id, + runner=runner, + capability=capability, + target_key=target_key, + parameters=canonical(dict(parameters)), + enabled=True, + updated_at=now(), + updated_by=actor, + consecutive_failures=0, + ) + ) + except IntegrityError: + raise SourceCollectionError(409, "conflict", "The target was added concurrently; retry.") from None + created = await self.target(connection, scope_id, target_id) + assert created is not None # noqa: S101 - written above in this transaction + return created, True + + async def update_target( + self, + connection: AsyncConnection, + target: Target, + *, + parameters: Mapping[str, JsonValue] | None, + enabled: bool | None, + actor: str, + ) -> Target: + values: dict[str, Any] = {"updated_at": now(), "updated_by": actor} + if parameters is not None: + values["parameters"] = canonical(dict(parameters)) + if enabled is not None: + values["enabled"] = enabled + await connection.execute( + update(_TARGETS) + .where(_TARGETS.c.scope_id == target.scope_id, _TARGETS.c.target_id == target.target_id) + .values(**values) + ) + updated = await self.target(connection, target.scope_id, target.target_id) + assert updated is not None # noqa: S101 - updated above in this transaction + return updated + + async def delete_target(self, connection: AsyncConnection, target: Target) -> None: + """Delete the target and its checkpoint; the Sources it collected stay.""" + + await connection.execute( + delete(_TARGETS).where(_TARGETS.c.scope_id == target.scope_id, _TARGETS.c.target_id == target.target_id) + ) + await connection.execute( + delete(CONNECTOR_CHECKPOINTS_TABLE).where( + CONNECTOR_CHECKPOINTS_TABLE.c.scope_id == target.scope_id, + CONNECTOR_CHECKPOINTS_TABLE.c.binding_id == target.target_id, + ) + ) + + async def assigned( + self, connection: AsyncConnection, runner: str, capabilities: Sequence[str] + ) -> tuple[Target, ...]: + """The runner's enabled targets of the given capabilities, across all Scopes.""" + + if not capabilities: + return () + statement = ( + select(_TARGETS) + .where(_TARGETS.c.runner == runner, _TARGETS.c.enabled.is_(True), _TARGETS.c.capability.in_(capabilities)) + .order_by(_TARGETS.c.scope_id, _TARGETS.c.target_id) + ) + return tuple(_target(row) for row in (await connection.execute(statement)).mappings().all()) + + async def due( + self, connection: AsyncConnection, scope_id: str, runner: str, capability: str, limit: int + ) -> tuple[Target, ...]: + """Up to ``limit`` enabled targets of one Scope, least recently attempted first, so many targets rotate.""" + + statement = ( + select(_TARGETS) + .where( + _TARGETS.c.scope_id == scope_id, + _TARGETS.c.runner == runner, + _TARGETS.c.capability == capability, + _TARGETS.c.enabled.is_(True), + ) + .order_by(_TARGETS.c.last_attempt_at.is_not(None), _TARGETS.c.last_attempt_at, _TARGETS.c.target_id) + .limit(limit) + ) + return tuple(_target(row) for row in (await connection.execute(statement)).mappings().all()) + + async def scopes_with_targets(self, connection: AsyncConnection, runner: str, capability: str) -> tuple[str, ...]: + statement = ( + select(_TARGETS.c.scope_id) + .where(_TARGETS.c.runner == runner, _TARGETS.c.capability == capability, _TARGETS.c.enabled.is_(True)) + .distinct() + .order_by(_TARGETS.c.scope_id) + ) + return tuple((await connection.execute(statement)).scalars().all()) + + async def record_status( + self, + connection: AsyncConnection, + scope_id: str, + target_id: str, + runner: str, + *, + error_code: str | None = None, + error_message: str | None = None, + ) -> bool: + """Record one collection attempt of a target of this runner; False when no such target exists.""" + + attempted = now() + matches = and_(_TARGETS.c.scope_id == scope_id, _TARGETS.c.target_id == target_id, _TARGETS.c.runner == runner) + if error_code is None: + values: dict[str, Any] = { + "last_attempt_at": attempted, + "last_success_at": attempted, + "consecutive_failures": 0, + "last_error_code": None, + "last_error_message": None, + } + else: + values = { + "last_attempt_at": attempted, + "consecutive_failures": _TARGETS.c.consecutive_failures + 1, + "last_error_code": error_code[:128], + "last_error_message": None if error_message is None else error_message[:1024], + } + result = await connection.execute(update(_TARGETS).where(matches).values(**values)) + return result.rowcount == 1 diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index b0f5883106..f58e46078b 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -857,11 +857,45 @@ def _entry_text_type(): UniqueConstraint("definition_name", "fingerprint", name="uq_pc_source_definition_manifest_fingerprint"), ) -SOURCE_WRITE_PLUGINS_TABLE = Table( - "pc_source_write_plugins", + +def _instant_type(): + return DateTime(timezone=True).with_variant(DATETIME(fsp=6), "mysql") + + +# Collection runners: the Server's own runner and every device runner that synced. A runner reports +# the Source plugins it loaded; a name is bound to the credential subject that first synced with it. +SOURCE_RUNNERS_TABLE = Table( + "pc_source_runners", + SHARED_METADATA, + Column("runner", identity_string(128), primary_key=True), + Column("subject", identity_string(256), nullable=False), + Column("capabilities", _canonical_payload_type(), nullable=False), + Column("capabilities_digest", identity_string(64), nullable=False), + # Null until the runner syncs, including after a restore. + Column("last_seen_at", _instant_type(), nullable=True), +) + +# Collection targets users assign to runners. Users write the configuration columns; the runner writes +# the status columns together with the target's checkpoint. +SOURCE_COLLECT_TARGETS_TABLE = Table( + "pc_source_collect_targets", SHARED_METADATA, - Column("plugin_id", identity_string(128), primary_key=True), - Column("definition", _canonical_payload_type(), nullable=False), + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("target_id", identity_string(64), primary_key=True), + Column("runner", identity_string(128), nullable=False), + Column("capability", identity_string(64), nullable=False), + Column("target_key", identity_string(64), nullable=False), + Column("parameters", _canonical_payload_type(), nullable=False), + Column("enabled", Boolean, nullable=False), + Column("updated_at", _instant_type(), nullable=False), + Column("updated_by", identity_string(256), nullable=False), + Column("last_attempt_at", _instant_type(), nullable=True), + Column("last_success_at", _instant_type(), nullable=True), + Column("consecutive_failures", BigInteger, nullable=False, server_default="0"), + Column("last_error_code", identity_string(128), nullable=True), + Column("last_error_message", identity_string(1024), nullable=True), + UniqueConstraint("scope_id", "runner", "capability", "target_key", name="uq_pc_source_collect_target_key"), + Index("ix_pc_source_collect_targets_runner", "runner"), ) EXTERNAL_SKILL_REGISTRATIONS_TABLE = Table( @@ -1120,7 +1154,8 @@ def _entry_text_type(): ARTIFACT_PROCESSING_MIGRATION_RECEIPTS_TABLE, CONNECTOR_CHECKPOINTS_TABLE, SOURCE_DEFINITION_MANIFESTS_TABLE, - SOURCE_WRITE_PLUGINS_TABLE, + SOURCE_RUNNERS_TABLE, + SOURCE_COLLECT_TARGETS_TABLE, EXTERNAL_SKILL_REGISTRATIONS_TABLE, SKILL_PACKAGES_TABLE, AGENT_SKILL_TARGETS_TABLE, diff --git a/src/powercontext/builtin/plugins/collector.py b/src/powercontext/builtin/plugins/collector.py new file mode 100644 index 0000000000..d2e32ead25 --- /dev/null +++ b/src/powercontext/builtin/plugins/collector.py @@ -0,0 +1,189 @@ +# 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. + +"""Run one collection by the Server's own runner inside a Supervisor Worker. + +One invocation collects one Scope: the enabled targets of this plugin assigned to the +``server`` runner, least recently attempted first. The collected Sources, each target's +new checkpoint, and each target's status commit in one transaction; a target the plugin +reports as failed keeps its checkpoint while the others still commit. +""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import AsyncExitStack +from pathlib import Path + +from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository +from powercontext.builtin.persistence.source_collection import ( + SERVER_RUNNER, + TARGET_CHECKPOINT_VERSION, + SourceCollectionRepository, +) +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.plugins.executor import PluginWorkerSpec, open_plugin_database +from powercontext.builtin.plugins.manifest import PluginManifest, load_plugin +from powercontext.builtin.plugins.observations import observe +from powercontext.builtin.runtime.processing_contracts import ( + ArtifactProcessingWorkAssignment, + ArtifactProcessingWorkerCompletion, +) +from powercontext.builtin.runtime.processing_execution import InvocationAlreadyHandled, ScopeInvocation +from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY +from powercontext.plugins import CollectRequest, CollectResult, CollectTarget, PluginContext, SourcePlugin +from powercontext.sources import ConnectorBinding + +logger = logging.getLogger(__name__) + + +class CollectionContractError(ValueError): + """The plugin's collect result breaks the collection contract; the whole call fails.""" + + +def collection_family(plugin: str) -> str: + """The Supervisor accounts bindings by family; a collection binding owns no Artifacts.""" + + return f"source-{plugin}" + + +def run_source_collect_worker( + spec: PluginWorkerSpec, assignment: ArtifactProcessingWorkAssignment, / +) -> ArtifactProcessingWorkerCompletion: + return asyncio.run(execute_collect(spec, assignment)) + + +async def execute_collect( # noqa: C901 - one collection from target selection through commit + spec: PluginWorkerSpec, assignment: ArtifactProcessingWorkAssignment, /, *, database=None +) -> ArtifactProcessingWorkerCompletion: + manifest = PluginManifest.model_validate(spec.manifest) + declared = manifest.source + if declared is None or declared.collect is None: + raise ValueError("only collecting Source plugins run collection Workers") # noqa: TRY003 + if assignment.binding_name != manifest.binding_name: + raise ValueError("assignment does not match the plugin declaration") # noqa: TRY003 + collect = declared.collect + scope_id = assignment.scope_id + targets_repository = SourceCollectionRepository() + checkpoints = ConnectorCheckpointRepository() + sources = SourceRepository(BUILTIN_SOURCE_REGISTRY) + + def binding(target_id: str) -> ConnectorBinding: + return ConnectorBinding( + scope_id=scope_id, + binding_id=target_id, + connector_name=manifest.name, + connector_version=TARGET_CHECKPOINT_VERSION, + ) + + async with AsyncExitStack() as resources: + if database is None: + database = await resources.enter_async_context(open_plugin_database(spec.database)) + from powercontext.server.processing_security import open_worker_security + + security = await resources.enter_async_context(open_worker_security(spec.security, database)) + + async def authorize(connection) -> None: + if declared.scopes is not None and scope_id not in declared.scopes: + raise CollectionContractError("the Scope is outside the plugin's scopes") # noqa: TRY003 + if security is not None: + await security.authorize_transaction(connection, scope_id) + + invocation = ScopeInvocation(assignment, authorize_transaction=authorize) + try: + async with database.transaction() as connection: + await invocation.start(connection) + due = await targets_repository.due( + connection, scope_id, SERVER_RUNNER, manifest.name, collect.max_targets + ) + requested = tuple([ + CollectTarget( + target_id=target.target_id, + parameters=target.parameters, + checkpoint=await checkpoints.load(connection, binding(target.target_id)), + ) + for target in due + ]) + if not requested: + async with database.transaction() as connection: + await invocation.complete(connection, remaining_work=False) + return ArtifactProcessingWorkerCompletion(noop=True) + directory = Path(spec.directory) + loaded, declaration = load_plugin(directory, manifest.name) + if loaded != manifest or not isinstance(declaration, SourcePlugin) or declaration.setup is None: + raise ValueError("the plugin changed after the Server checked it") # noqa: TRY003 + async with AsyncExitStack() as plugin_resources: + from powercontext.builtin.plugins.storage import open_plugin_resources + from powercontext.sources.definitions import SourceDefinitionRegistry + + storage, models = await open_plugin_resources( + spec.database, spec.storage_root, manifest.name, spec.inference, plugin_resources + ) + context = PluginContext( + name=manifest.name, + scope_id=scope_id, + plugin_dir=directory, + logger=logging.getLogger(f"powercontext.plugin.{manifest.name}"), + resources=plugin_resources, + storage=storage, + models=models, + ) + collector = await declaration.setup(context) + result = await collector.collect( + CollectRequest(scope_id=scope_id, targets=requested, max_items=collect.max_items) + ) + if not isinstance(result, CollectResult): + raise CollectionContractError("collect must return a CollectResult") # noqa: TRY003 + known = {target.target_id: target for target in requested} + if set(result.targets) - set(known): + raise CollectionContractError("collect returned a target that was not requested") # noqa: TRY003 + if sum(len(item.items) for item in result.targets.values()) > collect.max_items: + raise CollectionContractError("collect returned more items than max_items") # noqa: TRY003 + registry = SourceDefinitionRegistry(declaration.definitions) + observed = { + target_id: [ + await observe(registry, manifest.source_manifests, item.definition, item.value) + for item in outcome.items + ] + for target_id, outcome in result.targets.items() + if outcome.error_code is None + } + async with database.transaction() as connection: + await invocation.guard(connection) + for target_id, outcome in result.targets.items(): + if outcome.error_code is not None: + await targets_repository.record_status( + connection, + scope_id, + target_id, + SERVER_RUNNER, + error_code=outcome.error_code, + error_message=outcome.error_message, + ) + continue + for observation in observed[target_id]: + await sources.add(connection, scope_id, observation) + await checkpoints.save( + connection, + binding(target_id), + outcome.checkpoint, + expected=known[target_id].checkpoint, + ) + await targets_repository.record_status(connection, scope_id, target_id, SERVER_RUNNER) + remaining = any(outcome.has_more for outcome in result.targets.values()) + await invocation.complete(connection, remaining_work=remaining) + except InvocationAlreadyHandled: + return ArtifactProcessingWorkerCompletion(noop=True) + return ArtifactProcessingWorkerCompletion(noop=False) diff --git a/src/powercontext/builtin/plugins/executor.py b/src/powercontext/builtin/plugins/executor.py index 082f1baa26..325838f429 100644 --- a/src/powercontext/builtin/plugins/executor.py +++ b/src/powercontext/builtin/plugins/executor.py @@ -42,7 +42,7 @@ ) from powercontext.builtin.runtime.processing_execution import InvocationAlreadyHandled, ScopeInvocation from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY, SourceCursor -from powercontext.plugins import GenerateRequest, GenerationSource, PluginContext +from powercontext.plugins import ArtifactPlugin, GenerateRequest, GenerationSource, PluginContext logger = logging.getLogger(__name__) @@ -86,7 +86,9 @@ async def execute_plugin( # noqa: C901 - one window from selection through ackn spec: PluginWorkerSpec, assignment: ArtifactProcessingWorkAssignment, /, *, database: Any = None ) -> ArtifactProcessingWorkerCompletion: manifest = PluginManifest.model_validate(spec.manifest) - generate = manifest.spec + generate = manifest.artifact + if generate is None: + raise ValueError("only Artifact plugins generate") # noqa: TRY003 if assignment.binding_name != manifest.binding_name or assignment.artifact_family != generate.family: raise ValueError("assignment does not match the plugin declaration") # noqa: TRY003 register_plugin_family(generate.family) @@ -170,7 +172,7 @@ async def complete(connection) -> None: if call: directory = Path(spec.directory) loaded, declaration = load_plugin(directory, manifest.name) - if loaded != manifest: + if loaded != manifest or not isinstance(declaration, ArtifactPlugin): raise ValueError("the plugin changed after the Server checked it; restart the Server") # noqa: TRY003 async with AsyncExitStack() as plugin_resources: from powercontext.builtin.plugins.storage import open_plugin_resources diff --git a/src/powercontext/builtin/plugins/host.py b/src/powercontext/builtin/plugins/host.py index d1e9badafe..20ad829480 100644 --- a/src/powercontext/builtin/plugins/host.py +++ b/src/powercontext/builtin/plugins/host.py @@ -62,6 +62,7 @@ AccessDenied, ApiError, ApiRequest, + ArtifactPluginSpec, BinaryResponse, Caller, CreateRequest, @@ -145,7 +146,9 @@ def __init__(self, connection: Any, spec: PluginHostSpec) -> None: self.spec = spec self.send_lock = asyncio.Lock() self.manifest = PluginManifest.model_validate(spec.manifest) - self.family = self.manifest.spec.family + artifact = self.manifest.artifact + # Source plugins own no family: they have no Artifact reads, writes, presentation, or search. + self.family = None if artifact is None else artifact.family self.access: Any = None self.deployment_id: str | None = None self.plugin: Any = None @@ -209,7 +212,8 @@ async def _start(self, resources: AsyncExitStack, *, serve: bool = True) -> tupl loaded, declaration = load_plugin(directory, self.manifest.name) if loaded != self.manifest: raise PluginConfigError("the plugin changed after the Server checked it; restart the Server") # noqa: TRY003 - replace_registrations(tuple({*spec.plugin_families, self.family}), spec.source_bindings) + families = {*spec.plugin_families, *(() if self.family is None else (self.family,))} + replace_registrations(tuple(families), spec.source_bindings) self.database = await resources.enter_async_context(open_plugin_database(spec.database)) from powercontext.builtin.authz.worker import open_worker_security @@ -242,8 +246,10 @@ async def _start(self, resources: AsyncExitStack, *, serve: bool = True) -> tupl await self._upgrade(declaration.upgrade, context) if not serve: return () - self.plugin = await declaration.setup(context) - declared = self.manifest.spec + self.plugin = None if declaration.setup is None else await declaration.setup(context) + declared = self.manifest.artifact + if declared is None: + return () background = declared.schedule is not None or declared.on_sources required = ("render_artifact", *(("generate",) if background else ())) missing = [name for name in required if not callable(getattr(self.plugin, name, None))] @@ -265,7 +271,7 @@ async def _upgrade(self, upgrade: Any, context: PluginContext) -> None: def _plugin_writer(self, scope_id: str) -> PluginArtifactWriter: """A writer acting as the plugin, like a Worker's, with no invocation cursor.""" - spec = self.manifest.spec + spec = self._artifact() security = self.security async def authorize(connection: Any) -> None: @@ -277,7 +283,7 @@ async def authorize(connection: Any) -> None: return PluginArtifactWriter( database=self.database, scope_id=scope_id, - family=self.family, + family=spec.family, sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), metadata=revision_metadata(self.manifest.name, self.manifest.fingerprint), begin=authorize, @@ -333,6 +339,8 @@ async def _dispatch(self, request: HostRequest) -> HostResponse: return await self._api(request) if request.method == "create_source": return await self._create_source(request) + if request.method == "project_source": + return self._project_source(request) if request.method == "render_artifact": return await self._render(request) if request.method == "search_artifacts": @@ -397,12 +405,33 @@ async def _create_source(self, request: HostRequest) -> HostResponse: ref = await self.store_source(scope_id, request.payload["source_type"], value) return HostResponse(request.id, "ok", result=ref.model_dump(mode="json")) + def _artifact(self) -> ArtifactPluginSpec: + spec = self.manifest.artifact + if spec is None: + raise RuntimeError("Source plugins have no Artifact family to read or write") # noqa: TRY003 + return spec + + def _project_source(self, request: HostRequest) -> HostResponse: + """Recompute a runner's observation with this plugin version; the Server stores the result.""" + + from powercontext.builtin.plugins.observations import ObservationRejected, reproject + from powercontext.sources.observations import SourceObservation + + if self.definitions is None: + return HostResponse(request.id, "not_found") + observation = SourceObservation.model_validate(request.payload["observation"]) + try: + projected = reproject(self.definitions, self.manifest.source_manifests, observation) + except ObservationRejected as error: + return HostResponse(request.id, "invalid_content", message=str(error)) + return HostResponse(request.id, "ok", result=projected.model_dump(mode="json")) + def _writer(self, scope_id: str, *, change: UserChange | None = None) -> PluginArtifactWriter: - spec = self.manifest.spec + spec = self._artifact() return PluginArtifactWriter( database=self.database, scope_id=scope_id, - family=self.family, + family=spec.family, sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), metadata=revision_metadata(self.manifest.name, self.manifest.fingerprint), transaction_timeout_seconds=spec.transaction_timeout_seconds, @@ -413,9 +442,10 @@ def _writer(self, scope_id: str, *, change: UserChange | None = None) -> PluginA async def _authorize(self, scope_id: str, action: str, artifact_id: str | None) -> None: call = _CURRENT.get() - ref = None if artifact_id is None else ArtifactRef(family=self.family, artifact_id=artifact_id, revision=1) + family = self._artifact().family + ref = None if artifact_id is None else ArtifactRef(family=family, artifact_id=artifact_id, revision=1) async with self.database.transaction() as connection: - await ArtifactMergeAccess(self.family).authorize(connection, scope_id, call.context, action, ref) + await ArtifactMergeAccess(family).authorize(connection, scope_id, call.context, action, ref) async def _change(self, request: HostRequest) -> HostResponse: method = getattr(self.plugin, request.method, None) @@ -591,19 +621,9 @@ async def store_source(self, scope_id: str, definition: str, value: BaseModel) - if self.definitions is None: raise ValueError("the plugin declares no Source Definitions") # noqa: TRY003 - from powercontext.builtin.sources.observation_validation import validate_source_observation - from powercontext.sources.observations import SourceDefinitionManifest, project_source_for_transport - - selected = self.definitions.definition_for_name(definition) - if not isinstance(value, selected.input_class): - raise TypeError(f"{definition} expects {selected.input_class.__name__}") # noqa: TRY003 - observation = project_source_for_transport(self.definitions, await self.definitions.resolve(value)) - manifest = next( - SourceDefinitionManifest.model_validate(item) - for item in self.manifest.source_manifests - if item["name"] == definition - ) - validate_source_observation(observation, manifest) + from powercontext.builtin.plugins.observations import observe + + observation = await observe(self.definitions, self.manifest.source_manifests, definition, value) repository = SourceRepository(BUILTIN_SOURCE_REGISTRY) async with self.database.transaction() as connection: stored = await repository.add(connection, scope_id, observation) diff --git a/src/powercontext/builtin/plugins/host_protocol.py b/src/powercontext/builtin/plugins/host_protocol.py index 0052c9b3ee..895dffb381 100644 --- a/src/powercontext/builtin/plugins/host_protocol.py +++ b/src/powercontext/builtin/plugins/host_protocol.py @@ -26,6 +26,7 @@ "forget_artifact", "restore_artifact", "create_source", + "project_source", "render_artifact", "search_artifacts", "api", diff --git a/src/powercontext/builtin/plugins/manifest.py b/src/powercontext/builtin/plugins/manifest.py index 826e7cd3c7..acb8e45eb9 100644 --- a/src/powercontext/builtin/plugins/manifest.py +++ b/src/powercontext/builtin/plugins/manifest.py @@ -29,10 +29,11 @@ import sys from pathlib import Path from types import ModuleType +from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError -from powercontext.plugins import ArtifactPlugin, ArtifactPluginSpec +from powercontext.plugins import ArtifactPlugin, ArtifactPluginSpec, PluginSpec, SourcePlugin, SourcePluginSpec ENTRY_FILE = "plugin.py" _NAME = re.compile(r"^[a-z][a-z0-9-]{0,63}$") @@ -50,7 +51,7 @@ class PluginManifest(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) name: str = Field(pattern=_NAME.pattern) - spec: ArtifactPluginSpec + spec: Annotated[ArtifactPluginSpec | SourcePluginSpec, Field(discriminator="kind")] # Content digest of the plugin directory when it was checked; see ``code_fingerprint``. fingerprint: str = "" # Declarative manifests of the Source Definitions the plugin submits through its routes. @@ -63,6 +64,14 @@ class PluginManifest(BaseModel): def binding_name(self) -> str: return f"plugin:{self.name}" + @property + def artifact(self) -> ArtifactPluginSpec | None: + return self.spec if isinstance(self.spec, ArtifactPluginSpec) else None + + @property + def source(self) -> SourcePluginSpec | None: + return self.spec if isinstance(self.spec, SourcePluginSpec) else None + def mcp_tool_name(plugin: str, handler: str) -> str: # Plugin names have no underscores, so the first double underscore always ends the plugin name. @@ -103,7 +112,7 @@ def load_module(directory: Path, name: str) -> ModuleType: return module -def load_plugin(directory: Path, name: str | None = None) -> tuple[PluginManifest, ArtifactPlugin]: +def load_plugin(directory: Path, name: str | None = None) -> tuple[PluginManifest, ArtifactPlugin | SourcePlugin]: """Import ``plugin.py`` and validate its declaration without calling the factory. ``name`` defaults to the directory name; a version snapshot passes the plugin's name. @@ -113,14 +122,14 @@ def load_plugin(directory: Path, name: str | None = None) -> tuple[PluginManifes if _NAME.fullmatch(name) is None: raise PluginConfigError("the directory name is not a valid plugin name") # noqa: TRY003 declaration = getattr(load_module(directory, name), "plugin", None) - if not isinstance(declaration, ArtifactPlugin): - raise PluginConfigError(f"{ENTRY_FILE} must assign an ArtifactPlugin to plugin") # noqa: TRY003 - if not inspect.iscoroutinefunction(declaration.setup): - raise PluginConfigError("setup must be an async function") # noqa: TRY003 - if declaration.upgrade is not None and not inspect.iscoroutinefunction(declaration.upgrade): - raise PluginConfigError("upgrade must be an async function") # noqa: TRY003 + if not isinstance(declaration, ArtifactPlugin | SourcePlugin): + raise PluginConfigError(f"{ENTRY_FILE} must assign an ArtifactPlugin or a SourcePlugin to plugin") # noqa: TRY003 + for function in (declaration.setup, declaration.upgrade): + if function is not None and not inspect.iscoroutinefunction(function): + raise PluginConfigError("setup and upgrade must be async functions") # noqa: TRY003 + model: type[PluginSpec] = ArtifactPluginSpec if isinstance(declaration, ArtifactPlugin) else SourcePluginSpec try: - spec = ArtifactPluginSpec.model_validate(declaration.model_dump(mode="json")) + spec = model.model_validate(declaration.model_dump(mode="json")) except ValidationError as error: fields = ", ".join(".".join(str(part) for part in item["loc"]) or "plugin" for item in error.errors()) raise PluginConfigError(f"invalid declaration fields: {fields}") from error # noqa: TRY003 @@ -153,7 +162,7 @@ def load_plugin(directory: Path, name: str | None = None) -> tuple[PluginManifes ) -def _check_routes_and_pages(directory: Path, name: str, spec: ArtifactPluginSpec) -> None: +def _check_routes_and_pages(directory: Path, name: str, spec: PluginSpec) -> None: for route in () if spec.api is None else spec.api.routes: if route.mcp and len(mcp_tool_name(name, route.handler)) > MCP_TOOL_NAME_LIMIT: raise PluginConfigError(f"MCP tool names are at most {MCP_TOOL_NAME_LIMIT} characters") # noqa: TRY003 diff --git a/src/powercontext/builtin/plugins/observations.py b/src/powercontext/builtin/plugins/observations.py new file mode 100644 index 0000000000..75ae476cb2 --- /dev/null +++ b/src/powercontext/builtin/plugins/observations.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Turn a plugin's Source values into stored observations with the Server's own plugin version. + +Projections are computed when a Source is written, by the plugin the Server loaded, so +delivery only reads saved results. Host processes use this for Source writes and runner +observations; collection Workers use it for what the Server's own runner collects. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from pydantic import BaseModel, JsonValue + +from powercontext.sources.observations import SourceObservation + + +class ObservationRejected(ValueError): + """The observation does not match a Source Definition of the loaded plugin version.""" + + +def _manifest(manifests: Sequence[dict[str, JsonValue]], name: str) -> Any: + from powercontext.sources.observations import SourceDefinitionManifest + + for item in manifests: + if item["name"] == name: + return SourceDefinitionManifest.model_validate(item) + raise ObservationRejected(f"{name} is not a Source type of this plugin") # noqa: TRY003 + + +async def observe( + registry: Any, manifests: Sequence[dict[str, JsonValue]], definition: str, value: BaseModel +) -> SourceObservation: + """Resolve an input of one of the plugin's Source types and project it.""" + + from powercontext.builtin.sources.observation_validation import validate_source_observation + from powercontext.sources.observations import project_source_for_transport + + selected = registry.definition_for_name(definition) + if not isinstance(value, selected.input_class): + raise ObservationRejected(f"{definition} expects {selected.input_class.__name__}") # noqa: TRY003 + observation = project_source_for_transport(registry, await registry.resolve(value)) + validate_source_observation(observation, _manifest(manifests, definition)) + return observation + + +def reproject( + registry: Any, manifests: Sequence[dict[str, JsonValue]], observation: SourceObservation +) -> SourceObservation: + """Rebuild a runner's observation from its canonical value and project it with this plugin version.""" + + from powercontext.builtin.sources.observation_validation import validate_source_observation + from powercontext.sources.observations import project_source_for_transport + + try: + definition = registry.definition_for_name(observation.source_type) + except Exception: + raise ObservationRejected(f"{observation.source_type} is not a Source type of this plugin") from None # noqa: TRY003 + if definition.version != observation.definition_version: + raise ObservationRejected("the runner's Source Definition version is not the one this Server loaded") # noqa: TRY003 + source = definition.source_class.model_validate(observation.payload) + projected = project_source_for_transport(registry, source) + validate_source_observation(projected, _manifest(manifests, observation.source_type)) + return projected diff --git a/src/powercontext/builtin/portability/bundle.py b/src/powercontext/builtin/portability/bundle.py index 4992e483c3..fca4bb7f5b 100644 --- a/src/powercontext/builtin/portability/bundle.py +++ b/src/powercontext/builtin/portability/bundle.py @@ -110,8 +110,10 @@ SCOPE_EXTERNAL_REFERENCES_TABLE, SCOPES_TABLE, SKILL_PACKAGES_TABLE, + SOURCE_COLLECT_TARGETS_TABLE, SOURCE_DEFINITION_MANIFESTS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, + SOURCE_RUNNERS_TABLE, SOURCES_TABLE, TOPIC_MEMORY_REVISION_PUBLICATIONS_TABLE, ) @@ -177,6 +179,8 @@ "source_journal_head", "source", "source_evidence_binding", + "source_runner", + "source_collect_target", "skill_package", "artifact_revision", "artifact_handle", @@ -331,6 +335,15 @@ class _RecordSpec: ("scope_id", "source_type", "source_id"), ("material_digest", "definition_name", "definition_version", "manifest_fingerprint", "declaration"), ), + # A runner travels with the targets assigned to it so its name stays bound to the same subject. + # Its capabilities and last-seen time are refreshed by its next sync. + "source_runner": _RecordSpec(SOURCE_RUNNERS_TABLE, ("runner",), ("subject",), scope_field=None), + # Status columns are the runner's report and restart empty. + "source_collect_target": _RecordSpec( + SOURCE_COLLECT_TARGETS_TABLE, + ("scope_id", "target_id"), + ("runner", "capability", "target_key", "parameters", "enabled", "updated_at", "updated_by"), + ), "skill_package": _RecordSpec( SKILL_PACKAGES_TABLE, ("scope_id", "tree_digest"), @@ -1049,6 +1062,17 @@ async def _export_rows( yield cast(Mapping[str, Any], row) return + if kind == "source_runner": + targets = SOURCE_COLLECT_TARGETS_TABLE + runners = SOURCE_RUNNERS_TABLE + referenced = select(targets.c.runner).where(targets.c.scope_id.in_(scopes)).distinct() + rows = await connection.stream( + select(runners).where(runners.c.runner.in_(referenced)).order_by(runners.c.runner) + ) + async for row in rows.mappings(): + yield cast(Mapping[str, Any], row) + return + # Definition manifests are global, but export authority is scope-bound. # Include immutable manifests referenced by observations or retained bindings. table = SOURCE_DEFINITION_MANIFESTS_TABLE @@ -1188,6 +1212,8 @@ def _row_values(record: _Record) -> dict[str, Any]: values["value_search"] = str(record.payload["value"]) elif record.record_type == "skill_package": values["created_at"] = datetime.now(UTC) + elif record.record_type == "source_runner": + values.update(capabilities=b"[]", capabilities_digest=hashlib.sha256(b"[]").hexdigest(), last_seen_at=None) return values @@ -1547,6 +1573,13 @@ def bytes_values(*fields: str, nullable: tuple[str, ...] = ()) -> None: strings("fingerprint") bytes_values("manifest") _source_definition(record) + elif kind == "source_runner": + strings("subject") + elif kind == "source_collect_target": + strings("runner", "capability", "target_key", "updated_at", "updated_by") + bytes_values("parameters") + if type(payload["enabled"]) is not bool: + raise BundleFormatError("record payload has an invalid boolean field") elif kind == "source_evidence_binding": strings("material_digest", "definition_name", "definition_version") strings("manifest_fingerprint", nullable=("manifest_fingerprint",)) @@ -2137,6 +2170,9 @@ def _validate_record_dependencies( # noqa: C901 - logical record kinds have dis }, ): raise BundleFormatError("observation references missing or mismatched Source definition") + elif record.record_type == "source_collect_target": + if payload["runner"] != "server" and not contains("source_runner", {"runner": payload["runner"]}): + raise BundleFormatError("collection target references a missing runner") elif record.record_type == "source_evidence_binding": if not contains( "source", diff --git a/src/powercontext/cli/app.py b/src/powercontext/cli/app.py index ad79f9645c..6ca3bab88e 100644 --- a/src/powercontext/cli/app.py +++ b/src/powercontext/cli/app.py @@ -147,9 +147,10 @@ def main( ) registered_names = register_commands(cli) - from powercontext.source_management.cli import register_commands as register_source_commands + from powercontext.source_agent import source_agent - registered_names.update(register_source_commands(cli)) + cli.command(name="source-agent")(source_agent) + registered_names.add("source-agent") if commands is None: commands = _load_command_providers() diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 5646c6670e..17bf1f9882 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -98,10 +98,9 @@ "list_scopes", "merge_atomic_memories", "pc_source_add", - "pc_source_capabilities", - "pc_source_instance_set_enabled", - "pc_source_instances", + "pc_source_runners", "pc_source_status", + "pc_source_target_delete", "pc_source_target_set_enabled", "preview_atomic_memory_restoration", "propose_experience", diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 7d34289a1d..aef68c4ac0 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -336,6 +336,7 @@ PluginScopePage, PluginScopeState, PluginState, + PluginVersionRef, PrepareContextRequest, PrepareContextResult, PreparedContext, @@ -482,7 +483,21 @@ SourceProjectionValue, SourceRecord, SourceReference, + SourceRunner, + SourceRunnerCapability, + SourceRunnerPage, + SourceRunnerPluginReport, + SourceRunnerRejection, + SourceRunnerSyncRequest, + SourceRunnerSyncResult, + SourceRunnerTarget, SourceSequenceLocator, + SourceTarget, + SourceTargetCreateRequest, + SourceTargetPage, + SourceTargetResultStatus, + SourceTargetStatus, + SourceTargetUpdateRequest, SourceType, SourceTypeReference, StatsPeriod, @@ -852,6 +867,7 @@ "PluginScopePage", "PluginScopeState", "PluginState", + "PluginVersionRef", "PrepareContextRequest", "PrepareContextResult", "PrepareHandoffHintRequest", @@ -998,7 +1014,21 @@ "SourceProjectionValue", "SourceRecord", "SourceReference", + "SourceRunner", + "SourceRunnerCapability", + "SourceRunnerPage", + "SourceRunnerPluginReport", + "SourceRunnerRejection", + "SourceRunnerSyncRequest", + "SourceRunnerSyncResult", + "SourceRunnerTarget", "SourceSequenceLocator", + "SourceTarget", + "SourceTargetCreateRequest", + "SourceTargetPage", + "SourceTargetResultStatus", + "SourceTargetStatus", + "SourceTargetUpdateRequest", "SourceType", "SourceTypeReference", "StatsPeriod", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 0ad4302edd..839caf1fb4 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -683,6 +683,7 @@ class ArtifactDreamingOperationCapability(BaseModel): class PluginKind(StrEnum): ARTIFACT = "artifact" + SOURCE = "source" class PluginLoadStatus(StrEnum): @@ -719,6 +720,161 @@ class PluginRoute(BaseModel): mcp: Annotated[StrictBool, Field(description="The route is also an MCP tool.")] +class PluginVersionRef(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + name: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z][a-z0-9-]*$")] + version: Annotated[StrictStr, Field(max_length=64, min_length=1)] + + +class SourceRunnerPluginReport(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + name: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z][a-z0-9-]*$")] + version: Annotated[ + StrictStr | None, Field(description="Null when the plugin failed to load on the runner.", max_length=64) + ] + error: Annotated[StrictStr | None, Field(description="Safe load failure category on the runner.", max_length=1024)] + + +class SourceRunnerSyncRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + plugins: Annotated[list[SourceRunnerPluginReport], Field(max_length=200)] + + +class SourceRunnerTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: StrictStr + target_id: StrictStr + capability: StrictStr + parameters: dict[str, Any] + checkpoint: Annotated[ + Any | None, Field(description="The target's last committed checkpoint; null before the first commit.") + ] + + +class Code1(StrEnum): + PLUGIN_VERSION_INCOMPATIBLE = "plugin_version_incompatible" + PLUGIN_NOT_LOADED = "plugin_not_loaded" + + +class SourceRunnerRejection(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + name: StrictStr + code: Code1 + + +class SourceRunnerSyncResult(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + targets: list[SourceRunnerTarget] + rejected: list[SourceRunnerRejection] + + +class SourceRunnerCapability(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + name: StrictStr + version: Annotated[StrictStr | None, Field(description="The version the runner reported.")] + error: Annotated[StrictStr | None, Field(...)] + compatible: Annotated[ + StrictBool, Field(description="This Server loaded the plugin and accepts the runner's version.") + ] + description: Annotated[ + StrictStr | None, Field(description="From the plugin this Server loaded; null when it loaded none.") + ] + parameters_schema: Annotated[dict[str, Any] | None, Field(...)] + identity: Annotated[ + dict[str, list[StrictStr]] | None, Field(description="Identity parameters and their normalization steps.") + ] + + +class SourceRunner(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + runner: StrictStr + last_seen_at: Annotated[AwareDatetime | None, Field(description="Null for the Server's own runner.")] + capabilities: list[SourceRunnerCapability] + + +class SourceRunnerPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[SourceRunner] + + +class SourceTargetCreateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + runner: Annotated[StrictStr, Field(max_length=128, min_length=1)] + capability: Annotated[StrictStr, Field(max_length=64, min_length=1, pattern="^[a-z][a-z0-9-]*$")] + parameters: dict[str, Any] + + +class SourceTargetUpdateRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + enabled: StrictBool | None = None + parameters: dict[str, Any] | None = None + + +class SourceTargetStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + last_attempt_at: Annotated[AwareDatetime | None, Field(...)] + last_success_at: Annotated[AwareDatetime | None, Field(...)] + consecutive_failures: Annotated[StrictInt, Field(ge=0)] + last_error_code: Annotated[StrictStr | None, Field(...)] + last_error_message: Annotated[StrictStr | None, Field(...)] + + +class SourceTargetResultStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + succeeded: StrictBool + error_code: Annotated[StrictStr | None, Field(max_length=128)] = None + error_message: Annotated[StrictStr | None, Field(max_length=1024)] = None + + +class SourceTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: StrictStr + target_id: StrictStr + runner: StrictStr + capability: StrictStr + target_key: StrictStr + parameters: dict[str, Any] + enabled: StrictBool + updated_at: AwareDatetime + updated_by: StrictStr + status: SourceTargetStatus + + +class SourceTargetPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[SourceTarget] + + class PluginOpenApiDocument(BaseModel): model_config = ConfigDict( extra="allow", @@ -1456,6 +1612,12 @@ class SubmitSourceObservationRequest(BaseModel): ) scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] observation: SourceObservation + plugin: Annotated[ + PluginVersionRef | None, + Field( + description="Set by collection runners. The Server checks the version against the Source plugin it loaded and has that plugin resolve and project the observation; unsupported versions return plugin_version_incompatible." + ), + ] = None class CommitConnectorCheckpointRequest(BaseModel): @@ -1465,6 +1627,14 @@ class CommitConnectorCheckpointRequest(BaseModel): binding: ConnectorBinding expected: Annotated[Any | None, Field(...)] checkpoint: Annotated[Any | None, Field(...)] + plugin: Annotated[ + PluginVersionRef | None, + Field(description="Set by collection runners for a target's checkpoint; checked like a runner's observations."), + ] = None + status: Annotated[ + SourceTargetResultStatus | None, + Field(description="The collection attempt's outcome, recorded on the target together with the checkpoint."), + ] = None class Kind1(StrEnum): @@ -4377,273 +4547,6 @@ class CatalogTagResult(BaseModel): etag: Annotated[StrictStr, Field(min_length=1)] -class SourceCapabilityQuery(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - capability: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - input: dict[str, Any] | None = None - - -class SourceWritePluginLoadRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")] - entry: Annotated[StrictStr, Field(max_length=4096, min_length=1)] - - -class SourceWritePluginState(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - id: StrictStr - entry: StrictStr - capabilities: list[dict[str, Any]] - - -class SourceAddRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - capability: Annotated[StrictStr, Field(max_length=256, min_length=1)] - capability_version: Annotated[StrictStr, Field(max_length=256, min_length=1)] - instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - input: dict[str, Any] - - -class View(StrEnum): - RESULT = "result" - CONTROL = "control" - - -class SourceStatusRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - target_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - view: View = View.RESULT - - -class SourceInstanceSetEnabledRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - enabled: StrictBool - - -class SourceConfigureRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - update: StrictBool = False - registration: dict[str, Any] - - -class Management(StrEnum): - NOT_CONFIGURED = "not-configured" - ONLINE = "online" - UNAVAILABLE = "unavailable" - - -class SourceCapabilityResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - capabilities: list[dict[str, Any]] - management: Management - - -class SourceInstancesResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - instances: list[dict[str, Any]] - capabilities: list[dict[str, Any]] | None = None - management: Management | None = None - - -class Outcome5(StrEnum): - SAVED = "saved" - UNCHANGED = "unchanged" - - -class SourceCollectionResult(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - outcome: Outcome5 - source_refs: list[SourceTypeReference] - - -class Tracking(StrEnum): - ENABLED = "enabled" - STOPPING = "stopping" - STOPPED = "stopped" - - -class Availability(StrEnum): - ONLINE = "online" - OFFLINE = "offline" - UNAVAILABLE = "unavailable" - - -class Kind12(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: Kind12 | None = None - enabled: StrictBool | None = None - source_refs: Annotated[ - list[SourceTypeReference] | None, - Field( - description="Default collect status only. The single Source reference from the submission report most recently received by the manager, or empty before any report. Omitted from collect add responses; not a batch result or submission history.", - max_length=1, - ), - ] = None - last_submitted_at: Annotated[ - StrictStr | None, - Field( - description="Default collect status only. Runtime-reported submission time paired with source_refs, or null before any report. Selection follows manager receipt order, not timestamp order. Omitted from collect add responses." - ), - ] = None - - -class SourceInstanceStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - targets_initialized: StrictBool | None = None - target_id: StrictStr | None = None - instance_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - enabled: StrictBool | None = None - actual: StrictStr | None = None - run_id: StrictStr | None = None - availability: StrictStr | None = None - reused: StrictBool | None = None - - -class SourceChannelOpen(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - protocol: Annotated[StrictStr, Field(max_length=256, min_length=1)] - deployment_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - manager_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - -class SourceChannelRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - generation: Annotated[StrictStr, Field(max_length=256, min_length=1)] - message_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - response: dict[str, Any] | None = None - catalog: dict[str, Any] | None = None - - -class SourceChannelResponse(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - generation: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - protocol: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - confirmed: StrictBool | None = None - messages: list[dict[str, Any]] | None = None - - -class Status6(StrEnum): - RUNNING = "running" - COMPLETE = "complete" - COMPLETED = "completed" - PENDING = "pending" - FAILED = "failed" - STOPPED = "stopped" - CLEANUP_FAILED = "cleanup-failed" - - -class SourceInstanceExchangeRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - generation: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - protocol: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - message_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - execution_location: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - configuration_digest: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - run_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - execution: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - target_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - capabilities: Annotated[list[dict[str, Any]] | None, Field(max_length=64)] = None - response: dict[str, Any] | None = None - owner: dict[str, Any] | None = None - status: Status6 | None = None - source_refs: Annotated[ - list[SourceTypeReference] | None, - Field( - description="One confirmed Source submission reported by the Runtime; not a batch or historical reference list.", - max_length=1, - min_length=1, - ), - ] = None - error: Annotated[StrictStr | None, Field(max_length=1024)] = None - targets: Annotated[list[dict[str, Any]] | None, Field(max_length=256)] = None - cursor: Annotated[StrictStr | None, Field(max_length=256)] = None - owner_exited: StrictBool | None = None - previous_owner: dict[str, Any] | None = None - submitted_at: StrictStr | None = None - - -class SourceInstanceExchangeResponse(BaseModel): - model_config = ConfigDict( - extra="allow", - ) - - -class SourceTargetSetEnabledRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - target_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - enabled: StrictBool - - -class View1(StrEnum): - DISCOVERY = "discovery" - CONTROL = "control" - - -class SourceInstancesQuery(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - capability: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - instance_id: Annotated[StrictStr | None, Field(max_length=256, min_length=1)] = None - input: dict[str, Any] | None = None - view: View1 = View1.DISCOVERY - - class ScopeId1(RootModel[StrictStr]): root: Annotated[StrictStr, Field(max_length=256, min_length=1)] diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index e0071ade63..32cfbe872b 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -214,28 +214,17 @@ 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, + SourceRunnerPage, + SourceRunnerSyncRequest, + SourceRunnerSyncResult, + SourceTarget, + SourceTargetCreateRequest, + SourceTargetPage, + SourceTargetUpdateRequest, SubmitSourceObservationRequest, TopicMemoryArtifact, TopicMemoryProcessingBlock, @@ -1640,6 +1629,169 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), ) +PC_SOURCE_RUNNERS = Operation[None, SourceRunnerPage]( + method="GET", + path="/v1/source-runners", + operation_id="pc_source_runners", + request_type=None, + request_location=None, + path_parameters=(), + response_type=SourceRunnerPage, + success_status=200, + summary="List collection runners", + tags=("source-collection",), + scope_mode="none", + responses={ + 200: {"description": "Runners ordered by name."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +SYNC_SOURCE_RUNNER = Operation[SourceRunnerSyncRequest, SourceRunnerSyncResult]( + method="POST", + path="/v1/source-runners/{runner}/sync", + operation_id="sync_source_runner", + request_type=SourceRunnerSyncRequest, + request_location="body", + path_parameters=("runner",), + response_type=SourceRunnerSyncResult, + success_status=200, + summary="Sync a collection runner", + tags=("source-collection",), + scope_mode="none", + responses={ + 200: {"description": "The runner's current targets."}, + 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="access.self", resource="server", scope_id_field=None, resolver="static"), +) + +DELETE_SOURCE_RUNNER = Operation[None, None]( + method="DELETE", + path="/v1/source-runners/{runner}", + operation_id="delete_source_runner", + request_type=None, + request_location=None, + path_parameters=("runner",), + response_type=None, + success_status=204, + summary="Delete a collection runner record", + tags=("source-collection",), + scope_mode="none", + responses={ + 204: {"description": "The runner record is deleted."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="server.admin", resource="server", scope_id_field=None, resolver="static"), +) + +PC_SOURCE_STATUS = Operation[None, SourceTargetPage]( + method="GET", + path="/v1/scopes/{scope_id}/source-targets", + operation_id="pc_source_status", + request_type=None, + request_location=None, + path_parameters=("scope_id",), + response_type=SourceTargetPage, + success_status=200, + summary="List a Scope's collection targets", + tags=("source-collection",), + scope_mode="none", + responses={ + 200: {"description": "Targets ordered by ID."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement(action="scope.read", resource="scope", scope_id_field="scope_id", resolver="request"), +) + +PC_SOURCE_ADD = Operation[SourceTargetCreateRequest, SourceTarget]( + method="POST", + path="/v1/scopes/{scope_id}/source-targets", + operation_id="pc_source_add", + request_type=SourceTargetCreateRequest, + request_location="body", + path_parameters=("scope_id",), + response_type=SourceTarget, + success_status=201, + summary="Add a collection target", + tags=("source-collection",), + scope_mode="none", + responses={ + 201: {"description": "The target was added."}, + 200: {"description": "The same target already exists."}, + 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="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + +PC_SOURCE_TARGET_DELETE = Operation[None, None]( + method="DELETE", + path="/v1/scopes/{scope_id}/source-targets/{target_id}", + operation_id="pc_source_target_delete", + request_type=None, + request_location=None, + path_parameters=("scope_id", "target_id"), + response_type=None, + success_status=204, + summary="Delete a collection target", + tags=("source-collection",), + scope_mode="none", + responses={ + 204: {"description": "The target is deleted."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + +PC_SOURCE_TARGET_SET_ENABLED = Operation[SourceTargetUpdateRequest, SourceTarget]( + method="PATCH", + path="/v1/scopes/{scope_id}/source-targets/{target_id}", + operation_id="pc_source_target_set_enabled", + request_type=SourceTargetUpdateRequest, + request_location="body", + path_parameters=("scope_id", "target_id"), + response_type=SourceTarget, + success_status=200, + summary="Change or pause a collection target", + tags=("source-collection",), + scope_mode="none", + responses={ + 200: {"description": "The changed target."}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + }, + access=AccessRequirement( + action="scope.contribute", resource="scope", scope_id_field="scope_id", resolver="request" + ), +) + LIST_PLUGIN_SCOPES = Operation[ListPluginScopesRequest, PluginScopePage]( method="GET", path="/v1/plugins/{name}/scopes", @@ -3945,365 +4097,6 @@ 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" - ), -) - PLAN_ACCESS_OWNERSHIP = Operation[PlanAccessOwnershipRequest, AccessOwnershipPlan]( method="POST", path="/v1/access/ownership/plan", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index f86d1237ff..61c0da29a0 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -2070,6 +2070,333 @@ }, } }, + "/v1/source-runners": { + "get": { + "tags": ["source-collection"], + "summary": "List collection runners", + "description": "Lists the Server's own runner and every " + "runner that has synced, with the Source " + "plugins each loaded, whether each plugin " + "version is compatible with the version " + "this Server loaded, and when each runner " + "last synced. A capability's description, " + "parameter schema, and identity come from " + "the Source plugin this Server loaded.", + "operationId": "pc_source_runners", + "responses": { + "200": { + "description": "Runners ordered by name.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceRunnerPage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + } + }, + "/v1/source-runners/{runner}/sync": { + "post": { + "tags": ["source-collection"], + "summary": "Sync a collection runner", + "description": "Records the runner's " + "loaded Source plugins and " + "last-seen time and " + "returns every enabled " + "target assigned to it " + "across Scopes, with each " + "target's checkpoint. Only " + "the runner itself may " + "sync: the caller's " + "identifier equals the " + "runner name, or the " + "caller is the subject " + "that first synced with " + "this name. Targets of " + "plugins whose version " + "this Server does not " + "accept are withheld and " + "listed as rejected.", + "operationId": "sync_source_runner", + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, + "parameters": [ + { + "name": "runner", + "in": "path", + "required": True, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@-]*$", + }, + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceRunnerSyncRequest"}} + }, + }, + "responses": { + "200": { + "description": "The runner's current targets.", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceRunnerSyncResult"}} + }, + }, + "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-runners/{runner}": { + "delete": { + "tags": ["source-collection"], + "summary": "Delete a collection runner record", + "description": "Deletes a runner's record once it has no targets.", + "operationId": "delete_source_runner", + "x-powercontext-access": {"action": "server.admin", "resource": {"type": "server"}}, + "parameters": [ + { + "name": "runner", + "in": "path", + "required": True, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@-]*$", + }, + } + ], + "responses": { + "204": {"description": "The runner record is deleted."}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + } + }, + "/v1/scopes/{scope_id}/source-targets": { + "post": { + "tags": ["source-collection"], + "summary": "Add a collection target", + "description": "Assigns a target of a " + "Source plugin " + "capability to a " + "runner. Parameters " + "are validated against " + "the parameter schema " + "of the plugin version " + "this Server loaded, " + "and the declared " + "identity parameters " + "form the target key. " + "A target with the " + "same Scope, runner, " + "capability, and key " + "is returned unchanged " + "when its parameters " + "match and is a " + "conflict otherwise. " + "Targets of the " + "Server's own runner, " + "named server, require " + "server.admin.", + "operationId": "pc_source_add", + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceTargetCreateRequest"}} + }, + }, + "responses": { + "201": { + "description": "The target was added.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceTarget"}}}, + }, + "200": { + "description": "The same target already exists.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceTarget"}}}, + }, + "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"}, + }, + }, + "get": { + "tags": ["source-collection"], + "summary": "List a Scope's collection targets", + "description": "Returns each target's configuration and the status its runner last reported.", + "operationId": "pc_source_status", + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.read", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + } + ], + "responses": { + "200": { + "description": "Targets ordered by ID.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceTargetPage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + }, + }, + "/v1/scopes/{scope_id}/source-targets/{target_id}": { + "patch": { + "tags": ["source-collection"], + "summary": "Change or pause a collection target", + "description": "Enables " + "or " + "disables " + "a " + "target, " + "or " + "replaces " + "its " + "parameters. " + "Identity " + "parameters " + "cannot " + "change: " + "to " + "collect " + "another " + "object, " + "add a " + "target. " + "A " + "disabled " + "target " + "keeps " + "its " + "checkpoint " + "and " + "resumes " + "from it. " + "Targets " + "of the " + "server " + "runner " + "require " + "server.admin.", + "operationId": "pc_source_target_set_enabled", + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "target_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[0-9a-f]{1,64}$"}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/SourceTargetUpdateRequest"}} + }, + }, + "responses": { + "200": { + "description": "The changed target.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceTarget"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + }, + "delete": { + "tags": ["source-collection"], + "summary": "Delete a collection target", + "description": "Deletes " + "the " + "target " + "and its " + "checkpoint. " + "Sources " + "it " + "collected " + "stay. " + "Targets " + "of the " + "server " + "runner " + "require " + "server.admin.", + "operationId": "pc_source_target_delete", + "x-powercontext-scope-mode": "none", + "x-powercontext-access": { + "action": "scope.contribute", + "resource": {"type": "scope", "scope-id-from": "scope_id"}, + }, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "target_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[0-9a-f]{1,64}$"}, + }, + ], + "responses": { + "204": {"description": "The target is deleted."}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + }, + }, + }, "/v1/plugins/{name}/scopes": { "get": { "tags": ["plugins"], @@ -6184,625 +6511,55 @@ "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, - "/v1/source-management/capabilities": { + "/v1/access/ownership/plan": { "post": { - "tags": ["source-management"], - "summary": "pc source capabilities", - "operationId": "pc_source_capabilities", + "tags": ["access"], + "summary": "Inventory ownership data missing after switching Access enforcement on", + "description": "Inspect the Scopes that were " + "written while Access was " + "disabled: missing Artifact " + "owners, pending Review " + "Candidates without an owner " + "attestation, and, when named, " + "the background Principal's " + "per-Scope write authority. " + "Produces no change. In enforced " + "mode completes as a deployment " + "administrator; in disabled mode " + "completes without authentication " + "because the Server cannot yet " + "protect anything. A background " + "Principal check requires an " + "available Access store; " + "otherwise it reports 503 " + "relationship_management_unavailable. " + "Use the offline ownership " + "command to check the proposed " + "background Principal before " + "enforcement is enabled.", + "operationId": "plan_access_ownership", "requestBody": { - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceCapabilityQuery"}}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/PlanAccessOwnershipRequest"}} + }, "required": True, }, "responses": { "200": { - "description": "Confirmed Source management result.", + "description": "The missing-attribution inventory for the requested Scopes.", "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/SourceCapabilityResponse"}} + "application/json": {"schema": {"$ref": "#/components/schemas/AccessOwnershipPlan"}} }, }, - "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"}, - }, + "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, } }, - "/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"}, - }, - } - }, - "/v1/access/ownership/plan": { - "post": { - "tags": ["access"], - "summary": "Inventory ownership data missing after switching Access enforcement on", - "description": "Inspect the Scopes that were " - "written while Access was " - "disabled: missing Artifact " - "owners, pending Review " - "Candidates without an owner " - "attestation, and, when named, " - "the background Principal's " - "per-Scope write authority. " - "Produces no change. In enforced " - "mode completes as a deployment " - "administrator; in disabled mode " - "completes without authentication " - "because the Server cannot yet " - "protect anything. A background " - "Principal check requires an " - "available Access store; " - "otherwise it reports 503 " - "relationship_management_unavailable. " - "Use the offline ownership " - "command to check the proposed " - "background Principal before " - "enforcement is enabled.", - "operationId": "plan_access_ownership", - "requestBody": { - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/PlanAccessOwnershipRequest"}} - }, - "required": True, - }, - "responses": { - "200": { - "description": "The missing-attribution inventory for the requested Scopes.", - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/AccessOwnershipPlan"}} - }, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "403": {"$ref": "#/components/responses/Forbidden"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - }, - "x-powercontext-access": {"action": "access.self", "resource": {"type": "server"}}, - } - }, - "/v1/access/ownership/apply": { + "/v1/access/ownership/apply": { "post": { "tags": ["access"], "summary": "Backfill missing Artifact owners and Candidate owner attestations", @@ -8359,70 +8116,276 @@ }, "additionalProperties": False, "type": "object", - "required": [ - "candidate_kind", - "candidate_id", - "version", - "family", - "status", - "proposal", - "source_refs", - "artifact_refs", - "target", - "reason", - "result_artifact", - "decision_reason", - ], + "required": [ + "candidate_kind", + "candidate_id", + "version", + "family", + "status", + "proposal", + "source_refs", + "artifact_refs", + "target", + "reason", + "result_artifact", + "decision_reason", + ], + }, + "CandidatePage": { + "properties": { + "candidates": {"items": {"$ref": "#/components/schemas/Candidate"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["candidates", "next_cursor"], + }, + "ApproveCandidateRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "expected_version": {"type": "integer", "minimum": 1.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "candidate_id", "expected_version"], + }, + "ArtifactDreamingOperationCapability": { + "properties": { + "operation": {"$ref": "#/components/schemas/DreamOperation"}, + "output_kind": {"type": "string", "enum": ["candidate", "tag_candidate"]}, + "effect": { + "type": "string", + "enum": [ + "review_then_publish", + "review_then_publish_configuration", + "review_then_replace_tags", + ], + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["operation", "output_kind", "effect"], + }, + "PluginKind": {"type": "string", "enum": ["artifact", "source"]}, + "PluginLoadStatus": {"type": "string", "enum": ["loaded", "failed"]}, + "PluginTrigger": {"type": "string", "enum": ["schedule", "sources"]}, + "PluginRoute": { + "properties": { + "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, + "path": {"type": "string", "description": "Route template under /v1/plugins/{name}/api."}, + "access": {"type": "string", "enum": ["authenticated", "scope.read", "scope.contribute"]}, + "mcp": {"type": "boolean", "description": "The route is also an MCP tool."}, + }, + "additionalProperties": False, + "type": "object", + "required": ["method", "path", "access", "mcp"], + }, + "PluginVersionRef": { + "properties": { + "name": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[a-z][a-z0-9-]*$"}, + "version": {"type": "string", "maxLength": 64, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["name", "version"], + "description": "The Source plugin and version a " + "runner used; the Server accepts " + "it only within the loaded " + "plugin's compatible range.", + }, + "SourceRunnerPluginReport": { + "properties": { + "name": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[a-z][a-z0-9-]*$"}, + "version": { + "type": "string", + "maxLength": 64, + "description": "Null when the plugin failed to load on the runner.", + "nullable": True, + }, + "error": { + "type": "string", + "maxLength": 1024, + "description": "Safe load failure category on the runner.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["name", "version", "error"], + }, + "SourceRunnerSyncRequest": { + "properties": { + "plugins": { + "items": {"$ref": "#/components/schemas/SourceRunnerPluginReport"}, + "type": "array", + "maxItems": 200, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["plugins"], + }, + "SourceRunnerTarget": { + "properties": { + "scope_id": {"type": "string"}, + "target_id": {"type": "string"}, + "capability": {"type": "string"}, + "parameters": {"additionalProperties": True, "type": "object"}, + "checkpoint": { + "description": "The target's last committed checkpoint; null before the first commit.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "target_id", "capability", "parameters", "checkpoint"], + }, + "SourceRunnerRejection": { + "properties": { + "name": {"type": "string"}, + "code": {"type": "string", "enum": ["plugin_version_incompatible", "plugin_not_loaded"]}, + }, + "additionalProperties": False, + "type": "object", + "required": ["name", "code"], + }, + "SourceRunnerSyncResult": { + "properties": { + "targets": {"items": {"$ref": "#/components/schemas/SourceRunnerTarget"}, "type": "array"}, + "rejected": {"items": {"$ref": "#/components/schemas/SourceRunnerRejection"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["targets", "rejected"], + }, + "SourceRunnerCapability": { + "properties": { + "name": {"type": "string"}, + "version": {"type": "string", "description": "The version the runner reported.", "nullable": True}, + "error": {"type": "string", "nullable": True}, + "compatible": { + "type": "boolean", + "description": "This Server loaded the plugin and accepts the runner's version.", + }, + "description": { + "type": "string", + "description": "From the plugin this Server loaded; null when it loaded none.", + "nullable": True, + }, + "parameters_schema": {"additionalProperties": True, "type": "object", "nullable": True}, + "identity": { + "additionalProperties": {"items": {"type": "string"}, "type": "array"}, + "type": "object", + "description": "Identity parameters and their normalization steps.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["name", "version", "error", "compatible", "description", "parameters_schema", "identity"], + }, + "SourceRunner": { + "properties": { + "runner": {"type": "string"}, + "last_seen_at": { + "type": "string", + "format": "date-time", + "description": "Null for the Server's own runner.", + "nullable": True, + }, + "capabilities": {"items": {"$ref": "#/components/schemas/SourceRunnerCapability"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["runner", "last_seen_at", "capabilities"], + }, + "SourceRunnerPage": { + "properties": {"items": {"items": {"$ref": "#/components/schemas/SourceRunner"}, "type": "array"}}, + "additionalProperties": False, + "type": "object", + "required": ["items"], + }, + "SourceTargetCreateRequest": { + "properties": { + "runner": {"type": "string", "maxLength": 128, "minLength": 1}, + "capability": {"type": "string", "maxLength": 64, "minLength": 1, "pattern": "^[a-z][a-z0-9-]*$"}, + "parameters": {"additionalProperties": True, "type": "object"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["runner", "capability", "parameters"], }, - "CandidatePage": { + "SourceTargetUpdateRequest": { "properties": { - "candidates": {"items": {"$ref": "#/components/schemas/Candidate"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, + "enabled": {"type": "boolean"}, + "parameters": {"additionalProperties": True, "type": "object"}, }, "additionalProperties": False, "type": "object", - "required": ["candidates", "next_cursor"], }, - "ApproveCandidateRequest": { + "SourceTargetStatus": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "candidate_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "expected_version": {"type": "integer", "minimum": 1.0}, + "last_attempt_at": {"type": "string", "format": "date-time", "nullable": True}, + "last_success_at": {"type": "string", "format": "date-time", "nullable": True}, + "consecutive_failures": {"type": "integer", "minimum": 0.0}, + "last_error_code": {"type": "string", "nullable": True}, + "last_error_message": {"type": "string", "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "candidate_id", "expected_version"], + "required": [ + "last_attempt_at", + "last_success_at", + "consecutive_failures", + "last_error_code", + "last_error_message", + ], }, - "ArtifactDreamingOperationCapability": { + "SourceTargetResultStatus": { "properties": { - "operation": {"$ref": "#/components/schemas/DreamOperation"}, - "output_kind": {"type": "string", "enum": ["candidate", "tag_candidate"]}, - "effect": { - "type": "string", - "enum": [ - "review_then_publish", - "review_then_publish_configuration", - "review_then_replace_tags", - ], - }, + "succeeded": {"type": "boolean"}, + "error_code": {"type": "string", "maxLength": 128, "nullable": True}, + "error_message": {"type": "string", "maxLength": 1024, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["operation", "output_kind", "effect"], + "required": ["succeeded"], + "description": "The outcome of the collection attempt whose checkpoint this commits.", }, - "PluginKind": {"type": "string", "enum": ["artifact"]}, - "PluginLoadStatus": {"type": "string", "enum": ["loaded", "failed"]}, - "PluginTrigger": {"type": "string", "enum": ["schedule", "sources"]}, - "PluginRoute": { + "SourceTarget": { "properties": { - "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, - "path": {"type": "string", "description": "Route template under /v1/plugins/{name}/api."}, - "access": {"type": "string", "enum": ["authenticated", "scope.read", "scope.contribute"]}, - "mcp": {"type": "boolean", "description": "The route is also an MCP tool."}, + "scope_id": {"type": "string"}, + "target_id": {"type": "string"}, + "runner": {"type": "string"}, + "capability": {"type": "string"}, + "target_key": {"type": "string"}, + "parameters": {"additionalProperties": True, "type": "object"}, + "enabled": {"type": "boolean"}, + "updated_at": {"type": "string", "format": "date-time"}, + "updated_by": {"type": "string"}, + "status": {"$ref": "#/components/schemas/SourceTargetStatus"}, }, "additionalProperties": False, "type": "object", - "required": ["method", "path", "access", "mcp"], + "required": [ + "scope_id", + "target_id", + "runner", + "capability", + "target_key", + "parameters", + "enabled", + "updated_at", + "updated_by", + "status", + ], + }, + "SourceTargetPage": { + "properties": {"items": {"items": {"$ref": "#/components/schemas/SourceTarget"}, "type": "array"}}, + "additionalProperties": False, + "type": "object", + "required": ["items"], }, "PluginOpenApiDocument": { "additionalProperties": True, @@ -10332,6 +10295,37 @@ "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "observation": {"$ref": "#/components/schemas/SourceObservation"}, + "plugin": { + "allOf": [{"$ref": "#/components/schemas/PluginVersionRef"}], + "description": "Set " + "by " + "collection " + "runners. " + "The " + "Server " + "checks " + "the " + "version " + "against " + "the " + "Source " + "plugin " + "it " + "loaded " + "and " + "has " + "that " + "plugin " + "resolve " + "and " + "project " + "the " + "observation; " + "unsupported " + "versions " + "return " + "plugin_version_incompatible.", + }, }, "additionalProperties": False, "type": "object", @@ -10351,6 +10345,37 @@ "binding": {"$ref": "#/components/schemas/ConnectorBinding"}, "expected": {"nullable": True}, "checkpoint": {"nullable": True}, + "plugin": { + "allOf": [{"$ref": "#/components/schemas/PluginVersionRef"}], + "description": "Set " + "by " + "collection " + "runners " + "for " + "a " + "target's " + "checkpoint; " + "checked " + "like " + "a " + "runner's " + "observations.", + }, + "status": { + "allOf": [{"$ref": "#/components/schemas/SourceTargetResultStatus"}], + "description": "The " + "collection " + "attempt's " + "outcome, " + "recorded " + "on " + "the " + "target " + "together " + "with " + "the " + "checkpoint.", + }, }, "additionalProperties": False, "type": "object", @@ -15658,322 +15683,6 @@ "type": "object", "required": ["versions"], }, - "SourceCapabilityQuery": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "capability": {"type": "string", "maxLength": 256, "minLength": 1}, - "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "input": {"additionalProperties": True, "type": "object"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id"], - }, - "SourceWritePluginLoadRequest": { - "properties": { - "id": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_.-]*$", - }, - "entry": {"type": "string", "maxLength": 4096, "minLength": 1}, - }, - "additionalProperties": False, - "type": "object", - "required": ["id", "entry"], - }, - "SourceWritePluginState": { - "properties": { - "id": {"type": "string"}, - "entry": {"type": "string"}, - "capabilities": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["id", "entry", "capabilities"], - }, - "SourceAddRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "capability": {"type": "string", "maxLength": 256, "minLength": 1}, - "capability_version": {"type": "string", "maxLength": 256, "minLength": 1}, - "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "input": {"additionalProperties": True, "type": "object"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "capability", "capability_version", "input"], - }, - "SourceStatusRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "view": {"type": "string", "enum": ["result", "control"], "default": "result"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "target_id"], - }, - "SourceInstanceSetEnabledRequest": { - "properties": { - "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "enabled": {"type": "boolean"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["instance_id", "enabled"], - }, - "SourceConfigureRequest": { - "properties": { - "update": {"type": "boolean", "default": False}, - "registration": {"additionalProperties": True, "type": "object"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["registration"], - }, - "SourceCapabilityResponse": { - "properties": { - "capabilities": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, - "management": {"type": "string", "enum": ["not-configured", "online", "unavailable"]}, - }, - "additionalProperties": False, - "type": "object", - "required": ["capabilities", "management"], - }, - "SourceInstancesResponse": { - "properties": { - "instances": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, - "capabilities": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, - "management": {"type": "string", "enum": ["not-configured", "online", "unavailable"]}, - }, - "additionalProperties": False, - "type": "object", - "required": ["instances"], - }, - "SourceCollectionResult": { - "properties": { - "outcome": {"type": "string", "enum": ["saved", "unchanged"]}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["outcome", "source_refs"], - }, - "SourceManagementReceipt": { - "properties": { - "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "tracking": {"type": "string", "enum": ["enabled", "stopping", "stopped"]}, - "result": {"$ref": "#/components/schemas/SourceCollectionResult"}, - "error": {"type": "string"}, - "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "capability": {"type": "string", "maxLength": 256, "minLength": 1}, - "capability_version": {"type": "string", "maxLength": 256, "minLength": 1}, - "availability": {"type": "string", "enum": ["online", "offline", "unavailable"]}, - "updated_at": {"type": "string"}, - "kind": {"type": "string", "enum": ["collect", "write"]}, - "enabled": {"type": "boolean"}, - "source_refs": { - "items": {"$ref": "#/components/schemas/SourceTypeReference"}, - "type": "array", - "maxItems": 1, - "description": "Default " - "collect " - "status " - "only. " - "The " - "single " - "Source " - "reference " - "from " - "the " - "submission " - "report " - "most " - "recently " - "received " - "by " - "the " - "manager, " - "or " - "empty " - "before " - "any " - "report. " - "Omitted " - "from " - "collect " - "add " - "responses; " - "not " - "a " - "batch " - "result " - "or " - "submission " - "history.", - }, - "last_submitted_at": { - "type": "string", - "description": "Default " - "collect " - "status " - "only. " - "Runtime-reported " - "submission " - "time " - "paired " - "with " - "source_refs, " - "or " - "null " - "before " - "any " - "report. " - "Selection " - "follows " - "manager " - "receipt " - "order, " - "not " - "timestamp " - "order. " - "Omitted " - "from " - "collect " - "add " - "responses.", - "nullable": True, - }, - }, - "additionalProperties": False, - "type": "object", - }, - "SourceInstanceStatus": { - "properties": { - "targets_initialized": {"type": "boolean"}, - "target_id": {"type": "string"}, - "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "enabled": {"type": "boolean"}, - "actual": {"type": "string"}, - "run_id": {"type": "string", "nullable": True}, - "availability": {"type": "string"}, - "reused": {"type": "boolean"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["instance_id"], - }, - "SourceChannelOpen": { - "properties": { - "protocol": {"type": "string", "maxLength": 256, "minLength": 1}, - "deployment_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "manager_id": {"type": "string", "maxLength": 256, "minLength": 1}, - }, - "additionalProperties": False, - "type": "object", - "required": ["protocol", "deployment_id", "manager_id"], - }, - "SourceChannelRequest": { - "properties": { - "generation": {"type": "string", "maxLength": 256, "minLength": 1}, - "message_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "response": {"additionalProperties": True, "type": "object"}, - "catalog": {"additionalProperties": True, "type": "object"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["generation"], - }, - "SourceChannelResponse": { - "properties": { - "generation": {"type": "string", "maxLength": 256, "minLength": 1}, - "protocol": {"type": "string", "maxLength": 256, "minLength": 1}, - "confirmed": {"type": "boolean"}, - "messages": {"items": {"additionalProperties": True, "type": "object"}, "type": "array"}, - }, - "additionalProperties": False, - "type": "object", - }, - "SourceInstanceExchangeRequest": { - "properties": { - "generation": {"type": "string", "maxLength": 256, "minLength": 1}, - "protocol": {"type": "string", "maxLength": 256, "minLength": 1}, - "message_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "execution_location": {"type": "string", "maxLength": 256, "minLength": 1}, - "configuration_digest": {"type": "string", "maxLength": 256, "minLength": 1}, - "run_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "execution": {"type": "string", "maxLength": 256, "minLength": 1}, - "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "capabilities": { - "items": {"additionalProperties": True, "type": "object"}, - "type": "array", - "maxItems": 64, - }, - "response": {"additionalProperties": True, "type": "object"}, - "owner": {"additionalProperties": True, "type": "object"}, - "status": { - "type": "string", - "enum": ["running", "complete", "completed", "pending", "failed", "stopped", "cleanup-failed"], - }, - "source_refs": { - "items": {"$ref": "#/components/schemas/SourceTypeReference"}, - "type": "array", - "maxItems": 1, - "minItems": 1, - "description": "One " - "confirmed " - "Source " - "submission " - "reported " - "by " - "the " - "Runtime; " - "not " - "a " - "batch " - "or " - "historical " - "reference " - "list.", - }, - "error": {"type": "string", "maxLength": 1024}, - "targets": { - "items": {"additionalProperties": True, "type": "object"}, - "type": "array", - "maxItems": 256, - }, - "cursor": {"type": "string", "maxLength": 256}, - "owner_exited": {"type": "boolean"}, - "previous_owner": {"additionalProperties": True, "type": "object"}, - "submitted_at": {"type": "string"}, - }, - "additionalProperties": False, - "type": "object", - }, - "SourceInstanceExchangeResponse": {"additionalProperties": True, "type": "object"}, - "SourceTargetSetEnabledRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "target_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "enabled": {"type": "boolean"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "target_id", "enabled"], - }, - "SourceInstancesQuery": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "capability": {"type": "string", "maxLength": 256, "minLength": 1}, - "instance_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "input": {"additionalProperties": True, "type": "object"}, - "view": {"type": "string", "enum": ["discovery", "control"], "default": "discovery"}, - }, - "additionalProperties": False, - "type": "object", - }, "PlanAccessOwnershipRequest": { "properties": { "scope_ids": { diff --git a/src/powercontext/plugins.py b/src/powercontext/plugins.py index 42c326101b..c80eb8fa6a 100644 --- a/src/powercontext/plugins.py +++ b/src/powercontext/plugins.py @@ -214,11 +214,31 @@ def has_english(cls, value: dict[str, str]) -> dict[str, str]: return value -class ArtifactPluginSpec(BaseModel): - """The data part of a declaration: what core needs before it runs plugin code.""" +class PluginSpec(BaseModel): + """Declaration fields every kind of plugin shares: its host processes, routes, and pages.""" model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + # Time limit of one user change, presentation, search, or Source write in the host process. + change_timeout_seconds: float = Field(default=30, gt=0) + # Suggested ceiling on this plugin's host processes in each Server process; deployers may override it. + host_processes: int = Field(default=1, ge=1) + # Time limit of ``upgrade``, which runs every time a version of the plugin loads. + upgrade_timeout_seconds: float = Field(default=600, gt=0) + api: PluginApiSpec | None = None + pages: PluginPages | None = None + + @model_validator(mode="after") + def pages_need_api(self) -> PluginSpec: + if self.pages is not None and self.api is None: + raise ValueError("pages call the plugin's own routes; declare api as well") # noqa: TRY003 + return self + + +class ArtifactPluginSpec(PluginSpec): + """The data part of an Artifact plugin's declaration: what core needs before it runs plugin code.""" + + kind: Literal["artifact"] = "artifact" family: str = Field(pattern=r"^[a-z][a-z0-9-]{0,127}$") inputs: Inputs | None = None schedule: Interval | Cron | None = None @@ -229,14 +249,6 @@ class ArtifactPluginSpec(BaseModel): max_workers: int = Field(default=1, ge=1) timeout_seconds: float = Field(default=300, gt=0) transaction_timeout_seconds: float = Field(default=30, gt=0) - # Time limit of one user change or plugin API call in the host process. - change_timeout_seconds: float = Field(default=30, gt=0) - # Suggested ceiling on this plugin's host processes in each Server process; deployers may override it. - host_processes: int = Field(default=1, ge=1) - # Time limit of ``upgrade``, which runs every time a version of the plugin loads. - upgrade_timeout_seconds: float = Field(default=600, gt=0) - api: PluginApiSpec | None = None - pages: PluginPages | None = None @field_validator("family") @classmethod @@ -267,8 +279,6 @@ def consistent_triggers(self) -> ArtifactPluginSpec: raise ValueError("scheduled plugins without inputs must list scopes explicitly") # noqa: TRY003 if self.transaction_timeout_seconds > self.timeout_seconds: raise ValueError("transaction_timeout_seconds must not exceed timeout_seconds") # noqa: TRY003 - if self.pages is not None and self.api is None: - raise ValueError("pages call the plugin's own routes; declare api as well") # noqa: TRY003 return self @@ -405,8 +415,11 @@ def __init__( storage: PluginStorage | None = None, models: PluginModels | None = None, host: Any = None, + center: PluginCenter | None = None, ) -> None: self.name = name + # Set only on a device runner: the plugin's own routes on the central Server. + self.center = center self.scope_id = scope_id self.plugin_dir = plugin_dir self.logger = logger @@ -738,6 +751,199 @@ class ArtifactPlugin(ArtifactPluginSpec): upgrade: Callable[[PluginContext], Awaitable[None]] | None = Field(default=None, exclude=True) +Normalizer = Annotated[str, Field(pattern=r"^(strip|lower|collapse_spaces|remove_prefix:.+|remove_suffix:.+)$")] + + +class TargetDeclaration(BaseModel): + """The data part of a collection target: its parameter schema and which parameters identify it.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + description: str = Field(min_length=1, max_length=2048) + # Parameters that form the target's unique key, each with its normalization steps. + identity: dict[str, tuple[Normalizer, ...]] = Field(min_length=1) + parameters_schema: dict[str, JsonValue] = Field(default_factory=dict) + + +class TargetSpec(TargetDeclaration): + """What a collection target takes: a parameters model, and the parameters that make a target unique.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + parameters: type[BaseModel] = Field(exclude=True) + + @model_validator(mode="after") + def describe_parameters(self) -> TargetSpec: + fields = self.parameters.model_fields + for name in self.identity: + if name not in fields or fields[name].annotation is not str: + raise ValueError(f"identity parameter {name} must be a string field of parameters") # noqa: TRY003 + # Devices may run older plugin versions; parameters from a newer center must not be rejected. + if self.parameters.model_config.get("extra") == "forbid": + raise ValueError("target parameters must ignore unknown fields") # noqa: TRY003 + object.__setattr__(self, "parameters_schema", self.parameters.model_json_schema()) + return self + + +class Collect(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False) + + schedule: Interval | Cron + # Enabled targets of one Scope given to one call; more rotate into later calls. + max_targets: int = Field(default=50, ge=1, le=1000) + max_items: int = Field(default=200, ge=1, le=10000) + timeout_seconds: float = Field(default=120, gt=0) + max_workers: int = Field(default=1, ge=1) + + +class SourcePluginSpec(PluginSpec): + """The data part of a Source plugin's declaration; the plugin name is also its collection capability.""" + + kind: Literal["source"] = "source" + version: str + # The oldest device version this center version still accepts; defaults to ``version``. + min_compatible_version: str | None = None + collect: Collect | None = None + target: TargetDeclaration | None = None + # Scopes the Server's own runner may collect into; None allows every Scope. + scopes: tuple[Annotated[str, Field(min_length=1, max_length=256)], ...] | None = Field( + default=None, min_length=1, max_length=1000 + ) + + @model_validator(mode="after") + def consistent(self) -> SourcePluginSpec: + from packaging.version import InvalidVersion, Version + + try: + version = Version(self.version) + oldest = Version(self.min_compatible_version or self.version) + except InvalidVersion: + raise ValueError("version and min_compatible_version must be PEP 440 versions") from None # noqa: TRY003 + if oldest > version: + raise ValueError("min_compatible_version must not exceed version") # noqa: TRY003 + if self.min_compatible_version is None: + object.__setattr__(self, "min_compatible_version", self.version) + if (self.collect is None) != (self.target is None): + raise ValueError("collect and target go together") # noqa: TRY003 + return self + + def accepts(self, device_version: str) -> bool: + """Whether this center version accepts a device running ``device_version``.""" + + from packaging.version import InvalidVersion, Version + + try: + device = Version(device_version) + except InvalidVersion: + return False + return Version(self.min_compatible_version or self.version) <= device <= Version(self.version) + + +class CollectTarget(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + target_id: str + parameters: dict[str, JsonValue] + # The target's last committed checkpoint; None the first time; read-only. + checkpoint: JsonValue | None = None + + +class CollectRequest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + scope_id: str + reason: Literal["schedule", "target_added"] = "schedule" + targets: tuple[CollectTarget, ...] + max_items: int + + +class CollectedItem(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True) + + # The name of one of the plugin's Source Definitions, and an instance of its input class. + definition: str + value: BaseModel + + +class TargetResult(BaseModel): + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + items: list[CollectedItem] = Field(default_factory=list) + checkpoint: JsonValue | None = None + has_more: bool = False + # Set by ``fail``: this target keeps its checkpoint and records the error; others still commit. + error_code: str | None = None + error_message: str | None = None + + +class CollectResult(BaseModel): + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + targets: dict[str, TargetResult] = Field(default_factory=dict) + + def add( + self, + target: CollectTarget, + items: Sequence[CollectedItem], + *, + checkpoint: JsonValue | None, + has_more: bool = False, + ) -> None: + self.targets[target.target_id] = TargetResult(items=list(items), checkpoint=checkpoint, has_more=has_more) + + def fail(self, target: CollectTarget, *, code: str, message: str) -> None: + """Report that only this target failed.""" + + self.targets[target.target_id] = TargetResult(error_code=code[:128], error_message=message[:1024]) + + +class SourceCollector(Protocol): + async def collect(self, request: CollectRequest, /) -> CollectResult: ... + + +class PluginResponse(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + status: int + content_type: str | None = None + json_body: JsonValue | None = None + content: bytes | None = None + + +class PluginCenter(Protocol): + """A device runner's calls to its own plugin's routes on the central Server, with the runner's credential.""" + + async def call_plugin( + self, + method: str, + path: str, + /, + *, + json: JsonValue | None = None, + content: bytes | None = None, + content_type: str | None = None, + ) -> PluginResponse: ... + + +class SourcePlugin(SourcePluginSpec): + """The declaration of a plugin that provides Source types and, with ``collect``, collects into them.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + definitions: tuple[Any, ...] = Field(min_length=1, exclude=True) + target: TargetSpec | None = None + # Returns the collector; plugin routes are methods of its result too. Required with collect or api. + setup: Callable[[PluginContext], Awaitable[Any]] | None = Field(default=None, exclude=True) + api: PluginApi | None = None + upgrade: Callable[[PluginContext], Awaitable[None]] | None = Field(default=None, exclude=True) + + @model_validator(mode="after") + def setup_when_needed(self) -> SourcePlugin: + if (self.collect is not None or self.api is not None) and self.setup is None: + raise ValueError("collect and api need setup") # noqa: TRY003 + return self + + def _path_parameters(path: str) -> tuple[str, ...]: import re @@ -761,6 +967,11 @@ def _path_parameters(path: str) -> tuple[str, ...]: "BinaryResponse", "Caller", "ChatMessage", + "Collect", + "CollectRequest", + "CollectResult", + "CollectTarget", + "CollectedItem", "CreateRequest", "Cron", "ForgetRequest", @@ -775,12 +986,15 @@ def _path_parameters(path: str) -> tuple[str, ...]: "ModelUnavailable", "PluginApi", "PluginApiSpec", + "PluginCenter", "PluginContext", "PluginModels", "PluginPages", + "PluginResponse", "PluginScope", "PluginScopes", "PluginSources", + "PluginSpec", "PluginStorage", "RenderRequest", "Rendering", @@ -790,6 +1004,12 @@ def _path_parameters(path: str) -> tuple[str, ...]: "SearchHit", "SearchRequest", "SearchResult", + "SourceCollector", + "SourcePlugin", + "SourcePluginSpec", + "TargetDeclaration", + "TargetResult", + "TargetSpec", "UndoMergePreview", "UpdateRequest", ] diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index a46ad64075..5206535588 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -235,6 +235,7 @@ StoredPayloadConflictError, ) from powercontext.builtin.persistence.skill_publications import SkillPublication +from powercontext.builtin.persistence.source_collection import SourceCollectionError from powercontext.builtin.publication import ( ArtifactPublicationApplication, ArtifactPublicationConflictError, @@ -684,6 +685,13 @@ SourceObservationReceipt, SourcePage, SourceRecord, + SourceRunnerPage, + SourceRunnerSyncRequest, + SourceRunnerSyncResult, + SourceTarget, + SourceTargetCreateRequest, + SourceTargetPage, + SourceTargetUpdateRequest, SourceType, SubmitSourceObservationRequest, TopicMemoryArtifact, @@ -810,6 +818,7 @@ CREATE_SOURCE, CREATE_SUBJECT_SOURCE, CREATE_WORK_CONTRACT, + DELETE_SOURCE_RUNNER, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, ENROLL_REMOTE_SKILL_TARGET, @@ -875,6 +884,11 @@ LIST_SOURCES, MERGE_ARTIFACTS, OPENAPI_VERSION, + PC_SOURCE_ADD, + PC_SOURCE_RUNNERS, + PC_SOURCE_STATUS, + PC_SOURCE_TARGET_DELETE, + PC_SOURCE_TARGET_SET_ENABLED, PLAN_ACCESS_OWNERSHIP, PREPARE_BOOTSTRAP_CONTEXT, PREPARE_CONTEXT, @@ -919,6 +933,7 @@ SET_DEFAULT_SCOPE, SET_SCOPE_BINDING, SUBMIT_SOURCE_OBSERVATION, + SYNC_SOURCE_RUNNER, UNPUBLISH_REMOTE_SKILL, UPDATE_SCOPE, UPDATE_SKILL_LIFECYCLE, @@ -1552,6 +1567,7 @@ async def invalid_request( @app.exception_handler(PromptError) @app.exception_handler(SourceNotEligibleError) @app.exception_handler(PluginCallError) + @app.exception_handler(SourceCollectionError) @app.exception_handler(PowerContextError) @app.exception_handler(PersistenceError) async def application_error(request: Request, error: Exception) -> JSONResponse: @@ -1598,10 +1614,6 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, MERGE_ARTIFACTS, merge_artifacts) _add_route(app, FORGET_ARTIFACT, forget_artifact) _add_route(app, RESTORE_ARTIFACT, restore_artifact) - 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) @@ -1669,6 +1681,13 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, REGISTER_SOURCE_DEFINITION, register_source_definition) _add_route(app, GET_CONNECTOR_CHECKPOINT, get_connector_checkpoint) _add_route(app, SUBMIT_SOURCE_OBSERVATION, submit_source_observation) + _add_route(app, PC_SOURCE_RUNNERS, list_source_runners) + _add_route(app, SYNC_SOURCE_RUNNER, sync_source_runner) + _add_route(app, DELETE_SOURCE_RUNNER, delete_source_runner) + _add_route(app, PC_SOURCE_ADD, create_source_target) + _add_route(app, PC_SOURCE_STATUS, list_source_targets) + _add_route(app, PC_SOURCE_TARGET_SET_ENABLED, update_source_target) + _add_route(app, PC_SOURCE_TARGET_DELETE, delete_source_target) _add_route(app, COMMIT_CONNECTOR_CHECKPOINT, commit_connector_checkpoint) _add_route(app, FLUSH_MEMORY, flush_memory) _add_route(app, REMEMBER_MEMORY, remember_memory) @@ -3779,6 +3798,12 @@ async def call_plugin_api(name: str, path: str, http_request: Request) -> Respon manifest = manager.loaded_manifest(name) if manifest is None or manifest.spec.api is None: raise BaseValueNotFoundError("plugin", name) + runner_version = http_request.headers.get("x-powercontext-plugin-version") + if runner_version is not None and (manifest.source is None or not manifest.source.accepts(runner_version)): + # A device's copy of the plugin calls its own routes here; versions follow the Source plugin range. + raise PluginCallError( + 409, "plugin_version_incompatible", "This Server does not accept the plugin version of the caller." + ) target = "/" + path routes = sorted(manifest.spec.api.routes, key=lambda route: "{" in route.path) for route in routes: @@ -4012,7 +4037,25 @@ async def get_connector_checkpoint( async def submit_source_observation( request: SubmitSourceObservationRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> SourceObservationReceipt: + if request.plugin is not None: + manifest, host = _accepted_source_plugin(http_request, request.plugin) + if not any(item["name"] == request.observation.source_type for item in manifest.source_manifests): + raise PluginCallError(422, "invalid_content", "The Source type does not belong to this plugin.") + if host is None: + raise PluginCallError(503, "plugin_unavailable", "The plugin is temporarily unavailable.") + # The Server's own plugin version resolves and projects what the runner collected. + projected = await _call_plugin_host( + http_request, + host, + "project_source", + scope_id=request.scope_id, + payload={"observation": request.observation.model_dump(mode="json")}, + timeout=manifest.spec.change_timeout_seconds, + operation=SUBMIT_SOURCE_OBSERVATION.operation_id, + ) + request = request.model_copy(update={"observation": type(request.observation).model_validate(projected)}) result = await application.ingestion.submit(mapping.submit_source_observation_request(request)) return mapping.source_observation_receipt_response(result) @@ -4020,11 +4063,331 @@ async def submit_source_observation( async def commit_connector_checkpoint( request: CommitConnectorCheckpointRequest, application: Annotated[ServerApplication, Depends(_require_application)], + http_request: Request, ) -> ConnectorCheckpointState: + target = None + if request.plugin is not None: + target = await _runner_target(http_request, request) + elif request.status is not None: + raise InvalidBaseAccessRequestError("status", "is recorded only for a runner's collection target") result = await application.ingestion.commit(mapping.commit_connector_checkpoint_request(request)) + if target is not None and request.status is not None: + from powercontext.builtin.persistence.source_collection import SourceCollectionRepository + + status = request.status + async with _require_plugin_manager(http_request).database.transaction() as connection: + await SourceCollectionRepository().record_status( + connection, + target.scope_id, + target.target_id, + target.runner, + error_code=None if status.succeeded else status.error_code or "collection_failed", + error_message=None if status.succeeded else status.error_message, + ) return mapping.connector_checkpoint_response(result) +def _caller_id() -> str: + principal = current_principal() + return "local" if principal is None else f"{principal.type}:{principal.id}" + + +def _accepted_source_plugin(http_request: Request, plugin: Any) -> tuple[Any, Any]: + """The loaded Source plugin a runner used, when this Server accepts the runner's version.""" + + found = _require_plugin_manager(http_request).source_plugin(plugin.name) + if found is None or found[0].source is None or not found[0].source.accepts(plugin.version): + raise PluginCallError( + 409, + "plugin_version_incompatible", + "This Server does not accept the plugin version; upgrade the Server first, then the runner.", + ) + return found + + +async def _runner_target(http_request: Request, request: CommitConnectorCheckpointRequest) -> Any: + """The target a runner commits a checkpoint for; only its own runner may commit it.""" + + from powercontext.builtin.persistence.source_collection import TARGET_CHECKPOINT_VERSION, SourceCollectionRepository + + plugin = request.plugin + assert plugin is not None # noqa: S101 - checked by the caller + _accepted_source_plugin(http_request, plugin) + binding = request.binding + if binding.connector_name != plugin.name or binding.connector_version != TARGET_CHECKPOINT_VERSION: + raise InvalidBaseAccessRequestError("binding", "must name the plugin and the target checkpoint version") + repository = SourceCollectionRepository() + async with _require_plugin_manager(http_request).database.transaction() as connection: + target = await repository.target(connection, binding.scope_id, binding.binding_id) + if target is None or target.capability != plugin.name: + raise BaseValueNotFoundError("source_target", binding.binding_id) + await _require_runner(connection, repository, target.runner) + return target + + +async def _require_runner(connection: Any, repository: Any, runner: str) -> None: + """The caller is the runner: its identifier is the runner name, or it is the subject bound to that name.""" + + from powercontext.builtin.persistence.source_collection import SourceCollectionError + + principal = current_principal() + if principal is None or principal.id == runner: + return + record = await repository.runner(connection, runner) + if record is None or record.subject != _caller_id(): + raise SourceCollectionError(403, "access_denied", "A runner can sync and report only its own targets.") + + +async def _require_server_admin(http_request: Request, operation: str) -> None: + access = access_control_for_mode(http_request.app.state.access_control, mode=http_request.app.state.access_mode) + if access is None: + return + await access.require( + _require_principal(), + AccessAction.SERVER_ADMIN, + ResourceRef.server(access.deployment_id), + context=_access_audit_context(operation), + ) + + +def _capability(manager: Any, name: str, version: str | None, error: str | None) -> dict[str, Any]: + found = manager.source_plugin(name) + declared = None if found is None else found[0].source + target = None if declared is None else declared.target + return { + "name": name, + "version": version, + "error": error, + "compatible": declared is not None and error is None and version is not None and declared.accepts(version), + "description": None if target is None else target.description, + "parameters_schema": None if target is None else target.parameters_schema, + "identity": None if target is None else {key: list(steps) for key, steps in target.identity.items()}, + } + + +async def list_source_runners(http_request: Request) -> SourceRunnerPage: + from powercontext.builtin.persistence.source_collection import SERVER_RUNNER, SourceCollectionRepository + + manager = _require_plugin_manager(http_request) + async with manager.database.transaction() as connection: + runners = await SourceCollectionRepository().runners(connection) + collecting = [ + (name, declared.version) + for name in sorted(manager.plugins) + if (manifest := manager.loaded_manifest(name)) is not None + and (declared := manifest.source) is not None + and declared.collect is not None + ] + items: list[dict[str, Any]] = [ + { + "runner": SERVER_RUNNER, + "last_seen_at": None, + "capabilities": [_capability(manager, name, version, None) for name, version in collecting], + } + ] + for runner in runners: + if runner.runner == SERVER_RUNNER: + continue + capabilities = [] + for item in runner.capabilities: + version, error = item.get("version"), item.get("error") + capabilities.append( + _capability( + manager, + str(item["name"]), + None if version is None else str(version), + None if error is None else str(error), + ) + ) + items.append({"runner": runner.runner, "last_seen_at": runner.last_seen_at, "capabilities": capabilities}) + return SourceRunnerPage.model_validate({"items": items}) + + +async def sync_source_runner( + runner: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@-]*$")], + request: SourceRunnerSyncRequest, + http_request: Request, +) -> SourceRunnerSyncResult: + from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository + from powercontext.builtin.persistence.source_collection import ( + SERVER_RUNNER, + TARGET_CHECKPOINT_VERSION, + SourceCollectionError, + SourceCollectionRepository, + ) + from powercontext.sources import ConnectorBinding + + if runner == SERVER_RUNNER: + raise SourceCollectionError(403, "access_denied", "The name server is reserved for the Server's own runner.") + manager = _require_plugin_manager(http_request) + accepted = [] + rejected = [] + for plugin in request.plugins: + found = manager.source_plugin(plugin.name) + declared = None if found is None else found[0].source + if declared is None or declared.collect is None: + rejected.append({"name": plugin.name, "code": "plugin_not_loaded"}) + elif plugin.error is None and plugin.version is not None and declared.accepts(plugin.version): + accepted.append(plugin.name) + elif plugin.error is None: + rejected.append({"name": plugin.name, "code": "plugin_version_incompatible"}) + repository = SourceCollectionRepository() + checkpoints = ConnectorCheckpointRepository() + async with manager.database.transaction() as connection: + if await repository.runner(connection, runner) is not None: + await _require_runner(connection, repository, runner) + await repository.record_sync( + connection, runner, _caller_id(), [plugin.model_dump(mode="json") for plugin in request.plugins] + ) + targets = [] + for target in await repository.assigned(connection, runner, accepted): + checkpoint = await checkpoints.load( + connection, + ConnectorBinding( + scope_id=target.scope_id, + binding_id=target.target_id, + connector_name=target.capability, + connector_version=TARGET_CHECKPOINT_VERSION, + ), + ) + targets.append({ + "scope_id": target.scope_id, + "target_id": target.target_id, + "capability": target.capability, + "parameters": target.parameters, + "checkpoint": checkpoint, + }) + return SourceRunnerSyncResult.model_validate({"targets": targets, "rejected": rejected}) + + +async def delete_source_runner( + runner: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@-]*$")], + http_request: Request, +) -> Response: + from powercontext.builtin.persistence.source_collection import SourceCollectionRepository + + async with _require_plugin_manager(http_request).database.transaction() as connection: + await SourceCollectionRepository().delete_runner(connection, runner) + return Response(status_code=204) + + +def _source_target_response(target: Any) -> SourceTarget: + return SourceTarget.model_validate({ + "scope_id": target.scope_id, + "target_id": target.target_id, + "runner": target.runner, + "capability": target.capability, + "target_key": target.target_key, + "parameters": target.parameters, + "enabled": target.enabled, + "updated_at": target.updated_at, + "updated_by": target.updated_by, + "status": vars(target.status), + }) + + +def _target_key(manager: Any, capability: str, parameters: dict[str, Any]) -> str: + from powercontext.sources.collection import InvalidTargetParameters, target_key, validate_parameters + + found = manager.source_plugin(capability) + declared = None if found is None else found[0].source + if declared is None or declared.target is None: + raise InvalidBaseAccessRequestError("capability", "must be a loaded Source plugin that collects") + try: + validate_parameters(declared.target.parameters_schema, parameters) + return target_key(capability, parameters, declared.target.identity) + except InvalidTargetParameters as error: + raise PluginCallError(422, "invalid_parameters", str(error)) from None + + +async def create_source_target( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + request: SourceTargetCreateRequest, + http_request: Request, + response: Response, +) -> SourceTarget: + from powercontext.builtin.persistence.source_collection import SERVER_RUNNER, SourceCollectionRepository + + if request.runner == SERVER_RUNNER: + # The Server's runner collects with the Server's credentials: choosing what it collects is an operator decision. + await _require_server_admin(http_request, PC_SOURCE_ADD.operation_id) + manager = _require_plugin_manager(http_request) + key = _target_key(manager, request.capability, request.parameters) + async with manager.database.transaction() as connection: + target, created = await SourceCollectionRepository().add_target( + connection, + scope_id=scope_id, + runner=request.runner, + capability=request.capability, + target_key=key, + parameters=request.parameters, + actor=_caller_id(), + ) + if not created: + response.status_code = 200 + elif request.runner == SERVER_RUNNER: + await manager.request_collection(request.capability, scope_id) + return _source_target_response(target) + + +async def list_source_targets( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], http_request: Request +) -> SourceTargetPage: + from powercontext.builtin.persistence.source_collection import SourceCollectionRepository + + async with _require_plugin_manager(http_request).database.transaction() as connection: + targets = await SourceCollectionRepository().targets(connection, scope_id) + return SourceTargetPage(items=[_source_target_response(target) for target in targets]) + + +async def _managed_target(http_request: Request, scope_id: str, target_id: str, operation: str) -> Any: + from powercontext.builtin.persistence.source_collection import SERVER_RUNNER, SourceCollectionRepository + + async with _require_plugin_manager(http_request).database.transaction() as connection: + target = await SourceCollectionRepository().target(connection, scope_id, target_id) + if target is None: + raise BaseValueNotFoundError("source_target", target_id) + if target.runner == SERVER_RUNNER: + await _require_server_admin(http_request, operation) + return target + + +async def update_source_target( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + target_id: Annotated[str, Path(min_length=1, max_length=64, pattern=r"^[0-9a-f]{1,64}$")], + request: SourceTargetUpdateRequest, + http_request: Request, +) -> SourceTarget: + from powercontext.builtin.persistence.source_collection import SourceCollectionRepository + + target = await _managed_target(http_request, scope_id, target_id, PC_SOURCE_TARGET_SET_ENABLED.operation_id) + manager = _require_plugin_manager(http_request) + if ( + request.parameters is not None + and _target_key(manager, target.capability, request.parameters) != target.target_key + ): + # A checkpoint always belongs to one external object; collect another one with a new target. + raise PluginCallError(422, "identity_immutable", "Identity parameters of a target cannot change.") + async with manager.database.transaction() as connection: + updated = await SourceCollectionRepository().update_target( + connection, target, parameters=request.parameters, enabled=request.enabled, actor=_caller_id() + ) + return _source_target_response(updated) + + +async def delete_source_target( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + target_id: Annotated[str, Path(min_length=1, max_length=64, pattern=r"^[0-9a-f]{1,64}$")], + http_request: Request, +) -> Response: + from powercontext.builtin.persistence.source_collection import SourceCollectionRepository + + target = await _managed_target(http_request, scope_id, target_id, PC_SOURCE_TARGET_DELETE.operation_id) + async with _require_plugin_manager(http_request).database.transaction() as connection: + await SourceCollectionRepository().delete_target(connection, target) + return Response(status_code=204) + + async def flush_memory( request: FlushMemoryRequest, application: Annotated[ServerApplication, Depends(_require_application)], @@ -6791,8 +7154,12 @@ def _set_error_headers(response: Response, error: Exception) -> None: def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: + from powercontext.builtin.persistence.source_collection import SourceCollectionError + if isinstance(error, PluginCallError): return error.status, error.code, error.message, error.details + if isinstance(error, SourceCollectionError): + return error.status, error.code, error.message, None if isinstance(error, DreamError) and error.code == "client_upgrade_required": return 426, error.code, "Upgrade to a client supporting Dream contract 2.", {"required_dream_contract": 2} if isinstance(error, ArtifactSearchFamilyNotFound): diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 288db8b3bd..9aaa3d4bca 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -187,9 +187,6 @@ def create_server_app( # noqa: C901 authentication_provider=authentication_provider, ) ) - from powercontext.source_management.deployment import prepare as prepare_source_deployment - - configured_authentication, _ = prepare_source_deployment(resolved, configured_authentication) config = BuiltinConfig( code=resolved.code, runtime=resolved.runtime, @@ -214,7 +211,7 @@ def create_server_app( # noqa: C901 readiness_probe = _ServerReadinessProbe(metrics, tracing=resolved_tracing) @asynccontextmanager - async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: C901 - shared Server and plugin resource lifecycle + async def lifespan(app: FastAPI) -> AsyncIterator[None]: _log_lifecycle("server.starting", "PowerContext Server is starting") if resolved.allow_insecure_http: _log_insecure_remote_http_warning() @@ -389,9 +386,6 @@ def bind_supervisor(supervisors) -> None: ) await _remove_legacy_topic_owners(active_access_control) readiness_probe.bind(runtime) - gateway = await _source_gateway(runtime, resolved) - await resources.enter_async_context(gateway) - app.state.source_gateway = gateway app.state.plugin_api_max_body_bytes = resolved.plugin_api_max_body_bytes app.state.application = runtime app.state.dashboard_runtime = runtime @@ -414,24 +408,15 @@ def current_capabilities() -> Capabilities: app.state.capability_provider = current_capabilities app.state.server_info = server_info(server_id) await readiness_probe() - deployment_task = None - if resolved.source_plugins_dir is not None or resolved.source_management.autostart: - from powercontext.source_management.deployment import DeploymentCoordinator - - deployment_task = asyncio.create_task(DeploymentCoordinator(resolved).startup()) if metrics is not None: await resources.enter_async_context(readiness_probe.monitor()) try: yield finally: - if deployment_task is not None: - deployment_task.cancel() - await asyncio.gather(deployment_task, return_exceptions=True) _log_lifecycle("server.stopping", "PowerContext Server is stopping") readiness_probe.unbind() app.state.plugin_manager = None app.state.application = None - app.state.source_gateway = None app.state.dashboard_runtime = None app.state.capability_provider = None app.state.access_control = configured_access_control @@ -540,18 +525,6 @@ async def _initialize_server_identity(runtime: BuiltinRuntime, cursor_secret: by return server_id -async def _source_gateway(runtime: BuiltinRuntime, settings: ServerSettings): - from powercontext.source_management.gateway import SourceGateway - from powercontext.source_management.write import SourceWritePlugins - - records = runtime._records() - write_plugins = None - if isinstance(records, RelationalRecordService): - write_plugins = SourceWritePlugins(records.database, settings.workspace) - await write_plugins.restore() - return SourceGateway(settings, write_plugins) - - def _state_access_checks(app: FastAPI) -> Callable[[], Awaitable[Mapping[str, str]]]: async def checks() -> dict[str, str]: return await access_readiness_checks(app.state) diff --git a/src/powercontext/server/mcp.py b/src/powercontext/server/mcp.py index d947ab9470..274febfa72 100644 --- a/src/powercontext/server/mcp.py +++ b/src/powercontext/server/mcp.py @@ -161,12 +161,11 @@ 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_runners", "pc_source_add", "pc_source_status", "pc_source_target_set_enabled", - "pc_source_instances", - "pc_source_instance_set_enabled", + "pc_source_target_delete", "get_atomic_memory_state", "list_atomic_memories", "get_recall_manifest", @@ -226,9 +225,8 @@ PUBLISH_ARTIFACT.operation_id, }) _MCP_READ_ONLY_OPERATION_IDS = frozenset({ - "pc_source_capabilities", + "pc_source_runners", "pc_source_status", - "pc_source_instances", "get_atomic_memory_state", "list_atomic_memories", "get_recall_manifest", diff --git a/src/powercontext/server/plugins.py b/src/powercontext/server/plugins.py index 0fb064458c..07847b2866 100644 --- a/src/powercontext/server/plugins.py +++ b/src/powercontext/server/plugins.py @@ -49,6 +49,7 @@ from powercontext.builtin.persistence.processing_intents import ArtifactProcessingIntentRepository from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.tables import ARTIFACT_PROCESSING_INTENTS_TABLE +from powercontext.builtin.plugins.collector import collection_family, run_source_collect_worker from powercontext.builtin.plugins.executor import PluginWorkerSpec, run_plugin_worker from powercontext.builtin.plugins.families import ( register_plugin_family, @@ -139,22 +140,27 @@ def code_directory(self) -> Path: def state(self) -> PluginState: manifest = self.manifest spec = None if manifest is None else manifest.spec + artifact = None if manifest is None else manifest.artifact + source = None if manifest is None else manifest.source triggers = [] - if spec is not None: + if artifact is not None: triggers = [ name - for name, enabled in (("schedule", spec.schedule is not None), ("sources", spec.on_sources)) + for name, enabled in (("schedule", artifact.schedule is not None), ("sources", artifact.on_sources)) if enabled ] + elif source is not None and source.collect is not None: + triggers = ["schedule"] + scopes = artifact.scopes if artifact is not None else None if source is None else source.scopes return PluginState.model_validate({ "name": self.name, - "kind": "artifact", + "kind": "source" if source is not None else "artifact", "status": "failed" if self.failure is not None else "loaded", "error": self.failure, - "family": None if spec is None else spec.family, - "binding_name": None if manifest is None else manifest.binding_name, - "scopes": None if spec is None or spec.scopes is None else list(spec.scopes), - "source_types": None if spec is None or spec.inputs is None else list(spec.inputs.source_types), + "family": None if artifact is None else artifact.family, + "binding_name": None if manifest is None or not _binds(manifest) else manifest.binding_name, + "scopes": None if scopes is None else list(scopes), + "source_types": None if artifact is None or artifact.inputs is None else list(artifact.inputs.source_types), "triggers": triggers, "version_digest": None if manifest is None else manifest.fingerprint, "declaration_digest": None if manifest is None else manifest.declaration_digest, @@ -202,10 +208,29 @@ def _binding_fields(manifest: PluginManifest) -> dict[str, Any]: """The declaration fields that decide the plugin's Supervisor binding and schedule.""" return manifest.spec.model_dump( - include={"family", "inputs", "schedule", "on_sources", "scopes", "max_workers", "timeout_seconds"} + include={"family", "inputs", "schedule", "on_sources", "scopes", "max_workers", "timeout_seconds", "collect"} ) +def _binds(manifest: PluginManifest) -> bool: + """Whether the plugin has background work: generation, or collection by the Server's own runner.""" + + if manifest.artifact is not None: + return manifest.artifact.schedule is not None or manifest.artifact.on_sources + return manifest.source is not None and manifest.source.collect is not None + + +def _family(manifest: PluginManifest) -> str | None: + return None if manifest.artifact is None else manifest.artifact.family + + +def _worker_seconds(manifest: PluginManifest) -> float: + if manifest.artifact is not None: + return manifest.artifact.timeout_seconds + collect = None if manifest.source is None else manifest.source.collect + return 0.0 if collect is None else collect.timeout_seconds + + class _VersionLauncher: """Launch each Worker from the plugin version current at dispatch. @@ -222,7 +247,7 @@ async def start(self, assignment: Any) -> Any: manifest = plugin.manifest if manifest is None: raise RuntimeError("the plugin is no longer loaded") # noqa: TRY003 - self._manager.retain(plugin.snapshot, manifest.spec.timeout_seconds) + self._manager.retain(plugin.snapshot, _worker_seconds(manifest)) spec = PluginWorkerSpec( database=self._manager.config, directory=str(plugin.code_directory), @@ -231,7 +256,8 @@ async def start(self, assignment: Any) -> Any: inference=self._manager.inference_config, storage_root=self._manager.storage_root, ) - return await SpawnArtifactProcessingWorkerLauncher(partial(run_plugin_worker, spec)).start(assignment) + entrypoint = run_plugin_worker if manifest.artifact is not None else run_source_collect_worker + return await SpawnArtifactProcessingWorkerLauncher(partial(entrypoint, spec)).start(assignment) class PluginManager: @@ -401,10 +427,19 @@ async def _activate(self, plugin: _Plugin, digest: str) -> None: if any( other is not plugin and other.manifest is not None - and other.manifest.spec.family == manifest.spec.family + and _family(manifest) is not None + and _family(other.manifest) == _family(manifest) for other in self.plugins.values() ): raise ValueError("family is already provided by another plugin") # noqa: TRY003, TRY301 + types = {item["name"] for item in manifest.source_manifests} + if any( + other is not plugin + and other.manifest is not None + and types & {item["name"] for item in other.manifest.source_manifests} + for other in self.plugins.values() + ): + raise ValueError("a Source type is already provided by another plugin") # noqa: TRY003, TRY301 except Exception as error: self._check_failed(plugin, digest, str(error) if isinstance(error, ValueError) else type(error).__name__) return @@ -506,7 +541,7 @@ async def _update(self, plugin: _Plugin, manifest: PluginManifest, snapshot: Pat ) return old_snapshot, old_host = plugin.snapshot, plugin.host - family_changed = old.spec.family != manifest.spec.family + family_changed = _family(old) != _family(manifest) rebind = family_changed or _binding_fields(old) != _binding_fields(manifest) try: if rebind: @@ -525,7 +560,7 @@ async def _update(self, plugin: _Plugin, manifest: PluginManifest, snapshot: Pat self._spawn(self._retire(old_host, old_snapshot)) return if old_snapshot is not None: - self.retain(old_snapshot, old.spec.timeout_seconds) + self.retain(old_snapshot, _worker_seconds(old)) if old_host is not None: self._spawn(self._retire(old_host, old_snapshot)) plugin.activated_at = datetime.now(UTC) @@ -554,7 +589,7 @@ async def _remove(self, plugin: _Plugin) -> None: plugin.manifest = plugin.host = None self._sync_registry() if snapshot is not None: - self.retain(snapshot, manifest.spec.timeout_seconds) + self.retain(snapshot, _worker_seconds(manifest)) if host is not None: self._spawn(self._retire(host, snapshot)) self._event(plugin, "removed") @@ -576,12 +611,16 @@ def _spawn(self, coroutine: Any) -> None: task.add_done_callback(self._background.discard) def _registry(self) -> tuple[tuple[str, ...], tuple[tuple[str, tuple[str, ...] | None, tuple[str, ...]], ...]]: - loaded = [plugin.manifest for plugin in self.plugins.values() if plugin.manifest is not None] - families = tuple(sorted(manifest.spec.family for manifest in loaded)) + loaded = [ + (plugin.manifest, plugin.manifest.artifact) + for plugin in self.plugins.values() + if plugin.manifest is not None and plugin.manifest.artifact is not None + ] + families = tuple(sorted(artifact.family for _, artifact in loaded)) bindings = tuple( - (manifest.binding_name, manifest.spec.scopes, manifest.spec.inputs.source_types) - for manifest in loaded - if manifest.spec.inputs is not None + (manifest.binding_name, artifact.scopes, artifact.inputs.source_types) + for manifest, artifact in loaded + if artifact.inputs is not None ) return families, bindings @@ -595,8 +634,9 @@ def _sync_registry(self) -> None: def _host_spec(self, manifest: PluginManifest, directory: Path) -> PluginHostSpec: families, bindings = self._registry() - if manifest.spec.family not in families: - families = tuple(sorted((*families, manifest.spec.family))) + family = _family(manifest) + if family is not None and family not in families: + families = tuple(sorted((*families, family))) return PluginHostSpec( database=self.config, directory=str(directory), @@ -627,8 +667,10 @@ async def _register(self, plugin: _Plugin) -> None: manifest = plugin.manifest if manifest is None: raise ValueError("plugin manifest is unavailable") # noqa: TRY003 - generate = manifest.spec await self._register_source_definitions(manifest) + generate = manifest.artifact + if generate is None: + return register_plugin_family(generate.family) if self.register_merge is not None: if self.unregister_merge is not None: @@ -648,17 +690,23 @@ async def _register(self, plugin: _Plugin) -> None: async def _unregister(self, plugin: _Plugin, manifest: PluginManifest) -> None: # Source Definition manifests stay registered: stored Sources refer to them. - unregister_plugin_family(manifest.spec.family) + family = _family(manifest) + if family is None: + return + unregister_plugin_family(family) if self.unregister_merge is not None: - self.unregister_merge(manifest.spec.family) + self.unregister_merge(family) unregister_source_binding(manifest.binding_name) async def _bind(self, plugin: _Plugin) -> None: - """Register the plugin's background generation with the Supervisor.""" + """Register the plugin's background generation or collection with the Supervisor.""" manifest = plugin.manifest assert manifest is not None # noqa: S101 - only loaded plugins bind - generate = manifest.spec + generate = manifest.artifact + if generate is None: + await self._bind_collection(plugin, manifest) + return scopes = None if generate.scopes is None else frozenset(generate.scopes) inputs = generate.inputs interval = None @@ -704,6 +752,75 @@ async def in_scope(connection, candidates): asyncio.create_task(self._tick(manifest, generate.schedule.seconds), name=f"plugin-{plugin.name}") ) + async def _bind_collection(self, plugin: _Plugin, manifest: PluginManifest) -> None: + """The Server's own runner collects this plugin's targets in Supervisor Workers.""" + + declared = manifest.source + if not self.dispatch or declared is None or declared.collect is None: + return + collect = declared.collect + allowed = None if declared.scopes is None else frozenset(declared.scopes) + + async def in_scope(connection, candidates): + return frozenset(candidates if allowed is None else set(candidates) & allowed) + + supervisor = await self._ensure_supervisor() + await supervisor.replace_binding( + manifest.binding_name, + ArtifactProcessingBinding( + binding_name=manifest.binding_name, + artifact_family=collection_family(manifest.name), + launcher=_VersionLauncher(self, plugin.name), + max_workers=collect.max_workers, + worker_timeout_seconds=collect.timeout_seconds, + automatic_scope_filter=in_scope, + invocation_scope_filter=in_scope, + ), + ) + plugin.tasks.append(asyncio.create_task(self._collect_due(manifest), name=f"plugin-{plugin.name}")) + + async def _collect_due(self, manifest: PluginManifest) -> None: + from zoneinfo import ZoneInfo + + from apscheduler.triggers.cron import CronTrigger + + declared = manifest.source + assert declared is not None and declared.collect is not None # noqa: S101 - collecting plugins only + schedule = declared.collect.schedule + while True: + if isinstance(schedule, Interval): + delay = schedule.seconds + else: + zone = ZoneInfo(schedule.timezone or tzlocal.get_localzone_name()) + now = datetime.now(UTC) + upcoming = CronTrigger.from_crontab(schedule.expression, timezone=zone).get_next_fire_time(None, now) + delay = 60.0 if upcoming is None else max(0.0, (upcoming - now).total_seconds()) + await asyncio.sleep(delay) + await self.request_collection(manifest.name) + + async def request_collection(self, name: str, scope_id: str | None = None) -> None: + """Ask the Server's own runner to collect a plugin's targets now, in one Scope or wherever it has targets.""" + + from powercontext.builtin.persistence.source_collection import SERVER_RUNNER, SourceCollectionRepository + + manifest = self.loaded_manifest(name) + if manifest is None or manifest.source is None or manifest.source.collect is None or not self.dispatch: + return + intents = ArtifactProcessingIntentRepository() + try: + async with self.database.transaction() as connection: + scopes = ( + (scope_id,) + if scope_id is not None + else await SourceCollectionRepository().scopes_with_targets(connection, SERVER_RUNNER, name) + ) + for scope in scopes: + await intents.request(connection, scope, manifest.binding_name) + if self.supervisor is not None: + self.supervisor.wake(manifest.binding_name) + except Exception as error: + log_safely(logger, logging.WARNING, f"Plugin collection request failed ({type(error).__name__})") + async def _unbind(self, plugin: _Plugin) -> None: for task in plugin.tasks: task.cancel() @@ -780,7 +897,7 @@ async def scopes(self, name: str, *, cursor: str | None, limit: int) -> PluginSc if cursor is not None: statement = statement.where(intents.c.scope_id > cursor) sources = SourceRepository(BUILTIN_SOURCE_REGISTRY) - consumes = manifest.spec.inputs is not None + consumes = manifest.artifact is not None and manifest.artifact.inputs is not None items = [] async with self.database.transaction() as connection: rows = (await connection.execute(statement)).mappings().all() @@ -806,14 +923,14 @@ def families(self) -> frozenset[str]: """The families of the plugins this manager loaded.""" return frozenset( - plugin.manifest.spec.family + family for plugin in self.plugins.values() - if plugin.error is None and plugin.manifest is not None + if plugin.error is None and plugin.manifest is not None and (family := _family(plugin.manifest)) is not None ) def plugin_for_family(self, family: str) -> PluginManifest | None: for plugin in self.plugins.values(): - if plugin.error is None and plugin.manifest is not None and plugin.manifest.spec.family == family: + if plugin.error is None and plugin.manifest is not None and _family(plugin.manifest) == family: return plugin.manifest return None @@ -839,10 +956,18 @@ def host_for_source_type(self, source_type: str) -> tuple[PluginManifest, Plugin return manifest, plugin.host return None + def source_plugin(self, name: str) -> tuple[PluginManifest, PluginHost | None] | None: + """A loaded Source plugin and its host process, by plugin name.""" + + plugin = self.plugins.get(name) + if plugin is None or plugin.error is not None or plugin.manifest is None or plugin.manifest.source is None: + return None + return plugin.manifest, plugin.host + def host_for_family(self, family: str) -> tuple[PluginManifest, PluginHost] | None: for plugin in self.plugins.values(): manifest = plugin.manifest - if plugin.error is None and manifest is not None and manifest.spec.family == family: + if plugin.error is None and manifest is not None and _family(manifest) == family: return (manifest, plugin.host) if plugin.host is not None else None return None diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 341afbfd55..502d9c3f1d 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -167,22 +167,6 @@ def require_token_when_enabled(self) -> BearerAuthConfig: return self -class SourceManagementConfig(BaseModel): - """One trusted manager and optional local background deployment.""" - - process_mode: Literal["background", "local-service"] = Field( - default="background", - description="Local collect deployment requires Linux; local-service also requires systemd --user.", - ) - manager_subject: str | None = None - manager_id: str = "sources" - autostart: bool = False - state_dir: Path | None = None - request_seconds: float = Field(default=10, gt=0, le=60) - lease_seconds: float = Field(default=30, ge=3, le=300) - write_wait_timeout_seconds: float = Field(default=300, ge=1, le=300) - - class DashboardConfig(BaseModel): """Optional personal and demonstration UI sharing the Server access mode.""" @@ -255,7 +239,6 @@ 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 @@ -275,7 +258,6 @@ class ServerSettings(BaseSettings): plugin_host_processes: dict[str, Annotated[int, Field(ge=1)]] = Field(default_factory=dict) # Ceiling on host processes of all plugins; each plugin's first process starts regardless. plugin_total_host_processes: int | None = Field(default=None, ge=1) - 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) @@ -400,8 +382,6 @@ def reject_unauthenticated_non_loopback_bind(self) -> ServerSettings: # noqa: C raise ValueError("Configure either the identity map or the legacy token") # noqa: TRY003 if self.access.mode != "enforced": raise ValueError("An identity map requires enforced Access Mode") # noqa: TRY003 - if self.source_management.manager_subject is not None and self.access.mode != "enforced": - raise ValueError("Source management requires authenticated enforced Access Mode") # noqa: TRY003 if ( self.dashboard.enabled and self.access.mode == "enforced" diff --git a/src/powercontext/source_agent.py b/src/powercontext/source_agent.py new file mode 100644 index 0000000000..bd13c2d422 --- /dev/null +++ b/src/powercontext/source_agent.py @@ -0,0 +1,356 @@ +# 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 device collection runner: sync targets from the central Server, collect, and submit. + +The runner only connects out to the central Server. Each sync reports the Source +plugins it loaded and fetches every target assigned to it. Each plugin collects on its +own schedule, and new targets right away. For each target the runner submits its +Sources first and then commits the new checkpoint against the old one, together with +the target's status; a failure anywhere leaves the checkpoint where it was, so the next +collection repeats the same Sources, which the Server deduplicates. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +from collections import defaultdict +from contextlib import AsyncExitStack +from datetime import UTC, datetime +from pathlib import Path +from typing import Annotated, Any, Literal + +import httpx +import typer +from pydantic import JsonValue + +from powercontext.builtin.persistence.source_collection import TARGET_CHECKPOINT_VERSION +from powercontext.plugins import ( + Collect, + CollectRequest, + CollectResult, + CollectTarget, + Cron, + PluginContext, + PluginResponse, + SourcePlugin, +) + +logger = logging.getLogger("powercontext.source_agent") + + +class _Center: + """``ctx.center``: the plugin's own routes on the central Server, called with the runner's credential.""" + + def __init__(self, client: httpx.AsyncClient, plugin: str, version: str) -> None: + self._client = client + self._plugin = plugin + self._version = version + + async def call_plugin( + self, + method: str, + path: str, + /, + *, + json: JsonValue | None = None, + content: bytes | None = None, + content_type: str | None = None, + ) -> PluginResponse: + headers = {"X-PowerContext-Plugin-Version": self._version} + if content_type is not None: + headers["Content-Type"] = content_type + response = await self._client.request( + method, f"/v1/plugins/{self._plugin}/api{path}", json=json, content=content, headers=headers + ) + kind = response.headers.get("content-type", "") + is_json = kind.startswith("application/json") and bool(response.content) + return PluginResponse( + status=response.status_code, + content_type=kind or None, + json_body=response.json() if is_json else None, + content=None if is_json else response.content, + ) + + +class _Plugin: + def __init__(self, name: str, directory: Path, declaration: SourcePlugin) -> None: + from powercontext.sources.definitions import SourceDefinitionRegistry + + self.name = name + self.directory = directory + self.declaration = declaration + self.registry = SourceDefinitionRegistry(declaration.definitions) + self.collector: Any = None + + @property + def collect(self) -> Collect: + assert self.declaration.collect is not None # noqa: S101 - only collecting plugins load + return self.declaration.collect + + +def load_source_plugins(directory: Path) -> tuple[dict[str, _Plugin], list[dict[str, JsonValue]]]: + """Load every collecting Source plugin; the report lists each with its version or load error.""" + + from powercontext.builtin.plugins.manifest import load_plugin + + plugins = {} + report: list[dict[str, JsonValue]] = [] + for entry in sorted(path for path in directory.iterdir() if (path / "plugin.py").is_file()): + try: + _, declaration = load_plugin(entry) + except Exception as error: + message = str(error) if isinstance(error, ValueError) else type(error).__name__ + report.append({"name": entry.name, "version": None, "error": message[:1024]}) + continue + if isinstance(declaration, SourcePlugin) and declaration.collect is not None: + plugins[entry.name] = _Plugin(entry.name, entry.resolve(), declaration) + report.append({"name": entry.name, "version": declaration.version, "error": None}) + return plugins, report + + +def _checked(result: object, request: CollectRequest) -> CollectResult: + """The collection contract, enforced as on the Server: no unknown targets and no more than max_items.""" + + if not isinstance(result, CollectResult): + raise TypeError("collect must return a CollectResult") # noqa: TRY003 + if set(result.targets) - {item.target_id for item in request.targets}: + raise ValueError("collect returned a target that was not requested") # noqa: TRY003 + if sum(len(outcome.items) for outcome in result.targets.values()) > request.max_items: + raise ValueError("collect returned more items than max_items") # noqa: TRY003 + return result + + +def _next_delay(collect: Collect, now: datetime) -> float: + schedule = collect.schedule + if not isinstance(schedule, Cron): + return schedule.seconds + from zoneinfo import ZoneInfo + + import tzlocal + from apscheduler.triggers.cron import CronTrigger + + zone = ZoneInfo(schedule.timezone or tzlocal.get_localzone_name()) + upcoming = CronTrigger.from_crontab(schedule.expression, timezone=zone).get_next_fire_time(None, now) + return 60.0 if upcoming is None else max(0.0, (upcoming - now).total_seconds()) + + +class SourceAgent: + def __init__(self, server: str, token: str, plugins_dir: Path, *, name: str | None = None) -> None: + self.server = server + self.token = token + self.plugins, self.report = load_source_plugins(plugins_dir) + self.name = name + self.resources = AsyncExitStack() + # When each (plugin, Scope) collects next, and when each target was last attempted. + self._due: dict[tuple[str, str], float] = {} + self._attempted: dict[str, float] = {} + self._known: set[str] = set() + + async def __aenter__(self) -> SourceAgent: + self.http = await self.resources.enter_async_context( + httpx.AsyncClient(base_url=self.server, headers={"Authorization": f"Bearer {self.token}"}, timeout=60) + ) + if self.name is None: + # The runner is named after its credential's subject unless told otherwise. + response = await self.http.get("/v1/access/principal") + response.raise_for_status() + self.name = response.json()["principal"]["id"] + return self + + async def __aexit__(self, *_: object) -> None: + await self.resources.aclose() + + async def cycle(self, *, collect_all: bool = False) -> list[dict[str, Any]]: + """Sync once, then collect every (plugin, Scope) that is due, has new targets, or ``collect_all``.""" + + response = await self.http.post(f"/v1/source-runners/{self.name}/sync", json={"plugins": self.report}) + response.raise_for_status() + synced = response.json() + for rejected in synced["rejected"]: + logger.warning("Plugin %s rejected by the Server (%s)", rejected["name"], rejected["code"]) + groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for target in synced["targets"]: + groups[(target["capability"], target["scope_id"])].append(target) + now = time.monotonic() + outcomes = [] + for key, targets in groups.items(): + plugin = self.plugins.get(key[0]) + if plugin is None: + continue + added = [target for target in targets if target["target_id"] not in self._known] + if not (collect_all or added or now >= self._due.get(key, 0.0)): + continue + self._due[key] = now + _next_delay(plugin.collect, datetime.now(UTC)) + outcomes.extend(await self._collect(plugin, key[1], targets, "target_added" if added else "schedule")) + self._known = {target["target_id"] for target in synced["targets"]} + return outcomes + + async def _collector(self, plugin: _Plugin) -> Any: + if plugin.collector is None: + context = PluginContext( + name=plugin.name, + scope_id=None, + plugin_dir=plugin.directory, + logger=logging.getLogger(f"powercontext.plugin.{plugin.name}"), + resources=self.resources, + center=_Center(self.http, plugin.name, plugin.declaration.version), + ) + assert plugin.declaration.setup is not None # noqa: S101 - collect requires setup + plugin.collector = await plugin.declaration.setup(context) + return plugin.collector + + async def _collect( + self, + plugin: _Plugin, + scope_id: str, + targets: list[dict[str, Any]], + reason: Literal["schedule", "target_added"], + ) -> list[dict[str, Any]]: + collect = plugin.collect + # Many targets rotate: the least recently attempted go first. + chosen = sorted(targets, key=lambda target: self._attempted.get(target["target_id"], 0.0))[ + : collect.max_targets + ] + request = CollectRequest( + scope_id=scope_id, + reason=reason, + targets=tuple( + CollectTarget(target_id=item["target_id"], parameters=item["parameters"], checkpoint=item["checkpoint"]) + for item in chosen + ), + max_items=collect.max_items, + ) + for item in chosen: + self._attempted[item["target_id"]] = time.monotonic() + try: + result = _checked( + await asyncio.wait_for((await self._collector(plugin)).collect(request), collect.timeout_seconds), + request, + ) + except Exception as error: + # A failed call commits nothing; the same checkpoints are offered next time. + logger.warning("Plugin %s collect failed (%s)", plugin.name, type(error).__name__) + for item in request.targets: + await self._commit( + plugin, scope_id, item, item.checkpoint, error=("collect_failed", type(error).__name__) + ) + return [{"target_id": item.target_id, "error": "collect_failed"} for item in request.targets] + outcomes = [] + for item in request.targets: + outcome = result.targets.get(item.target_id) + if outcome is None: + continue + if outcome.error_code is not None: + await self._commit( + plugin, scope_id, item, item.checkpoint, error=(outcome.error_code, outcome.error_message or "") + ) + outcomes.append({"target_id": item.target_id, "error": outcome.error_code}) + continue + try: + submitted = [await self._submit(plugin, scope_id, collected) for collected in outcome.items] + await self._commit(plugin, scope_id, item, outcome.checkpoint) + except httpx.HTTPError as error: + logger.warning("Plugin %s submit failed (%s)", plugin.name, type(error).__name__) + outcomes.append({"target_id": item.target_id, "error": "submit_failed"}) + continue + outcomes.append({"target_id": item.target_id, "sources": submitted}) + return outcomes + + async def _submit(self, plugin: _Plugin, scope_id: str, collected: Any) -> str: + from powercontext.sources.observations import project_source_for_transport + + definition = plugin.registry.definition_for_name(collected.definition) + if not isinstance(collected.value, definition.input_class): + raise TypeError(f"{collected.definition} expects {definition.input_class.__name__}") # noqa: TRY003 + observation = project_source_for_transport(plugin.registry, await plugin.registry.resolve(collected.value)) + response = await self.http.post( + "/v1/source-observations", + json={ + "scope_id": scope_id, + "observation": observation.model_dump(mode="json"), + "plugin": {"name": plugin.name, "version": plugin.declaration.version}, + }, + ) + response.raise_for_status() + return response.json()["source"]["source_id"] + + async def _commit( + self, + plugin: _Plugin, + scope_id: str, + target: CollectTarget, + checkpoint: JsonValue | None, + *, + error: tuple[str, str] | None = None, + ) -> None: + status: dict[str, Any] = {"succeeded": error is None} + if error is not None: + status.update(error_code=error[0][:128], error_message=error[1][:1024]) + response = await self.http.post( + "/v1/connector-checkpoints/commit", + json={ + "binding": { + "scope_id": scope_id, + "binding_id": target.target_id, + "connector_name": plugin.name, + "connector_version": TARGET_CHECKPOINT_VERSION, + }, + "expected": target.checkpoint, + "checkpoint": checkpoint, + "plugin": {"name": plugin.name, "version": plugin.declaration.version}, + "status": status, + }, + ) + if error is None: + response.raise_for_status() + elif response.is_error: + logger.warning("Plugin %s status report failed (%s)", plugin.name, response.status_code) + + +def source_agent( + server: Annotated[str, typer.Option("--server", help="The central PowerContext Server URL")], + plugins: Annotated[Path, typer.Option("--plugins", help="Directory of Source plugins")], + name: Annotated[ + str | None, typer.Option("--name", help="Runner name; defaults to the credential's subject") + ] = None, + interval: Annotated[float, typer.Option("--interval", min=1, help="Seconds between syncs")] = 30.0, + once: Annotated[bool, typer.Option("--once", help="Sync and collect every target once, then exit")] = False, +) -> None: + """Collect on this device with Source plugins and submit to the central Server.""" + + token = os.environ.get("POWERCONTEXT_SOURCE_AGENT_TOKEN") + if not token: + raise typer.BadParameter("set POWERCONTEXT_SOURCE_AGENT_TOKEN to the runner's credential") # noqa: TRY003 + + async def run() -> None: + async with SourceAgent(server, token, plugins, name=name) as agent: + while True: + try: + outcomes = await agent.cycle(collect_all=once) + if once: + typer.echo(json.dumps({"runner": agent.name, "outcomes": outcomes})) + return + except httpx.HTTPError as error: + if once: + raise + # Includes plugin_version_incompatible while the Server upgrades; retry at the next sync. + logger.warning("Sync failed (%s); retrying", type(error).__name__) + await asyncio.sleep(interval) + + asyncio.run(run()) diff --git a/src/powercontext/source_management/background.py b/src/powercontext/source_management/background.py deleted file mode 100644 index 26bf152388..0000000000 --- a/src/powercontext/source_management/background.py +++ /dev/null @@ -1,232 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Detached Source processes with explicit startup, identity checks and graceful stop.""" - -from __future__ import annotations - -import argparse -import asyncio -import hashlib -import json -import os -import select -import signal -import socket -import struct -import subprocess -import sys -import threading -import time -from contextlib import suppress -from pathlib import Path -from typing import cast - -from typing_extensions import override - -from powercontext.source_management.config import ManagerConfig, RuntimeConfig, read_config, secret -from powercontext.source_management.durable import RuntimeOwner -from powercontext.source_management.models import ManagementError -from powercontext.source_management.service import protected_write - - -def _alive(state): - if not state: - return False - owner = state["owner"] - if owner["boot_id"] != Path("/proc/sys/kernel/random/boot_id").read_text().strip(): - return False - if owner["machine_id"] != Path("/etc/machine-id").read_text().strip(): - raise ManagementError("foreign-process", "Process state belongs to another machine") - try: - stat = Path(f"/proc/{int(owner['pid'])}/stat").read_text().rsplit(")", 1)[1].split() - except (FileNotFoundError, ProcessLookupError): - return False - return stat[0] != "Z" and stat[19] == owner["start_time"] - - -def process_action(role, config, action): - """Start or stop only a confirmed local process; repeated starts preserve its state.""" - if sys.platform != "linux": - raise ManagementError("background-unsupported", "Source background processes currently require Linux") - - import fcntl - - if action == "start": - secret(config.server.credential_file) - if isinstance(config, RuntimeConfig): - secret(config.management.credential_file) - directory = config.state_dir / (role + "-process") - directory.mkdir(mode=0o700, parents=True, exist_ok=True) - descriptor = os.open(directory / "command.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - try: - fcntl.flock(descriptor, fcntl.LOCK_EX) - return _operate(role, config, action, directory) - finally: - os.close(descriptor) - - -def _operate(role, config, action, directory): # noqa: C901 - serialized process control - state_path, snapshot = directory / "process.json", directory / "config.json" - state = json.loads(state_path.read_text()) if state_path.exists() else {} - alive = _alive(state) - if action == "start": - serialized = config.model_dump_json(indent=2) - if alive: - if not state.get("ready") or state.get("stopping"): - raise ManagementError("process-starting", "Process startup or cleanup has not completed") - if not snapshot.exists() or snapshot.read_text() != serialized: - raise ManagementError("configuration-conflict", "Stop the process before changing its configuration") - else: - protected_write(snapshot, serialized) - _spawn(role, snapshot, directory) - state = json.loads(state_path.read_text()) - return {"state": "running", "pid": state["owner"]["pid"], "configuration": str(snapshot)} - if action == "stop" and alive: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: - connection.settimeout(5) - try: - connection.connect(_address(directory)) - connection.sendall(json.dumps(state["owner"]).encode()) - if connection.recv(16) != b"stopping": - raise ManagementError("process-changed", "The running process did not accept the stop request") - except (ConnectionRefusedError, FileNotFoundError): - if _alive(state): - raise ManagementError("process-unavailable", "Cannot confirm process cleanup") from None - deadline = time.monotonic() + 45 - while _alive(state): - if time.monotonic() >= deadline: - raise ManagementError("stop-timeout", "Process cleanup is unfinished; replacement is blocked") - time.sleep(0.05) - alive = False - elif action not in {"stop", "status"}: - raise ManagementError("invalid-action", "Choose start, status or stop") - return {"state": "running" if alive else "stopped", "pid": state["owner"]["pid"] if state else None} - - -def _spawn(role, snapshot, directory): - read_fd, write_fd = os.pipe() - try: - log_fd = os.open(directory / "process.log", os.O_CREAT | os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW, 0o600) - with os.fdopen(log_fd, "a") as log: - process = subprocess.Popen( # noqa: S603 - fixed interpreter and module, no shell - [sys.executable, "-m", __name__, role, str(snapshot), str(write_fd)], - stdin=subprocess.DEVNULL, - stdout=log, - stderr=log, - start_new_session=True, - pass_fds=(write_fd,), - ) - os.close(write_fd) - write_fd = -1 - try: - if not select.select([read_fd], [], [], 60)[0] or os.read(read_fd, 16) != b"ready": - raise ManagementError("startup-failed", "Source process did not become ready; inspect process.log") # noqa: TRY301 - except BaseException: - if process.poll() is None: - process.terminate() - # Keep identity and locks if cleanup has not completed. - with suppress(subprocess.TimeoutExpired): - process.wait(timeout=45) - raise - finally: - os.close(read_fd) - if write_fd >= 0: - os.close(write_fd) - - -def _address(directory): - return "\0pc-source-managed-" + hashlib.sha256(os.fsencode(directory.resolve())).hexdigest() - - -def _control(listener, identity, state_path): - if sys.platform != "linux": - raise ManagementError("unsupported", "Source process control requires Linux") - stopping = False - while True: - try: - connection, _ = listener.accept() - with connection: - peer_cred = cast(int, vars(socket)["SO_PEERCRED"]) - _, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, peer_cred, 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 deleted file mode 100644 index 7b97cf7749..0000000000 --- a/src/powercontext/source_management/bootstrap.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""HTTP helpers for authenticated Source instance deployment.""" - -from __future__ import annotations - -import hashlib -from typing import Any - -import httpx - -from powercontext.source_management.models import ManagementError -from powercontext.source_management.storage import canonical - - -def request(client: httpx.Client, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: - try: - response = client.request(method, path, json=body) if body is not None else client.request(method, path) - except httpx.HTTPError as error: - raise ManagementError("server-unreachable", "Deployment cannot contact the configured Server", 503) from error - if response.is_error: - # Error bodies may contain plugin data; expose the stable code only. - try: - code = response.json().get("error", {}).get("code", "request-failed") - except (ValueError, AttributeError): - code = "request-failed" - raise ManagementError( - str(code), f"Deployment request {path} failed (HTTP {response.status_code})", response.status_code - ) - return response.json() - - -def _principal(client: httpx.Client) -> dict[str, Any]: - result = request(client, "GET", "/v1/access/me") - if result["mode"] != "enforced": - raise ManagementError( - "access-required", "Shared Source deployment requires enforced Access and distinct identities" - ) - return result["principal"] - - -def _allowed(client: httpx.Client, action: str, resource: dict[str, Any]) -> bool: - result = request( - client, - "POST", - "/v1/access/check", - { - "match": "all", - "requirements": [{"action": action, "resource": resource}], - }, - ) - return result["allowed"] - - -def _grant(admin: httpx.Client, principal: dict[str, Any], scope: str) -> None: - subject = {key: principal[key] for key in ("type", "id")} - identity = hashlib.sha256(canonical([subject, scope, "scope.contributor"]).encode()).hexdigest() - request( - admin, - "POST", - "/v1/access/bindings/create", - { - "subject": subject, - "resource": {"type": "scope", "scope_id": scope}, - "role": "scope.contributor", - "idempotency_key": f"source-up-{identity}", - "reason": "Source workspace deployment", - }, - ) diff --git a/src/powercontext/source_management/channel.py b/src/powercontext/source_management/channel.py deleted file mode 100644 index 17a0c6c8fa..0000000000 --- a/src/powercontext/source_management/channel.py +++ /dev/null @@ -1,143 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Authenticated reverse request/reply channel with a single expiring lease.""" - -from __future__ import annotations - -import asyncio -from contextlib import suppress -from dataclasses import dataclass, field -from time import monotonic -from typing import Any -from uuid import uuid4 - -from powercontext.source_management.models import MAX_MESSAGE_BYTES, ManagementError -from powercontext.source_management.storage import canonical - - -@dataclass -class Connection: - generation: str - owner: str - seen: float - queue: asyncio.Queue[dict[str, Any]] = field(default_factory=lambda: asyncio.Queue(maxsize=32)) - pending: dict[str, asyncio.Future[dict[str, Any]]] = field(default_factory=dict) - polling: bool = False - - -class ReverseChannel: - def __init__(self, *, lease_seconds: float = 30, request_seconds: float = 10) -> None: - self.lease_seconds = lease_seconds - self.request_seconds = request_seconds - self.connection: Connection | None = None - self.catalog: dict[str, Any] = {"revision": 0, "capabilities": [], "instances": []} - - @property - def online(self) -> bool: - connection = self.connection - return connection is not None and monotonic() - connection.seen <= self.lease_seconds - - def open(self, owner: str) -> str: - if self.online: - raise ManagementError("connection-active", "A live connection already owns this identity", 409) - self.close() - generation = uuid4().hex - self.connection = Connection(generation, owner, monotonic()) - return generation - - def check(self, owner: str, generation: str) -> Connection: - connection = self.connection - if not self.online or connection is None or connection.owner != owner or connection.generation != generation: - raise ManagementError("connection-expired", "Reconnect and synchronize the current state", 409) - connection.seen = monotonic() - return connection - - async def poll(self, owner: str, generation: str) -> list[dict[str, Any]]: - connection = self.check(owner, generation) - if connection.polling: - raise ManagementError("poll-active", "Only one poll may be active on a connection", 409) - connection.polling = True - try: - async with asyncio.timeout(min(self.lease_seconds / 3, 5)): - while True: - message = await connection.queue.get() - future = connection.pending.get(message["message_id"]) - if future is not None and not future.done(): - break - except TimeoutError: - return [] - finally: - connection.polling = False - return [message] - - async def request( - self, operation: str, arguments: dict[str, Any], *, wait_seconds: float | None = None - ) -> dict[str, Any]: - if len(canonical(arguments).encode()) > MAX_MESSAGE_BYTES - 1024: - raise ManagementError("request-too-large", "The management message exceeds its size limit") - connection = self.connection - if not self.online or connection is None: - raise ManagementError("management-unavailable", "Source management is unavailable", 503) - if len(connection.pending) >= 32: - raise ManagementError("management-busy", "The management connection has reached its request limit", 503) - identifier = uuid4().hex - future = asyncio.get_running_loop().create_future() - connection.pending[identifier] = future - try: - try: - connection.queue.put_nowait({ - "generation": connection.generation, - "message_id": identifier, - "operation": operation, - "arguments": arguments, - }) - except asyncio.QueueFull as error: - raise ManagementError("management-busy", "The management request was not sent", 503) from error - async with asyncio.timeout(self.request_seconds if wait_seconds is None else wait_seconds): - result = await future - if "error" in result: - error = result["error"] - raise ManagementError(error["code"], error["message"], error.get("status", 400)) - return result["result"] - except (TimeoutError, ConnectionError) as error: - raise ManagementError( - "result-unconfirmed", "The result is unconfirmed; query or retry the original request", 503 - ) from error - finally: - connection.pending.pop(identifier, None) - if not future.done(): - future.cancel() - - def reply(self, owner: str, generation: str, message_id: str, result: dict[str, Any]) -> None: - if len(canonical(result).encode()) > MAX_MESSAGE_BYTES - 1024: - raise ManagementError("response-too-large", "The management reply exceeds its size limit") - connection = self.check(owner, generation) - future = connection.pending.get(message_id) - if future is not None and not future.done(): - future.set_result(result) - - def close(self, owner: str | None = None, generation: str | None = None) -> None: - connection = self.connection - if connection is None: - return - if owner is not None and (connection.owner != owner or connection.generation != generation): - raise ManagementError("connection-expired", "Connection ownership changed", 409) - self.connection = None - for future in connection.pending.values(): - if not future.done(): - future.set_exception(ConnectionError("channel closed")) - with suppress(asyncio.QueueEmpty): - while True: - connection.queue.get_nowait() diff --git a/src/powercontext/source_management/cli.py b/src/powercontext/source_management/cli.py deleted file mode 100644 index e9df21d7be..0000000000 --- a/src/powercontext/source_management/cli.py +++ /dev/null @@ -1,241 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Deployment commands and native service entry points for Source management.""" - -# Credential input is validated and errors are redacted at the command boundary. -# ruff: noqa: TRY301 - -from __future__ import annotations - -import asyncio -import json -import re -from pathlib import Path -from typing import Annotated - -import typer - -from powercontext.paths import powercontext_data_dir -from powercontext.source_management.config import ManagerConfig, RuntimeConfig, read_config, secret -from powercontext.source_management.models import ManagementError -from powercontext.source_management.service import Role, protected_write, service_action - -source_app = typer.Typer(name="source", help="Manage Source services and instances.", no_args_is_help=True) -manager_app = typer.Typer(name="manager", no_args_is_help=True) -runtime_app = typer.Typer(name="runtime", no_args_is_help=True) -credentials_app = typer.Typer(name="credentials", no_args_is_help=True) -source_app.add_typer(manager_app) -source_app.add_typer(runtime_app) -ConfigFile = Annotated[Path, typer.Option("--config", help="Path to the deployment configuration.")] - - -def output(value: object) -> None: - typer.echo(json.dumps(value, ensure_ascii=False, indent=2)) - - -def fail(error: Exception) -> None: - if isinstance(error, ManagementError): - typer.echo(f"{error.code}: {error.message}", err=True) - else: - # Validation inputs can include tokens. Do not print exception payloads. - typer.echo( - f"deployment-failed: {type(error).__name__}; check configuration and private credential files", err=True - ) - raise typer.Exit(1) from error - - -def _operate(role: Role, action: str, path: Path, system_service: bool = False) -> None: - try: - config = read_config(path, ManagerConfig if role == "manager" else RuntimeConfig) - if action == "install": - secret(config.server.credential_file) - if isinstance(config, RuntimeConfig): - secret(config.management.credential_file) - if action in {"install", "uninstall"} or system_service: - output(service_action(role, config, "install" if action == "start" else action)) - else: - from powercontext.source_management.background import process_action - - output(process_action(role, config, action)) - except (OSError, ValueError, RuntimeError, ManagementError) as error: - fail(error) - - -def _commands(app: typer.Typer, role: Role) -> None: - for action in ("start", "install", "status", "stop", "uninstall"): - - def register(action: str) -> None: - def command( - config: ConfigFile, - system_service: Annotated[ - bool, typer.Option(help="Operate an explicitly installed system service.") - ] = False, - ) -> None: - _operate(role, action, config, system_service) - - app.command(action)(command) - - register(action) - - -_commands(manager_app, "manager") -_commands(runtime_app, "runtime") - - -@manager_app.command("run", hidden=True) -def manager_run(config: ConfigFile) -> None: - """Native service entry point; install uses systemd and returns immediately.""" - import uvicorn - - from powercontext.source_management.manager import create_manager_app - - try: - settings = read_config(config, ManagerConfig) - secret(settings.server.credential_file) - uvicorn.run(create_manager_app(settings), host=settings.host, port=settings.port, access_log=False) - except Exception as error: - fail(error) - - -@runtime_app.command("run", hidden=True) -def runtime_run(config: ConfigFile) -> None: - """Native service entry point for one managed Source instance.""" - from powercontext.source_management.runtime import run_runtime - - try: - asyncio.run(run_runtime(read_config(config, RuntimeConfig))) - except Exception as error: - fail(error) - - -@credentials_app.command("set") -def credentials_set( - alias: Annotated[str, typer.Argument(help="Local external credential reference.")], - plugin: Annotated[str, typer.Option(help="Module or local entry declaring credential fields.")], -) -> None: - """Prompt for plugin-declared external secrets and persist only protected files.""" - from powercontext.source_management.loading import load_local_module - - try: - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", alias): - raise ManagementError("invalid-alias", "Credential aliases must be simple local names") - # Loading a plugin is a trusted local code operation; no Server is contacted. - module = load_local_module(Path.cwd(), plugin) - names = sorted(getattr(module, "credential_fields", ())) - if not names: - raise ManagementError("no-credentials", "This plugin declares no external credential fields") - if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name) for name in names): - raise ManagementError("invalid-secret-name", "Plugin credential names must be simple local names") - values = {name: typer.prompt(name, hide_input=True) for name in names} - if any(not value.strip() for value in values.values()): - raise ManagementError("empty-secret", "Credential values must not be empty") - directory = powercontext_data_dir() / "source-credentials" / alias - for name, value in values.items(): - protected_write(directory / name, value) - output({"credential": alias, "fields": names}) - except Exception as error: - fail(error) - - -@source_app.command("load") -def load_command( # noqa: C901 - collect/write deployment validation - 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 - if url is None: - if settings.http.uds is not None: - raise ManagementError( - "server-url-required", - "UDS Source writes require --server-url or POWERCONTEXT_SERVER_PUBLIC_URL " - "pointing to an HTTP(S) proxy", - ) - url = 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 deleted file mode 100644 index 609bd5650c..0000000000 --- a/src/powercontext/source_management/config.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Deployment configuration and protected secret references.""" - -from __future__ import annotations - -import os -from hashlib import sha256 -from pathlib import Path -from typing import Any, Literal -from urllib.parse import urlsplit - -import yaml -from pydantic import Field, field_validator - -from powercontext.client.transport_policy import normalize_client_url -from powercontext.source_management.models import Model -from powercontext.source_management.storage import canonical -from powercontext.transport import is_loopback_host - - -class Endpoint(Model): - url: str - credential_file: Path - - @field_validator("url") - @classmethod - def normalize_url(cls, value: str) -> str: - normalized = normalize_client_url(value) - parsed = urlsplit(normalized) - if parsed.scheme != "https" and not is_loopback_host(parsed.hostname or ""): - raise ValueError("Source management credentials require HTTPS outside loopback") # noqa: TRY003 - return normalized - - -class ManagerConfig(Model): - server: Endpoint - deployment_id: str - manager_id: str - state_dir: Path - host: str = "127.0.0.1" - port: int = Field(default=8010, ge=1, le=65535) - request_seconds: float = Field(default=10, gt=0, le=60) - lease_seconds: float = Field(default=30, ge=3, le=300) - - -class RuntimeConfig(Model): - deployment_id: str = "powercontext" - instance_id: str - plugin: str - plugin_dir: Path - management: Endpoint - server: Endpoint - state_dir: Path - execution_location: str - configuration_digest: str - config: dict[str, Any] = Field(default_factory=dict) - secrets: dict[str, Path] = Field(default_factory=dict) - - -class CollectDeployment(Model): - """Execution-machine deployment references; administrator credentials stay out of RuntimeConfig.""" - - server: Endpoint - management_url: str - execution_credential_file: Path - state_dir: Path - deployment_id: str = "powercontext" - start: Literal["background", "local-service"] = "background" - - @field_validator("management_url") - @classmethod - def normalize_management_url(cls, value: str) -> str: - return Endpoint(url=value, credential_file=Path("unused")).url - - -def configuration_digest(plugin: str, config: dict[str, Any], secrets) -> str: - """Digest the approved business parameters and declared secret aliases, without their values.""" - return sha256(canonical({"plugin": plugin, "config": config, "secrets": sorted(secrets)}).encode()).hexdigest() - - -def read_config(path: Path, model): - path = path.expanduser().resolve() - value = model.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) - for name in ("state_dir", "plugin_dir"): - configured = getattr(value, name, None) - if configured is not None: - object.__setattr__(value, name, (path.parent / configured).resolve()) - for name in ("server", "management"): - endpoint = getattr(value, name, None) - if endpoint is not None: - object.__setattr__(endpoint, "credential_file", (path.parent / endpoint.credential_file).resolve()) - return value - - -def secret(path: Path) -> str: - if path.is_symlink() or not path.is_file(): - raise ValueError("Credential must be a regular protected file") # noqa: TRY003 - if os.name == "posix" and path.stat().st_mode & 0o077: - raise ValueError("Credential file must not be accessible to other users (chmod 600)") # noqa: TRY003 - value = path.read_text(encoding="utf-8").strip() - if not value: - raise ValueError("Credential file is empty") # noqa: TRY003 - return value diff --git a/src/powercontext/source_management/context.py b/src/powercontext/source_management/context.py deleted file mode 100644 index 82f75581af..0000000000 --- a/src/powercontext/source_management/context.py +++ /dev/null @@ -1,149 +0,0 @@ -# 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 deleted file mode 100644 index 30d3e9fca3..0000000000 --- a/src/powercontext/source_management/deployment.py +++ /dev/null @@ -1,406 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Local deployment coordinator; services and collection run in separate processes.""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import logging -import secrets -import socket -import sys -from contextlib import AsyncExitStack -from pathlib import Path -from typing import Any - -import httpx -import yaml -from pydantic import Field -from typing_extensions import override - -from powercontext._logging import log_safely -from powercontext.server.authentication import ( - AuthenticationRejectedError, - MultiBearerAuthenticationProvider, -) -from powercontext.source_management.config import ( - CollectDeployment, - Endpoint, - ManagerConfig, - RuntimeConfig, - configuration_digest, - secret, -) -from powercontext.source_management.models import InstanceRegistration, ManagementError, Model -from powercontext.source_management.service import launch, protected_write - -logger = logging.getLogger(__name__) - - -class InitialTarget(Model): - scope_id: str - capability: str - capability_version: str - input: dict[str, Any] - - -class LocalInstance(Model): - id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", max_length=128) - plugin: str - config: dict[str, Any] = Field(default_factory=dict) - secrets: dict[str, Path] = Field(default_factory=dict) - scope_ids: tuple[str, ...] = () - targets: tuple[InitialTarget, ...] = () - public_principals: tuple[str, ...] = ("*",) - default_capabilities: tuple[str, ...] = () - deployment: CollectDeployment | None = None - - -class DeploymentAuthentication(MultiBearerAuthenticationProvider): - """Add generated service credentials without changing user-managed identity files.""" - - def __init__(self, path, delegate): - super().__init__(path) - self.delegate = delegate - - @override - async def authenticate(self, request): - try: - return await super().authenticate(request) - except AuthenticationRejectedError: - if self.delegate is None: - raise - return await self.delegate.authenticate(request) - - @override - def principals(self): - original = self.delegate.principals() if isinstance(self.delegate, MultiBearerAuthenticationProvider) else () - return (*original, *super().principals()) - - @override - async def readiness(self): - current = await super().readiness() - if not current.ready or self.delegate is None: - return current - return await self.delegate.readiness() - - -def deployment_root(settings): - from powercontext.paths import powercontext_data_dir - - address = settings.public_url - if address is None: - if settings.http.uds is not None: - raise ManagementError( - "server-url-required", - "UDS Source deployment requires POWERCONTEXT_SERVER_PUBLIC_URL pointing to an HTTP(S) proxy", - ) - address = f"http://127.0.0.1:{settings.http.port}" - key = hashlib.sha256((address + "\0" + settings.access.deployment_id).encode()).hexdigest()[:24] - return ( - (settings.source_management.state_dir or powercontext_data_dir() / "source-deployments" / key) - .expanduser() - .resolve() - ) - - -def credential(root, name, subject): - """Serialize credential creation and keep their plaintext in protected startup files.""" - from powercontext.source_management.durable import RuntimeOwner - - with_lock = RuntimeOwner(root / "configuration-lock") - try: - path = root / (name + ".token") - if not path.exists(): - protected_write(path, secrets.token_urlsafe(32), immutable=True) - token = secret(path) - identities_file = root / "identities.json" - values = json.loads(identities_file.read_text())["identities"] if identities_file.exists() else [] - digest = hashlib.sha256(token.encode()).hexdigest() - expected = {"credential_id": name, "token_digest": digest, "subject": {"type": "service", "id": subject}} - existing = next((v for v in values if v["credential_id"] == name), None) - if existing is not None and existing != expected: - raise ManagementError("credential-conflict", "Generated service credential configuration differs") - if existing is None: - protected_write(identities_file, json.dumps({"identities": [*values, expected]})) - return path - finally: - with_lock.close() - - -def prepare(settings, provider): - if settings.source_plugins_dir is None and not settings.source_management.autostart: - return provider, None - if settings.access.mode != "enforced": - raise ManagementError("access-required", "Automatic Source deployment requires enforced PC Access") - root = deployment_root(settings) - key = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:16] - subject = "pc-source-deployer-" + key - manager = "pc-source-manager-" + key - credential(root, "deployer", subject) - credential(root, "manager", manager) - if settings.source_management.manager_subject not in (None, "service:" + manager): - raise ManagementError("manager-conflict", "Automatic deployment cannot replace a configured remote manager") - settings.source_management.manager_subject = "service:" + manager - settings.auth.administrators = (*settings.auth.administrators, "service:" + subject) - provider = DeploymentAuthentication(root / "identities.json", provider) - return provider, next(p for p in provider.principals() if p.id == subject) - - -class DeploymentCoordinator: - def __init__(self, settings): - self.settings = settings - self.root = deployment_root(settings) - self.server_url = settings.public_url or f"http://127.0.0.1:{settings.http.port}" - self._lock = asyncio.Lock() - - async def request(self, client, path, body): - response = await client.post(path, json=body) - if response.status_code >= 400: - error = response.json().get("error", {}) - raise ManagementError( - error.get("code", "deployment-failed"), - error.get("message", "Deployment request failed"), - response.status_code, - ) - return response.json() - - async def manager(self): - config_path = self.root / "manager.json" - if config_path.exists(): - config = ManagerConfig.model_validate_json(config_path.read_text()) - else: - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - config = ManagerConfig( - server=Endpoint(url=self.server_url, credential_file=self.root / "manager.token"), - deployment_id=self.settings.access.deployment_id, - manager_id=self.settings.source_management.manager_id, - state_dir=self.root / "manager", - port=port, - ) - protected_write(config_path, config.model_dump_json(indent=2), immutable=True) - await asyncio.to_thread(launch, "manager", config, self.settings.source_management.process_mode) - return config - - async def load(self, path: Path, *, update=False): # noqa: C901 - ordered deployment and readiness checks - path = await asyncio.to_thread(lambda: path.expanduser().resolve(strict=True)) - local = LocalInstance.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) - if local.deployment is not None: - raise ManagementError("execution-machine-required", "Run source load on the configured execution machine") - async with self._lock, AsyncExitStack() as updating: - manager = await self.manager() - instance_root = self.root / "instances" / local.id - execution_subject = "pc-source-" + hashlib.sha256((str(self.root) + local.id).encode()).hexdigest()[:24] - execution_file = credential(self.root, "execution-" + local.id, execution_subject) - instance_root.mkdir(parents=True, exist_ok=True, mode=0o700) - if update: - from powercontext.source_management.durable import RuntimeOwner - - updating.callback(RuntimeOwner(instance_root).close) - instance_file = instance_root / "instance.token" - if not instance_file.exists(): - protected_write(instance_file, secrets.token_urlsafe(32), immutable=True) - aliases = {k: (path.parent / v).resolve() for k, v in local.secrets.items()} - config = RuntimeConfig( - instance_id=local.id, - deployment_id=self.settings.access.deployment_id, - plugin=local.plugin, - plugin_dir=path.parent, - config=local.config, - secrets=aliases, - server=Endpoint(url=self.server_url, credential_file=execution_file), - management=Endpoint(url=f"http://127.0.0.1:{manager.port}", credential_file=instance_file), - state_dir=instance_root, - execution_location=str(path.parent), - configuration_digest=configuration_digest(local.plugin, local.config, aliases), - ) - from powercontext.client import PowerContextClient - from powercontext.http import RegisterSourceDefinitionRequest, SourceDefinitionManifest - - manifests = await inspect_manifests(config) - token = secret(self.root / "deployer.token") - async with PowerContextClient(self.server_url, token=token) as client: - for value in manifests: - manifest = SourceDefinitionManifest.model_validate(value) - registered = await client.register_source_definition( - RegisterSourceDefinitionRequest(manifest=manifest) - ) - if registered != manifest: - raise ManagementError("manifest-mismatch", "Server returned a different Definition manifest") - async with httpx.AsyncClient( - base_url=self.server_url, headers={"Authorization": "Bearer " + token}, timeout=65 - ) as client: - registration_path = instance_root / "registration.json" - previous = json.loads(registration_path.read_text()) if registration_path.exists() else None - scopes = ( - tuple(previous["scope_ids"]) - if previous is not None and not update - else tuple(dict.fromkeys((*local.scope_ids, *(t.scope_id for t in local.targets)))) - ) - registration = InstanceRegistration( - instance_id=local.id, - credential_digest=hashlib.sha256(secret(instance_file).encode()).hexdigest(), - execution_location=config.execution_location, - scope_ids=scopes, - public_principals=local.public_principals, - default_capabilities=local.default_capabilities, - configuration_digest=config.configuration_digest, - ) - async with asyncio.timeout(60): - while True: - try: - result = await self.request( - client, - "/v1/source-management/configure", - {"registration": registration.model_dump(mode="json"), "update": update}, - ) - break - except ManagementError as error: - if error.code not in {"management-unavailable", "result-unconfirmed"}: - raise - await asyncio.sleep(0.2) - protected_write(registration_path, registration.model_dump_json(indent=2)) - for scope in scopes: - await self.request( - client, - "/v1/access/bindings/create", - { - "subject": {"type": "service", "id": execution_subject}, - "resource": {"type": "scope", "scope_id": scope}, - "role": "scope.contributor", - "idempotency_key": "source-" - + hashlib.sha256((execution_subject + scope).encode()).hexdigest()[:48], - }, - ) - protected_write(instance_root / "runtime-config.json", config.model_dump_json(indent=2)) - await updating.aclose() - result["service"] = await asyncio.to_thread( - launch, "runtime", config, self.settings.source_management.process_mode - ) - if result.get("targets_initialized"): - return result - principal_response = await client.get("/v1/access/me") - principal_response.raise_for_status() - initializer = principal_response.json()["principal"] - for scope in dict.fromkeys(t.scope_id for t in local.targets): - await self.request( - client, - "/v1/access/bindings/create", - { - "subject": {"type": initializer["type"], "id": initializer["id"]}, - "resource": {"type": "scope", "scope_id": scope}, - "role": "scope.contributor", - "idempotency_key": "source-seed-" - + hashlib.sha256((initializer["id"] + scope).encode()).hexdigest()[:48], - }, - ) - async with asyncio.timeout(60): - while True: - try: - await self.request( - client, - "/v1/source-management/configure", - { - "registration": { - "action": "initialize_targets", - "instance_id": local.id, - "targets": [t.model_dump(mode="json") for t in local.targets], - } - }, - ) - break - except ManagementError as error: - if error.code not in { - "instance-unavailable", - "management-unavailable", - "capability-unavailable", - "result-unconfirmed", - }: - raise - await asyncio.sleep(0.2) - return result - - async def startup(self): - # Run after the HTTP listener becomes ready; one failed plugin must not block built-ins. - async with httpx.AsyncClient(timeout=2) as client: - for _ in range(100): - try: - response = await client.get(self.server_url + "/health") - if response.status_code < 500: - break - except httpx.HTTPError: - pass - await asyncio.sleep(0.1) - if self.settings.source_management.autostart: - try: - await self.manager() - except Exception as error: - log_safely( - logger, - logging.ERROR, - "Source manager startup failed; inspect local service configuration", - extra={ - "error_code": error.code - if isinstance(error, ManagementError) - and error.code in {"background-unsupported", "service-unavailable"} - else "deployment-failed", - }, - ) - directory = self.settings.source_plugins_dir - if directory is not None: - for path in sorted(directory.expanduser().glob("*/powercontext.yml")): - try: - await self.load(path) - except Exception as error: - log_safely( - logger, - logging.ERROR, - "Source instance preparation failed; inspect local configuration", - extra={ - "error_code": error.code - if isinstance(error, ManagementError) - and error.code in {"background-unsupported", "service-unavailable"} - else "deployment-failed", - }, - ) - - -async def inspect_manifests(config): - process = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "powercontext.source_management.inspection", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - try: - async with asyncio.timeout(30): - output, _ = await process.communicate(config.model_dump_json().encode()) - if process.returncode or len(output) > 2 * 1024 * 1024: - raise ManagementError("plugin-inspection-failed", "The plugin entry or declarations could not be loaded") - return json.loads(output) - except (ValueError, TimeoutError) as error: - raise ManagementError( - "plugin-inspection-failed", "The plugin inspection did not return valid declarations" - ) from error - finally: - if process.returncode is None: - process.kill() - await process.wait() diff --git a/src/powercontext/source_management/durable.py b/src/powercontext/source_management/durable.py deleted file mode 100644 index 989731caa5..0000000000 --- a/src/powercontext/source_management/durable.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Exclusive process ownership for local services and plugin instances.""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path -from typing import Any - -from powercontext.source_management.models import ManagementError - - -def require_process_identity() -> None: - if sys.platform != "linux": - raise ManagementError("background-unsupported", "Source collect Runtimes currently require Linux") - - -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 deleted file mode 100644 index f3e39ca32e..0000000000 --- a/src/powercontext/source_management/errors.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index f6f912ec21..0000000000 --- a/src/powercontext/source_management/gateway.py +++ /dev/null @@ -1,437 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Server public Source facade, without a custom-target database or delivery queue.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse - -from powercontext.builtin.authz import AccessAction, AccessAuditContext, ResourceRef, access_control_for_mode -from powercontext.builtin.runtime.models import CaptureSource -from powercontext.http._generated import operations as operations -from powercontext.server.authentication import AuthenticationRequest -from powercontext.server.context import current_principal -from powercontext.source_management.channel import ReverseChannel -from powercontext.source_management.models import ( - BUILTIN_CAPABILITY, - MAX_ADD_BYTES, - MAX_MESSAGE_BYTES, - PROTOCOL_VERSION, - Caller, - Capability, - ManagementError, -) -from powercontext.source_management.storage import canonical - - -class SourceGateway: - def __init__(self, settings, write_plugins=None): - self.settings = settings - self.write_plugins = write_plugins - self.channel = ReverseChannel( - lease_seconds=settings.source_management.lease_seconds, - request_seconds=settings.source_management.request_seconds, - ) - self.ready = False - self._has_catalog = False - self._contracts = {} - self._manager_headers = {} - self._writes: set[asyncio.Task[dict[str, Any]]] = set() - self._catalog_lock = asyncio.Lock() - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - self.channel.close() - for task in self._writes: - task.cancel() - if self._writes: - await asyncio.gather(*self._writes, return_exceptions=True) - - async def available(self, request: Request) -> bool: - if not self.ready or not self.channel.online: - return False - provider = request.app.state.authentication_provider - if provider is None: - return False - try: - result = await provider.authenticate( - AuthenticationRequest(transport="http", headers=self._manager_headers, client_host=None) - ) - except Exception: - self.channel.close() - self.ready = False - return False - return f"{result.subject.type}:{result.subject.id}" == self.settings.source_management.manager_subject - - def synchronize(self, catalog: dict[str, Any]) -> None: - declarations = catalog["capabilities"] - contracts = dict(self._contracts) - keys = set() - for value in declarations: - declared = {key: value[key] for key in Capability.model_fields if key in value} - capability = Capability.model_validate(declared) - if capability.kind != "collect": - raise ManagementError( - "capability-conflict", "The management catalog accepts only collect capabilities", 409 - ) - key = capability.name, capability.version - if ( - capability.name == BUILTIN_CAPABILITY.name - or key in keys - or (self.write_plugins is not None and self.write_plugins.find(*key) is not None) - ): - raise ManagementError("capability-conflict", "The capability snapshot has conflicting identities", 409) - keys.add(key) - contract = canonical(capability.declaration()) - if contracts.get(key, contract) != contract: - raise ManagementError("capability-conflict", "A capability version cannot change its contract", 409) - contracts[key] = contract - if self.ready and catalog["revision"] < self.channel.catalog["revision"]: - raise ManagementError("stale-catalog", "The capability snapshot is stale", 409) - self.channel.catalog = catalog - self._contracts = contracts - self._has_catalog = True - self.ready = True - - async def call(self, request: Request, operation: str, parameters: dict[str, Any]) -> dict[str, Any]: # noqa: C901 - explicit protocol state transitions - if operation == "add": - try: - size = len(canonical(parameters).encode()) - except (ValueError, UnicodeError) as error: - raise ManagementError("invalid-input", "Source add parameters must be valid JSON") from error - if size > MAX_ADD_BYTES: - raise ManagementError("input-too-large", "Source add parameters exceed 1 MiB") - control = parameters.get("view") == "control" - admin = operation in {"configure", "set_enabled", "write_load"} or (operation == "instances" and control) - if operation == "instances": - if control and (not parameters.get("instance_id") or set(parameters) - {"instance_id", "view"}): - raise ManagementError("invalid-query", "Control requires only instance_id and view") - if not control and not parameters.get("scope_id"): - raise ManagementError("invalid-query", "Discovery requires scope_id") - principal = current_principal() - access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) - scope = parameters.get("scope_id") - action = ( - AccessAction.SERVER_ADMIN - if admin - else ( - AccessAction.SCOPE_CONTRIBUTE - if operation in {"add", "target_set_enabled"} or (operation == "status" and control) - else AccessAction.SCOPE_READ - ) - ) - if access is not None: - await access.require( - principal, - action, - ResourceRef.server(access.deployment_id) if admin else ResourceRef.scope(scope or ""), - context=AccessAuditContext(transport="http", operation="pc_source_" + operation), - ) - if operation == "add": - await access.require( - principal, - AccessAction.SCOPE_READ, - ResourceRef.scope(scope or ""), - context=AccessAuditContext(transport="http", operation="pc_source_" + operation), - ) - if ( - operation == "configure" - and parameters["registration"].get("action") == "initialize_targets" - and access is not None - ): - for target in parameters["registration"].get("targets", []): - for target_action in (AccessAction.SCOPE_READ, AccessAction.SCOPE_CONTRIBUTE): - await access.require( - principal, - target_action, - ResourceRef.scope(target["scope_id"]), - context=AccessAuditContext(transport="http", operation="source_initialize_targets"), - ) - can_write = True - if operation in {"capabilities", "instances"} and not control and access is not None: - can_write = ( - await access.check( - principal, - AccessAction.SCOPE_CONTRIBUTE, - ResourceRef.scope(scope or ""), - context=AccessAuditContext(transport="http", operation="pc_source_" + operation), - ) - ).allowed - caller = Caller( - deployment=self.settings.access.deployment_id, - subject="anonymous" if principal is None else f"{principal.type}:{principal.id}", - scope_id=scope, - operation=operation, - administrator=admin, - ) - if operation == "write_load": - if self.write_plugins is None: - raise ManagementError("server-unavailable", "Source write storage is not configured", 503) - from powercontext.source_management.write import WritePluginConfig - - async with self._catalog_lock: - return await self.write_plugins.load( - WritePluginConfig.model_validate(parameters), reserved_capabilities=set(self._contracts) - ) - if operation in {"capabilities", "instances"} and not control: - return self.discover(parameters, caller, can_write, await self.available(request), operation) - if operation == "add" and ( - parameters["capability"] == BUILTIN_CAPABILITY.name - or ( - self.write_plugins is not None - and ( - self.write_plugins.find(parameters["capability"], parameters["capability_version"]) is not None - or ( - parameters.get("instance_id") is None - and any(c.name == parameters["capability"] for c in self.write_plugins.capabilities()) - ) - ) - ) - ): - if parameters.get("instance_id") is not None: - raise ManagementError("invalid-input", "write does not accept instance_id") - result = await self.write_add(request, caller, parameters) - else: - if not await self.available(request): - raise ManagementError("management-unavailable", "Source management is unavailable", 503) - call = self.channel.request( - operation, - {"caller": caller.model_dump(mode="json"), "parameters": parameters}, - wait_seconds=self.settings.source_management.write_wait_timeout_seconds if operation == "add" else None, - ) - if operation == "add": - pending = asyncio.create_task(call) - try: - while not pending.done(): - await asyncio.wait({pending}, timeout=1) - await self.require_result_read(request, caller) - result = await pending - finally: - if not pending.done(): - pending.cancel() - await asyncio.gather(pending, return_exceptions=True) - else: - result = await call - if operation == "add" or (operation == "status" and not control): - await self.require_result_read(request, caller) - return result - - def discover(self, parameters, caller, can_write, online, operation): # noqa: C901 - explicit protocol state transitions - capabilities = [] - instances = {} - if parameters.get("instance_id") is None and parameters.get("capability") in (None, BUILTIN_CAPABILITY.name): - capabilities.append({**BUILTIN_CAPABILITY.declaration(), "instances": [], "can_write": can_write}) - if self.write_plugins is not None and parameters.get("instance_id") is None: - for capability in self.write_plugins.capabilities(): - if parameters.get("capability") in (None, capability.name): - capabilities.append({**capability.declaration(), "instances": [], "can_write": can_write}) - if self._has_catalog: - for capability in self.channel.catalog["capabilities"]: - if parameters.get("capability") not in (None, capability["name"]): - continue - visible = [] - for instance in capability["instances"]: - if caller.scope_id not in instance["scope_ids"]: - continue - if caller.subject not in instance["public_principals"] and "*" not in instance["public_principals"]: - continue - if parameters.get("instance_id") not in (None, instance["id"]): - continue - public = { - k: v for k, v in instance.items() if k not in {"scope_ids", "public_principals", "run_id"} - } - public["can_write"] = can_write - if not online: - public["availability"] = "unavailable" - visible.append(public) - instances[instance["id"]] = public - if visible: - item = {**capability, "instances": visible, "can_write": can_write} - if item.get("default_instance_id") not in {v["id"] for v in visible}: - item.pop("default_instance_id", None) - capabilities.append(item) - return { - "capabilities": capabilities, - "management": "online" - if online - else ("not-configured" if self.settings.source_management.manager_subject is None else "unavailable"), - **({"instances": list(instances.values())} if operation == "instances" else {}), - } - - async def builtin_add(self, request, caller, parameters): - if parameters["capability_version"] != BUILTIN_CAPABILITY.version or parameters.get("instance_id") is not None: - raise ManagementError("capability-unavailable", "Use the discovered built-in text capability") - value = parameters["input"] - BUILTIN_CAPABILITY.validate_input(value) - if not value["text"].strip(): - raise ManagementError("invalid-input", "Text must contain non-whitespace content") - from powercontext.server.app import _reject_reserved_handoff_receipt_content - - _reject_reserved_handoff_receipt_content(value["text"]) - receipt = await request.app.state.application.sources.for_scope(caller.scope_id).capture( - CaptureSource(source_id=value["source_id"], content=value["text"], metadata=value.get("metadata") or {}) - ) - return { - "kind": "write", - "result": {"outcome": "saved", "source_refs": [receipt.source_ref.model_dump(mode="json")]}, - } - - async def write_add(self, request, caller, parameters): - if len(self._writes) >= 64: - raise ManagementError("server-busy", "Too many writes are awaiting completion", 503) - - async def authorize(): - await self.require_result_read(request, caller) - access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) - if access is not None: - await access.require( - current_principal(), - AccessAction.SCOPE_CONTRIBUTE, - ResourceRef.scope(caller.scope_id), - context=AccessAuditContext(transport="http", operation="pc_source_add"), - ) - - async def capture(): - if parameters["capability"] == BUILTIN_CAPABILITY.name: - await authorize() - return await self.builtin_add(request, caller, parameters) - if self.write_plugins is None: - raise ManagementError("capability-unavailable", "The write plugin is not loaded", 503) - return await self.write_plugins.write( - request.app.state.application, - caller.scope_id, - parameters["capability"], - parameters["capability_version"], - parameters["input"], - authorize, - ) - - task = asyncio.create_task(capture()) - self._writes.add(task) - try: - async with asyncio.timeout(self.settings.source_management.write_wait_timeout_seconds): - while not task.done(): - await asyncio.wait({task}, timeout=1) - await self.require_result_read(request, caller) - return await task - except TimeoutError as error: - raise ManagementError( - "result-unconfirmed", "The write result was not confirmed; the Source may already be saved", 503 - ) from error - finally: - if not task.done(): - task.cancel() - await asyncio.gather(task, return_exceptions=True) - self._writes.discard(task) - - async def require_result_read(self, request, caller): - access = access_control_for_mode(request.app.state.access_control, mode=request.app.state.access_mode) - if access is not None: - await access.require( - current_principal(), - AccessAction.SCOPE_READ, - ResourceRef.scope(caller.scope_id or ""), - context=AccessAuditContext(transport="http", operation="pc_source_result"), - ) - - async def channel_operation(self, request: Request, operation: str, data: dict[str, Any]) -> dict[str, Any]: - if len(canonical(data).encode()) > MAX_MESSAGE_BYTES: - raise ManagementError("request-too-large", "The management exchange exceeds its size limit") - principal = current_principal() - if principal is None or f"{principal.type}:{principal.id}" != self.settings.source_management.manager_subject: - raise ManagementError("forbidden", "This identity is not bound to the Source management channel", 403) - owner = self.settings.source_management.manager_subject - if operation == "open": - if ( - data["protocol"] != PROTOCOL_VERSION - or data["deployment_id"] != self.settings.access.deployment_id - or data["manager_id"] != self.settings.source_management.manager_id - ): - raise ManagementError("manager-binding", "The management binding or protocol does not match", 403) - generation = self.channel.open(owner) - self._manager_headers = {"authorization": request.headers.get("authorization", "")} - self.ready = False - return {"generation": generation, "protocol": PROTOCOL_VERSION} - generation = data["generation"] - self.channel.check(owner, generation) - if operation == "catalog": - async with self._catalog_lock: - self.synchronize(data["catalog"]) - elif operation == "poll": - return {"messages": await self.channel.poll(owner, generation)} - elif operation == "reply": - self.channel.reply(owner, generation, data["message_id"], data["response"]) - elif operation == "close": - self.channel.close(owner, generation) - self.ready = False - return {"confirmed": True} - - -def mount_source_routes(app: FastAPI) -> None: - app.state.source_gateway = None - - @app.exception_handler(ManagementError) - async def source_error(request, error): - return JSONResponse( - {"error": {"code": error.code, "message": error.message, "details": None}}, status_code=error.status - ) - - def endpoint(operation, model, channel=False): - async def call(body, request: Request): - if operation == "add" and len(await request.body()) > MAX_ADD_BYTES: - raise ManagementError("input-too-large", "Source add parameters exceed 1 MiB") - gateway = request.app.state.source_gateway - if gateway is None: - raise ManagementError("server-unavailable", "The Server is not ready", 503) - data = body.model_dump(mode="json", exclude_none=True) - return await ( - gateway.channel_operation(request, operation, data) - if channel - else gateway.call(request, operation, data) - ) - - call.__annotations__["body"] = model - return call - - specs = [ - ("capabilities", operations.PC_SOURCE_CAPABILITIES), - ("add", operations.PC_SOURCE_ADD), - ("status", operations.PC_SOURCE_STATUS), - ("target_set_enabled", operations.PC_SOURCE_TARGET_SET_ENABLED), - ("instances", operations.PC_SOURCE_INSTANCES), - ("set_enabled", operations.PC_SOURCE_INSTANCE_SET_ENABLED), - ("configure", operations.SOURCE_CONFIGURE), - ("write_load", operations.LOAD_SOURCE_WRITE_PLUGIN), - ] - specs += [ - (name, getattr(operations, "SOURCE_CHANNEL_" + name.upper())) - for name in ("open", "catalog", "poll", "reply", "close") - ] - for name, operation in specs: - app.add_api_route( - operation.path, - endpoint(name, operation.request_type, name in {"open", "catalog", "poll", "reply", "close"}), - methods=[operation.method], - operation_id=operation.operation_id, - response_model=operation.response_type, - response_model_exclude_none=True, - status_code=operation.success_status, - ) diff --git a/src/powercontext/source_management/inspection.py b/src/powercontext/source_management/inspection.py deleted file mode 100644 index 5a4a1ca24b..0000000000 --- a/src/powercontext/source_management/inspection.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Inspect trusted local plugin declarations outside the Server process.""" - -from __future__ import annotations - -import json -import sys - -from powercontext.source_management.config import RuntimeConfig -from powercontext.source_management.loading import load_runtime_plugin -from powercontext.sources import manifest_for_definition - - -def main() -> None: - config = RuntimeConfig.model_validate_json(sys.stdin.buffer.read(2 * 1024 * 1024 + 1)) - loaded = load_runtime_plugin(config) - print( - json.dumps([ - manifest_for_definition(d).model_dump(mode="json", by_alias=True) for d in loaded.registry.definitions - ]) - ) - - -if __name__ == "__main__": - main() diff --git a/src/powercontext/source_management/loading.py b/src/powercontext/source_management/loading.py deleted file mode 100644 index a851c5f37d..0000000000 --- a/src/powercontext/source_management/loading.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Load trusted local code on the execution machine, never in the manager.""" - -from __future__ import annotations - -import hashlib -import importlib -import importlib.util -import inspect -import sys -from dataclasses import dataclass -from pathlib import Path -from types import ModuleType - -from pydantic import BaseModel - -from powercontext.source_management.config import RuntimeConfig -from powercontext.source_management.errors import PluginError -from powercontext.source_management.models import DYNAMIC_PLUGIN_API_VERSION, Capability -from powercontext.sources import SourceDefinitionRegistry - - -@dataclass(frozen=True) -class LoadedRuntimePlugin: - module: ModuleType - config: BaseModel - registry: SourceDefinitionRegistry - connector_name: str - connector_version: str - capabilities: tuple[Capability, ...] - - -def load_local_module(directory: Path, entry: str) -> ModuleType: - path = (directory / entry).resolve() - if path.is_dir(): - path /= "__init__.py" - if path.suffix != ".py" or not path.is_file(): - if "/" not in entry and "\\" not in entry and not entry.endswith(".py"): - return importlib.import_module(entry) - raise PluginError("plugin-entry", "The trusted local Python entry does not exist") - name = "_pc_managed_" + hashlib.sha256(str(path).encode()).hexdigest() - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise PluginError("plugin-entry", "Could not load the local Python entry") - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - try: - spec.loader.exec_module(module) - except BaseException: - for key in tuple(sys.modules): - if key == name or key.startswith(name + "."): - del sys.modules[key] - raise - return module - - -def load_runtime_plugin(config: RuntimeConfig) -> LoadedRuntimePlugin: - module = load_local_module(config.plugin_dir, config.plugin) - if getattr(module, "plugin_api_version", None) != DYNAMIC_PLUGIN_API_VERSION: - raise PluginError("plugin-version", "Managed dynamic plugins require plugin_api_version = '2'") - configuration = getattr(module, "Config", None) - if not isinstance(configuration, type) or not issubclass(configuration, BaseModel): - raise PluginError("plugin-contract", "Config must be a Pydantic model") - if not inspect.iscoroutinefunction(getattr(module, "apply", None)): - raise PluginError("plugin-contract", "apply must be an async function") - definitions = getattr(module, "definitions", None) - if not isinstance(definitions, tuple) or not definitions: - raise PluginError("plugin-contract", "definitions must be a non-empty tuple") - capabilities = tuple( - capability - for value in getattr(module, "capabilities", ()) - if (capability := Capability.model_validate(value)).kind == "collect" - ) - if not capabilities: - raise PluginError("plugin-contract", "Managed plugins must declare capabilities") - if any(c.kind == "collect" for c in capabilities) and not inspect.iscoroutinefunction( - getattr(module, "identify_target", None) - ): - raise PluginError("plugin-contract", "collect requires async identify_target(request, config)") - identities = [] - for field in ("connector_name", "connector_version"): - value = getattr(module, field, None) - if not isinstance(value, str) or not value or value != value.strip() or len(value) > 128: - raise PluginError("plugin-contract", "Connector identities must be non-empty trimmed strings") - identities.append(value) - return LoadedRuntimePlugin( - module, - configuration.model_validate(config.config), - SourceDefinitionRegistry(definitions), - identities[0], - identities[1], - capabilities, - ) diff --git a/src/powercontext/source_management/manager.py b/src/powercontext/source_management/manager.py deleted file mode 100644 index 16c0c20a94..0000000000 --- a/src/powercontext/source_management/manager.py +++ /dev/null @@ -1,720 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Standalone management authority, reached through its outbound Server channel.""" - -from __future__ import annotations - -import asyncio -import hashlib -from contextlib import asynccontextmanager, suppress -from datetime import UTC, datetime -from secrets import compare_digest -from typing import Any -from uuid import uuid4 - -import httpx -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse -from pydantic import ValidationError - -from powercontext.http._generated.models import SourceInstanceExchangeRequest -from powercontext.source_management.channel import ReverseChannel -from powercontext.source_management.config import ManagerConfig, secret -from powercontext.source_management.durable import RuntimeOwner -from powercontext.source_management.models import ( - MAX_ADD_BYTES, - MAX_MESSAGE_BYTES, - PROTOCOL_VERSION, - Caller, - Capability, - InstanceRegistration, - ManagementError, - SourceTarget, - TargetIdentity, - TargetIdentityRequest, - TargetSubmissionReport, -) -from powercontext.source_management.storage import Store, canonical - - -def timestamp() -> str: - return datetime.now(UTC).isoformat() - - -def error_payload(error: ManagementError) -> dict[str, Any]: - return {"error": {"code": error.code, "message": error.message, "status": error.status}} - - -def _sync_target_size(snapshot: dict[str, Any]) -> int: - length = len(canonical(snapshot).encode()) - if length > MAX_MESSAGE_BYTES - 3072: - raise ManagementError("response-too-large", "One target exceeds the sync limit") - return length - - -class SourceManager: - def __init__(self, config: ManagerConfig) -> None: - self.config = config - self.owner = RuntimeOwner(config.state_dir) - self.store = Store(config.state_dir) - binding = { - "server_url": config.server.url, - "deployment_id": config.deployment_id, - "manager_id": config.manager_id, - } - existing = self.store.get("binding", "deployment") - if existing is not None and existing != binding: - self.store.close() - self.owner.close() - raise ManagementError("state-binding-conflict", "This management state belongs to another Server", 409) - self.store.put("binding", "deployment", binding) - self.channels: dict[str, ReverseChannel] = {} - self._lock = asyncio.Lock() - self._generation: str | None = None - self._catalog_revision = 0 - self._catalog_event = asyncio.Event() - self._initialize_locks: dict[str, asyncio.Lock] = {} - - def channel(self, instance_id: str) -> ReverseChannel: - return self.channels.setdefault( - instance_id, - ReverseChannel(lease_seconds=self.config.lease_seconds, request_seconds=self.config.request_seconds), - ) - - def instance(self, identifier: str) -> dict[str, Any]: - instance = self.store.get("instance", identifier) - if instance is None: - raise ManagementError("instance-not-found", "The instance is not registered", 404) - return instance - - def authorize_instance(self, request: Request, identifier: str) -> dict[str, Any]: - instance = self.instance(identifier) - supplied = request.headers.get("authorization", "") - scheme, _, token = supplied.partition(" ") - digest = hashlib.sha256(token.encode()).hexdigest() - if scheme.lower() != "bearer" or not compare_digest(digest, instance["credential_digest"]): - raise ManagementError("unauthorized", "A valid instance credential is required", 401) - return instance - - def runnable(self, instance: dict[str, Any]) -> bool: - return self.channel(instance["instance_id"]).online and instance["enabled"] and instance["actual"] == "running" - - def catalog(self) -> dict[str, Any]: - capabilities: dict[tuple[str, str], dict[str, Any]] = {} - instances = [] - for instance in self.store.all("instance"): - availability = "online" if self.runnable(instance) else "unavailable" - public = { - "id": instance["instance_id"], - "availability": availability, - "scope_ids": instance["scope_ids"], - "public_principals": instance["public_principals"], - "desired": "enabled" if instance["enabled"] else "disabled", - "actual": instance["actual"], - "run_id": instance["run_id"], - "updated_at": instance["updated_at"], - } - instances.append(public) - for old_declaration in instance.get("capabilities", []): - declaration = {k: v for k, v in old_declaration.items() if k in Capability.model_fields} - key = declaration["name"], declaration["version"] - item = capabilities.setdefault(key, {**declaration, "instances": []}) - item["instances"].append(public) - if declaration["name"] in instance["default_capabilities"]: - if item.get("default_instance_id") not in (None, instance["instance_id"]): - raise ManagementError("default-conflict", "Multiple default instances were declared", 409) - item["default_instance_id"] = instance["instance_id"] - return {"revision": self._catalog_revision, "capabilities": list(capabilities.values()), "instances": instances} - - def changed(self) -> None: - self._catalog_revision += 1 - self._catalog_event.set() - - async def handle(self, operation: str, arguments: dict[str, Any]) -> dict[str, Any]: - caller = Caller.model_validate(arguments["caller"]) - parameters = arguments["parameters"] - if ( - caller.deployment != self.config.deployment_id - or caller.operation != operation - or caller.scope_id != parameters.get("scope_id") - or not caller.operation_authorized - ): - raise ManagementError( - "untrusted-context", "The operation is not bound to an authorized Server context", 403 - ) - if operation == "configure": - return await self.configure(caller, parameters) - if operation == "set_enabled": - return await self.set_enabled(caller, parameters) - if operation == "instances" and parameters.get("view") == "control": - self.require_admin(caller) - return {"instances": [self.instance_control(self.instance(parameters["instance_id"]))]} - if operation == "add": - return await self.add(caller, parameters) - if operation == "status": - target = self.target(parameters["target_id"]) - self.require_target(caller, target) - return self.target_control(target) if parameters.get("view") == "control" else self.receipt(target) - if operation == "target_set_enabled": - return await self.target_set_enabled(caller, parameters) - raise ManagementError("unknown-operation", "Unknown Source management operation") - - @staticmethod - def require_admin(caller): - if not caller.administrator: - raise ManagementError("forbidden", "This operation requires server.admin", 403) - - async def configure(self, caller, parameters): # noqa: C901 - explicit protocol state transitions - self.require_admin(caller) - data = parameters["registration"] - if data.get("action") == "initialize_targets": - identifier = data["instance_id"] - async with self._initialize_locks.setdefault(identifier, asyncio.Lock()): - instance = self.instance(identifier) - if instance.get("targets_initialized"): - return {"instance_id": identifier, "reused": True} - for entry in data.get("targets", []): - if entry["scope_id"] not in instance["scope_ids"]: - raise ManagementError("scope-mismatch", "Initial target lies outside instance Scopes", 403) - await self.add( - caller.model_copy(update={"scope_id": entry["scope_id"], "operation": "add"}), - {**entry, "instance_id": identifier}, - ) - # A lost response can safely repeat add; an empty initialized list stays initialized. - instance = self.instance(identifier) - instance["targets_initialized"] = True - self.store.put("instance", identifier, instance) - return {"instance_id": identifier, "reused": False} - registration = InstanceRegistration.model_validate(data) - value = registration.model_dump(mode="json") - async with self._lock: - current = self.store.get("instance", registration.instance_id) - for other in self.store.all("instance"): - if other["instance_id"] != registration.instance_id and set(other["default_capabilities"]) & set( - registration.default_capabilities - ): - raise ManagementError("default-conflict", "This capability already has a default instance", 409) - if current is not None: - configured = current.get("registration", {}) - configured = {k: v for k, v in configured.items() if k in InstanceRegistration.model_fields} - same_deployment = configured == value or ( - current.get("targets_initialized") - and not parameters.get("update") - and {k: v for k, v in configured.items() if k != "scope_ids"} - == {k: v for k, v in value.items() if k != "scope_ids"} - ) - if same_deployment and not parameters.get("update"): - return { - "instance_id": registration.instance_id, - "reused": True, - "targets_initialized": current.get("targets_initialized", False), - } - extending = ( - parameters.get("update") - and {k: v for k, v in configured.items() if k != "scope_ids"} - == {k: v for k, v in value.items() if k != "scope_ids"} - and set(current["scope_ids"]) < set(value["scope_ids"]) - ) - if not extending and not ( - parameters.get("update") - and not current["enabled"] - and current["actual"] == "stopped" - and not self.channel(registration.instance_id).online - ): - raise ManagementError( - "instance-conflict", "Stop the instance before changing its configuration", 409 - ) - desired = current["enabled"] - current.update(value) - current["registration"] = value - current["enabled"] = desired if extending else False - if not extending: - current.update( - run_id=uuid4().hex, - execution=None, - owner=None, - actual="stopped", - ) - self.store.put("instance", registration.instance_id, current) - else: - value.update( - registration=registration.model_dump(mode="json"), - run_id=uuid4().hex, - execution=None, - owner=None, - actual="pending" if registration.enabled else "stopped", - capabilities=[], - updated_at=timestamp(), - targets_initialized=False, - ) - self.store.put("instance", registration.instance_id, value) - self.changed() - return { - "instance_id": registration.instance_id, - "reused": False, - "targets_initialized": False if current is None else current.get("targets_initialized", False), - } - - async def set_enabled(self, caller, parameters): - self.require_admin(caller) - async with self._lock: - instance = self.instance(parameters["instance_id"]) - if instance["enabled"] == parameters["enabled"]: - return self.instance_control(instance) - if parameters["enabled"]: - if instance["actual"] != "stopped": - raise ManagementError("stop-unconfirmed", "Wait for the previous Context to finish cleanup", 409) - instance.update(run_id=uuid4().hex, execution=None, actual="pending") - else: - instance["actual"] = "stopping" - instance.update(enabled=parameters["enabled"], updated_at=timestamp()) - self.store.put("instance", instance["instance_id"], instance) - self.changed() - return self.instance_control(instance) - - def instance_control(self, instance): - return { - "instance_id": instance["instance_id"], - "enabled": instance["enabled"], - "actual": instance["actual"], - "availability": "online" if self.channel(instance["instance_id"]).online else "offline", - } - - def target(self, identifier): - target = self.store.get("target", identifier) - if target is None: - raise ManagementError("target-not-found", "The target does not exist", 404) - return target - - def save_work(self, target): - self.store.put("target", target["target_id"], target) - - @staticmethod - def require_target(caller, target): - if caller.scope_id != target["scope_id"]: - raise ManagementError("scope-mismatch", "The target does not belong to the authorized Scope", 403) - - @staticmethod - def target_control(target): - return { - "kind": "collect", - "target_id": target["target_id"], - "instance_id": target["instance_id"], - "enabled": target["tracking"] == "enabled", - "tracking": target["tracking"], - } - - def receipt(self, target): - instance = self.instance(target["instance_id"]) - return { - **self.target_control(target), - "capability": target["capability"], - "capability_version": target["capability_version"], - "source_refs": target.get("source_refs", []), - "last_submitted_at": target.get("last_submitted_at"), - "availability": "online" if self.runnable(instance) else "unavailable", - } - - async def identify(self, instance, capability, value): - request = TargetIdentityRequest(capability=capability.name, capability_version=capability.version, input=value) - result = TargetIdentity.model_validate( - await self.channel(instance["instance_id"]).request("identify", request.model_dump(mode="json")) - ) - capability.validate_input(result.normalized_input) - return result - - @staticmethod - def identity(scope, instance, capability, key): - return canonical([scope, instance, capability.name, capability.version, key]) - - async def add(self, caller, parameters): - if len(canonical(parameters).encode()) > MAX_ADD_BYTES: - raise ManagementError("input-too-large", "Source add exceeds 1 MiB") - if not parameters.get("instance_id"): - raise ManagementError("instance-selection-required", "Select an explicit instance_id") - instance = self.instance(parameters["instance_id"]) - if caller.scope_id not in instance["scope_ids"]: - raise ManagementError("scope-mismatch", "The instance cannot serve this Scope", 403) - declaration = next( - ( - c - for c in instance["capabilities"] - if c["name"] == parameters["capability"] and c["version"] == parameters["capability_version"] - ), - None, - ) - if declaration is None: - raise ManagementError("capability-unavailable", "The capability version is unavailable", 503) - capability = Capability.model_validate({k: v for k, v in declaration.items() if k in Capability.model_fields}) - capability.validate_input(parameters["input"]) - if capability.kind != "collect": - raise ManagementError("capability-unavailable", "write must be loaded and executed by the PC Server") - identified = await self.identify(instance, capability, parameters["input"]) - identity = self.identity(caller.scope_id, instance["instance_id"], capability, identified.target_key) - async with self._lock: - matches = [t for t in self.store.all("target") if t.get("identity") == identity] - if len(matches) > 1: - raise ManagementError("target-identity-conflict", "Conflicting collect target identities", 409) - if matches: - target = matches[0] - if target["input"] != identified.normalized_input: - raise ManagementError("target-config-conflict", "The target already has different settings", 409) - self.store.claim_identity(identity, target["target_id"]) - return self.target_control(target) - instance = self.instance(instance["instance_id"]) - if not self.runnable(instance): - raise ManagementError("instance-unavailable", "The plugin is not running", 503) - target: dict[str, Any] = { - "target_id": "tgt_" + uuid4().hex, - "instance_id": instance["instance_id"], - "scope_id": caller.scope_id, - "capability": capability.name, - "capability_version": capability.version, - "input": identified.normalized_input, - "identity": identity, - "tracking": "enabled", - "creator": caller.subject, - "source_refs": [], - "updated_at": timestamp(), - } - snapshot = {k: target[k] for k in SourceTarget.model_fields if k in target} - _sync_target_size(snapshot) - with self.store.transaction(): - self.store.claim_identity(identity, target["target_id"]) - self.save_work(target) - return self.target_control(target) - - async def target_set_enabled(self, caller, parameters): - async with self._lock: - target = self.target(parameters["target_id"]) - self.require_target(caller, target) - if (target["tracking"] == "enabled") == parameters["enabled"]: - return self.target_control(target) - if parameters["enabled"]: - if target["tracking"] != "stopped": - raise ManagementError("stop-unconfirmed", "Wait for target cleanup", 409) - if caller.scope_id not in self.instance(target["instance_id"])["scope_ids"]: - raise ManagementError("scope-mismatch", "The instance cannot serve this Scope", 403) - declarations = self.instance(target["instance_id"])["capabilities"] - if not any( - c["name"] == target["capability"] and c["version"] == target["capability_version"] - for c in declarations - ): - raise ManagementError( - "capability-unavailable", "The target's capability version is unavailable", 409 - ) - target["tracking"] = "enabled" - else: - target["tracking"] = "stopping" - self.save_work(target) - return self.target_control(target) - - async def connect_instance(self, instance, data): # noqa: C901 - ownership and contract checks - declarations = [Capability.model_validate(c).declaration() for c in data["capabilities"]] - if any(c["kind"] != "collect" for c in declarations): - raise ManagementError("capability-conflict", "Managed instances may publish only collect capabilities", 409) - if len({(c["name"], c["version"]) for c in declarations}) != len(declarations): - raise ManagementError("capability-conflict", "Duplicate capability declarations", 409) - async with self._lock: - instance = self.instance(instance["instance_id"]) - if ( - data["protocol"] != PROTOCOL_VERSION - or data["execution_location"] != instance["execution_location"] - or data["configuration_digest"] != instance["configuration_digest"] - ): - raise ManagementError("instance-binding", "The runtime does not match registration", 409) - for c in declarations: - key = canonical([c["name"], c["version"]]) - known = self.store.get("capability", key) - if known is not None: - known = {k: v for k, v in known.items() if k in Capability.model_fields} - if c["name"] == "content.inline" or (known is not None and known != c): - raise ManagementError("capability-conflict", "A capability version cannot change its contract", 409) - owner = data["owner"] - old_owner = instance.get("owner") - # Lease expiry alone never authorizes a second execution process. - if ( - old_owner is not None - and old_owner != owner - and (not data.get("owner_exited") or data.get("previous_owner") != old_owner) - ): - raise ManagementError("execution-owned", "The previous process has not confirmed exit", 409) - if old_owner is None and instance.get("execution") and instance["actual"] != "stopped": - raise ManagementError("execution-owned", "The existing execution has not confirmed exit", 409) - offered = {(c["name"], c["version"]) for c in declarations} - if any( - t["instance_id"] == instance["instance_id"] - and (t["capability"], t["capability_version"]) not in offered - for t in self.store.all("target") - ): - raise ManagementError("capability-in-use", "Keep capability versions required by retained targets", 409) - generation = self.channel(instance["instance_id"]).open(instance["instance_id"]) - try: - with self.store.transaction(): - for c in declarations: - self.store.put("capability", canonical([c["name"], c["version"]]), c) - if owner != old_owner: - instance.update( - run_id=uuid4().hex, execution=None, actual="pending" if instance["enabled"] else "stopped" - ) - instance.update(owner=owner, capabilities=declarations) - self.store.put("instance", instance["instance_id"], instance) - except BaseException: - self.channel(instance["instance_id"]).close() - raise - self.changed() - return {"generation": generation, "protocol": PROTOCOL_VERSION} - - async def sync_instance(self, instance, generation, cursor=""): - self.channel(instance["instance_id"]).check(instance["instance_id"], generation) - targets, stopping, size, next_cursor, last_id = [], [], 1024, None, cursor - for target in sorted(self.store.all("target"), key=lambda t: t["target_id"]): - if target["instance_id"] != instance["instance_id"] or target["target_id"] <= cursor: - continue - if target["tracking"] == "stopping": - snapshot = {"target_id": target["target_id"]} - destination = stopping - elif target["tracking"] == "enabled" and target["scope_id"] in instance["scope_ids"]: - snapshot = {k: target[k] for k in SourceTarget.model_fields if k in target} - destination = targets - else: - continue - length = _sync_target_size(snapshot) - if size + length > MAX_MESSAGE_BYTES - 2048: - next_cursor = last_id - break - destination.append(snapshot) - size += length - last_id = target["target_id"] - return { - "enabled": instance["enabled"], - "run_id": instance["run_id"], - "actual": instance["actual"], - "execution": instance["execution"], - "targets": targets, - "stopping": stopping, - "next_cursor": next_cursor, - } - - async def execution(self, instance, data): - async with self._lock: - instance = self.instance(instance["instance_id"]) - if data["run_id"] != instance["run_id"]: - raise ManagementError("stale-run", "This report belongs to an old execution", 409) - if instance["execution"] not in (None, data["execution"]): - raise ManagementError("execution-owned", "Another Context owns this run", 409) - if data["status"] == "running" and ( - not instance["enabled"] or instance["actual"] in {"complete", "failed", "stopped", "cleanup-failed"} - ): - raise ManagementError("run-terminal", "This business run cannot be restarted", 409) - if instance["actual"] == "cleanup-failed" and data["status"] != "cleanup-failed": - raise ManagementError("cleanup-unconfirmed", "Previous resources have not been cleaned up", 409) - instance.update(execution=data["execution"], actual=data["status"], updated_at=timestamp()) - self.store.put("instance", instance["instance_id"], instance) - self.changed() - return {"confirmed": True} - - async def report_result(self, instance, data): - report = TargetSubmissionReport.model_validate(data) - target = self.target(report.target_id) - if target["instance_id"] != instance["instance_id"]: - raise ManagementError("forbidden", "The target belongs to another instance", 403) - # This is the last received submission report, not a history or a completed batch. - target["source_refs"] = [report.source_refs[0].model_dump(mode="json")] - target["last_submitted_at"] = report.submitted_at - self.save_work(target) - return {"confirmed": True} - - async def stopped_targets(self, instance, data): - async with self._lock: - instance = self.instance(instance["instance_id"]) - if data["run_id"] != instance["run_id"] or data.get("execution") != instance["execution"]: - raise ManagementError("execution-owned", "Only the current Context can confirm cleanup", 409) - if instance["actual"] == "cleanup-failed": - raise ManagementError("cleanup-unconfirmed", "Resource cleanup failed", 409) - with self.store.transaction(): - for stop in data["targets"]: - target = self.target(stop["target_id"]) - if target["instance_id"] != instance["instance_id"]: - raise ManagementError("forbidden", "The target belongs to another instance", 403) - if target["tracking"] == "stopping": - target["tracking"] = "stopped" - self.save_work(target) - return {"confirmed": True} - - async def serve_channel(self) -> None: - headers = {"Authorization": "Bearer " + secret(self.config.server.credential_file)} - async with httpx.AsyncClient(base_url=self.config.server.url, headers=headers, timeout=20) as client: - while True: - try: - # Refresh credentials on reconnect; every transport operation is reauthenticated by Server. - client.headers["Authorization"] = "Bearer " + secret(self.config.server.credential_file) - response = await client.post( - "/v1/source-management/channel/open", - json={ - "protocol": PROTOCOL_VERSION, - "deployment_id": self.config.deployment_id, - "manager_id": self.config.manager_id, - }, - ) - response.raise_for_status() - self._generation = response.json()["generation"] - await self._connected(client) - except (httpx.HTTPError, ManagementError): - await asyncio.sleep(0.2) - finally: - if self._generation is not None: - with suppress(httpx.HTTPError): - await client.post( - "/v1/source-management/channel/close", json={"generation": self._generation} - ) - self._generation = None - - async def _connected(self, client: httpx.AsyncClient) -> None: # noqa: C901 - bounded channel tasks and their cleanup - generation = self._generation - - async def synchronize() -> None: - while True: - self._catalog_event.clear() - response = await client.post( - "/v1/source-management/channel/catalog", json={"generation": generation, "catalog": self.catalog()} - ) - response.raise_for_status() - try: - async with asyncio.timeout(1): - await self._catalog_event.wait() - except TimeoutError: - pass - - async def respond(message: dict[str, Any]) -> None: - try: - result = {"result": await self.handle(message["operation"], message["arguments"])} - except ManagementError as error: - result = error_payload(error) - except (ValidationError, KeyError, TypeError, ValueError): - result = error_payload(ManagementError("invalid-request", "The management request is invalid")) - response = await client.post( - "/v1/source-management/channel/reply", - json={"generation": generation, "message_id": message["message_id"], "response": result}, - ) - response.raise_for_status() - - async def poll() -> None: - additions, controls = asyncio.Semaphore(16), asyncio.Semaphore(8) - - async def limited_respond(message: dict[str, Any], capacity) -> None: - try: - await respond(message) - finally: - capacity.release() - - async with asyncio.TaskGroup() as requests: - while True: - response = await client.post("/v1/source-management/channel/poll", json={"generation": generation}) - response.raise_for_status() - for message in response.json()["messages"]: - if message["generation"] == generation: - capacity = additions if message["operation"] == "add" else controls - if capacity.locked(): - response = await client.post( - "/v1/source-management/channel/reply", - json={ - "generation": generation, - "message_id": message["message_id"], - "response": error_payload( - ManagementError( - "management-busy", "Too many concurrent Source operations", 503 - ) - ), - }, - ) - response.raise_for_status() - continue - await capacity.acquire() - requests.create_task(limited_respond(message, capacity)) - - try: - async with asyncio.TaskGroup() as tasks: - tasks.create_task(synchronize()) - tasks.create_task(poll()) - except* (httpx.HTTPError, ManagementError): - pass - - -def create_manager_app(config: ManagerConfig) -> FastAPI: # noqa: C901 - lifecycle and nested protocol dispatcher - manager = SourceManager(config) - - @asynccontextmanager - async def lifespan(app): - task = asyncio.create_task(manager.serve_channel()) - try: - yield - finally: - task.cancel() - with suppress(asyncio.CancelledError): - await task - for channel in manager.channels.values(): - channel.close() - manager.store.close() - manager.owner.close() - - app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) - app.state.source_manager = manager - - @app.exception_handler(ManagementError) - async def management_error(request, error): - return JSONResponse(error_payload(error), status_code=error.status) - - @app.post("/v1/source-management/instances/{instance_id}/{operation}") - async def instance_endpoint( # noqa: C901 - one branch per authenticated protocol operation - instance_id: str, operation: str, request: Request - ): - instance = manager.authorize_instance(request, instance_id) - try: - content = await request.body() - from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES - - if len(content) > MAX_SOURCE_OBSERVATION_BYTES: - raise ManagementError("request-too-large", "The instance exchange exceeds its size limit") - data = SourceInstanceExchangeRequest.model_validate_json(content).model_dump(mode="json", exclude_none=True) - if operation == "owner": - return {"owner": instance.get("owner")} - if operation == "connect": - return await manager.connect_instance(instance, data) - generation = data["generation"] - channel = manager.channel(instance_id) - channel.check(instance_id, generation) - if operation == "poll": - return {"messages": await channel.poll(instance_id, generation)} - if operation == "reply": - channel.reply(instance_id, generation, data["message_id"], data["response"]) - return {"confirmed": True} - if operation == "sync": - return await manager.sync_instance(instance, generation, data.get("cursor", "")) - if operation == "execution": - return await manager.execution(instance, data) - if operation == "results": - return await manager.report_result(instance, data) - if operation == "stopped-targets": - return await manager.stopped_targets(instance, data) - if operation == "close": - channel.close(instance_id, generation) - manager.changed() - return {"confirmed": True} - except (KeyError, TypeError, ValidationError) as error: - raise ManagementError("invalid-request", "The instance request is invalid") from error - raise ManagementError("unknown-operation", "Unknown instance operation", 404) - - return app diff --git a/src/powercontext/source_management/models.py b/src/powercontext/source_management/models.py deleted file mode 100644 index aed143496d..0000000000 --- a/src/powercontext/source_management/models.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Serializable Source capability, authorization and target contracts.""" - -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator - -from powercontext.sources import SourceRef - -PROTOCOL_VERSION = "3" -DYNAMIC_PLUGIN_API_VERSION = "2" -MAX_ADD_BYTES = 1024 * 1024 -MAX_MESSAGE_BYTES = 2 * 1024 * 1024 - - -class ManagementError(Exception): - def __init__(self, code: str, message: str, status: int = 400) -> None: - self.code, self.message, self.status = code, message, status - super().__init__(message) - - -class Model(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - -class Capability(Model): - name: str = Field(min_length=1, max_length=128, pattern=r"^[a-z][a-z0-9_.-]*$") - version: str = Field(min_length=1, max_length=64) - description: str = Field(min_length=1, max_length=2048) - input_schema: dict[str, JsonValue] - kind: Literal["collect", "write"] = "collect" - definition_name: str | None = Field(default=None, min_length=1, max_length=128) - definition_version: str | None = Field(default=None, min_length=1, max_length=128) - - @model_validator(mode="after") - def validate_schema(self) -> Capability: - from jsonschema import Draft202012Validator - - Draft202012Validator.check_schema(self.input_schema) - return self - - def declaration(self) -> dict[str, Any]: - return self.model_dump(mode="json") - - def validate_input(self, value: dict[str, Any]) -> None: - from jsonschema import Draft202012Validator - - if next(Draft202012Validator(self.input_schema).iter_errors(value), None) is not None: - raise ManagementError("invalid-input", "Input does not satisfy the discovered capability schema") - - -class Caller(Model): - deployment: str - subject: str - scope_id: str | None = None - operation: str - administrator: bool = False - operation_authorized: bool = True - - -class TargetIdentityRequest(Model): - capability: str - capability_version: str - input: dict[str, JsonValue] - - -class TargetIdentity(Model): - target_key: str = Field(min_length=1, max_length=512) - normalized_input: dict[str, JsonValue] - - @model_validator(mode="after") - def nonblank_key(self) -> TargetIdentity: - if not self.target_key.strip(): - raise ValueError("target_key must not be blank") # noqa: TRY003 - return self - - -class SourceTarget(Model): - target_id: str - scope_id: str - instance_id: str - capability: str - capability_version: str - input: dict[str, JsonValue] - - -class InstanceRegistration(Model): - instance_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$") - credential_digest: str = Field(pattern=r"^[0-9a-f]{64}$", repr=False) - execution_location: str = Field(min_length=1, max_length=255) - scope_ids: tuple[str, ...] = () - public_principals: tuple[str, ...] = () - default_capabilities: tuple[str, ...] = () - enabled: bool = True - configuration_digest: str = Field(min_length=1) - - -class TargetSubmissionReport(Model): - generation: str - target_id: str = Field(min_length=1, max_length=128) - source_refs: tuple[SourceRef, ...] = Field(min_length=1, max_length=1) - submitted_at: str - - -BUILTIN_CAPABILITY = Capability( - name="content.inline", - version="2", - kind="write", - description="Save supplied text as a captured Source", - input_schema={ - "type": "object", - "properties": { - "source_id": {"type": "string", "minLength": 1, "maxLength": 256}, - "text": {"type": "string", "minLength": 1}, - "metadata": {"type": "object"}, - }, - "required": ["source_id", "text"], - "additionalProperties": False, - }, -) diff --git a/src/powercontext/source_management/remote_deployment.py b/src/powercontext/source_management/remote_deployment.py deleted file mode 100644 index b5f0ae3284..0000000000 --- a/src/powercontext/source_management/remote_deployment.py +++ /dev/null @@ -1,161 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Load a collect instance on its execution machine using existing Server identities.""" - -from __future__ import annotations - -import hashlib -import json -import secrets -import socket -import time -from contextlib import ExitStack - -import httpx - -from powercontext.source_management.bootstrap import _allowed, _grant, _principal, request -from powercontext.source_management.config import Endpoint, RuntimeConfig, configuration_digest, secret -from powercontext.source_management.durable import RuntimeOwner -from powercontext.source_management.loading import load_runtime_plugin -from powercontext.source_management.models import InstanceRegistration, ManagementError -from powercontext.source_management.service import launch, protected_write -from powercontext.sources import manifest_for_definition - - -def load(path, local, *, update=False): - """Register, authorize and start locally without changing remote authentication configuration.""" - root = path.expanduser().resolve(strict=True).parent - deployment = local.deployment - state = (root / deployment.state_dir).resolve() - lock = RuntimeOwner(state / "deployment-lock") - try: - with ExitStack() as updating: - if update: - updating.callback(RuntimeOwner(state).close) - return _load(root, state, local, deployment, update, updating) - finally: - lock.close() - - -def _load(root, state, local, deployment, update, updating): # noqa: C901 - validate identities before registering or starting - admin_token = secret(root / deployment.server.credential_file) - execution_file = root / deployment.execution_credential_file - execution_token = secret(execution_file) - instance_file = state / "instance.token" - if not instance_file.exists(): - protected_write(instance_file, secrets.token_urlsafe(32), immutable=True) - aliases = {key: (root / value).resolve() for key, value in local.secrets.items()} - config = RuntimeConfig( - deployment_id=deployment.deployment_id, - instance_id=local.id, - plugin=local.plugin, - plugin_dir=root, - config=local.config, - secrets=aliases, - management=Endpoint(url=deployment.management_url, credential_file=instance_file), - server=Endpoint(url=deployment.server.url, credential_file=execution_file), - state_dir=state, - execution_location=socket.gethostname() + ":" + str(root), - configuration_digest=configuration_digest(local.plugin, local.config, aliases), - ) - runtime_path = state / "runtime-config.json" - if not update and runtime_path.exists() and RuntimeConfig.model_validate_json(runtime_path.read_text()) != config: - raise ManagementError( - "configuration-conflict", "Stop the instance and its Runtime, then use source load --update" - ) - registration_path = state / "registration.json" - previous = json.loads(registration_path.read_text()) if registration_path.exists() else None - scopes = ( - tuple(previous["scope_ids"]) - if previous is not None and not update - else tuple(dict.fromkeys((*local.scope_ids, *(target.scope_id for target in local.targets)))) - ) - with ( - httpx.Client( - base_url=deployment.server.url, headers={"Authorization": "Bearer " + admin_token}, timeout=65 - ) as admin, - httpx.Client( - base_url=deployment.server.url, headers={"Authorization": "Bearer " + execution_token}, timeout=20 - ) as execution, - ): - admin_identity, identity = _principal(admin), _principal(execution) - server = {"type": "server", "deployment_id": deployment.deployment_id} - if not _allowed(admin, "server.admin", server): - raise ManagementError("deployment-forbidden", "Loading a collect instance requires server.admin") - if admin_identity == identity or _allowed(execution, "server.admin", server): - raise ManagementError("credential-separation", "The Runtime requires a separate non-administrator identity") - plugin = load_runtime_plugin(config) - for definition in plugin.registry.definitions: - manifest = manifest_for_definition(definition).model_dump(mode="json", by_alias=True) - registered = request(admin, "POST", "/v1/source-definitions/register", {"manifest": manifest}) - if registered != manifest: - raise ManagementError("manifest-mismatch", "Server returned a different Definition manifest") - for scope in scopes: - _grant(admin, identity, scope) - if any( - not _allowed(execution, action, {"type": "scope", "scope_id": scope}) - for action in ("scope.read", "scope.contribute") - ): - raise ManagementError("execution-forbidden", "Execution identity cannot access the configured Scope") - registration = InstanceRegistration( - instance_id=local.id, - credential_digest=hashlib.sha256(secret(instance_file).encode()).hexdigest(), - execution_location=config.execution_location, - scope_ids=scopes, - public_principals=local.public_principals, - default_capabilities=local.default_capabilities, - configuration_digest=config.configuration_digest, - ) - result = _retry(admin, {"registration": registration.model_dump(mode="json"), "update": update}) - protected_write(registration_path, registration.model_dump_json(indent=2)) - protected_write(runtime_path, config.model_dump_json(indent=2)) - result["runtime_config"] = str(runtime_path) - updating.close() - result["service"] = launch("runtime", config, deployment.start) - if not result.get("targets_initialized"): - for scope in dict.fromkeys(target.scope_id for target in local.targets): - _grant(admin, admin_identity, scope) - _retry( - admin, - { - "registration": { - "action": "initialize_targets", - "instance_id": local.id, - "targets": [target.model_dump(mode="json") for target in local.targets], - } - }, - ) - return result - - -def _retry(admin, body): - deadline = time.monotonic() + 60 - while True: - try: - return request(admin, "POST", "/v1/source-management/configure", body) - except ManagementError as error: - if ( - error.code - not in { - "management-unavailable", - "management-busy", - "result-unconfirmed", - "instance-unavailable", - "capability-unavailable", - } - or time.monotonic() >= deadline - ): - raise - time.sleep(0.2) diff --git a/src/powercontext/source_management/runtime.py b/src/powercontext/source_management/runtime.py deleted file mode 100644 index 3b9dc010bf..0000000000 --- a/src/powercontext/source_management/runtime.py +++ /dev/null @@ -1,354 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# ruff: noqa: TRY301 - -"""Independent runtime owner: control stays responsive while apply owns scheduling.""" - -from __future__ import annotations - -import asyncio -import logging -import signal -from contextlib import suppress -from typing import Any -from uuid import uuid4 - -import httpx - -from powercontext.client import PowerContextClient -from powercontext.source_management.config import RuntimeConfig, configuration_digest, secret -from powercontext.source_management.context import PluginContext -from powercontext.source_management.durable import InstanceOwner, RuntimeOwner, require_process_identity -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: - require_process_identity() - 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 deleted file mode 100644 index 8f498bfaf6..0000000000 --- a/src/powercontext/source_management/service.py +++ /dev/null @@ -1,191 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Linux systemd user services for the Source manager and managed instances.""" - -from __future__ import annotations - -import hashlib -import json -import os -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import Any, Literal - -from powercontext.source_management.config import ManagerConfig, RuntimeConfig -from powercontext.source_management.models import ManagementError - -Role = Literal["manager", "runtime"] -MARKER = "# Managed by PowerContext Source services v1" - - -def protected_write(path: Path, content: str, *, immutable: bool = False) -> None: - """Atomically write private configuration, refusing foreign paths and conflicts.""" - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - if path.is_symlink() or (path.exists() and not path.is_file()): - raise ManagementError("unsafe-file", "Configuration must be a regular file") - if path.exists() and immutable: - if path.read_text(encoding="utf-8") != content: - raise ManagementError( - "configuration-conflict", "Existing configuration differs; preserve it or deploy a new instance ID" - ) - return - descriptor, name = tempfile.mkstemp(prefix=".pc-", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - os.fchmod(stream.fileno(), 0o600) - stream.write(content) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) - - -def _systemctl(*arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: - executable = shutil.which("systemctl") - if sys.platform != "linux" or executable is None: - raise ManagementError("service-unavailable", "Source services require Linux with systemd --user") - try: - result = subprocess.run( # noqa: S603 - [executable, "--user", *arguments], - capture_output=True, - text=True, - timeout=45, - check=False, - ) - except (OSError, subprocess.TimeoutExpired) as error: - raise ManagementError("service-unavailable", "The user service manager did not respond") from error - if check and result.returncode: - raise ManagementError("service-operation-failed", "systemd operation failed; inspect the user journal") - return result - - -def require_service_support() -> None: - _systemctl("show-environment") - - -def _identity(role: Role, config: ManagerConfig | RuntimeConfig) -> tuple[str, Path]: - identity = config.manager_id if isinstance(config, ManagerConfig) else config.instance_id - digest = hashlib.sha256(f"{config.server.url}\n{identity}".encode()).hexdigest()[:24] - name = f"powercontext-source-{role}-{digest}.service" - root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")).expanduser() - return name, root / "systemd" / "user" / name - - -def _quote(value: str) -> str: - if any(ord(char) < 32 or ord(char) == 127 for char in value): - raise ManagementError("invalid-service-path", "Service arguments must not contain control characters") - return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"' - - -def render(role: Role, config_file: Path) -> str: - command = " ".join( - _quote(part) - for part in ( - sys.executable, - "-m", - "powercontext.source_management.cli", - role, - "run", - "--config", - str(config_file), - ) - ) - return f"""{MARKER} -[Unit] -Description=PowerContext Source {role} -After=network.target -StartLimitIntervalSec=60 -StartLimitBurst=5 - -[Service] -Type=simple -ExecStart={command} -Restart=on-failure -RestartSec=5 -TimeoutStopSec=45 -KillMode=control-group -UMask=0077 -NoNewPrivileges=true -Environment=PYTHONUNBUFFERED=1 - -[Install] -WantedBy=default.target -""" - - -def _service_action(role: Role, config: ManagerConfig | RuntimeConfig, action: str) -> dict[str, Any]: - """Operate only the exact managed unit; repeated install never restarts it.""" - require_service_support() - name, path = _identity(role, config) - snapshot = config.state_dir / f"{role}-service.json" - contents = render(role, snapshot) - if path.is_symlink() or (path.exists() and path.read_text(encoding="utf-8") != contents): - raise ManagementError("service-conflict", "The existing service differs from this configuration") - if action == "install": - serialized = json.dumps(config.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n" - if snapshot.exists() and snapshot.read_text(encoding="utf-8") != serialized: - state = _systemctl("is-active", name, check=False).stdout.strip() - if state not in {"inactive", "failed", "unknown"}: - raise ManagementError( - "configuration-conflict", "Stop the existing service before replacing its configuration" - ) - protected_write(snapshot, serialized) - protected_write(path, contents, immutable=True) - _systemctl("daemon-reload") - _systemctl("enable", "--now", name) - elif action in {"stop", "uninstall"}: - if not path.exists(): - return {"unit": name, "state": "not-installed"} - _systemctl("stop", name) - if action == "uninstall": - _systemctl("disable", name) - path.unlink() - if snapshot.is_symlink(): - raise ManagementError("unsafe-file", "Service configuration must not be a symlink") - snapshot.unlink(missing_ok=True) - _systemctl("daemon-reload") - elif action != "status": - raise ManagementError("invalid-action", "Unknown service operation") - state = _systemctl("is-active", name, check=False).stdout.strip() or "unknown" - return {"unit": name, "state": state, "configuration": str(snapshot), "logs": f"journalctl --user -u {name}"} - - -def service_action(role: Role, config: ManagerConfig | RuntimeConfig, action: str) -> dict[str, Any]: - """Serialize changes to a unit even when two initializers run concurrently.""" - require_service_support() - import fcntl - - _, path = _identity(role, config) - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - descriptor = os.open(path.with_suffix(".lock"), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - try: - fcntl.flock(descriptor, fcntl.LOCK_EX) - return _service_action(role, config, action) - finally: - os.close(descriptor) - - -def launch(role: Role, config: ManagerConfig | RuntimeConfig, mode: str = "background") -> dict[str, Any]: - """Default to a detached process; system service installation is explicit.""" - if mode == "local-service": - return service_action(role, config, "install") - from powercontext.source_management.background import process_action - - return process_action(role, config, "start") diff --git a/src/powercontext/source_management/storage.py b/src/powercontext/source_management/storage.py deleted file mode 100644 index 4ed9a43d5d..0000000000 --- a/src/powercontext/source_management/storage.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Small protected transactional store for the sole management authority.""" - -from __future__ import annotations - -import json -import os -import sqlite3 -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path -from typing import Any - -from powercontext.source_management.models import ManagementError - - -def canonical(value: object) -> str: - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) - - -class Store: - def __init__(self, directory: Path) -> None: - directory.mkdir(mode=0o700, parents=True, exist_ok=True) - if directory.is_symlink(): - raise ManagementError("invalid-storage", "State directory must not be a symlink") - os.chmod(directory, 0o700) - path = directory / "management.sqlite3" - if path.is_symlink(): - raise ManagementError("invalid-storage", "State database must not be a symlink") - self.connection = sqlite3.connect(path, isolation_level=None) - os.chmod(path, 0o600) - self.connection.execute("PRAGMA journal_mode=WAL") - self.connection.execute("PRAGMA synchronous=FULL") - self.connection.execute("CREATE TABLE IF NOT EXISTS records(kind TEXT,id TEXT,value TEXT,PRIMARY KEY(kind,id))") - self.connection.execute("CREATE TABLE IF NOT EXISTS follow_identity(identity TEXT PRIMARY KEY,record_id TEXT)") - - def claim_identity(self, identity: str, identifier: str) -> None: - existing = self.connection.execute( - "SELECT record_id FROM follow_identity WHERE identity=?", (identity,) - ).fetchone() - if existing is not None and existing[0] != identifier: - raise ManagementError("target-identity-conflict", "This follow identity is already reserved", 409) - self.connection.execute("INSERT OR IGNORE INTO follow_identity VALUES(?,?)", (identity, identifier)) - - def retire_identity(self, identity: str, identifier: str) -> None: - self.connection.execute("DELETE FROM follow_identity WHERE identity=? AND record_id=?", (identity, identifier)) - - def close(self) -> None: - self.connection.close() - - def get(self, kind: str, identifier: str) -> dict[str, Any] | None: - row = self.connection.execute("SELECT value FROM records WHERE kind=? AND id=?", (kind, identifier)).fetchone() - return None if row is None else json.loads(row[0]) - - def put(self, kind: str, identifier: str, value: dict[str, Any]) -> None: - self.connection.execute( - "INSERT INTO records VALUES(?,?,?) ON CONFLICT(kind,id) DO UPDATE SET value=excluded.value", - (kind, identifier, canonical(value)), - ) - - def all(self, kind: str) -> list[dict[str, Any]]: - return [ - json.loads(row[0]) - for row in self.connection.execute("SELECT value FROM records WHERE kind=? ORDER BY id", (kind,)) - ] - - @contextmanager - def transaction(self) -> Iterator[None]: - self.connection.execute("BEGIN IMMEDIATE") - try: - yield - self.connection.execute("COMMIT") - except BaseException: - self.connection.execute("ROLLBACK") - raise diff --git a/src/powercontext/source_management/targets.py b/src/powercontext/source_management/targets.py deleted file mode 100644 index 0f55ac0d0f..0000000000 --- a/src/powercontext/source_management/targets.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""In-memory target snapshots and submission tracking; no execution journal.""" - -from __future__ import annotations - -from builtins import list as BuiltinList -from datetime import UTC, datetime -from typing import Any - -from powercontext.source_management.models import ManagementError, SourceTarget - - -class ManagedTargets: - def __init__(self, *, instance_id: str, report) -> None: - self.instance_id = instance_id - self.report = report - self._targets: dict[str, SourceTarget] = {} - self._synchronized = False - self._current: BatchObserver | None = None - self._stopping: set[str] = set() - - def synchronize(self, targets: BuiltinList[dict[str, Any]], stopping: BuiltinList[dict[str, Any]]) -> None: - self._targets = {t.target_id: t for v in targets if (t := SourceTarget.model_validate(v))} - self._stopping = {v["target_id"] for v in stopping} - self._synchronized = True - - async def list(self) -> tuple[SourceTarget, ...]: - if not self._synchronized: - raise ManagementError("targets-unavailable", "Targets have not yet been synchronized", 503) - return tuple(t for t in self._targets.values() if t.target_id not in self._stopping) - - def begin(self, binding, selected) -> BatchObserver: - if self._current is not None: - raise ManagementError("batch-running", "A batch is already running", 409) - if not self._synchronized: - raise ManagementError("targets-unavailable", "Targets have not yet been synchronized", 503) - seen = set() - for item in selected: - known = self._targets.get(item.target_id) - if ( - item.target_id in seen - or known is None - or known != item - or item.instance_id != self.instance_id - or item.scope_id != binding.scope_id - or item.target_id in self._stopping - ): - raise ManagementError("stale-target", "The batch contains a stopped, stale or foreign item", 409) - seen.add(item.target_id) - self._current = BatchObserver(self, binding, selected) - return self._current - - def end(self) -> None: - self._current = None - - def active_target_ids(self) -> set[str]: - return set() if self._current is None else set(self._current.selected) - - def batch(self, target, session): - current = self._current - if current is None or current.session is not session or current.selected.get(target.target_id) != target: - raise ManagementError("invalid-batch", "The item does not belong to this batch") - return current - - async def submit(self, target, *, session, item_id, definition_name, value): - batch = self.batch(target, session) - if item_id in batch.items: - raise ManagementError("duplicate-item", "Each batch item needs a distinct item_id") - batch.items[item_id] = target.target_id - return await session.submit(item_id, definition_name, value) - - -class BatchObserver: - def __init__(self, targets, binding, selected): - self.targets, self.binding, self.session = targets, binding, None - self.selected = {t.target_id: t for t in selected} - self.items: dict[str, str] = {} - self.refs: dict[str, list[Any]] = {} - - async def started(self, session): - self.session = session - - async def materialized(self, binding, item_id, observation): - if binding != self.binding or (self.selected and item_id not in self.items): - raise ManagementError("unassociated-source", "Use targets.submit for managed collect batches") - - async def accepted(self, item_id, source_ref): - if item_id not in self.items: - return - identifier = self.items[item_id] - self.refs.setdefault(identifier, []).append(source_ref) - if isinstance(self.selected[identifier], SourceTarget): - # A best-effort report cannot revoke the Source that the Server has already accepted. - await self.targets.report({ - "target_id": identifier, - "source_refs": [source_ref.model_dump(mode="json")], - "submitted_at": datetime.now(UTC).isoformat(), - }) - - async def prepared(self, session, completion): - pass - - async def completed(self, result): - pass - - async def interrupted(self, error): - pass diff --git a/src/powercontext/source_management/write.py b/src/powercontext/source_management/write.py deleted file mode 100644 index d3348c0311..0000000000 --- a/src/powercontext/source_management/write.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Server-owned executable Source definitions and synchronous writes.""" - -from __future__ import annotations - -import asyncio -import inspect -import json -import logging -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from pydantic import BaseModel, Field -from sqlalchemy import insert, select - -from powercontext.builtin.persistence.errors import StoredPayloadConflictError -from powercontext.builtin.persistence.source_definitions import SourceDefinitionManifestRepository -from powercontext.builtin.persistence.tables import SOURCE_WRITE_PLUGINS_TABLE -from powercontext.builtin.runtime.models import SubmitSourceObservation -from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY -from powercontext.builtin.sources.observation_validation import validate_source_definition_manifest -from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES -from powercontext.source_management.loading import load_local_module -from powercontext.source_management.models import BUILTIN_CAPABILITY, Capability, ManagementError, Model -from powercontext.sources import SourceDefinitionRegistry, manifest_for_definition, project_source_for_transport - -logger = logging.getLogger(__name__) - - -class WritePluginConfig(Model): - id: str = Field(min_length=1, max_length=128, pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$") - entry: str = Field(min_length=1, max_length=4096) - - -@dataclass(frozen=True) -class LoadedWritePlugin: - configuration: WritePluginConfig - registry: SourceDefinitionRegistry - capabilities: tuple[Capability, ...] - - def declaration(self) -> dict[str, Any]: - return { - "configuration": self.configuration.model_dump(mode="json"), - "capabilities": [capability.declaration() for capability in self.capabilities], - "definitions": [ - manifest_for_definition(d).model_dump(mode="json", by_alias=True) for d in self.registry.definitions - ], - } - - -def load_write_plugin(config: WritePluginConfig, workspace: Path) -> LoadedWritePlugin: - path = (workspace / config.entry).expanduser() - if path.exists(): - config = config.model_copy(update={"entry": str(path.resolve())}) - module = load_local_module(workspace, config.entry) - registry = SourceDefinitionRegistry(module.definitions) - if not registry.definitions: - raise ManagementError("invalid-plugin", "A write plugin must export Source definitions") - builtin_names = {d.name for d in BUILTIN_SOURCE_REGISTRY.definitions} - if any(d.name in builtin_names for d in registry.definitions): - raise ManagementError("definition-conflict", "A plugin cannot replace a built-in Source definition", 409) - capabilities = tuple(c for value in module.capabilities if (c := Capability.model_validate(value)).kind == "write") - if not capabilities: - raise ManagementError("invalid-plugin", "A write plugin must export write capabilities") - identities = set() - for capability in capabilities: - key = capability.name, capability.version - if capability.name == BUILTIN_CAPABILITY.name or key in identities: - raise ManagementError("capability-conflict", "Write capability identities must be unique", 409) - identities.add(key) - definition = registry.definition_for_name(capability.definition_name or "") - if not issubclass(definition.input_class, BaseModel) or capability.input_schema.get("type") != "object": - raise ManagementError("invalid-plugin", "Write inputs must use a Pydantic object model") - if ( - definition.version != capability.definition_version - or capability.input_schema != definition.input_class.model_json_schema() - ): - raise ManagementError("invalid-plugin", "Write capabilities must match their Definition and input model") - for definition in registry.definitions: - _validate_definition(definition) - return LoadedWritePlugin(config, registry, capabilities) - - -def _validate_definition(definition) -> None: - if any(not inspect.iscoroutinefunction(getattr(definition, name)) for name in ("resolve", "read")): - raise ManagementError("invalid-plugin", "Source Definition resolve and read must be async") - if any(inspect.iscoroutinefunction(p.project) for p in definition.projections): - raise ManagementError("invalid-plugin", "Source Projection project must be synchronous") - for method in (definition.resolve, definition.read, *(p.project for p in definition.projections)): - try: - inspect.signature(method).bind(object()) - except (TypeError, ValueError) as error: - raise ManagementError( - "invalid-plugin", "Definition and Projection methods must accept one value" - ) from error - validate_source_definition_manifest(manifest_for_definition(definition)) - - -class SourceWritePlugins: - def __init__(self, database, workspace: Path) -> None: - self.database, self.workspace = database, workspace - self.plugins: dict[str, LoadedWritePlugin] = {} - self._lock = asyncio.Lock() - self._jobs: set[asyncio.Task[Any]] = set() - - def capabilities(self) -> tuple[Capability, ...]: - return tuple(c for p in self.plugins.values() for c in p.capabilities) - - def find(self, name: str, version: str) -> tuple[LoadedWritePlugin, Capability] | None: - return next( - ((p, c) for p in self.plugins.values() for c in p.capabilities if (c.name, c.version) == (name, version)), - None, - ) - - async def restore(self) -> None: - async with self.database.transaction() as connection: - rows = (await connection.execute(select(SOURCE_WRITE_PLUGINS_TABLE))).mappings().all() - for row in rows: - try: - definition = json.loads(row["definition"]) - await self.load(WritePluginConfig.model_validate(definition["configuration"])) - except Exception as error: - # Entrypoint/configuration exceptions may contain secrets or submitted content. - logger.error( # noqa: TRY400 - never log entry/configuration exception inputs - "Source write plugin %s could not be restored (%s)", row["plugin_id"], type(error).__name__ - ) - - async def load( - self, config: WritePluginConfig, *, reserved_capabilities: set[tuple[str, str]] | None = None - ) -> dict[str, Any]: - async with self._lock: - path = (self.workspace / config.entry).expanduser() - if path.exists(): - config = config.model_copy(update={"entry": str(path.resolve())}) - old = self.plugins.get(config.id) - if old is not None: - if old.configuration != config: - raise ManagementError("plugin-conflict", "The plugin id already has a different entry", 409) - return self.state(old) - try: - candidate = await asyncio.to_thread(load_write_plugin, config, self.workspace) - except ManagementError: - raise - except Exception as error: - raise ManagementError( - "invalid-plugin", "The local entry or Source declarations could not be loaded" - ) from error - # Each capability identity has exactly one loaded owner. A collect declaration - # cannot make write executable, and conflicts are checked before publication. - if any( - self.find(c.name, c.version) is not None or (c.name, c.version) in (reserved_capabilities or ()) - for c in candidate.capabilities - ): - raise ManagementError("capability-conflict", "The write capability is already loaded", 409) - await self._persist(candidate) - self.plugins[config.id] = candidate - return self.state(candidate) - - async def _persist(self, plugin: LoadedWritePlugin) -> None: - definition = plugin.declaration() - table = SOURCE_WRITE_PLUGINS_TABLE - try: - async with self.database.transaction() as connection: - saved = await connection.scalar( - select(table.c.definition).where(table.c.plugin_id == plugin.configuration.id) - ) - if saved is not None and json.loads(saved) != definition: - raise ManagementError( - "plugin-conflict", "The persisted plugin configuration or contract differs", 409 - ) - repository = SourceDefinitionManifestRepository() - for source in plugin.registry.definitions: - await repository.register(connection, manifest_for_definition(source)) - if saved is None: - await connection.execute( - insert(table).values( - plugin_id=plugin.configuration.id, - definition=json.dumps(definition, ensure_ascii=False).encode(), - ) - ) - except StoredPayloadConflictError as error: - raise ManagementError( - "definition-conflict", "A Source Definition version cannot change its contract", 409 - ) from error - - @staticmethod - def state(plugin: LoadedWritePlugin) -> dict[str, Any]: - return { - "id": plugin.configuration.id, - "entry": plugin.configuration.entry, - "capabilities": [c.declaration() for c in plugin.capabilities], - } - - async def write( - self, application, scope_id: str, name: str, version: str, value: dict[str, Any], authorize - ) -> dict[str, Any]: - found = self.find(name, version) - if found is None: - raise ManagementError("capability-unavailable", "The write capability version is not loaded", 503) - plugin, capability = found - definition = plugin.registry.definition_for_name(capability.definition_name or "") - - def validate_input(): - capability.validate_input(value) - try: - return definition.input_class.model_validate(value) - except Exception as error: - raise ManagementError("invalid-input", "The Source input is invalid") from error - - def materialize(source): - try: - observation = project_source_for_transport(plugin.registry, source) - size = len(observation.model_dump_json().encode()) - except Exception as error: - raise ManagementError("invalid-input", "The materialized Source output is invalid") from error - if ( - observation.source_type != capability.definition_name - or observation.definition_version != capability.definition_version - ): - raise ManagementError("invalid-input", "The resolved Source does not match the write Definition") - if size > MAX_SOURCE_OBSERVATION_BYTES: - raise ManagementError("observation-too-large", "Materialized Source observation exceeds 4 MiB") - return observation - - validated = await self._in_thread(validate_input) - try: - source = await plugin.registry.resolve(validated) - except Exception as error: - raise ManagementError("invalid-input", "The Source could not be resolved") from error - observation = await self._in_thread(materialize, source) - await authorize() - receipt = await application.ingestion.submit( - SubmitSourceObservation(scope_id=scope_id, observation=observation) - ) - return { - "kind": "write", - "result": {"outcome": "saved", "source_refs": [receipt.source_ref.model_dump(mode="json")]}, - } - - async def _in_thread(self, function, *args): - # Cancelling an await does not stop trusted Python code already executing in - # a thread. Keep its slot until it actually exits, including after a timeout. - if len(self._jobs) >= 64: - raise ManagementError("server-busy", "Too many Source materializations are running", 503) - task = asyncio.create_task(asyncio.to_thread(function, *args)) - self._jobs.add(task) - - def completed(task): - self._jobs.discard(task) - if not task.cancelled(): - task.exception() - - task.add_done_callback(completed) - return await asyncio.shield(task) diff --git a/src/powercontext/sources/collection.py b/src/powercontext/sources/collection.py new file mode 100644 index 0000000000..51f5cd5e65 --- /dev/null +++ b/src/powercontext/sources/collection.py @@ -0,0 +1,66 @@ +# 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. + +"""Collection target keys and parameter validation, shared by the central Server and every runner.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence + +from pydantic import JsonValue + + +class InvalidTargetParameters(ValueError): + """Target parameters do not match the capability's schema or identity.""" + + +def normalize(value: str, steps: Sequence[str]) -> str: + for step in steps: + if step == "strip": + value = value.strip() + elif step == "lower": + value = value.lower() + elif step == "collapse_spaces": + value = " ".join(value.split()) + elif step.startswith("remove_prefix:"): + value = value.removeprefix(step.split(":", 1)[1]) + elif step.startswith("remove_suffix:"): + value = value.removesuffix(step.split(":", 1)[1]) + else: + raise InvalidTargetParameters(f"unknown normalization step {step}") # noqa: TRY003 + return value + + +def target_key(capability: str, parameters: Mapping[str, JsonValue], identity: Mapping[str, Sequence[str]]) -> str: + """SHA-256 of the capability and each identity parameter's normalized value, ordered by parameter name.""" + + values = {} + for name in sorted(identity): + value = parameters.get(name) + if not isinstance(value, str): + raise InvalidTargetParameters(f"identity parameter {name} must be a string") # noqa: TRY003 + values[name] = normalize(value, identity[name]) + encoded = json.dumps([capability, values], ensure_ascii=False, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(encoded.encode()).hexdigest() + + +def validate_parameters(schema: Mapping[str, JsonValue], parameters: Mapping[str, JsonValue]) -> None: + from jsonschema import Draft202012Validator + + error = next(Draft202012Validator(dict(schema)).iter_errors(dict(parameters)), None) + if error is not None: + field = ".".join(str(part) for part in error.absolute_path) or "parameters" + raise InvalidTargetParameters(f"{field}: {error.message}") # noqa: TRY003 diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index a60c79869c..2d5be0d08a 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -43,7 +43,7 @@ def test_managed_sqlite_vector_schema_includes_registered_nullable_description(t database = tmp_path / "vector.db" runner = SQLiteMigrationRunner(database, bundle, configuration=configuration) result = runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True) - assert result.revision == "v1_5_0_005" + assert result.revision == "v1_5_0_006" with closing(sqlite3.connect(database)) as connection: columns = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_atomic_memory_current)")} assert columns["description"][2:4] == ("VARCHAR(200)", 0) @@ -156,7 +156,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm upgraded = SQLiteMigrationRunner(database, current, configuration=configuration) plan = upgraded.plan() assert plan.source_revision == "v1_4_1_004" - assert plan.target_revision == "v1_5_0_005" + assert plan.target_revision == "v1_5_0_006" assert plan.revisions == ( "v1_4_1_005", "v1_4_1_006", @@ -165,6 +165,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ) assert plan.domain_steps == ( "artifact_storage", @@ -173,7 +174,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm "plugin_artifacts", ) assert upgraded.evidence_path.read_bytes() == original_evidence_bytes - assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_005" + assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_006" assert upgraded.verify().state == "ready" evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) for revision, checksum in original_evidence["release_checksums"].items(): diff --git a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py index 34a15a0b03..02b0a33c7c 100644 --- a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py +++ b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py @@ -168,6 +168,7 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, PROJECTION_REVISION, "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ) assert plan.domain_steps == ( "artifact_storage", @@ -197,6 +198,7 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, PROJECTION_REVISION, "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", } ready = reopened.plan() assert ready.state == "ready" @@ -250,7 +252,7 @@ def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_ before = old.evidence_path.read_bytes() upgraded = SQLiteMigrationRunner(database, current, configuration=config) plan = upgraded.plan() - assert plan.revisions == (PROJECTION_REVISION, "v1_5_0_004", "v1_5_0_005") + assert plan.revisions == (PROJECTION_REVISION, "v1_5_0_004", "v1_5_0_005", "v1_5_0_006") assert plan.domain_steps == ("atomic_memory_evidence_projection", "plugin_artifacts") assert old.evidence_path.read_bytes() == before assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == current.head diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 5e873b53d2..e793d4bdcd 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,7 +73,7 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_005" + assert plan.source_revision == "s1300" and plan.target_revision == "v1_5_0_006" assert plan.revisions == ( "s1400", "s1401", @@ -86,6 +86,7 @@ def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ) assert dump(source.database) == before result = apply(source) diff --git a/tests/builtin/persistence/test_mysql_initialization_preflight.py b/tests/builtin/persistence/test_mysql_initialization_preflight.py index 496196e83b..9f91a61e66 100644 --- a/tests/builtin/persistence/test_mysql_initialization_preflight.py +++ b/tests/builtin/persistence/test_mysql_initialization_preflight.py @@ -253,7 +253,7 @@ def test_evidence_recovery_resumes_before_final_schema_marker(tmp_path, monkeypa before = database.read_bytes(), journal.path.read_bytes() plan = runner.plan() assert plan.state == "recovery_required" and plan.source_revision == predecessor - assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004", "v1_5_0_005") + assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", "v1_5_0_006") assert (database.read_bytes(), journal.path.read_bytes()) == before assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [] diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 3f24ea5bf5..3a35135cb3 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -706,7 +706,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_5_0_005" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_006" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -837,7 +837,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_005", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_006", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -847,7 +847,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_5_0_005", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_006", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -914,7 +914,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_5_0_005", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_5_0_006", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -982,7 +982,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_5_0_005", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_5_0_006", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -1146,7 +1146,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_005" + assert original.source_revision == "s1300" and original.target_revision == "v1_5_0_006" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1168,7 +1168,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_5_0_005" + assert result.readiness_scope == "server" and result.revision == "v1_5_0_006" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1248,7 +1248,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_005" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_006" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index f908d698d4..df32a15fdd 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -45,8 +45,6 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks "INSERT INTO pc_scopes (scope_id,title,summary,scope_id_search,title_search,summary_search,version) " "VALUES ('original','title','summary','original','title','summary',1)" ) - if previous_revision == "v1_4_1_006": - connection.execute("INSERT INTO pc_source_write_plugins VALUES (?, ?)", ("tickets", b"retained definition")) if previous_revision == "v1_4_1_004": connection.execute( "INSERT INTO pc_access_owner_recovery (operation_id,deployment_id,scope_id,completed,payload) " @@ -72,10 +70,9 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks connection.execute("SELECT operation_id,payload,completed FROM pc_access_owner_recovery").fetchall() == expected_intents ) - expected_plugins = [("tickets", b"retained definition")] if previous_revision == "v1_4_1_006" else [] - assert connection.execute("SELECT * FROM pc_source_write_plugins").fetchall() == expected_plugins - # v1_5_0_005 drops the empty storage of the removed Artifact plugin framework. - assert not connection.execute("SELECT 1 FROM sqlite_schema WHERE name='pc_artifact_family_schemas'").fetchall() + # v1_5_0_005 and v1_5_0_006 drop the empty storage of the removed plugin frameworks. + for retired in ("pc_artifact_family_schemas", "pc_source_write_plugins"): + assert not connection.execute("SELECT 1 FROM sqlite_schema WHERE name=?", (retired,)).fetchall() fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", current, configuration=configuration(embedding)) apply(fresh) assert upgraded.plan().schema_fingerprint == fresh.plan().schema_fingerprint diff --git a/tests/builtin/persistence/test_plugin_storage_migration.py b/tests/builtin/persistence/test_plugin_storage_migration.py index 089d3944c8..df26bfba2a 100644 --- a/tests/builtin/persistence/test_plugin_storage_migration.py +++ b/tests/builtin/persistence/test_plugin_storage_migration.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""v1_5_0_005 removes the previous plugin framework's storage only when it holds no data.""" +"""v1_5_0_005 and v1_5_0_006 remove the previous plugin frameworks' storage only when it holds no data.""" from __future__ import annotations @@ -97,9 +97,9 @@ def test_upgrade_refuses_retained_plugin_data_and_drops_only_empty_storage(tmp_p with closing(sqlite3.connect(database)) as connection, connection: connection.execute("DELETE FROM pc_artifact_family_schemas") plan = upgraded.plan() - assert plan.revisions == ("v1_5_0_005",) - assert plan.domain_steps == ("plugin_artifacts",) - assert apply(upgraded).revision == "v1_5_0_005" + assert plan.revisions == ("v1_5_0_005", "v1_5_0_006") + assert plan.domain_steps == ("plugin_artifacts", "source_collection") + assert apply(upgraded).revision == "v1_5_0_006" assert upgraded.verify().state == "ready" with closing(sqlite3.connect(database)) as connection: tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_schema WHERE type='table'")} @@ -109,3 +109,24 @@ def test_upgrade_refuses_retained_plugin_data_and_drops_only_empty_storage(tmp_p artifacts = {row[1]: row for row in connection.execute("PRAGMA table_info(pc_artifacts)")} assert artifacts["metadata"][3] == 0 assert connection.execute("SELECT scope_id FROM pc_scopes").fetchall() == [("kept",)] + + +def test_upgrade_refuses_loaded_source_write_plugins_and_adds_collection_tables(tmp_path): + database = tmp_path / "managed.db" + old = SQLiteMigrationRunner(database, previous_bundle(tmp_path / "previous"), configuration=configuration()) + assert apply(old).revision == PREVIOUS + with closing(sqlite3.connect(database)) as connection, connection: + connection.execute("INSERT INTO pc_source_write_plugins VALUES (?, ?)", ("tickets", b"loaded definition")) + + upgraded = SQLiteMigrationRunner(database, server_bundle(), configuration=configuration()) + with pytest.raises(MigrationError) as refused: + upgraded.plan() + assert refused.value.code == "unsupported_plugin_data" + + with closing(sqlite3.connect(database)) as connection, connection: + connection.execute("DELETE FROM pc_source_write_plugins") + assert apply(upgraded).revision == "v1_5_0_006" + with closing(sqlite3.connect(database)) as connection: + tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_schema WHERE type='table'")} + assert "pc_source_write_plugins" not in tables + assert {"pc_source_runners", "pc_source_collect_targets"} <= tables diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 3aad19bcad..493fcdbdec 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -131,8 +131,9 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ) - assert apply(upgraded).revision == "v1_5_0_005" + assert apply(upgraded).revision == "v1_5_0_006" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) @@ -176,7 +177,7 @@ def test_pending_portable_restore_blocks_migration_readiness_without_repair(tmp_ @pytest.mark.parametrize("vectors", [False, True]) @pytest.mark.parametrize("include_owner", [False, True]) -def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, vectors, include_owner): +def test_release_upgrade_preserves_data_and_adds_collection_runners(tmp_path, vectors, include_owner): embedding = EmbeddingProfile(profile_id="source-vectors", model="fixture", dimension=3) if vectors else None config = configuration(embedding) previous = previous_bundle(tmp_path / "previous", embedding, through=("v1_4_1_004" if include_owner else "s1402")) @@ -191,7 +192,7 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v bundle = server_bundle(embedding_profile=embedding) upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) assert upgraded.plan().revisions == ( - ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", "v1_5_0_005") + ("v1_4_1_005", "v1_4_1_006", "v1_5_0_001", "v1_5_0_002", "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", "v1_5_0_006") if include_owner else ( "v1_4_1_004", @@ -202,19 +203,23 @@ def test_source_write_plugin_upgrade_preserves_data_and_registration(tmp_path, v "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ) ) - assert apply(upgraded).revision == "v1_5_0_005" - definition = b'{"configuration":{"id":"tickets","entry":"tickets"}}' + assert apply(upgraded).revision == "v1_5_0_006" + capabilities = b'[{"error":null,"name":"tickets","version":"1.0.0"}]' with closing(sqlite3.connect(database)) as connection, connection: assert connection.execute("SELECT title FROM pc_scopes").fetchall() == [("preserved",)] - connection.execute("INSERT INTO pc_source_write_plugins VALUES (?, ?)", ("tickets", definition)) + connection.execute( + "INSERT INTO pc_source_runners (runner, subject, capabilities, capabilities_digest) VALUES (?, ?, ?, ?)", + ("robot-7", "service:robot", capabilities, "0" * 64), + ) reopened = SQLiteMigrationRunner(database, bundle, configuration=config) assert reopened.verify().state == "ready" assert not apply(reopened).changed with closing(sqlite3.connect(database)) as connection: - assert connection.execute("SELECT plugin_id, definition FROM pc_source_write_plugins").fetchall() == [ - ("tickets", definition) + assert connection.execute("SELECT runner, capabilities FROM pc_source_runners").fetchall() == [ + ("robot-7", capabilities) ] fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) apply(fresh) diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index 70bcc4d12d..14cd160ae5 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -158,6 +158,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ) @@ -371,7 +372,7 @@ def connect(): finally: engine.dispose() - assert _apply(runner).revision == "v1_5_0_005" + assert _apply(runner).revision == "v1_5_0_006" assert runner.verify().state == "ready" assert not _apply(runner).changed app = create_server_app( diff --git a/tests/builtin/portability/test_bundle.py b/tests/builtin/portability/test_bundle.py index 5592666e59..0898b8049e 100644 --- a/tests/builtin/portability/test_bundle.py +++ b/tests/builtin/portability/test_bundle.py @@ -616,6 +616,58 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_bundle_restores_collection_targets_with_their_runner_and_without_status(tmp_path: Path) -> None: + from powercontext.builtin.persistence.source_collection import SourceCollectionRepository + + async def scenario() -> None: + archive = tmp_path / "scope.pcb" + async with SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'source.db'}"), tables=BUILTIN_TABLES + ) as source: + repository = SourceCollectionRepository() + async with source.database.transaction() as connection: + await _insert_scope(connection) + await repository.record_sync( + connection, "robot-7", "service:robot", [{"name": "tickets", "version": "1.0.0", "error": None}] + ) + for runner in ("robot-7", "server"): + target, _ = await repository.add_target( + connection, + scope_id="project:one", + runner=runner, + capability="tickets", + target_key=hashlib.sha256(runner.encode()).hexdigest(), + parameters={"file": runner}, + actor="service:admin", + ) + await repository.record_status( + connection, "project:one", target.target_id, runner, error_code="file_unreadable" + ) + await PortableBundleService(source.database).export(["project:one"], archive, authorize=_authorize_export) + + async with SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'target.db'}"), tables=BUILTIN_TABLES + ) as target: + await PortableBundleService(target.database).restore(archive, supported_source_types=("content",)) + repository = SourceCollectionRepository() + async with target.database.transaction() as connection: + restored = await repository.targets(connection, "project:one") + runner = await repository.runner(connection, "robot-7") + assert sorted((item.runner, item.parameters["file"], item.enabled) for item in restored) == [ + ("robot-7", "robot-7", True), + ("server", "server", True), + ] + # The runner keeps the subject its name is bound to; what it reports returns at its next sync. + assert runner is not None and (runner.subject, runner.capabilities, runner.last_seen_at) == ( + "service:robot", + (), + None, + ) + assert all(item.status.consecutive_failures == 0 and item.status.last_error_code is None for item in restored) + + asyncio.run(scenario()) + + def test_export_is_byte_deterministic_and_preserves_scope_relationships(tmp_path: Path) -> None: async def scenario() -> None: first = tmp_path / "first.pcb" diff --git a/tests/e2e/plugin_server.py b/tests/e2e/plugin_server.py index 7e9b10cc33..981775c30d 100644 --- a/tests/e2e/plugin_server.py +++ b/tests/e2e/plugin_server.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio +import hashlib import json import os import socket @@ -34,9 +35,17 @@ @asynccontextmanager async def running_server( - directory: Path, plugins: Path | None = None, *, extra_env: Mapping[str, str | None] | None = None + directory: Path, + plugins: Path | None = None, + *, + extra_env: Mapping[str, str | None] | None = None, + identities: dict[str, str] | None = None, ): - """Start the Server; an ``extra_env`` value of ``None`` removes that default variable.""" + """Run Server with the admin Bearer, or with an identity map of ``{name: token}`` where ``admin`` administers. + + An ``extra_env`` value of ``None`` removes that default variable. + """ + with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] @@ -57,7 +66,11 @@ async def running_server( # Worker children import test-owned plugin helpers from the repository. "PYTHONPATH": os.pathsep.join(filter(None, (str(REPOSITORY), env.get("PYTHONPATH")))), }) - for key, value in (extra_env or {}).items(): + overrides: dict[str, str | None] = dict(extra_env or {}) + if identities is not None: + overrides |= {"POWERCONTEXT_SERVER_AUTH_ENABLED": None, "POWERCONTEXT_SERVER_AUTH_TOKEN": None} + overrides |= _identity_environment(directory, identities) + for key, value in overrides.items(): if value is None: env.pop(key, None) else: @@ -107,6 +120,34 @@ async def running_server( await process.wait() +def _identity_environment(directory: Path, identities: dict[str, str]) -> dict[str, str]: + """Write an identity map of ``{name: token}`` plus the admin token, and configure Server to use it.""" + + tokens = {"admin": ADMIN_AUTHORIZATION_VALUE, **identities} + mapping = directory / "identities.json" + mapping.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() + ] + }) + ) + mapping.chmod(0o600) + return { + "POWERCONTEXT_SERVER_AUTH": json.dumps({ + "enabled": True, + "identities_file": str(mapping), + "administrators": ["service:admin"], + }), + "POWERCONTEXT_SERVER_ACCESS_BACKGROUND_PRINCIPAL_ID": "admin", + } + + async def create_scope(client: httpx.AsyncClient, key: str) -> str: response = await client.post( "/v1/scopes", json={"title": key, "summary": "Plugin acceptance", "idempotency_key": key} diff --git a/tests/e2e/source_collect_helpers.py b/tests/e2e/source_collect_helpers.py deleted file mode 100644 index e0786490ed..0000000000 --- a/tests/e2e/source_collect_helpers.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Deploy and stop collect plugins using the delivered CLI and public management API.""" - -from __future__ import annotations - -import asyncio -import json -import os -import sys -from pathlib import Path -from typing import Any -from uuid import uuid4 - -import httpx - - -def prepare_environment(directory: Path, base: Path | None = None) -> tuple[Path, Path]: - state = directory / "source-services" / uuid4().hex - envfile = directory / "source.env" - original = base.read_text() if base is not None else "" - values = { - "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), - "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), - "POWERCONTEXT_SERVER_ACCESS_MODE": "enforced", - } - envfile.write_text(original + "\n" + "\n".join(f"{key}='{value}'" for key, value in values.items()) + "\n") - return envfile, state - - -def bind_server(envfile: Path, url: str) -> None: - with envfile.open("a") as stream: - stream.write(f"POWERCONTEXT_SERVER_PUBLIC_URL='{url.rstrip('/')}'\n") - - -async def cli(directory: Path, *args: str) -> dict[str, Any]: - environment = {k: v for k, v in os.environ.items() if not k.startswith("POWERCONTEXT_SERVER_")} - environment["POWERCONTEXT_HOME"] = str(directory / "home") - process = await asyncio.create_subprocess_exec( - str(Path(sys.executable).with_name("powercontext")), - *args, - env=environment, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(process.communicate(), 100) - finally: - if process.returncode is None: - process.kill() - await process.wait() - assert process.returncode == 0, (stdout + stderr).decode() - return json.loads(stdout) - - -async def instance_state(client: httpx.AsyncClient, identifier: str, actual: str | None = None) -> dict[str, Any]: - async with asyncio.timeout(40): - while True: - response = await client.post( - "/v1/source-management/instances", json={"instance_id": identifier, "view": "control"} - ) - if response.status_code == 200: - state = response.json()["instances"][0] - if actual is None or state["actual"] == actual: - return state - await asyncio.sleep(0.1) - - -async def set_enabled(client: httpx.AsyncClient, identifier: str, enabled: bool) -> dict[str, Any]: - response = await client.post( - "/v1/source-management/instance-set-enabled", json={"instance_id": identifier, "enabled": enabled} - ) - assert response.status_code == 200, response.text - return await instance_state(client, identifier, "running" if enabled else "stopped") - - -async def stop_services(directory: Path, state: Path) -> None: - for path in sorted((state / "instances").glob("*/runtime-config.json")): - await cli(directory, "source", "runtime", "stop", "--config", str(path)) - if (state / "manager.json").exists(): - await cli(directory, "source", "manager", "stop", "--config", str(state / "manager.json")) - - -async def grant_scope(client: httpx.AsyncClient, scope: str) -> None: - identity = await client.get("/v1/access/me") - assert identity.status_code == 200, identity.text - principal = identity.json()["principal"] - response = await client.post( - "/v1/access/bindings/create", - json={ - "subject": {"type": principal["type"], "id": principal["id"]}, - "resource": {"type": "scope", "scope_id": scope}, - "role": "scope.contributor", - "idempotency_key": "collect-test-" + scope, - }, - ) - assert response.status_code == 201, response.text diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 4b9c07d343..0f28552683 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -68,7 +68,6 @@ McpConfig, MetricsConfig, ServerSettings, - SourceManagementConfig, ) from tests.e2e.dream_support import atomic_memory_pipeline, memory_source_text from tests.e2e.server_storage import prepare_server_database @@ -534,10 +533,7 @@ async def 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: +def test_scope_access_does_not_depend_on_server_administrator_count(tmp_path: Path, authentication: str) -> None: async def scenario() -> None: seed_settings = ServerSettings( database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'scope-access.db'}"), @@ -578,9 +574,6 @@ async def scenario() -> None: 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, @@ -589,8 +582,6 @@ async def scenario() -> None: 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({ diff --git a/tests/e2e/test_atomic_memory_description_upgrade.py b/tests/e2e/test_atomic_memory_description_upgrade.py index 4aa47d9fb2..3579ca7bd1 100644 --- a/tests/e2e/test_atomic_memory_description_upgrade.py +++ b/tests/e2e/test_atomic_memory_description_upgrade.py @@ -85,9 +85,9 @@ def test_nonempty_released_memory_upgrades_without_rewriting_history(tmp_path, r ) result = _apply(runner) - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_005" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_5_0_006" verified = runner.verify() - assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_005" + assert verified.state == "ready" and verified.readiness_scope == "server" and verified.revision == "v1_5_0_006" assert not _apply(runner).changed with closing(sqlite3.connect(runner.database)) as connection: assert ( diff --git a/tests/e2e/test_mcp_transport.py b/tests/e2e/test_mcp_transport.py index 4912590e33..5b2aad98ac 100644 --- a/tests/e2e/test_mcp_transport.py +++ b/tests/e2e/test_mcp_transport.py @@ -217,10 +217,9 @@ async def exercise_tools() -> set[str]: assert tools == { "pc_source_add", - "pc_source_capabilities", - "pc_source_instance_set_enabled", - "pc_source_instances", + "pc_source_runners", "pc_source_status", + "pc_source_target_delete", "pc_source_target_set_enabled", "generate_experience", "get_experience", diff --git a/tests/e2e/test_source_citation_locators.py b/tests/e2e/test_source_citation_locators.py index cfb0d400f6..d6a3b59e30 100644 --- a/tests/e2e/test_source_citation_locators.py +++ b/tests/e2e/test_source_citation_locators.py @@ -69,11 +69,14 @@ def test_locators_survive_upgrade_reopen_http_and_mcp(tmp_path): legacy_id = legacy.json()["artifact_id"] # Reconstruct the previous registered layout, preserving its real historical evidence. - # v1_5_0_005 is undone first: its retired plugin storage returns from its frozen resource. - retired = json.loads( - (Path(migrations.__file__).parent / "server_resources/schemas/sqlite-v1_5_0_001.json").read_text() - ) + # v1_5_0_006 and v1_5_0_005 are undone first: retired plugin storage returns from frozen resources. + resources = Path(migrations.__file__).parent / "server_resources/schemas" + retired = json.loads((resources / "sqlite-v1_5_0_001.json").read_text()) + write_plugins = json.loads((resources / "sqlite-v1_4_1_006.json").read_text()) with sqlite3.connect(database) as connection: + connection.execute("DROP TABLE pc_source_collect_targets") + connection.execute("DROP TABLE pc_source_runners") + connection.execute(write_plugins["create"]) connection.execute("ALTER TABLE pc_artifacts DROP COLUMN metadata") for statement in retired["statements"]: connection.execute(statement) @@ -82,7 +85,7 @@ def test_locators_survive_upgrade_reopen_http_and_mcp(tmp_path): runner = deployment_runner(_settings(database)) plan = runner.plan() - assert plan.revisions == ("v1_5_0_004", "v1_5_0_005") + assert plan.revisions == ("v1_5_0_004", "v1_5_0_005", "v1_5_0_006") runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) locators = [ diff --git a/tests/e2e/test_source_collect.py b/tests/e2e/test_source_collect.py deleted file mode 100644 index a3675dc653..0000000000 --- a/tests/e2e/test_source_collect.py +++ /dev/null @@ -1,440 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Managed collect instances preserve Scope progress and independent resource lifetimes.""" - -from __future__ import annotations - -import asyncio -import json -import shutil - -import yaml - -from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES -from tests.e2e.source_collect_helpers import ( - bind_server, - cli, - grant_scope, - instance_state, - prepare_environment, - set_enabled, - stop_services, -) -from tests.source_helpers import ROOT, create_scope, requires_linux, running_server - -EXAMPLE = ROOT / "examples/source_plugins/ticket_chat" - - -async def checkpoint(client, scope, identifier, *, after=None): - async with asyncio.timeout(40): - while True: - response = await client.post( - "/v1/connector-checkpoints/get", - json={ - "binding": { - "scope_id": scope, - "binding_id": identifier, - "connector_name": "example-ticket-chat", - "connector_version": "1", - } - }, - ) - assert response.status_code == 200, response.text - current = response.json()["checkpoint"] - if current is not None and current != after: - return current - await asyncio.sleep(0.1) - - -async def received(client, scope, target, count, *, namespace): - async with asyncio.timeout(40): - while True: - response = await client.post("/v1/source-management/status", json={"scope_id": scope, "target_id": target}) - assert response.status_code == 200, response.text - status = response.json() - page = await client.get(f"/v1/scopes/{scope}/sources") - assert page.status_code == 200, page.text - assert page.json()["next_cursor"] is None - sources = [ - item for item in page.json()["items"] if item["content"]["payload"]["capture"]["namespace"] == namespace - ] - if len(sources) == count: - latest = max(sources, key=lambda item: item["position"]) - if status["source_refs"] == [{"source_type": latest["source_type"], "source_id": latest["source_id"]}]: - return status - await asyncio.sleep(0.1) - - -@requires_linux -def test_managed_instances_isolate_scopes_and_release_resources(tmp_path): - asyncio.run(_managed_instances(tmp_path)) - - -async def _managed_instances(directory): - envfile, state = prepare_environment(directory) - environment = { - "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), - "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), - } - async with running_server(directory, environment=environment) as (admin, url, _process): - bind_server(envfile, url) - scopes = [await create_scope(admin, name) for name in ("first", "second")] - for scope in scopes: - await grant_scope(admin, scope) - first, second = directory / "first", directory / "second" - shutil.copytree(EXAMPLE, first, ignore=shutil.ignore_patterns("__pycache__")) - second.mkdir() - shutil.copy(first / "events.json", second / "events.json") - entry = first / "__init__.py" - entry.write_text( - entry.read_text() - + """ - -_collect = apply - -async def apply(ctx, config): - def closed(): - (ctx.plugin_dir / "closed").touch() - ctx.on_dispose(closed) - await _collect(ctx, config) -""" - ) - targets = {} - try: - for instance, selected in ((first, scopes), (second, scopes[1:])): - manifest = { - "id": instance.name, - "plugin": str(first), - "scope_ids": selected, - "config": {"namespace": instance.name, "interval_seconds": 0.1}, - "targets": [ - {"scope_id": scope, "capability": "ticket-chat.dataset", "capability_version": "1", "input": {}} - for scope in selected - ], - } - path = instance / "powercontext.yml" - path.write_text(yaml.safe_dump(manifest)) - await cli(directory, "source", "load", "--config", str(path), "--env-file", str(envfile)) - await instance_state(admin, instance.name, "running") - for scope in selected: - added = await admin.post( - "/v1/source-management/add", - json={ - "scope_id": scope, - "instance_id": instance.name, - "capability": "ticket-chat.dataset", - "capability_version": "1", - "input": {}, - }, - ) - assert added.status_code == 200, added.text - target = added.json()["target_id"] - targets[instance.name, scope] = target - await received(admin, scope, target, 2, namespace=instance.name) - previous = await checkpoint(admin, scopes[1], "first") - stopped_target = await admin.post( - "/v1/source-management/target-set-enabled", - json={ - "scope_id": scopes[1], - "target_id": targets["first", scopes[1]], - "enabled": False, - }, - ) - assert stopped_target.status_code == 200, stopped_target.text - async with asyncio.timeout(40): - while True: - status = await admin.post( - "/v1/source-management/status", - json={ - "scope_id": scopes[1], - "target_id": targets["first", scopes[1]], - }, - ) - if status.json()["tracking"] == "stopped": - break - await asyncio.sleep(0.1) - fixture = json.loads((first / "events.json").read_text()) - fixture["tickets"][0]["body"] = "Changed in the first Scope only" - replacement = first / "events.next.json" - replacement.write_text(json.dumps(fixture)) - replacement.replace(first / "events.json") - await received(admin, scopes[0], targets["first", scopes[0]], 3, namespace="first") - assert await checkpoint(admin, scopes[1], "first") == previous - await set_enabled(admin, "first", False) - assert (first / "closed").exists() - assert not (second / "closed").exists() - assert (await instance_state(admin, "second"))["actual"] == "running" - previous = await checkpoint(admin, scopes[1], "second") - fixture["tickets"][0]["body"] = "Second instance continues after the first stops" - replacement = second / "events.next.json" - replacement.write_text(json.dumps(fixture)) - replacement.replace(second / "events.json") - last_success = await received(admin, scopes[1], targets["second", scopes[1]], 3, namespace="second") - # Submission reports can precede checkpoint persistence. Wait for both. - previous = await checkpoint(admin, scopes[1], "second", after=previous) - # A rejected observation must not advance the Scope's checkpoint. - fixture["tickets"][0]["body"] = "x" * MAX_SOURCE_OBSERVATION_BYTES - replacement.write_text(json.dumps(fixture)) - replacement.replace(second / "events.json") - await instance_state(admin, "second", "failed") - assert (second / "closed").exists() - assert await checkpoint(admin, scopes[1], "second") == previous - failed = await received(admin, scopes[1], targets["second", scopes[1]], 3, namespace="second") - assert failed["source_refs"] == last_success["source_refs"] - assert failed["last_submitted_at"] == last_success["last_submitted_at"] - finally: - await stop_services(directory, state) - - -@requires_linux -def test_scope_shrink_preserves_progress_and_offline_discovery(tmp_path): - asyncio.run(_scope_shrink_and_discovery(tmp_path)) - - -async def _scope_shrink_and_discovery(directory): - from fastmcp import Client - - from tests.source_helpers import TOKENS, call - - envfile, state = prepare_environment(directory) - environment = { - "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), - "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), - } - async with running_server(directory, environment=environment) as (admin, url, _process): - bind_server(envfile, url) - scopes = [await create_scope(admin, name) for name in ("kept", "removed")] - for scope in scopes: - await grant_scope(admin, scope) - plugin = directory / "collector" - shutil.copytree(EXAMPLE, plugin, ignore=shutil.ignore_patterns("__pycache__")) - manifest = { - "id": "scope-review", - "plugin": str(plugin), - "scope_ids": scopes, - "config": {"namespace": "scope-review", "interval_seconds": 0.1}, - "targets": [ - {"scope_id": s, "capability": "ticket-chat.dataset", "capability_version": "1", "input": {}} - for s in scopes - ], - } - path = plugin / "powercontext.yml" - path.write_text(yaml.safe_dump(manifest)) - try: - await cli(directory, "source", "load", "--config", str(path), "--env-file", str(envfile)) - await instance_state(admin, "scope-review", "running") - targets = {} - for scope in scopes: - response = await admin.post( - "/v1/source-management/add", - json={ - "scope_id": scope, - "instance_id": "scope-review", - "capability": "ticket-chat.dataset", - "capability_version": "1", - "input": {}, - }, - ) - assert response.status_code == 200, response.text - targets[scope] = response.json()["target_id"] - await received(admin, scope, targets[scope], 2, namespace="scope-review") - previous = await checkpoint(admin, scopes[1], "scope-review") - await set_enabled(admin, "scope-review", False) - runtime_config = state / "instances/scope-review/runtime-config.json" - await cli(directory, "source", "runtime", "stop", "--config", str(runtime_config)) - manifest["scope_ids"] = scopes[:1] - manifest["targets"] = manifest["targets"][:1] - path.write_text(yaml.safe_dump(manifest)) - await cli(directory, "source", "load", "--config", str(path), "--env-file", str(envfile), "--update") - await instance_state(admin, "scope-review", "stopped") - fixture = json.loads((plugin / "events.json").read_text()) - fixture["tickets"][0]["body"] = "Changed after removing a Scope" - (plugin / "events.json").write_text(json.dumps(fixture)) - await set_enabled(admin, "scope-review", True) - await received(admin, scopes[0], targets[scopes[0]], 3, namespace="scope-review") - # Observe another collection cycle before draining, regardless of target iteration order. - fixture["tickets"][0]["body"] = "Another change while the Scope remains excluded" - replacement = plugin / "events.next.json" - replacement.write_text(json.dumps(fixture)) - replacement.replace(plugin / "events.json") - await received(admin, scopes[0], targets[scopes[0]], 4, namespace="scope-review") - # Stop and drain actual work before checking the removed Scope's stored progress. - await set_enabled(admin, "scope-review", False) - assert await checkpoint(admin, scopes[1], "scope-review") == previous - await received(admin, scopes[1], targets[scopes[1]], 2, namespace="scope-review") - page = await admin.get(f"/v1/scopes/{scopes[1]}/sources") - assert page.status_code == 200 and len(page.json()["items"]) == 2 - - # Re-admit the Scope through the public management API, without recreating its target. - registration = json.loads((state / "instances/scope-review/registration.json").read_text()) - registration["scope_ids"] = scopes - response = await admin.post( - "/v1/source-management/configure", json={"registration": registration, "update": True} - ) - assert response.status_code == 200, response.text - await set_enabled(admin, "scope-review", True) - restored = await received(admin, scopes[1], targets[scopes[1]], 3, namespace="scope-review") - assert restored["target_id"] == targets[scopes[1]] - # The server reports the submission before the collector completes the batch and appends its receipt. - receipts_path = plugin / "receipts.jsonl" - async with asyncio.timeout(40): - while True: - receipts = map(json.loads, receipts_path.read_text(encoding="utf-8").splitlines()) - if any(r["scope_id"] == scopes[1] and r["source"] in restored["source_refs"] for r in receipts): - break - await asyncio.sleep(0.1) - - 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) - - -@requires_linux -def test_tickets_refreshes_targets_when_a_later_scope_is_disabled(tmp_path): - asyncio.run(_tickets_target_refresh(tmp_path)) - - -async def _tickets_target_refresh(directory): - envfile, state = prepare_environment(directory) - environment = { - "POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR": str(directory / "source-plugins"), - "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"state_dir": str(state)}), - } - async with running_server(directory, environment=environment) as (admin, url, _process): - bind_server(envfile, url) - scopes = sorted([await create_scope(admin, name) for name in ("first", "second")]) - for scope in scopes: - await grant_scope(admin, scope) - plugin = directory / "tickets" - shutil.copytree(ROOT / "examples/source_plugins/tickets", plugin, ignore=shutil.ignore_patterns("__pycache__")) - entry = plugin / "__init__.py" - # Pause real connector I/O so a later Scope's saved target becomes stale deterministically. - entry.write_text( - entry.read_text() - + f""" - -_run = TicketConnector.run -async def paused_run(self, session): - if self.targets[0].scope_id == {scopes[0]!r} and (self.ctx.plugin_dir / "pause").exists(): - (self.ctx.plugin_dir / "paused").touch() - while not (self.ctx.plugin_dir / "resume").exists(): - await asyncio.sleep(0.05) - result = await _run(self, session) - if (self.ctx.plugin_dir / "paused").exists(): - (self.ctx.plugin_dir / "finished").touch() - return result -TicketConnector.run = paused_run -""" - ) - records = plugin / "tickets.json" - records.write_text('[{"id":"A","body":"Initial"}]') - manifest = plugin / "powercontext.yml" - manifest.write_text( - yaml.safe_dump({ - "id": "tickets-refresh", - "plugin": str(plugin), - "scope_ids": scopes, - "config": {"namespace": "refresh", "poll_interval": 0.1}, - "targets": [ - {"scope_id": scope, "capability": "tickets.item", "capability_version": "1", "input": {"id": "A"}} - for scope in scopes - ], - }) - ) - try: - await cli(directory, "source", "load", "--config", str(manifest), "--env-file", str(envfile)) - targets = [] - for scope in scopes: - response = await admin.post( - "/v1/source-management/add", - json={ - "scope_id": scope, - "instance_id": "tickets-refresh", - "capability": "tickets.item", - "capability_version": "1", - "input": {"id": "A"}, - }, - ) - assert response.status_code == 200, response.text - targets.append(response.json()["target_id"]) - await received(admin, scope, targets[-1], 1, namespace="refresh") - (plugin / "pause").touch() - async with asyncio.timeout(40): - while not (plugin / "paused").exists(): # noqa: ASYNC110 - subprocess rendezvous - await asyncio.sleep(0.1) - response = await admin.post( - "/v1/source-management/target-set-enabled", - json={ - "scope_id": scopes[1], - "target_id": targets[1], - "enabled": False, - }, - ) - assert response.status_code == 200, response.text - async with asyncio.timeout(40): - while True: - response = await admin.post( - "/v1/source-management/status", - json={ - "scope_id": scopes[1], - "target_id": targets[1], - }, - ) - assert response.status_code == 200, response.text - if response.json()["tracking"] == "stopped": - break - await asyncio.sleep(0.1) - (plugin / "resume").touch() - # The paused batch may finish; require another batch to consume a new snapshot. - async with asyncio.timeout(40): - while not (plugin / "finished").exists(): # noqa: ASYNC110 - subprocess rendezvous - await asyncio.sleep(0.1) - replacement = plugin / "tickets.next.json" - replacement.write_text('[{"id":"A","body":"After target stop"}]') - replacement.replace(records) - await received(admin, scopes[0], targets[0], 2, namespace="refresh") - assert (await instance_state(admin, "tickets-refresh"))["actual"] == "running" - await received(admin, scopes[1], targets[1], 1, namespace="refresh") - finally: - (plugin / "resume").touch() - await stop_services(directory, state) diff --git a/tests/e2e/test_source_deployment.py b/tests/e2e/test_source_deployment.py deleted file mode 100644 index 68c32a6d96..0000000000 --- a/tests/e2e/test_source_deployment.py +++ /dev/null @@ -1,321 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Load collect plugins through the public CLI against real Server and Runtime processes.""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -import sys -from pathlib import Path - -import httpx -import pytest -import yaml - -from tests.source_helpers import protected, requires_linux, wait_until - -EXAMPLES = Path(__file__).resolve().parents[2] / "examples/source_plugins" -DEPLOYMENT_TIMEOUT_SECONDS = 100 - - -@requires_linux -@pytest.mark.parametrize("remote", [False, True]) -def test_load_real_background_processes_and_preserves_disabled_state(tmp_path, remote): # noqa: C901 - real deployment lifecycle - import hashlib - import socket - - def port(): - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - async def scenario(): # noqa: C901 - real deployment lifecycle - tokens = {name: "load-e2e-" + name for name in ("deployer", "manager", "worker")} - for name, token in tokens.items(): - protected(tmp_path / (name + ".token"), token) - protected( - tmp_path / "identities.json", - json.dumps({ - "identities": [ - { - "credential_id": name, - "token_digest": hashlib.sha256(token.encode()).hexdigest(), - "subject": {"type": "service", "id": name}, - } - for name, token in tokens.items() - ] - }), - ) - server_port, manager_port = port(), port() - url = f"http://127.0.0.1:{server_port}" - plugins = tmp_path / "plugins" - plugins.mkdir() - settings = { - "POWERCONTEXT_SERVER_HTTP": {"host": "127.0.0.1", "port": server_port}, - "POWERCONTEXT_SERVER_DATABASE": {"kind": "sqlite", "url": f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"}, - "POWERCONTEXT_SERVER_AUTH": { - "enabled": True, - "identities_file": str(tmp_path / "identities.json"), - "administrators": ["service:deployer"], - }, - "POWERCONTEXT_SERVER_ACCESS": {"mode": "enforced", "background_principal_id": "deployer"}, - "POWERCONTEXT_SERVER_RUNTIME": {"artifact_processing_families": []}, - "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": {"manager_subject": "service:manager"} - if remote - else {"state_dir": str(tmp_path / "deployment")}, - } - values = {key: json.dumps(value) for key, value in settings.items()} - values["POWERCONTEXT_SERVER_PUBLIC_URL"] = url - if not remote: - values["POWERCONTEXT_SERVER_SOURCE_PLUGINS_DIR"] = str(plugins) - envfile = tmp_path / "server.env" - protected(envfile, "\n".join(f"{key}='{value}'" for key, value in values.items()) + "\n") - environment = {key: value for key, value in os.environ.items() if not key.startswith("POWERCONTEXT_SERVER_")} - environment["POWERCONTEXT_HOME"] = str(tmp_path / "home") - entrypoint = str(Path(sys.executable).with_name("powercontext")) - - async def cli(*args, success=True): - child = await asyncio.create_subprocess_exec( - entrypoint, *args, env=environment, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - try: - out, err = await asyncio.wait_for(child.communicate(), DEPLOYMENT_TIMEOUT_SECONDS) - finally: - if child.returncode is None: - child.kill() - await child.wait() - if success: - assert child.returncode == 0, err.decode() - return json.loads(out) - assert child.returncode != 0 - return err.decode() - - manager_path = tmp_path / "manager.json" if remote else tmp_path / "deployment/manager.json" - if remote: - protected( - manager_path, - json.dumps({ - "server": {"url": url, "credential_file": str(tmp_path / "manager.token")}, - "deployment_id": "powercontext", - "manager_id": "sources", - "state_dir": str(tmp_path / "manager"), - "port": manager_port, - }), - ) - runtime_paths = [] - process = None - with (tmp_path / "server.log").open("a") as log: - - async def start_server(): - child = await asyncio.create_subprocess_exec( - entrypoint, "server", "run", "--env-file", str(envfile), env=environment, stdout=log, stderr=log - ) - async with httpx.AsyncClient(base_url=url) as client, asyncio.timeout(40): - while True: - assert child.returncode is None, (tmp_path / "server.log").read_text() - try: - if (await client.get("/health/ready")).status_code == 200: - return child - except httpx.HTTPError: - pass - await asyncio.sleep(0.1) - - try: - process = await start_server() - if remote: - await cli("source", "manager", "start", "--config", str(manager_path)) - async with httpx.AsyncClient( - base_url=url, headers={"Authorization": "Bearer " + tokens["deployer"]}, timeout=65 - ) as admin: - created = await admin.post( - "/v1/scopes", json={"title": "Load", "summary": "Real processes", "idempotency_key": "load"} - ) - assert created.status_code == 201, created.text - scope = created.json()["scope_id"] - await admin.post( - "/v1/access/bindings/create", - json={ - "subject": {"type": "service", "id": "deployer"}, - "resource": {"type": "scope", "scope_id": scope}, - "role": "scope.contributor", - "idempotency_key": "admin-read", - }, - ) - directory = plugins / "tickets" - shutil.copytree(EXAMPLES / "tickets", directory, ignore=shutil.ignore_patterns("__pycache__")) - (directory / "tickets.json").write_text('[{"id":"A","body":"alpha"},{"id":"B","body":"bravo"}]') - config = { - "id": "cli-tickets", - "plugin": ".", - "scope_ids": [scope], - "config": {"poll_interval": 0.1}, - "targets": [ - { - "scope_id": scope, - "capability": "tickets.item", - "capability_version": "1", - "input": {"id": "A"}, - } - ], - } - if remote: - config["deployment"] = { - "server": {"url": url, "credential_file": str(tmp_path / "deployer.token")}, - "management_url": f"http://127.0.0.1:{manager_port}", - "execution_credential_file": str(tmp_path / "worker.token"), - "state_dir": str(tmp_path / "runtime"), - } - path = directory / "powercontext.yml" - path.write_text(yaml.safe_dump(config)) - args = ["source", "load", "--config", str(path)] - automatic = None - if not remote: - args += ["--env-file", str(envfile)] - # The Scope already exists; restart with a new manifest present before startup. - # Verify registration AND collection before invoking source load. - runtime_path = tmp_path / "deployment/instances/cli-tickets/runtime-config.json" - runtime_paths.append(runtime_path) - process.terminate() - await asyncio.wait_for(process.wait(), 25) - process = await start_server() - await wait_until( - lambda: admin.post( - "/v1/source-management/instances", - json={"instance_id": "cli-tickets", "view": "control"}, - ), - lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "running", - # Server readiness precedes manager and Runtime startup; use the CLI deployment budget. - timeout_seconds=DEPLOYMENT_TIMEOUT_SECONDS, - ) - await wait_until( - lambda: admin.get(f"/v1/scopes/{scope}/sources"), - lambda r: r.status_code == 200 and bool(r.json()["items"]), - ) - automatic = await cli("source", "runtime", "status", "--config", str(runtime_path)) - first = await cli(*args) - runtime_path = Path( - first.get("runtime_config", tmp_path / "deployment/instances/cli-tickets/runtime-config.json") - ) - if runtime_path not in runtime_paths: - runtime_paths.append(runtime_path) - pid = first["service"]["pid"] - if not remote: - assert automatic is not None - assert pid == automatic["pid"] - repeated = await cli(*args) - assert repeated["service"]["pid"] == pid - added = await admin.post( - "/v1/source-management/add", - json={ - "scope_id": scope, - "instance_id": "cli-tickets", - "capability": "tickets.item", - "capability_version": "1", - "input": {"id": "A"}, - }, - ) - assert added.status_code == 200, added.text - target = added.json()["target_id"] - await wait_until( - lambda: admin.post( - "/v1/source-management/status", json={"scope_id": scope, "target_id": target} - ), - lambda r: r.status_code == 200 and bool(r.json()["source_refs"]), - ) - runtime_config = json.loads(await asyncio.to_thread(runtime_path.read_text)) - execution_token = ( - await asyncio.to_thread(Path(runtime_config["server"]["credential_file"]).read_text) - ).strip() - denied = await admin.post( - "/v1/source-management/instance-set-enabled", - headers={"Authorization": "Bearer " + execution_token}, - json={"instance_id": "cli-tickets", "enabled": False}, - ) - assert denied.status_code == 403 - response = await admin.post( - "/v1/source-management/instance-set-enabled", - json={"instance_id": "cli-tickets", "enabled": False}, - ) - assert response.status_code == 200, response.text - await wait_until( - lambda: admin.post( - "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} - ), - lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "stopped", - ) - config["targets"][0]["input"] = {"id": "B"} - path.write_text(yaml.safe_dump(config)) - assert (await cli(*args))["service"]["pid"] == pid - process.terminate() - await asyncio.wait_for(process.wait(), 25) - process = await start_server() - state = await wait_until( - lambda: admin.post( - "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} - ), - lambda r: r.status_code == 200, - ) - assert not state.json()["instances"][0]["enabled"] - assert (await cli(*args))["service"]["pid"] == pid - config["config"]["poll_interval"] = 0.2 - path.write_text(yaml.safe_dump(config)) - assert "conflict" in await cli(*args, success=False) - assert (await cli("source", "runtime", "status", "--config", str(runtime_path)))["pid"] == pid - # An explicit update cannot replace an outer Runtime that is still alive. - assert "instance-running" in await cli(*args, "--update", success=False) - await cli("source", "runtime", "stop", "--config", str(runtime_path)) - await wait_until( - lambda: admin.post( - "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} - ), - lambda r: r.status_code == 200 and r.json()["instances"][0]["availability"] == "offline", - ) - updated = await cli(*args, "--update") - assert updated["service"]["pid"] != pid - state = await admin.post( - "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} - ) - assert state.status_code == 200, state.text - assert not state.json()["instances"][0]["enabled"] - assert state.json()["instances"][0]["actual"] == "stopped" - status = await admin.post( - "/v1/source-management/status", json={"scope_id": scope, "target_id": target} - ) - assert status.status_code == 200 and status.json()["source_refs"] - enabled = await admin.post( - "/v1/source-management/instance-set-enabled", - json={"instance_id": "cli-tickets", "enabled": True}, - ) - assert enabled.status_code == 200, enabled.text - await wait_until( - lambda: admin.post( - "/v1/source-management/instances", json={"instance_id": "cli-tickets", "view": "control"} - ), - lambda r: r.status_code == 200 and r.json()["instances"][0]["actual"] == "running", - ) - finally: - for runtime_path in runtime_paths: - if runtime_path.exists(): - await cli("source", "runtime", "stop", "--config", str(runtime_path)) - if manager_path.exists(): - await cli("source", "manager", "stop", "--config", str(manager_path)) - if process is not None and process.returncode is None: - process.terminate() - await asyncio.wait_for(process.wait(), 25) - - asyncio.run(scenario()) diff --git a/tests/e2e/test_source_plugins.py b/tests/e2e/test_source_plugins.py new file mode 100644 index 0000000000..b85a958f5e --- /dev/null +++ b/tests/e2e/test_source_plugins.py @@ -0,0 +1,179 @@ +"""Source plugins collect through the Server's own runner and through a device runner that syncs targets.""" + +import asyncio +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any + +import httpx + +from tests.e2e.plugin_server import create_scope, running_server, wait_for + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" / "source_plugins" +ROBOT = "robot-token" + + +async def sources(client, scope, source_type): + response = await client.get(f"/v1/scopes/{scope}/sources", params={"limit": 100}) + assert response.status_code == 200, response.text + return [item for item in response.json()["items"] if item["source_type"] == source_type] + + +async def targets(client, scope): + response = await client.get(f"/v1/scopes/{scope}/source-targets") + assert response.status_code == 200, response.text + return {item["target_id"]: item for item in response.json()["items"]} + + +async def run_agent(server: str, plugins: Path, *, name: str) -> dict[str, Any]: + entrypoint = Path(sys.executable).with_name("powercontext") + process = await asyncio.create_subprocess_exec( + sys.executable, + str(entrypoint), + "source-agent", + "--server", + server, + "--plugins", + str(plugins), + "--name", + name, + "--once", + env={**os.environ, "POWERCONTEXT_SOURCE_AGENT_TOKEN": ROBOT}, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(process.communicate(), 120) + assert process.returncode == 0, stderr.decode() + return json.loads(stdout.decode().strip().splitlines()[-1]) + + +def test_server_and_device_runners_collect_assigned_targets(tmp_path): + plugins = tmp_path / "plugins" + shutil.copytree(EXAMPLES / "tickets", plugins / "tickets") + shutil.copytree(EXAMPLES / "ticket-chat", plugins / "ticket-chat") + device = tmp_path / "device" + shutil.copytree(EXAMPLES / "ticket-chat", device / "ticket-chat") + tickets = tmp_path / "tickets.json" + tickets.write_text(json.dumps([{"id": "A", "title": "Disk full", "body": "db-1 is out of space"}])) + log = tmp_path / "server.log" + + async def scenario(): + async with running_server(tmp_path, plugins, identities={"robot": ROBOT}) as admin: + scope = await create_scope(admin, "source-plugins-e2e") + # Server administrators get no Scope access implicitly; both principals contribute to this Scope. + for subject in ("admin", "robot"): + granted = await admin.post( + "/v1/access/bindings/create", + json={ + "subject": {"type": "service", "id": subject}, + "resource": {"type": "scope", "scope_id": scope}, + "role": "scope.contributor", + "idempotency_key": f"{scope}-{subject}", + }, + ) + assert granted.status_code == 201, granted.text + robot = httpx.AsyncClient(base_url=admin.base_url, headers={"Authorization": f"Bearer {ROBOT}"}) + base = f"/v1/scopes/{scope}/source-targets" + + runners = (await admin.get("/v1/source-runners")).json()["items"] + server = next(item for item in runners if item["runner"] == "server") + assert {item["name"]: item["compatible"] for item in server["capabilities"]} == { + "ticket-chat": True, + "tickets": True, + } + + # Choosing what the Server's own runner collects is an operator decision. + target = {"runner": "server", "capability": "tickets", "parameters": {"file": str(tickets)}} + denied = await robot.post(base, json=target) + assert denied.status_code == 403, denied.text + created = await admin.post(base, json=target) + assert created.status_code == 201, created.text + assert (await admin.post(base, json=target)).status_code == 200 + other = {**target, "parameters": {"file": f" {tickets} ", "namespace": "other"}} + assert (await admin.post(base, json=other)).json()["error"]["code"] == "target_conflict" + invalid = await admin.post(base, json={**target, "parameters": {"namespace": "x"}}) + assert (invalid.status_code, invalid.json()["error"]["code"]) == (422, "invalid_parameters") + + # Adding a target collects right away; the Source and the status commit together. + await wait_for(lambda: sources(admin, scope, "example-ticket"), lambda items: len(items) == 1, log=log) + good = created.json()["target_id"] + assert (await targets(admin, scope))[good]["status"]["last_success_at"] is not None + + # A target the plugin reports as failed keeps its checkpoint; the others in the call still commit. + tickets.write_text( + json.dumps([ + {"id": "A", "title": "Disk full", "body": "db-1 is out of space"}, + {"id": "B", "title": "Slow query", "body": "orders scan"}, + ]) + ) + broken = await admin.post( + base, json={"runner": "server", "capability": "tickets", "parameters": {"file": "/missing.json"}} + ) + assert broken.status_code == 201, broken.text + status = await wait_for( + lambda: targets(admin, scope), + lambda value: value[broken.json()["target_id"]]["status"]["consecutive_failures"] >= 1, + log=log, + ) + assert status[broken.json()["target_id"]]["status"]["last_error_code"] == "file_unreadable" + await wait_for(lambda: sources(admin, scope, "example-ticket"), lambda items: len(items) == 2, log=log) + + # Identity parameters cannot change; the other parameters and enablement can. + changed = await admin.patch(f"{base}/{good}", json={"parameters": {"file": "/elsewhere.json"}}) + assert (changed.status_code, changed.json()["error"]["code"]) == (422, "identity_immutable") + renamed = await admin.patch(f"{base}/{good}", json={"parameters": {"file": str(tickets), "project": "ops"}}) + assert renamed.status_code == 200 and renamed.json()["parameters"]["project"] == "ops" + + # A device runner collects the targets assigned to it, submitting Sources and then checkpoints. + chats = tmp_path / "chats.json" + shutil.copy(EXAMPLES / "ticket-chat" / "chats.json", chats) + assigned = await robot.post( + base, json={"runner": "robot-7", "capability": "ticket-chat", "parameters": {"file": str(chats)}} + ) + assert assigned.status_code == 201, assigned.text + first = await run_agent(str(admin.base_url), device, name="robot-7") + assert [len(item["sources"]) for item in first["outcomes"]] == [1] + assert len(await sources(admin, scope, "example-chat")) == 1 + device_target = (await targets(admin, scope))[assigned.json()["target_id"]] + assert device_target["status"]["last_success_at"] is not None + # The committed checkpoint means nothing new is submitted the next time. + second = await run_agent(str(admin.base_url), device, name="robot-7") + assert [item["sources"] for item in second["outcomes"]] == [[]] + runners = {item["runner"]: item for item in (await admin.get("/v1/source-runners")).json()["items"]} + assert runners["robot-7"]["capabilities"][0]["compatible"] is True + + # Another credential cannot sync as this runner. + hijack = await admin.post("/v1/source-runners/robot-7/sync", json={"plugins": []}) + assert hijack.status_code == 403, hijack.text + + # A device plugin newer than the Server's is rejected until the Server upgrades. + source = (device / "ticket-chat" / "plugin.py").read_text() + (device / "ticket-chat" / "plugin.py").write_text(source.replace('version="1.0.0"', 'version="2.0.0"')) + newer = await run_agent(str(admin.base_url), device, name="robot-7") + assert newer["outcomes"] == [] + sync = await robot.post( + "/v1/source-runners/robot-7/sync", + json={"plugins": [{"name": "ticket-chat", "version": "2.0.0", "error": None}]}, + ) + assert sync.json() == { + "targets": [], + "rejected": [{"name": "ticket-chat", "code": "plugin_version_incompatible"}], + } + + # Disabled targets are not synced; deleting one removes its checkpoint but keeps its Sources. + paused = await robot.patch(f"{base}/{assigned.json()['target_id']}", json={"enabled": False}) + assert paused.status_code == 200 and paused.json()["enabled"] is False + current = await robot.post( + "/v1/source-runners/robot-7/sync", + json={"plugins": [{"name": "ticket-chat", "version": "1.0.0", "error": None}]}, + ) + assert current.json()["targets"] == [] + deleted = await robot.delete(f"{base}/{assigned.json()['target_id']}") + assert deleted.status_code == 204, deleted.text + assert len(await sources(admin, scope, "example-chat")) == 1 + await robot.aclose() + + asyncio.run(scenario()) diff --git a/tests/e2e/test_source_write.py b/tests/e2e/test_source_write.py deleted file mode 100644 index c3e0d29fa1..0000000000 --- a/tests/e2e/test_source_write.py +++ /dev/null @@ -1,364 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Deploy trusted write code through the CLI and use the real public MCP.""" - -from __future__ import annotations - -import asyncio -import base64 -import io -import json -import os -import shutil -from pathlib import Path - -import httpx -import pytest -from fastmcp import Client -from fastmcp.exceptions import ToolError -from PIL import Image - -from powercontext.builtin.persistence.migrations.deployment import deployment_runner -from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.runtime import RuntimeConfig -from powercontext.server.settings import ServerSettings -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_pre_locator_ticket_definition_keeps_write_and_collect_usable_after_restart(tmp_path): - """Reuse the declaration captured from the shipped plugin before locators existed.""" - - async def scenario(): - deployment = tmp_path / "tickets" - shutil.copytree(EXAMPLE, deployment, ignore=shutil.ignore_patterns("__pycache__")) - manifest = json.loads((ROOT / "tests/fixtures/tickets_manifest_before_locators.json").read_text()) - value = { - "namespace": "support", - "project": "powercontext", - "ticket_id": "PC-upgrade", - "title": "保留插件身份", - "body": "升级后继续写入。", - "labels": [], - "metadata": {}, - } - baseline = os.environ.get("POWERCONTEXT_TEST_SOURCE_BASE_ROOT") - environment = {"PYTHONPATH": str(Path(baseline) / "src")} if baseline else None - async with running_server(tmp_path, environment=environment) as (admin, url, _process): - scope = await create_scope(admin, "ticket-upgrade") - await grant(admin, scope, "deployer", "scope.viewer") - await grant(admin, scope) - registered = await admin.post("/v1/source-definitions/register", json={"manifest": manifest}) - assert registered.status_code == 200, registered.text - loaded = await load_write_cli(deployment / "write.yml", url) - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - args = {"scope_id": scope, "capability": "ticket.write", "capability_version": "1", "input": value} - captured = await call(host, "pc_source_add", args) - settings = ServerSettings( - database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'server.db'}"), - runtime=RuntimeConfig(artifact_processing_families=()), - ) - runner = deployment_runner(settings) - plan = runner.plan() - if baseline: - assert plan.source_revision == "v1_5_0_003" - assert plan.revisions == ("v1_5_0_004",) - runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) - async with running_server(tmp_path) as (admin, url, _process): - assert await load_write_cli(deployment / "write.yml", url) == loaded - registered = await admin.post("/v1/source-definitions/register", json={"manifest": manifest}) - assert registered.status_code == 200, registered.text - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - assert any(item["name"] == "ticket.write" for item in (await discover(host, scope))["capabilities"]) - assert await call(host, "pc_source_add", args) == captured - updated = await call(host, "pc_source_add", {**args, "input": {**value, "body": "升级后的新快照。"}}) - assert updated["result"]["source_refs"] != captured["result"]["source_refs"] - assert (await read(admin, scope, captured["result"]["source_refs"][0]))["content"]["payload"][ - "capture" - ] == value - - asyncio.run(scenario()) - - -def test_server_local_write_mcp_projection_and_restart(tmp_path): - asyncio.run(_write_and_restart(tmp_path)) - - -async def _write_and_restart(directory): - deployment = directory / "tickets" - shutil.copytree(EXAMPLE, deployment, ignore=shutil.ignore_patterns("__pycache__")) - value = { - "namespace": "support", - "project": "powercontext", - "ticket_id": "PC-123", - "title": "数据库恢复", - "body": "重试连接后服务恢复。", - "labels": ["数据库"], - "metadata": {"owner": None, "attempts": 2, "nested": {"verified": True}}, - } - async with running_server(directory) as (admin, url, _process): - scope = await create_scope(admin, "write-evidence") - await grant(admin, scope, "deployer", "scope.viewer") - await grant(admin, scope) - await grant(admin, scope, "reader", "scope.viewer") - other_scope = await create_scope(admin, "other-evidence") - await grant(admin, other_scope, "deployer", "scope.viewer") - configuration = deployment / "write.yml" - first = await load_write_cli(configuration, url) - assert await load_write_cli(configuration, url) == first - assert first["entry"] == str(deployment) - args = {"scope_id": scope, "capability": "ticket.write", "capability_version": "1", "input": value} - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - catalog = await discover(host, scope) - assert catalog["management"] == "not-configured" - ticket = next(c for c in catalog["capabilities"] if c["name"] == "ticket.write") - assert ticket["kind"] == "write" and ticket["can_write"] and ticket["instances"] == [] - assert ticket["definition_name"] == "example-ticket" - a, repeated = await asyncio.gather(call(host, "pc_source_add", args), call(host, "pc_source_add", args)) - assert a == repeated - assert a["kind"] == "write" and "request_id" not in a and "target_id" not in a - ref = a["result"]["source_refs"][0] - stored = await read(admin, scope, ref) - assert stored["content"]["payload"]["capture"] == value - details = next(p for p in stored["content"]["projections"] if p["key"]["name"] == "example.ticket-details") - assert details == {"key": {"name": "example.ticket-details", "version": "1"}, "value": value} - changed = await call(host, "pc_source_add", {**args, "input": {**value, "body": "服务持续稳定。"}}) - second_ref = changed["result"]["source_refs"][0] - assert second_ref != ref - page = await admin.get(f"/v1/scopes/{scope}/sources", params={"limit": 1}) - assert page.status_code == 200, page.text - assert [s["source_id"] for s in page.json()["items"]] == [ref["source_id"]] - page = await admin.get( - f"/v1/scopes/{scope}/sources", params={"limit": 1, "cursor": page.json()["next_cursor"]} - ) - assert [s["source_id"] for s in page.json()["items"]] == [second_ref["source_id"]] - assert page.json()["next_cursor"] is None - with pytest.raises(ToolError): - await call(host, "pc_source_add", {**args, "scope_id": other_scope}) - with pytest.raises(ToolError): - await call(host, "pc_source_add", {**args, "instance_id": "unused"}) - with pytest.raises(ToolError, match="capability-unavailable"): - await call(host, "pc_source_add", {**args, "capability_version": "999"}) - async with Client(url + "/mcp", auth=TOKENS["reader"]) as reader: - assert not next(c for c in (await discover(reader, scope))["capabilities"] if c["name"] == "ticket.write")[ - "can_write" - ] - with pytest.raises(ToolError): - await call(reader, "pc_source_add", args) - response = await admin.post(LOAD, json={"id": first["id"], "entry": "does-not-exist.py"}) - assert response.status_code == 409 - await grant(admin, other_scope) - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - isolated = await call(host, "pc_source_add", {**args, "scope_id": other_scope}) - assert isolated["result"]["source_refs"] == [ref] - assert (await read(admin, other_scope, ref))["content"] == stored["content"] - async with running_server(directory) as (admin, url, _process): - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - assert any(c["name"] == "ticket.write" for c in (await discover(host, scope))["capabilities"]) - assert (await call(host, "pc_source_add", args))["result"]["source_refs"] == [ref] - assert (await read(admin, scope, ref))["position"] == stored["position"] - shutil.move(deployment, directory / "unavailable-tickets") - async with running_server(directory) as (admin, url, _process): - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - catalog = await discover(host, scope) - assert [c["name"] for c in catalog["capabilities"]] == ["content.inline"] - builtin = await call( - host, - "pc_source_add", - { - "scope_id": scope, - "capability": "content.inline", - "capability_version": "2", - "input": {"source_id": "still-available", "text": "Server remains usable"}, - }, - ) - assert builtin["result"]["source_refs"][0]["source_type"] == "content" - # Saved projections remain readable without executable write code. - assert (await read(admin, scope, ref))["content"] == stored["content"] - - -BOUNDARY_PLUGIN = """ -import asyncio -import os -from pathlib import Path -from pydantic import BaseModel, ConfigDict, Field -from powercontext.sources import Source, SourceMaterialization -from powercontext.source_management import Capability - -Path(__file__).with_name("loaded-pid").write_text(str(os.getpid())) - -class Input(BaseModel): - model_config = ConfigDict(extra="forbid") - source_id: str - data: str = "" - repeat: int = Field(default=1, ge=1, le=5000000) - delay: float = Field(default=0, ge=0, le=30) - fail: bool = False - -class Saved(Source): - data: str - -class Definition: - name = "boundary-example" - version = "1" - input_class = Input - source_class = Saved - projections = () - async def resolve(self, value, /): - if value.delay: - Path(__file__).with_name("resolving").write_text(value.source_id) - await asyncio.sleep(value.delay) - if value.fail: - raise ValueError("private input must not be echoed: " + value.data) - return Saved(name=value.source_id, materialization=SourceMaterialization.CAPTURED, data=value.data * value.repeat) - async def read(self, source, /): - return source.data - -definitions = (Definition(),) -capabilities = (Capability(name="boundary.write", version="7", kind="write", description="Capture supplied content", - definition_name="boundary-example", definition_version="1", input_schema=Input.model_json_schema()),) -""" - - -def test_write_loading_limits_conflicts_and_live_permissions(tmp_path): - asyncio.run(_write_boundaries(tmp_path)) - - -async def _write_boundaries(directory): - entry = directory / "plugin.py" - entry.write_text(BOUNDARY_PLUGIN) - async with running_server(directory, write_timeout=1) as (admin, url, process): - scope = await create_scope(admin, "write-limits") - await grant(admin, scope, "deployer", "scope.viewer") - binding = await grant(admin, scope) - config = {"id": "boundary", "entry": str(entry)} - response = await admin.post(LOAD, json=config) - assert response.status_code == 200, response.text - assert int((directory / "loaded-pid").read_text()) == process.pid - response = await admin.post(LOAD, json={**config, "id": "conflicting-capability"}) - assert response.status_code == 409 and response.json()["error"]["code"] == "capability-conflict" - invalid = directory / "invalid.py" - invalid.write_text(BOUNDARY_PLUGIN.replace('definition_version="1"', 'definition_version="wrong"')) - response = await admin.post(LOAD, json={"id": "invalid", "entry": str(invalid)}) - assert response.status_code == 400 - invalid.write_text(BOUNDARY_PLUGIN.replace("resolve(self, value, /)", "resolve(self, value, required, /)")) - response = await admin.post(LOAD, json={"id": "invalid", "entry": str(invalid)}) - assert response.status_code == 400 - conflict = directory / "definition_conflict.py" - conflict.write_text( - BOUNDARY_PLUGIN.replace('name="boundary.write"', 'name="conflicting-definition.write"').replace( - "class Saved(Source):\n data: str", "class Saved(Source):\n data: str\n extra: str = ''" - ) - ) - response = await admin.post(LOAD, json={"id": "definition-conflict", "entry": str(conflict)}) - assert response.status_code == 409 and response.json()["error"]["code"] == "definition-conflict" - async with httpx.AsyncClient(base_url=url, headers={"Authorization": "Bearer " + TOKENS["host"]}) as http_host: - assert (await http_host.post(LOAD, json=config)).status_code == 403 - response = await admin.post(LOAD, json={"id": "images", "entry": str(ROOT / "examples/source_plugins/images")}) - assert response.status_code == 200, response.text - args = { - "scope_id": scope, - "capability": "boundary.write", - "capability_version": "7", - "input": {"source_id": "one", "data": "original"}, - } - async with Client(url + "/mcp", auth=TOKENS["host"]) as host: - names = {c["name"] for c in (await discover(host, scope))["capabilities"]} - assert names == {"content.inline", "boundary.write", "images.write"} - output = io.BytesIO() - Image.new("RGB", (2, 2), "red").save(output, format="PNG") - image_args = { - "scope_id": scope, - "capability": "images.write", - "capability_version": "1", - "input": {"media_type": "image/png", "data_base64": base64.b64encode(output.getvalue()).decode()}, - } - image = await call(host, "pc_source_add", image_args) - assert (await call(host, "pc_source_add", image_args)) == image - assert "request_id" not in image - stored_image = await read(admin, scope, image["result"]["source_refs"][0]) - assert stored_image["content"]["payload"]["capture"] == {**image_args["input"], "title": ""} - saved = await call(host, "pc_source_add", args) - ref = saved["result"]["source_refs"][0] - assert (await call(host, "pc_source_add", args)) == saved - with pytest.raises(ToolError): - await call(host, "pc_source_add", {**args, "input": {"source_id": "one", "data": "different"}}) - assert (await read(admin, scope, ref))["content"]["payload"]["data"] == "original" - with pytest.raises(ToolError, match="invalid-input"): - await call(host, "pc_source_add", {**args, "input": {"source_id": "bad", "unknown": True}}) - with pytest.raises(ToolError, match="input-too-large"): - await call( - host, "pc_source_add", {**args, "input": {"source_id": "oversize", "data": "x" * (1024 * 1024)}} - ) - with pytest.raises(ToolError, match="input-too-large"): - await call( - host, - "pc_source_add", - { - "scope_id": scope, - "capability": "images.write", - "capability_version": "1", - "input": {"media_type": "image/png", "data_base64": "A" * (1024 * 1024)}, - }, - ) - with pytest.raises(ToolError, match="observation-too-large"): - await call( - host, - "pc_source_add", - {**args, "input": {"source_id": "expanded", "data": "x", "repeat": 4 * 1024 * 1024}}, - ) - with pytest.raises(ToolError, match="invalid-input") as failure: - await call( - host, - "pc_source_add", - {**args, "input": {"source_id": "failure", "data": "sensitive-marker", "fail": True}}, - ) - assert "sensitive-marker" not in str(failure.value) - before_timeout = (await admin.get(f"/v1/scopes/{scope}/sources")).json()["items"] - assert len(before_timeout) == 2 - with pytest.raises(ToolError, match="result-unconfirmed"): - await call(host, "pc_source_add", {**args, "input": {"source_id": "timed-out", "delay": 10}}) - page = await admin.get(f"/v1/scopes/{scope}/sources") - assert page.status_code == 200 and page.json()["items"] == before_timeout - # A timed-out invocation must not prevent subsequent synchronous writes. - recovered = await call(host, "pc_source_add", {**args, "input": {"source_id": "after-timeout"}}) - assert "request_id" not in recovered - assert (await read(admin, scope, recovered["result"]["source_refs"][0]))["content"]["payload"]["data"] == "" - page = await admin.get(f"/v1/scopes/{scope}/sources") - assert page.status_code == 200 - (directory / "resolving").unlink(missing_ok=True) - pending = asyncio.create_task( - call(host, "pc_source_add", {**args, "input": {"source_id": "revoked", "delay": 0.6}}) - ) - async with asyncio.timeout(5): - while True: - if (directory / "resolving").exists(): - break - await asyncio.sleep(0.01) - revoked = await admin.post( - "/v1/access/bindings/revoke", - json={ - "binding_id": binding["binding_id"], - "expected_version": binding["version"], - "idempotency_key": "revoke-host", - }, - ) - assert revoked.status_code == 200, revoked.text - with pytest.raises(ToolError): - await pending - assert (await admin.get(f"/v1/scopes/{scope}/sources")).json()["items"] == page.json()["items"] - assert "sensitive-marker" not in (directory / "server.log").read_text() diff --git a/tests/plugin_support.py b/tests/plugin_support.py index 71b0283573..68bb33f68c 100644 --- a/tests/plugin_support.py +++ b/tests/plugin_support.py @@ -56,6 +56,11 @@ def write_plugin(root: Path, name: str, code: str, **spec: Any) -> Path: return directory +def _family(manifest) -> str: + assert manifest.artifact is not None + return manifest.artifact.family + + async def invoke(database, directory: Path, scope_id: str): manifest, _ = load_plugin(directory) async with database.transaction() as connection: @@ -64,7 +69,7 @@ async def invoke(database, directory: Path, scope_id: str): assignment = ArtifactProcessingWorkAssignment( binding_name=manifest.binding_name, scope_id=scope_id, - artifact_family=manifest.spec.family, + artifact_family=_family(manifest), claimed_request_generation=intent.requested_generation, fence=lease.fence("single-process"), worker_id="test-worker", @@ -86,7 +91,7 @@ async def edit(database, directory: Path, scope_id: str, artifact_id: str, conte writer = PluginArtifactWriter( database=database, scope_id=scope_id, - family=manifest.spec.family, + family=_family(manifest), sources=SourceRepository(BUILTIN_SOURCE_REGISTRY), change=UserChange("update", artifact_id), ) @@ -98,7 +103,7 @@ def governance(database, directory: Path, scope_id: str): """The merge, forget and restoration the Server's generic routes run for a plugin family.""" manifest, _ = load_plugin(directory) - family = manifest.spec.family + family = _family(manifest) service = plugin_merge_service(family, ArtifactMergeAccess(family), SourceRepository(BUILTIN_SOURCE_REGISTRY)) application = ArtifactMergeApplication( database, (service,), default_context=ArtifactSearchExecutionContext(trusted_local=True) diff --git a/tests/source_helpers.py b/tests/source_helpers.py deleted file mode 100644 index 55739bf482..0000000000 --- a/tests/source_helpers.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Shared HTTP, MCP and real Server CLI helpers for Source acceptance tests.""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import os -import socket -import sys -from contextlib import asynccontextmanager -from pathlib import Path - -import httpx -import pytest - -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")} -requires_linux = pytest.mark.skipif(sys.platform != "linux", reason="Source collect Runtimes require Linux") - - -@asynccontextmanager -async def running_server(directory: Path, *, write_timeout=10, environment=None): - identities = directory / "identities.json" - identities.write_text( - json.dumps({ - "identities": [ - { - "credential_id": name, - "token_digest": hashlib.sha256(token.encode()).hexdigest(), - "subject": {"type": "service", "id": name}, - } - for name, token in TOKENS.items() - ] - }) - ) - identities.chmod(0o600) - env = {key: value for key, value in os.environ.items() if not key.startswith("POWERCONTEXT_SERVER_")} - env.update({ - "POWERCONTEXT_HOME": str(directory / "home"), - "POWERCONTEXT_SERVER_DATABASE": json.dumps({ - "kind": "sqlite", - "url": f"sqlite+aiosqlite:///{directory / 'server.db'}", - }), - "POWERCONTEXT_SERVER_WORKSPACE": str(directory), - "POWERCONTEXT_SERVER_RUNTIME": json.dumps({"artifact_processing_families": []}), - "POWERCONTEXT_SERVER_AUTH": json.dumps({ - "enabled": True, - "identities_file": str(identities), - "administrators": ["service:deployer"], - }), - "POWERCONTEXT_SERVER_ACCESS": json.dumps({"mode": "enforced", "background_principal_id": "deployer"}), - "POWERCONTEXT_SERVER_SOURCE_MANAGEMENT": json.dumps({"write_wait_timeout_seconds": write_timeout}), - "POWERCONTEXT_SERVER_MCP_ENABLED": "true", - "POWERCONTEXT_SERVER_DASHBOARD_ENABLED": "false", - }) - env.update(environment or {}) - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - url = f"http://127.0.0.1:{port}" - with (directory / "server.log").open("a") as log: - process = await asyncio.create_subprocess_exec( - str(ENTRYPOINT), - "server", - "run", - "--no-env-file", - "--host", - "127.0.0.1", - "--port", - str(port), - cwd=directory, - env=env, - stdout=log, - stderr=log, - ) - try: - async with httpx.AsyncClient( - base_url=url, headers={"Authorization": "Bearer " + TOKENS["deployer"]}, timeout=15 - ) as admin: - async with asyncio.timeout(40): - while True: - assert process.returncode is None, (directory / "server.log").read_text() - try: - if (await admin.get("/health/ready")).status_code == 200: - break - except httpx.ConnectError: - pass - await asyncio.sleep(0.1) - yield admin, url, process - finally: - if process.returncode is None: - process.terminate() - try: - await asyncio.wait_for(process.wait(), 20) - except TimeoutError: - process.kill() - await process.wait() - - -async def load_write_cli(config: Path, url: str): - credential = config.parent / "admin-token" - credential.write_text(TOKENS["deployer"]) - credential.chmod(0o600) - process = await asyncio.create_subprocess_exec( - str(ENTRYPOINT), - "source", - "load", - "--kind", - "write", - "--config", - str(config), - "--server-url", - url, - "--credential-file", - str(credential), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(process.communicate(), 40) - finally: - if process.returncode is None: - process.kill() - await process.wait() - assert process.returncode == 0, (stdout + stderr).decode() - return json.loads(stdout) - - -async def create_scope(client, key): - response = await client.post( - "/v1/scopes", json={"title": key, "summary": "Source acceptance", "idempotency_key": key} - ) - assert response.status_code == 201, response.text - return response.json()["scope_id"] - - -async def grant(admin, scope, subject="host", role="scope.contributor"): - response = await admin.post( - "/v1/access/bindings/create", - json={ - "subject": {"type": "service", "id": subject}, - "resource": {"type": "scope", "scope_id": scope}, - "role": role, - "idempotency_key": f"{scope}-{subject}-{role}", - }, - ) - assert response.status_code == 201, response.text - return response.json() - - -async def call(client, name, arguments): - result = await client.call_tool(name, arguments) - assert not result.is_error, result.content - return result.data - - -async def discover(client, scope): - return await call(client, "pc_source_capabilities", {"scope_id": scope}) - - -async def read(admin, scope, ref): - response = await admin.get(f"/v1/scopes/{scope}/sources/{ref['source_type']}/{ref['source_id']}") - assert response.status_code == 200, response.text - return response.json() - - -def protected(path: Path, value: str) -> Path: - path.write_text(value, encoding="utf-8") - path.chmod(0o600) - return path - - -async def wait_until(call, predicate, *, timeout_seconds: float = 15): - async with asyncio.timeout(timeout_seconds): - while True: - value = await call() - if predicate(value): - return value - await asyncio.sleep(0.05) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index ec32a0d5f8..fa39bc5642 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -382,7 +382,8 @@ def test_source_observation_contract_uses_explicit_connector_scope_and_captured_ request = schemas["SubmitSourceObservationRequest"] observation = schemas["SourceObservation"] - assert set(request["properties"]) == {"scope_id", "observation"} + assert set(request["properties"]) == {"scope_id", "observation", "plugin"} + assert "plugin" not in request["required"] assert request["properties"]["observation"] == {"$ref": "#/components/schemas/SourceObservation"} assert observation["properties"]["materialization"]["enum"] == ["captured"] assert "ProjectedSource" not in schemas diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 82e2d13d90..a70022ac38 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_5_0_005" + assert plan["target_revision"] == "v1_5_0_006" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_006",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index 54c56c3365..6da09e0a1b 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_006",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 4c53aecfe7..5b36fcca90 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -85,10 +85,9 @@ async def inspect_components() -> tuple[list[str], int, int]: assert not [name for name in tool_names if "__" in name] assert set(tool_names) == { "pc_source_add", - "pc_source_capabilities", - "pc_source_instance_set_enabled", - "pc_source_instances", + "pc_source_runners", "pc_source_status", + "pc_source_target_delete", "pc_source_target_set_enabled", "generate_experience", "get_experience", diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 4cc5ea46a2..5c03f53fff 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -66,9 +66,10 @@ def invoke(action, *options): "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_5_0_005", "name": "v1_5_0_005", "software_version": "1.5.0", "sequence": 5} + target = {"revision": "v1_5_0_006", "name": "v1_5_0_006", "software_version": "1.5.0", "sequence": 6} assert plan["target_migration"] == target applied = invoke( @@ -83,7 +84,7 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_005",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_5_0_006",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] @@ -100,6 +101,7 @@ def invoke(action, *options): "v1_5_0_003", "v1_5_0_004", "v1_5_0_005", + "v1_5_0_006", } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 26a2f0e355..b47b6c70ea 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -66,7 +66,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_005" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_5_0_006" assert plan["readiness_scope"] == "server" assert plan["compatibility_steps"] == [] full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) diff --git a/tests/test_server_uds.py b/tests/test_server_uds.py index 4de6e4459f..67f9a38b40 100644 --- a/tests/test_server_uds.py +++ b/tests/test_server_uds.py @@ -305,103 +305,3 @@ def returned_startup(server, sockets=None): result = CliRunner().invoke(create_cli([app]), ["server", "run", "--no-env-file", "--uds", str(path)]) assert result.exit_code == (0 if started else 3), result.output assert not path.exists() - - -@_UNIX -@pytest.mark.parametrize("deployment", ["none", "directory", "autostart"]) -@pytest.mark.parametrize("public_url", [None, "https://proxy.example"]) -def test_uds_source_deployment_requires_proxy_before_creating_state(tmp_path, short_tmp_path, deployment, public_url): - from powercontext.server.factory import create_server_app - from powercontext.server.settings import BearerAuthConfig, SourceManagementConfig - from powercontext.source_management.models import ManagementError - - state = tmp_path / "deployment" - settings = ServerSettings( - http=HttpConfig(uds=short_tmp_path / "core.sock"), - auth=BearerAuthConfig(enabled=True, token=SecretStr("uds-source-test-token")), - source_plugins_dir=tmp_path / "plugins" if deployment == "directory" else None, - source_management=SourceManagementConfig(autostart=deployment == "autostart", state_dir=state), - public_url=public_url, - ) - if deployment != "none" and public_url is None: - with pytest.raises(ManagementError, match="POWERCONTEXT_SERVER_PUBLIC_URL"): - create_server_app(settings=settings) - assert not state.exists() - else: - assert create_server_app(settings=settings) is not None - - -@_UNIX -def test_uds_collect_load_requires_proxy_before_creating_state(tmp_path, short_tmp_path, monkeypatch): - from powercontext.source_management.cli import source_app - - state = tmp_path / "deployment" - monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_UDS", str(short_tmp_path / "core.sock")) - monkeypatch.setenv("POWERCONTEXT_SERVER_SOURCE_MANAGEMENT_AUTOSTART", "true") - monkeypatch.setenv("POWERCONTEXT_SERVER_SOURCE_MANAGEMENT_STATE_DIR", str(state)) - config = tmp_path / "collect.yml" - config.write_text("id: uds-collect\nplugin: unused.module\n", encoding="utf-8") - - result = CliRunner().invoke(source_app, ["load", "--config", str(config)]) - - assert result.exit_code == 1, result.output - assert "server-url-required" in result.output - assert "POWERCONTEXT_SERVER_PUBLIC_URL" in result.output - assert not state.exists() - - -@_UNIX -@pytest.mark.parametrize("endpoint", ["missing", "public_url", "argument"]) -def test_uds_write_load_uses_only_explicit_proxy(tmp_path, short_tmp_path, monkeypatch, endpoint): - import json - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - from threading import Thread - - from powercontext.source_management.cli import source_app - - requests = [] - - class Proxy(BaseHTTPRequestHandler): - def do_POST(self): - body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) - requests.append((self.path, body)) - payload = json.dumps({"id": body["id"]}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def log_message(self, format, *args): # noqa: A002 - match the stdlib signature. - pass - - config = tmp_path / "write.yml" - config.write_text("id: uds-write\nentry: unused.module\n", encoding="utf-8") - monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_UDS", str(short_tmp_path / "core.sock")) - # This server setting must not prevent a write-only command from selecting its own proxy. - monkeypatch.setenv("POWERCONTEXT_SERVER_SOURCE_MANAGEMENT_AUTOSTART", "true") - with ThreadingHTTPServer(("127.0.0.1", 0), Proxy) as proxy: - url = f"http://127.0.0.1:{proxy.server_port}" - monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_PORT", str(proxy.server_port)) - arguments = ["load", "--kind", "write", "--config", str(config)] - if endpoint == "public_url": - monkeypatch.setenv("POWERCONTEXT_SERVER_PUBLIC_URL", url) - elif endpoint == "argument": - arguments.extend(["--server-url", url]) - thread = Thread(target=proxy.serve_forever, daemon=True) - thread.start() - try: - result = CliRunner().invoke(source_app, arguments) - finally: - proxy.shutdown() - thread.join(timeout=5) - - if endpoint == "missing": - assert result.exit_code == 1, result.output - assert "server-url-required" in result.output - assert "--server-url or POWERCONTEXT_SERVER_PUBLIC_URL" in result.output - assert requests == [] - else: - assert result.exit_code == 0, result.output - assert json.loads(result.output) == {"id": "uds-write"} - assert requests == [("/v1/source-management/write/load", {"id": "uds-write", "entry": "unused.module"})] diff --git a/tests/test_source_management.py b/tests/test_source_management.py deleted file mode 100644 index 1c6db150e8..0000000000 --- a/tests/test_source_management.py +++ /dev/null @@ -1,950 +0,0 @@ -# Copyright (c) 2026 OceanBase. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""HTTP/MCP integration and fault recovery for the Source management boundary.""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import socket -import sys -from contextlib import asynccontextmanager, contextmanager, suppress -from pathlib import Path - -import httpx -import pytest -import uvicorn -from fastmcp import Client -from fastmcp.exceptions import ToolError -from sse_starlette.sse import AppStatus - -from examples.source_plugins.tickets.definition import TicketCapture -from powercontext.builtin.persistence.sqlite import SQLiteConfig -from powercontext.builtin.runtime import RuntimeConfig as BuiltinRuntimeConfig -from powercontext.client import PowerContextClient, register_source_definitions -from powercontext.server.factory import create_server_app -from powercontext.server.settings import ( - AccessControlConfig, - BearerAuthConfig, - MetricsConfig, - ServerSettings, - SourceManagementConfig, -) -from powercontext.source_management.config import Endpoint, ManagerConfig, RuntimeConfig, configuration_digest -from powercontext.source_management.loading import load_runtime_plugin -from powercontext.source_management.manager import create_manager_app -from powercontext.source_management.models import InstanceRegistration, ManagementError -from powercontext.source_management.runtime import SourceRuntime -from powercontext.source_management.storage import Store -from tests.source_helpers import protected, requires_linux, 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, - ), - ), - ) - 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_runtime_reports_unsupported_platform(tmp_path, monkeypatch): - monkeypatch.setattr(sys, "platform", "darwin") - endpoint = Endpoint(url="http://127.0.0.1:1", credential_file=tmp_path / "token") - config = RuntimeConfig( - instance_id="tickets", - plugin=str(EXAMPLES / "tickets"), - plugin_dir=tmp_path, - management=endpoint, - server=endpoint, - state_dir=tmp_path / "state", - execution_location=str(tmp_path), - configuration_digest=configuration_digest(str(EXAMPLES / "tickets"), {}, {}), - ) - with pytest.raises(ManagementError) as raised: - SourceRuntime(config) - assert raised.value.code == "background-unsupported" - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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()) - - -@requires_linux -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 a6acd155118c2d14888e9cdc44d5a1a0ea2963d4 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 15:47:49 -0800 Subject: [PATCH 30/38] fix(sources): keep the device runner free of server dependencies and document the new restore tables Co-Authored-By: Claude Opus 5.5 --- docs/en/docs/operate/troubleshoot.md | 2 +- docs/zh/docs/operate/troubleshoot.md | 2 +- src/powercontext/builtin/persistence/source_collection.py | 3 +-- src/powercontext/source_agent.py | 2 +- src/powercontext/sources/collection.py | 3 +++ .../persistence/test_atomic_description_vector_layout.py | 1 + .../persistence/test_atomic_evidence_upgrade_history.py | 3 ++- 7 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 90f011aac9..9655e29906 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -269,7 +269,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_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' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_source_runners,pc_source_collect_targets,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 e8b27f5fa3..65e232bb57 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -260,7 +260,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_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' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_portable_restore_receipts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_work_blocks,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_source_runners,pc_source_collect_targets,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/src/powercontext/builtin/persistence/source_collection.py b/src/powercontext/builtin/persistence/source_collection.py index f1d45def88..e2e520b47a 100644 --- a/src/powercontext/builtin/persistence/source_collection.py +++ b/src/powercontext/builtin/persistence/source_collection.py @@ -40,11 +40,10 @@ SOURCE_COLLECT_TARGETS_TABLE, SOURCE_RUNNERS_TABLE, ) +from powercontext.sources.collection import TARGET_CHECKPOINT_VERSION as TARGET_CHECKPOINT_VERSION # The Server's own runner, which collects in Supervisor Workers. SERVER_RUNNER = "server" -# Checkpoints of collection targets are keyed by target; the plugin version is not part of their identity. -TARGET_CHECKPOINT_VERSION = "target" _TARGETS = SOURCE_COLLECT_TARGETS_TABLE _RUNNERS = SOURCE_RUNNERS_TABLE diff --git a/src/powercontext/source_agent.py b/src/powercontext/source_agent.py index bd13c2d422..12555ab21e 100644 --- a/src/powercontext/source_agent.py +++ b/src/powercontext/source_agent.py @@ -39,7 +39,6 @@ import typer from pydantic import JsonValue -from powercontext.builtin.persistence.source_collection import TARGET_CHECKPOINT_VERSION from powercontext.plugins import ( Collect, CollectRequest, @@ -50,6 +49,7 @@ PluginResponse, SourcePlugin, ) +from powercontext.sources.collection import TARGET_CHECKPOINT_VERSION logger = logging.getLogger("powercontext.source_agent") diff --git a/src/powercontext/sources/collection.py b/src/powercontext/sources/collection.py index 51f5cd5e65..ec37f2a2d3 100644 --- a/src/powercontext/sources/collection.py +++ b/src/powercontext/sources/collection.py @@ -22,6 +22,9 @@ from pydantic import JsonValue +# Checkpoints of collection targets are keyed by target; the plugin version is not part of their identity. +TARGET_CHECKPOINT_VERSION = "target" + class InvalidTargetParameters(ValueError): """Target parameters do not match the capability's schema or identity.""" diff --git a/tests/builtin/persistence/test_atomic_description_vector_layout.py b/tests/builtin/persistence/test_atomic_description_vector_layout.py index 2d5be0d08a..0a61d3790c 100644 --- a/tests/builtin/persistence/test_atomic_description_vector_layout.py +++ b/tests/builtin/persistence/test_atomic_description_vector_layout.py @@ -172,6 +172,7 @@ def test_description_upgrade_preserves_registered_owner_recovery_and_evidence(tm "atomic_memory_evidence", "atomic_memory_evidence_projection", "plugin_artifacts", + "source_collection", ) assert upgraded.evidence_path.read_bytes() == original_evidence_bytes assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == "v1_5_0_006" diff --git a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py index 02b0a33c7c..262a87ffb0 100644 --- a/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py +++ b/tests/builtin/persistence/test_atomic_evidence_upgrade_history.py @@ -175,6 +175,7 @@ def test_evidence_upgrade_accepts_upstream_history_and_preserves_data(tmp_path, "atomic_memory_evidence", "atomic_memory_evidence_projection", "plugin_artifacts", + "source_collection", ) assert upgraded.evidence_path.read_bytes() == old_evidence_bytes result = upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) @@ -253,7 +254,7 @@ def test_evidence_projection_upgrade_preserves_registered_evidence_revision(tmp_ upgraded = SQLiteMigrationRunner(database, current, configuration=config) plan = upgraded.plan() assert plan.revisions == (PROJECTION_REVISION, "v1_5_0_004", "v1_5_0_005", "v1_5_0_006") - assert plan.domain_steps == ("atomic_memory_evidence_projection", "plugin_artifacts") + assert plan.domain_steps == ("atomic_memory_evidence_projection", "plugin_artifacts", "source_collection") assert old.evidence_path.read_bytes() == before assert upgraded.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True).revision == current.head assert upgraded.verify().state == "ready" From 99e0e4d24210795bac7f318a50d18a1d5b4cfee3 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 20:44:49 -0800 Subject: [PATCH 31/38] build: regenerate the Desktop contract identity and classify Source runner deletion in the access audit The Source collection contract removed the old source management operations and added delete_source_runner, which returns no Scope content. Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- e2e/bub/tests/test_access_audit.py | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 14a4573db8..9ddce890f9 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "e12490d40a86f30b8540727d3fd9c4ce50a9edcb9c8a17d8b22634f06d942073", + "contractSha256": "5006d9629a88652b9eab191ef4b8f3d2d54c7a1361780279ed2d4109a7f5e595", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index d69f4aa283..2580bb7a12 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 = "e12490d40a86f30b8540727d3fd9c4ce50a9edcb9c8a17d8b22634f06d942073"; +export const contractSha256 = "5006d9629a88652b9eab191ef4b8f3d2d54c7a1361780279ed2d4109a7f5e595"; export const operations = { "get_liveness": { "method": "GET", diff --git a/e2e/bub/tests/test_access_audit.py b/e2e/bub/tests/test_access_audit.py index 4f1f8bf54d..c98c727422 100644 --- a/e2e/bub/tests/test_access_audit.py +++ b/e2e/bub/tests/test_access_audit.py @@ -260,13 +260,12 @@ def test_a_handoff_report_over_every_scope_reaches_them_although_it_is_authorize "set_scope_binding", "clear_scope_binding", "register_source_definition", + # Deletes a collection runner record that has no targets. + "delete_source_runner", # Plugin descriptors, and per-Scope processing positions of a plugin's Supervisor binding. "list_plugins", "get_plugin", "list_plugin_scopes", - "load_source_write_plugin", - "source_configure", - "pc_source_instance_set_enabled", "scan_external_skills", "list_external_skills", "resolve_external_skill", From 006e9d9217d9521942830c31438c423cecad4644 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Fri, 13 Nov 2026 22:36:58 -0800 Subject: [PATCH 32/38] test(bub): classify the Source runner listing and sync in the access audit Co-Authored-By: Claude Opus 5.5 --- e2e/bub/tests/test_access_audit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/bub/tests/test_access_audit.py b/e2e/bub/tests/test_access_audit.py index c98c727422..6d268770fe 100644 --- a/e2e/bub/tests/test_access_audit.py +++ b/e2e/bub/tests/test_access_audit.py @@ -273,8 +273,9 @@ def test_a_handoff_report_over_every_scope_reaches_them_although_it_is_authorize }) -# Authorized for any authenticated caller like the Access API, but returning only a plugin's route descriptions. -_AUTHENTICATED_WITHOUT_CONTENT = frozenset({"get_plugin_openapi"}) +# Authorized for any authenticated caller like the Access API, but returning no Scope content: a plugin's route +# descriptions, the collection runners and their plugin versions, and a runner's own targets and checkpoints. +_AUTHENTICATED_WITHOUT_CONTENT = frozenset({"get_plugin_openapi", "pc_source_runners", "sync_source_runner"}) def test_every_server_wide_operation_is_classified_by_whether_it_returns_scope_content() -> None: From 297efd7e5f8192c0b0a0b3923497e344ad79245e Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:22:29 -0800 Subject: [PATCH 33/38] fix(sources): use the built-in runtime's worker security in the collector Co-Authored-By: Claude Opus 5.5 --- src/powercontext/builtin/plugins/collector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/powercontext/builtin/plugins/collector.py b/src/powercontext/builtin/plugins/collector.py index d2e32ead25..cb1f9b383f 100644 --- a/src/powercontext/builtin/plugins/collector.py +++ b/src/powercontext/builtin/plugins/collector.py @@ -91,7 +91,7 @@ def binding(target_id: str) -> ConnectorBinding: async with AsyncExitStack() as resources: if database is None: database = await resources.enter_async_context(open_plugin_database(spec.database)) - from powercontext.server.processing_security import open_worker_security + from powercontext.builtin.authz.worker import open_worker_security security = await resources.enter_async_context(open_worker_security(spec.security, database)) From 055199cabfbe6c2c4c9f75e99d955f702dc93dcb Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:23:13 -0800 Subject: [PATCH 34/38] build: regenerate the Desktop contract identity and review persistence after rebasing onto master Co-Authored-By: Claude Opus 5.5 --- desktop/src-tauri/src/transport/operations.json | 2 +- desktop/ui/src/generated/operations.ts | 2 +- release/upgrade-support.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 9ddce890f9..ada02a2bee 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "5006d9629a88652b9eab191ef4b8f3d2d54c7a1361780279ed2d4109a7f5e595", + "contractSha256": "6ac08e129deb33a55e59c618005dfc14ac5a1df4d1df31b94e669e89dd314e40", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 2580bb7a12..1acf9efd04 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 = "5006d9629a88652b9eab191ef4b8f3d2d54c7a1361780279ed2d4109a7f5e595"; +export const contractSha256 = "6ac08e129deb33a55e59c618005dfc14ac5a1df4d1df31b94e669e89dd314e40"; export const operations = { "get_liveness": { "method": "GET", diff --git a/release/upgrade-support.json b/release/upgrade-support.json index 6d99fbbd0b..37c55db3d6 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,7 +3,7 @@ "target_version": "1.5.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "08098928cff34642895f967549c5759a1c5ed47c842823551537e7045668f2ce", + "persistence_sha256": "36be0a1618a40ccdb7ab37ee80a64112c5253862374adb2b91de13fcd1d9f658", "rationale": "Revision v1_5_0_006 follows v1_5_0_005 on both registered source chains and all vector/Code profiles. It adds pc_source_runners (runner, bound subject, reported capabilities and digest, nullable last_seen_at) and pc_source_collect_targets (Scope and target identity, runner, capability, target key unique per Scope, runner and capability, parameters, enabled, update audit, and runner-written status columns), and drops pc_source_write_plugins. The source_collection domain step refuses the upgrade while pc_source_write_plugins lists a plugin, so no loaded write plugin is lost silently; otherwise no existing record is rewritten. Earlier revision resources, frozen checksums and SQLite fingerprints are unchanged; the MySQL layout for the new tables is registered from the same SQLAlchemy DDL. Target checkpoints reuse pc_connector_checkpoints keyed by (scope_id, target_id) with a constant connector version. Portable bundles add source_runner (name and bound subject) and source_collect_target records; reported capabilities, last-seen time and target status restart empty. Plugin reloading can unregister, in the Server process, the Artifact access profile a plugin family registered when that plugin is removed or renames its family; built-in family profiles are never removed, and no table layout, migration resource, frozen checksum or persisted encoding changes. Revision v1_5_0_005 follows v1_5_0_004 on both registered source chains and all vector/Code profiles. It removes the storage of the previous Artifact plugin framework (family schemas, declarative search and vector index tables, and the plugin registration columns of pc_artifact_processing_binding_states) and adds the nullable pc_artifacts.metadata column, which records the plugin name and code digest of plugin-written revisions and stays NULL for built-in families. Its domain preflight refuses the upgrade while the previous framework still holds any registration, declaration or index row, and native maintenance refuses registered native vector spaces, so no data is dropped silently. Its readiness accepts directory-loaded 'plugin:' Processing bindings without durable registration and keeps the frozen built-in full-text checks; plugin families write no rows to pc_artifact_fts. Revision reads omit metadata so domain steps at earlier registered revisions still run. Portable Artifact revision records advance to schema version 3 with metadata, and version 2 records restore with metadata NULL. Earlier revision resources, fingerprints and checksums remain unchanged; local SQLite migration tests do not establish native seekDB/OceanBase or installed-release acceptance. Topic Memory processing failures attach bounded, content-free stage, reason and budget diagnostics to in-flight exceptions. Rejected reservations retain their existing durable charges; readback omits token-specific fields unless committed evidence establishes the limiting dimension, and repeated rejected calls cannot reinterpret prior evidence. Source structure and history-count guards do not imply context overflow. Missing FTS capability is configuration, and incomplete vector projections are business validation. No database schema, migration resource, registered table layout, frozen revision checksum, row encoding, persisted journal format or worker retry behavior is changed by these diagnostic annotations. The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum. Revision v1_5_0_004 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged. Projection verification and repair read only the exact authoritative head content and state, avoiding target-only Source locator lineage tables at earlier registered migration checkpoints. Frozen resources and revision checksums remain unchanged. Source locator line-count and heading-path limits are centralized without changing their values, accepted payloads or stored encodings. Gateway authorization adds the Scope-level gateway.user role and gateway.invoke action to the existing binding vocabulary. It does not change stored binding structure or migration resources: existing records retain their role and do not implicitly gain gateway.invoke; new grants use the existing binding persistence and resource validation. Existing upgrade support remains upgrade-required and is not certified by this digest refresh. OceanBase Atomic Memory lexical retrieval uses native MATCH with minimum_should_match and SCORE when a controlled capability probe confirms support, and retains the existing MATCH AGAINST plus complete-word coverage path for unsupported backends. Capability decisions are held only in memory per connection pool. Search, complete related enumeration and recovery preserve Analyzer term deduplication, coverage thresholds, scope and content filters, vector completeness checks, same-statement authority heads and the existing oceanbase_match raw-score contract. This query-only change adds no tables, public interfaces, schema revisions, stored fields or row encodings; existing SPACE index definitions, projection formats, historical migration resources and revision checksums remain unchanged. No schema migration or projection rebuild is required for this query implementation. The upgrade-required policy, registered sources and complete installed-release acceptance matrix remain unchanged; this review does not certify installed-release upgrades. Queries above 256 distinct terms retain whole-word coverage unless the engine is identified as OceanBase 4.6 or newer, because earlier native engines can accept MATCH while ignoring that threshold. This compatibility guard changes query selection only. Native capability caching obtains the pool through run_sync, including pinned historical migration connections, without acquiring another connection or releasing maintenance locks. Queries containing SPACE stopwords retain Analyzer-based whole-word admission in all lexical operations. These corrections change neither stored projections nor registered schema resources. SQLite Topic Memory full-text helper version 2 assigns each Scope's helper rows to a hashed 2^32-rowid range above every rowid that version 1 assigned, so Topic replacement and search bound their FTS5 cursors to one Scope's range. Virtual table definitions, registered schema fingerprints, migration resources and authoritative active Topic rows are unchanged. Unmanaged initialization and the existing s1400 domain step rebuild version-1 helper rows from the active tables with exact identities and revisions; a managed database that still holds version-1 helper rows keeps the previous identity filters until the helper is rebuilt. bm25 statistics remain table-wide, so search hits, ordering and scores are unchanged. The standalone seekdb maintenance identity is derived from @@server_uuid and DATABASE() when ob_compatibility_mode is missing, NULL, or empty and VERSION() contains -OceanBase seekdb-. The db-migrate identity probe and the server-run tenant gate share one compatibility classifier; ORACLE and non-seekdb servers remain rejected. This is an admission-only change and does not alter registered table layouts, migration resources, persisted row encodings, or public interfaces. Source writes and text retrieval reuse parameterized SQL structures with fresh Scope, identity, revision, binding and increment values on every execution. Only the fixed built-in Content manifest construction and exact validated payload bytes reuse cached metadata; database registration and indexed-identity checks remain current, and callers receive deep copies. MySQL/OceanBase journal allocation retains SELECT FOR UPDATE; SQLite retains its write reservation. Concurrent Content captures coalesce already queued requests into bounded transactions, isolate individual failures after batch rollback, replay stable identities on cancellation and drain admitted callers during shutdown. No schema, migration, stored encoding, Source identity, journal ordering, processing counter semantics or upgrade support claims change. A Topic Memory window attempt whose publication loses the Topic head CAS to a concurrent replace or Dream records last_error_code head_conflict on its existing pc_topic_memory_work_budgets row, and the attempt ceiling and the next attempt's counter treat that one attempt as not spent. Provider request and token charges stay committed and are never refunded. No table layout, column, constraint, migration resource, revision checksum or row encoding changes; existing rows never carry head_conflict, so their counters and blocks keep their meaning. A binary without this change counts such an attempt as spent, which can only stop that frontier sooner. The Dashboard processing page reads the existing Source journal and Artifact lineage relation and marks only Sources with an exact Artifact citation. It introduces no new table or public JSON interface and does not reconstruct execution history, delivery or adoption. Source journal envelopes record an optional UTC saved_at timestamp for newly inserted records. This adds write metadata inside the existing powercontext-source-v1 payload, without changing registered tables, schema revisions, frozen migration resources or checksums. Historical envelopes and raw legacy payloads remain readable with unknown save time; replay does not invent a timestamp, and handoff receipt upgrades preserve the original timestamp. Source identity, material digests, journal positions and processing intent identities remain unchanged. This compatibility review does not establish installed-release upgrade acceptance; the upgrade-required policy and complete automatic matrix remain unchanged. Relocating access control from the Server package to the built-in runtime, removing the unused Source-cursor discovery query, and moving the code command out of the built-in Code package are code-organization changes. They change neither registered table layout nor migration data encodings: the generated table and index DDL for every persistence and access table is identical on the SQLite and MySQL dialects." }, "cases": [], From c90fd0182dbf726ea6e0d0b9ccb5c4dc3a3d288b Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:23:48 -0800 Subject: [PATCH 35/38] build(integrations): rebuild the DSH bundles from the Source collection operation tables Co-Authored-By: Claude Opus 5.5 --- integrations/dsh/plugins/dsh-pet/lib/index.js | 231 +++------ .../dsh/plugins/powercontext/lib/index.js | 447 ++++++++++++++++-- 2 files changed, 491 insertions(+), 187 deletions(-) diff --git a/integrations/dsh/plugins/dsh-pet/lib/index.js b/integrations/dsh/plugins/dsh-pet/lib/index.js index 33d9489c35..6135480fa2 100644 --- a/integrations/dsh/plugins/dsh-pet/lib/index.js +++ b/integrations/dsh/plugins/dsh-pet/lib/index.js @@ -683,6 +683,83 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + pc_source_runners: { + method: "GET", + path: "/v1/source-runners", + location: null, + scopeMode: "none", + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + sync_source_runner: { + method: "POST", + path: "/v1/source-runners/{runner}/sync", + location: "body", + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + delete_source_runner: { + method: "DELETE", + path: "/v1/source-runners/{runner}", + location: null, + scopeMode: "none", + pathParameters: ["runner"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, + pc_source_status: { + method: "GET", + path: "/v1/scopes/{scope_id}/source-targets", + location: null, + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_add: { + method: "POST", + path: "/v1/scopes/{scope_id}/source-targets", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201, 200], + emptyStatuses: [] + }, + pc_source_target_set_enabled: { + method: "PATCH", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + pc_source_target_delete: { + method: "DELETE", + path: "/v1/scopes/{scope_id}/source-targets/{target_id}", + location: null, + scopeMode: "none", + pathParameters: ["scope_id", "target_id"], + queryParams: [], + headerParams: [], + successStatuses: [204], + emptyStatuses: [204] + }, list_plugin_scopes: { method: "GET", path: "/v1/plugins/{name}/scopes", @@ -1700,160 +1777,6 @@ const OPERATIONS = { 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: [] - }, plan_access_ownership: { method: "POST", path: "/v1/access/ownership/plan", diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 1033fe4bae..8b4db60c57 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -570,7 +570,7 @@ const OPERATIONS = { scopeMode: "current", pathParameters: [], queryParams: [], - headerParams: [], + headerParams: ["X-PowerContext-Resolve-Target"], successStatuses: [200], emptyStatuses: [] }, @@ -2039,9 +2039,9 @@ function isPlainObject(value) { } function queryString(payload) { const params = new URLSearchParams(); - for (const [key, value] of Object.entries(payload ?? {})) { + for (const [key$1, value] of Object.entries(payload ?? {})) { if (value === void 0 || value === null) continue; - for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)); + for (const item of Array.isArray(value) ? value : [value]) params.append(key$1, String(item)); } const encoded = params.toString(); return encoded ? `?${encoded}` : ""; @@ -2437,11 +2437,11 @@ function createDiagnosticEmitter(write, now = Date.now, cooldownMs = 6e4) { "empty", "skipped" ].includes(outcome)) { - const key = outcome; + const key$1 = outcome; const timestamp = now(); - const previous = lastEmitted.get(key); + const previous = lastEmitted.get(key$1); if (previous !== void 0 && timestamp - previous < cooldownMs) return; - lastEmitted.set(key, timestamp); + lastEmitted.set(key$1, timestamp); } return write(JSON.stringify(normalized)); }; @@ -2462,7 +2462,7 @@ function isRecord$1(value) { function validatePreparedContext(response, path = "/v1/context/prepare", maxBytes = MAX_CONTEXT_BYTES) { if (!isRecord$1(response)) throw new InvalidResponseError(path, void 0, void 0, "prepared_object"); const keys = Object.keys(response); - if (keys.length !== PREPARED_FIELDS.size || keys.some((key) => !PREPARED_FIELDS.has(key))) throw new InvalidResponseError(path, void 0, void 0, "prepared_fields"); + if (keys.length !== PREPARED_FIELDS.size || keys.some((key$1) => !PREPARED_FIELDS.has(key$1))) throw new InvalidResponseError(path, void 0, void 0, "prepared_fields"); if (response.schema !== PREPARED_CONTEXT_SCHEMA) throw new InvalidResponseError(path, void 0, void 0, "prepared_schema"); const status = response.status; const content = response.content; @@ -2745,7 +2745,7 @@ function capabilities(response) { "artifact_families", "search_modes", "context_versions" - ].every((key) => Array.isArray(body[key]) && body[key].every((value) => typeof value === "string"))) invalid(operation, response, "capabilities"); + ].every((key$1) => Array.isArray(body[key$1]) && body[key$1].every((value) => typeof value === "string"))) invalid(operation, response, "capabilities"); if (!body.context_versions.includes(PREPARED_CONTEXT_SCHEMA)) return { ...check(operation, "unsupported_context_schema", "The Server does not advertise the PreparedContext schema required by this plugin.", "Install Server and plugin from the same supported release tag or checkout commit."), http_status: response.status, @@ -2985,11 +2985,28 @@ const CONTENT_FIELDS = new Map([ ["finalize_handoff", ["draft"]], ["commit_handoff", ["handoff"]] ]); -function containsSecret(text) { +const SECRET_PATTERNS = [ + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/giu, + /(? scrubbed.replace(pattern, "[REDACTED]"), text); +} +/** Detect secrets without rejecting ordinary words that merely contain a marker. */ +function containsSecret(text, authorization) { + return scrubSecrets(text, authorization) !== text; +} +function containsSecretMarker(text) { return SECRET_MARKERS.some((marker) => text.includes(marker)); } function containsSecretValue(value) { - if (typeof value === "string") return containsSecret(value); + if (typeof value === "string") return containsSecretMarker(value); if (!value || typeof value !== "object") return false; return Object.values(value).some(containsSecretValue); } @@ -3362,18 +3379,18 @@ var RuntimeStatus = class { this.now = now; } begin(sessionId, cwd, turn) { - const key = sessionKey(sessionId, cwd); + const key$1 = sessionKey(sessionId, cwd); const attempt = { attempt: ++this.sequence, turn: /^\d{1,20}$/.test(turn) ? turn : "(unavailable)", started_at: this.now(), stages: initialStages() }; - this.sessions.delete(key); - this.sessions.set(key, attempt); + this.sessions.delete(key$1); + this.sessions.set(key$1, attempt); if (this.sessions.size > STATUS_SESSION_LIMIT) this.sessions.delete(this.sessions.keys().next().value); const record$3 = (stage, result) => { - if (this.sessions.get(key) !== attempt) return; + if (this.sessions.get(key$1) !== attempt) return; attempt.stages[stage] = { ...result, operation: OPERATIONS$1[stage], @@ -3382,7 +3399,7 @@ var RuntimeStatus = class { }; return { scope: (scopeId) => { - if (this.sessions.get(key) !== attempt) return; + if (this.sessions.get(key$1) !== attempt) return; attempt.scope_key = fingerprint(scopeId); attempt.scope_id = /^[a-zA-Z0-9_-]{1,128}$/.test(scopeId) ? scopeId : "(redacted)"; record$3("scope", { state: "resolved" }); @@ -3654,6 +3671,8 @@ function storedAuthorization(env, baseUrl) { } function resolveConfig(config = {}, env = process.env) { const transport = resolveTransport("dsh", env, config.baseUrl, config.allowInsecureHttp, DEFAULTS.baseUrl); + if (config.updateChannel !== void 0 && config.updateChannel !== "stable" && config.updateChannel !== "preview") throw new Error("updateChannel must be stable or preview"); + if (config.updateHold !== void 0 && typeof config.updateHold !== "boolean") throw new Error("updateHold must be a boolean"); const maxBytes = config.maxBytes ?? DEFAULTS.maxBytes; if (maxBytes < 512 || maxBytes > 32768) throw new Error("maxBytes must be between 512 and 32768"); return { @@ -3673,7 +3692,9 @@ function resolveConfig(config = {}, env = process.env) { capturePrompts: envBoolean(env, "POWERCONTEXT_DSH_CAPTURE_PROMPTS") ?? config.capturePrompts ?? DEFAULTS.capturePrompts, flushOnCapture: envBoolean(env, "POWERCONTEXT_DSH_FLUSH_ON_CAPTURE") ?? config.flushOnCapture ?? DEFAULTS.flushOnCapture, flushMaxCalls: config.flushMaxCalls ?? DEFAULTS.flushMaxCalls, - ownerLocal: ownerLocal(env, config.ownerLocal) + ownerLocal: ownerLocal(env, config.ownerLocal), + updateChannel: config.updateChannel, + updateHold: config.updateHold }; } @@ -3879,7 +3900,7 @@ async function loadPeer(specifier) { const POWERCONTEXT_MCP_SERVER_NAME = "powercontext"; const MCP_TOOL_PREFIX = `mcp__${POWERCONTEXT_MCP_SERVER_NAME}__`; const MCP_STARTUP_WAIT_MS = 5e3; -const MUTATING_MCP_OPERATIONS = new Set(Object.entries(MCP_OPERATIONS).filter(([, metadata]) => !metadata.readOnly).map(([operation]) => operation)); +const MUTATING_MCP_OPERATIONS = new Set(Object.entries(MCP_OPERATIONS).filter(([, metadata$1]) => !metadata$1.readOnly).map(([operation]) => operation)); function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -3889,9 +3910,9 @@ function rawOperation(name$1) { return Object.prototype.hasOwnProperty.call(MCP_OPERATIONS, operation) ? operation : void 0; } function usesScope(operation, args) { - const metadata = OPERATIONS[operation]; - if (metadata.pathParameters.some((parameter) => parameter === "scope_id")) return "current"; - return metadata.scopeMode; + const metadata$1 = OPERATIONS[operation]; + if (metadata$1.pathParameters.some((parameter) => parameter === "scope_id")) return "current"; + return metadata$1.scopeMode; } function matchesScope(argumentsValue, mode, scopeId) { if (!isRecord(argumentsValue)) return false; @@ -4013,7 +4034,7 @@ async function captureUserPrompt(input) { observation?.skip("capture", "capture_disabled"); return; } - if (input.prompt.length > MAX_SOURCE_LENGTH || containsSecret(input.prompt)) { + if (input.prompt.length > MAX_SOURCE_LENGTH || containsSecret(input.prompt, input.config.authorization)) { observation?.skip("capture", input.prompt.length > MAX_SOURCE_LENGTH ? "source_too_long" : "sensitive_content"); logSafely(input.log, { event: "capture_content_source", @@ -4129,7 +4150,7 @@ async function recallContent(input, query, scopeId, observation) { if (input.signal?.aborted) throw new TransportError("", input.signal.reason); const result = await input.client.request("prepare_context", { scope_id: scopeId, - query: boundedRecallQuery(query), + query: boundedRecallQuery(scrubSecrets(query, input.config.authorization)), max_bytes: input.config.maxBytes, ...input.config.contextAssembly === void 0 ? {} : { assembly: input.config.contextAssembly } }, input.signal); @@ -4777,8 +4798,8 @@ var StatusBridge = class { }; } async resolveScope(session, now) { - const key = `${session.id}\u0000${session.cwd ?? ""}`; - const cached = this.scopes.get(key); + const key$1 = `${session.id}\u0000${session.cwd ?? ""}`; + const cached = this.scopes.get(key$1); if (cached !== void 0 && now - cached.observed_at < SCOPE_TTL_MS) return cached; try { const valid = safeScopeId(await this.options.resolveScope(session.cwd, this.abort.signal)); @@ -4793,7 +4814,7 @@ var StatusBridge = class { state: "resolved", observed_at: now }; - this.scopes.set(key, observation); + this.scopes.set(key$1, observation); return observation; } catch (error) { const failure = operationFailure("resolve_scope_binding", error); @@ -4804,7 +4825,7 @@ var StatusBridge = class { ...failure.message ? { message: failure.message } : {}, observed_at: now }; - this.scopes.set(key, observation); + this.scopes.set(key$1, observation); return observation; } } @@ -4881,8 +4902,8 @@ var StatusBridge = class { } async readPackageVersion() { try { - const response = await this.options.client.request("get_server_info", {}, combineSignals([this.abort.signal, AbortSignal.timeout(1e3)])); - if (!this.disposed) this.packageVersion = response.status === 200 && record(response.value) ? packageVersion(response.value.package_version) : null; + const version = await readServerPackageVersion(this.options.client, this.abort.signal); + if (!this.disposed) this.packageVersion = version; } catch { this.packageVersion = null; } @@ -4934,7 +4955,7 @@ var StatusBridge = class { "artifact_families", "search_modes", "context_versions" - ].every((key) => Array.isArray(value[key]) && value[key].every((item) => typeof item === "string"))) throw new TypeError("Capabilities response does not match its schema"); + ].every((key$1) => Array.isArray(value[key$1]) && value[key$1].every((item) => typeof item === "string"))) throw new TypeError("Capabilities response does not match its schema"); const contextVersions = value.context_versions.slice(0, 32); const observedAt = this.now(); if (!contextVersions.includes(PREPARED_CONTEXT_SCHEMA)) return { @@ -4958,6 +4979,11 @@ var StatusBridge = class { } } }; +/** Read the running Server package identity without treating metadata as readiness. */ +async function readServerPackageVersion(client, signal) { + const response = await client.request("get_server_info", {}, combineSignals(signal ? [signal, AbortSignal.timeout(1e3)] : [AbortSignal.timeout(1e3)])); + return response.status === 200 && record(response.value) ? packageVersion(response.value.package_version) : null; +} function sendJson(response, status, payload) { if (response.writableEnded) return; response.statusCode = status; @@ -5043,6 +5069,360 @@ function registerStatusBridge(ctx, runtime) { }); } +//#endregion +//#region src/update-contract.ts +const UPDATE_ROUTE = "/powercontext/updates"; +const UPDATE_ACTION_HEADER = "x-powercontext-update-action"; +const UPDATE_PLAN_HEADER = "x-powercontext-update-plan"; +/** Releases use the same numeric version in Python and npm, with different RC spellings. */ +function releaseVersion(value) { + if (typeof value !== "string" || value.length > 64) return null; + const parts = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})(?:(?:(a|b|rc)|-(alpha|beta|rc)\.)(0|[1-9]\d{0,8}))?$/.exec(value); + if (!parts) return null; + const stage = parts[4] ?? { + alpha: "a", + beta: "b", + rc: "rc" + }[parts[5]]; + const core = parts.slice(1, 4).join("."); + const npmStage = stage === "a" ? "alpha" : stage === "b" ? "beta" : stage; + return { + python: stage ? core + stage + parts[6] : core, + npm: stage ? core + "-" + npmStage + "." + parts[6] : core, + prerelease: Boolean(stage), + order: [ + ...parts.slice(1, 4).map(Number), + stage === "a" ? 0 : stage === "b" ? 1 : stage === "rc" ? 2 : 3, + Number(parts[6] ?? 0) + ] + }; +} +function compareVersions(a, b) { + return a.order.map((part, index) => part - b.order[index]).find((part) => part !== 0) ?? 0; +} + +//#endregion +//#region src/release-updates.ts +const RELEASES = "https://api.github.com/repos/oceanbase/powercontext/releases?per_page=30"; +const MAX_METADATA_BYTES = 2 * 1024 * 1024; +function object(value) { + return value !== null && typeof value === "object" ? value : {}; +} +async function metadata(url, fetcher) { + const response = await fetcher(url, { + redirect: "error", + signal: AbortSignal.timeout(6e3), + headers: { + accept: "application/json", + "user-agent": "powercontext-dsh-update-check" + } + }); + if (!response.ok || !response.body) { + await response.body?.cancel(); + throw new Error("release metadata unavailable"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let json = ""; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) return JSON.parse(json + decoder.decode()); + size += value.byteLength; + if (size > MAX_METADATA_BYTES) throw new Error("release metadata too large"); + json += decoder.decode(value, { stream: true }); + } + } finally { + await reader.cancel(); + } +} +async function officialRelease(channel, fetcher = fetch) { + const releases = await metadata(RELEASES, fetcher); + if (!Array.isArray(releases)) throw new Error("invalid release listing"); + const candidates = releases.flatMap((raw) => { + const release = object(raw); + const tag = release.tag_name; + if (typeof tag !== "string" || !tag.startsWith("powercontext-v") || release.draft !== false || typeof release.published_at !== "string" || typeof release.prerelease !== "boolean") return []; + const version = releaseVersion(tag.slice(14)); + if (!version || tag !== "powercontext-v" + version.python || channel === "stable" && (version.prerelease || release.prerelease)) return []; + const assets = Array.isArray(release.assets) ? release.assets.map(object).filter((asset) => asset.state === "uploaded") : []; + const plugin = assets.find((asset) => asset.name === "powercontext-dsh-" + version.npm + ".tgz"); + const wheel = assets.find((asset) => asset.name === "powercontext-" + version.python + "-py3-none-any.whl"); + const digest = typeof wheel?.digest === "string" && /^sha256:[a-f0-9]{64}$/.test(wheel.digest) ? wheel.digest.slice(7) : null; + return [{ + tag, + version, + pluginPublished: plugin !== void 0, + wheelDigest: digest + }]; + }).sort((a, b) => compareVersions(b.version, a.version)); + if (!candidates[0]) throw new Error("no published PowerContext release found"); + return candidates[0]; +} +async function serverDistributionAvailable(release, fetcher = fetch) { + if (release.wheelDigest === null) return false; + try { + const value = object(await metadata("https://pypi.org/pypi/powercontext/" + release.version.python + "/json", fetcher)); + return Array.isArray(value.urls) && value.urls.map(object).some((file) => file.filename === "powercontext-" + release.version.python + "-py3-none-any.whl" && file.yanked === false && object(file.digests).sha256 === release.wheelDigest); + } catch { + return false; + } +} + +//#endregion +//#region src/update-bridge.ts +function supportsRegistryUpdate(bundle) { + return bundle.update === void 0 ? bundle.updatable === true : bundle.update === "registry"; +} +const PUBLIC_REGISTRIES = new Set(["https://registry.npmjs.org/", "https://registry.npmmirror.com/"]); +const UPDATE_CHECK_INTERVAL_MS = 60 * 6e4; +const UPDATE_STARTUP_DELAY_MS = 15e3; +const UPDATE_BUSY_RETRY_MS = 6e4; +const PLAN_TTL_MS = UPDATE_CHECK_INTERVAL_MS + 5 * 6e4; +const CHECK_COOLDOWN_MS = 5e3; +function publicRegistry(registry) { + if (registry === null) return false; + try { + return PUBLIC_REGISTRIES.has(new URL(registry).href); + } catch { + return false; + } +} +function updateLedger() { + return { + snapshot: { + phase: "idle", + plugin_version: null, + plugin_target: null, + server_target: null, + server_state: "unknown", + plan: null, + release_tag: null + }, + plan: null, + checkedAt: Number.NEGATIVE_INFINITY + }; +} +const key = Symbol.for("powercontext.dsh-update-ledgers.v1"); +const global = globalThis; +const ledgers = global[key] ??= /* @__PURE__ */ new WeakMap(); +var DshUpdates = class { + options; + constructor(options) { + this.options = options; + } + get ledger() { + return this.options.ledger; + } + now() { + return this.options.now?.() ?? Date.now(); + } + read() { + const { snapshot, plan } = this.ledger; + const plugin_version = packageVersion(this.options.pluginVersion); + if (plan && (plan.expires <= this.now() || plan.from !== plugin_version || plan.channel !== this.options.channel || this.options.held)) { + this.ledger.plan = null; + this.ledger.snapshot = { + ...snapshot, + phase: "idle", + plan: null + }; + } + if (snapshot.phase === "restart_required" && snapshot.plugin_target === plugin_version) this.ledger.snapshot = { + ...snapshot, + phase: "updated" + }; + return { + ...this.ledger.snapshot, + plugin_version + }; + } + check() { + const current = this.read(); + if ([ + "checking", + "installing", + "restart_required" + ].includes(current.phase) || this.now() - this.ledger.checkedAt < CHECK_COOLDOWN_MS) return current; + this.ledger.checkedAt = this.now(); + this.ledger.plan = null; + this.ledger.snapshot = { + ...current, + phase: "checking", + plan: null + }; + this.resolveCheck().catch(() => this.phase("check_failed")); + return this.read(); + } + /** Schedule the same read-only check once per hour, independent of the panel. */ + scheduleChecks() { + let timer; + const remaining = () => this.ledger.checkedAt + UPDATE_CHECK_INTERVAL_MS - this.now(); + const schedule = (delay) => { + timer = setTimeout(tick, delay); + timer.unref(); + }; + const tick = () => { + if (remaining() <= 0) this.check(); + const delay = remaining(); + schedule(delay > 0 ? delay : UPDATE_BUSY_RETRY_MS); + }; + schedule(Math.max(UPDATE_STARTUP_DELAY_MS, remaining())); + return () => clearTimeout(timer); + } + install(id) { + if (this.read().phase !== "available" || this.ledger.plan?.id !== id) return null; + const plan = this.ledger.plan; + this.ledger.plan = null; + this.phase("installing"); + this.apply(plan).catch(() => this.phase("unconfirmed")); + return this.read(); + } + phase(phase) { + this.ledger.snapshot = { + ...this.ledger.snapshot, + phase, + plan: null + }; + } + async resolveCheck() { + const { fetch: fetcher, channel } = this.options; + const [release, server] = await Promise.all([officialRelease(channel, fetcher), this.options.serverVersion()]); + const installedServer = releaseVersion(server); + const serverHasUpdate = server === null || installedServer !== null && compareVersions(release.version, installedServer) > 0; + const serverAvailable = serverHasUpdate && await serverDistributionAvailable(release, fetcher); + const server_state = serverHasUpdate ? serverAvailable ? "available" : "artifact_unavailable" : installedServer ? "current" : "unknown"; + this.ledger.snapshot = { + ...this.ledger.snapshot, + server_state, + server_target: serverHasUpdate ? release.version.python : null, + release_tag: release.tag, + plugin_target: release.version.npm + }; + const running = releaseVersion(this.options.pluginVersion); + const manager = this.options.manager(); + const bundle = (await manager?.listBundles())?.find((bundle$1) => bundle$1.name === PLUGIN_NAME); + if (!running || !manager || !bundle?.installed || !bundle.enabled || !supportsRegistryUpdate(bundle) || !bundle.source || bundle.version !== running.npm) return this.phase("manual"); + if (compareVersions(release.version, running) <= 0) return this.phase("current"); + if (this.options.held) return this.phase("held"); + if (!release.pluginPublished) return this.phase("artifact_unavailable"); + const registries = await manager.registries(); + if (!publicRegistry(registries.registry ?? registries.resolved) || registries.fallbackRegistries.some((registry$1) => !publicRegistry(registry$1))) return this.phase("manual"); + const versions = await manager.listVersions(PLUGIN_NAME, void 0, AbortSignal.timeout(12e3)); + if (versions.status !== "listed") return this.phase(versions.problem === "not-found" ? "artifact_unavailable" : "check_failed"); + const registry = versions.registry ?? registries.resolved; + if (!publicRegistry(registry) || versions.installed !== running.npm) return this.phase("manual"); + const target = versions.versions.find((version) => version.version === release.version.npm); + if (!target) return this.phase("artifact_unavailable"); + if (!target.bundle || target.deprecated !== void 0 || target.incompatible) return this.phase("incompatible"); + if (installedServer?.python !== release.version.python) return this.phase("server_first"); + const plan = { + id: randomUUID(), + expires: this.now() + PLAN_TTL_MS, + from: running.npm, + source: bundle.source, + to: target.version, + server: release.version.python, + registry, + channel + }; + this.ledger.plan = plan; + this.ledger.snapshot = { + ...this.ledger.snapshot, + phase: "available", + plan: plan.id + }; + } + async apply(plan) { + const manager = this.options.manager(); + if (!manager || this.options.held) return this.phase("manual"); + const [bundles, server, registries] = await Promise.all([ + manager.listBundles(), + this.options.serverVersion(), + manager.registries() + ]); + const current = bundles.find((bundle) => bundle.name === PLUGIN_NAME); + if (current?.version !== plan.from || current.source !== plan.source || !current.enabled || !current.installed || !supportsRegistryUpdate(current) || !publicRegistry(registries.registry ?? registries.resolved) || !publicRegistry(plan.registry) || registries.fallbackRegistries.some((registry) => !publicRegistry(registry))) return this.phase("manual"); + if (releaseVersion(server)?.python !== plan.server) return this.phase("server_first"); + if (!this.options.canInstall()) return this.phase("busy"); + const { result, restore } = await manager.updateBundle(PLUGIN_NAME, plan.to, { registry: plan.registry }); + if (result.application === "applied" || result.application === "restart-required") { + const installed = (await manager.listBundles()).find((bundle) => bundle.name === PLUGIN_NAME); + if (installed?.version !== plan.to || !installed.enabled || !installed.installed) return this.phase("unconfirmed"); + return this.phase("restart_required"); + } + if (restore?.application === "applied" || restore?.application === "restart-required") { + const installed = (await manager.listBundles()).find((bundle) => bundle.name === PLUGIN_NAME); + if (installed?.version === plan.from && installed.enabled && installed.installed) return this.phase("restored"); + } + return this.phase("install_failed"); + } +}; +function createUpdateRouteHandler(updates, connection, facts) { + return (request$2, response) => { + const rejection = connection.requestRejection(request$2); + const deployment = deploymentRejection(request$2, facts); + if (rejection !== void 0 || deployment) return sendJson(response, rejection ?? 403, { code: "owner_required" }); + if (request$2.method === "GET") return sendJson(response, 200, updates.read()); + if (request$2.method !== "POST") { + response.setHeader("allow", "GET, POST"); + return sendJson(response, 405, { code: "method_not_allowed" }); + } + const action = request$2.headers[UPDATE_ACTION_HEADER]; + if (action === "check") return sendJson(response, 202, updates.check()); + if (action !== "install") return sendJson(response, 400, { code: "unknown_action" }); + if (connection.admit?.(request$2).peer?.principal?.kind !== "owner") return sendJson(response, 403, { code: "owner_required" }); + const id = request$2.headers[UPDATE_PLAN_HEADER]; + const snapshot = typeof id === "string" && updates.install(id); + if (!snapshot) return sendJson(response, 409, { code: "update_plan_changed" }); + return sendJson(response, 202, snapshot); + }; +} +function hostUpdates(ctx, runtime, facts) { + let ledger = ledgers.get(ctx.root); + if (!ledger) ledgers.set(ctx.root, ledger = updateLedger()); + return new DshUpdates({ + ledger, + pluginVersion: PLUGIN_VERSION, + channel: runtime.config.updateChannel ?? "stable", + held: runtime.config.updateHold === true, + manager: () => { + if (typeof ctx.connection.admit !== "function") return void 0; + if (ctx.get("profileContext")?.name !== (facts.desktop ? "desktop" : "web")) return void 0; + const manager = ctx.root.get("pluginManager") ?? ctx.get("pluginManager"); + return manager && typeof manager.listVersions === "function" && typeof manager.updateBundle === "function" ? manager : void 0; + }, + serverVersion: async () => { + try { + return await readServerPackageVersion(runtime.client); + } catch { + return null; + } + }, + canInstall: () => { + const sessions = ctx.get("sessions"); + const agents = ctx.get("agents"); + return typeof sessions?.list === "function" && typeof agents?.turnBoundaryOf === "function" && sessions.list().every((session) => agents.turnBoundaryOf(session).openTurnStartSeq === null); + } + }); +} +function registerUpdateBridge(ctx, runtime) { + if (typeof ctx.inject !== "function") return; + ctx.inject(["webServer", "connection"], (host) => { + const ui = host; + const facts = localDeploymentFacts(ui, runtime.config.ownerLocal); + const updates = hostUpdates(ui, runtime, facts); + host.effect(() => ui.webServer.register({ + kind: "exact", + path: UPDATE_ROUTE, + handler: createUpdateRouteHandler(updates, ui.connection, facts) + }), "powercontext: update route"); + const profile = ui.get("profileContext"); + if (resolveDeployment(facts) && profile?.name === (facts.desktop ? "desktop" : "web")) host.effect(() => updates.scheduleChecks(), "powercontext: hourly update check"); + }); +} + //#endregion //#region src/index.ts const name = PLUGIN_NAME; @@ -5097,12 +5477,12 @@ function createSessionScopeResolver(runtime) { const cache = /* @__PURE__ */ new Map(); const keyFor = (sessionId, cwd) => sessionId ? `session:${sessionId}` : `cwd:${cwd ?? ""}`; const resolve$1 = async (request$2, refresh) => { - const key = keyFor(request$2.sessionId, request$2.cwd); - const cached = cache.get(key); + const key$1 = keyFor(request$2.sessionId, request$2.cwd); + const cached = cache.get(key$1); if (!refresh && cached && cached.cwd === request$2.cwd) return cached.scopeId; - cache.delete(key); + cache.delete(key$1); const scopeId = await runtime.resolveScope(request$2.cwd, request$2.signal); - if (scopeId) cache.set(key, { + if (scopeId) cache.set(key$1, { cwd: request$2.cwd, scopeId }); @@ -5188,6 +5568,7 @@ async function apply(ctx, config) { registerRecall(ctx, runtime, llmMod.createUserMessage, sessionScope.forPreStep); registerCommands(ctx, runtime); registerStatusBridge(ctx, runtime); + registerUpdateBridge(ctx, runtime); await registerMcp(ctx, runtime.config); } From 01d7ce8fc6e1f03d46a34bc3daf231d528e3f202 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:25:13 -0800 Subject: [PATCH 36/38] test(desktop): qualify the Source plugins contract Co-Authored-By: Claude Opus 5.5 --- desktop/VALIDATION.md | 31 +++++++++++++++++++ .../src/connections/compatibility.json | 22 +++++++++++++ 2 files changed, 53 insertions(+) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index 8c2c221cca..741a18530d 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -3495,3 +3495,34 @@ uv run --no-sync python desktop/tests/real_server.py \ --product-qualification sqlite-plugin-reload-8effcedd-v1 \ --wheel desktop/.artifacts/plugin-reload/powercontext-1.5.2.dev173+g8effcedde-py3-none-any.whl ``` + + +## Server c90fd018 Source plugins protocol qualification + +`sqlite-source-plugins-c90fd018-v1` qualifies all thirteen Desktop protocol operations against +Server source `c90fd0182dbf726ea6e0d0b9ccb5c4dc3a3d288b`, which adds Source plugin collection runners and +targets and removes the Source management operations. + +- Normalized OpenAPI SHA-256: `6ac08e129deb33a55e59c618005dfc14ac5a1df4d1df31b94e669e89dd314e40`. +- Wheel: `powercontext-1.5.2.dev181+gc90fd0182-py3-none-any.whl`. +- Wheel SHA-256: `3593d8fcda89d620414ad65dc628c70edb38c0a9d8450b3aa5e6d7865f3134d0`. +- Native debug probe SHA-256 (built without debug info): `edb3cf2828f9f91bf5962eefe5ac123bb95d401a1553003840aadc4d0e5fe521`. + +On 2026-11-14, Linux 7.0 x86_64 (Ubuntu 24.04, glibc 2.39) and Python 3.12.3 passed anonymous loopback, +static Bearer, HTTPS with explicit CA/base path, and enforced Provider modes of the unchanged +`desktop/tests/real_server.py` through the ordinary product ConnectionManager. The probe was built with +the product Cargo lock without `ci-fixtures` and selected this bundled qualification; the client and +qualified contract digests are identical. `bundled_product_qualification_covers_every_desktop_operation` +passed against the same records. The wheel and report are retained under ignored +`desktop/.artifacts/source-plugins/` and `desktop/.artifacts/real-server.json`. +This qualifies the observed local SQLite protocol; it does not establish installed WebView, native +credential persistence, other operating systems, native database upgrades or release acceptance. + +Reproduce against the recorded artifact: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-source-plugins-c90fd018-v1 \ + --wheel desktop/.artifacts/source-plugins/powercontext-1.5.2.dev181+gc90fd0182-py3-none-any.whl +``` diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 758330a8c5..4402df24c1 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1728,5 +1728,27 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-8effcedd-plugin-reload-protocol-qualification" + }, + { + "id": "sqlite-source-plugins-c90fd018-v1", + "serverCommit": "c90fd0182dbf726ea6e0d0b9ccb5c4dc3a3d288b", + "contractSha256": "6ac08e129deb33a55e59c618005dfc14ac5a1df4d1df31b94e669e89dd314e40", + "artifactSha256": "3593d8fcda89d620414ad65dc628c70edb38c0a9d8450b3aa5e6d7865f3134d0", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-c90fd018-source-plugins-protocol-qualification" } ] From c9aecfa5dce40cc5911754d34e266cb69bc535a6 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Sat, 14 Nov 2026 09:52:53 -0800 Subject: [PATCH 37/38] fix(dashboard): close the navigation group loop and list plugin pages in their own section Rebasing onto master's grouped navigation dropped the closing tag of the group loop, so every Dashboard page with plugin pages failed to render. Co-Authored-By: Claude Opus 5.5 --- src/powercontext/server/dashboard/labels.en.json | 1 + src/powercontext/server/dashboard/labels.json | 1 + .../server/dashboard/templates/components/navigation.html | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json index 566a352a6a..ea385a0df2 100644 --- a/src/powercontext/server/dashboard/labels.en.json +++ b/src/powercontext/server/dashboard/labels.en.json @@ -648,6 +648,7 @@ "processing_dream_more_inputs": "{count} additional inputs are listed under references.", "processing_dream_source_inputs": "This run recorded {count} source references. Expand references to inspect them.", "observability": "Cloud-native monitoring", + "nav_plugins": "Plugins", "plugin": "Plugin", "plugin_page_stopped": "The plugin page navigated away from its start page, so requests are no longer forwarded for it. Reopen the page to continue." } diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json index 17889e8d4c..0a1a072087 100644 --- a/src/powercontext/server/dashboard/labels.json +++ b/src/powercontext/server/dashboard/labels.json @@ -648,6 +648,7 @@ "processing_dream_more_inputs": "另有 {count} 项输入,可展开引用查看。", "processing_dream_source_inputs": "本次任务记录了 {count} 项资料引用,可展开查看。", "observability": "云原生监控", + "nav_plugins": "插件", "plugin": "插件", "plugin_page_stopped": "插件页面已离开起始页,已停止为它转发请求。重新打开页面后继续使用。" } diff --git a/src/powercontext/server/dashboard/templates/components/navigation.html b/src/powercontext/server/dashboard/templates/components/navigation.html index fc0bbb7183..aa93735c46 100644 --- a/src/powercontext/server/dashboard/templates/components/navigation.html +++ b/src/powercontext/server/dashboard/templates/components/navigation.html @@ -46,6 +46,8 @@ {{ icon(item) }}{{ t.processing_dream_tab if item == 'dream_records' else t.processing_sources_title if item == 'source_records' else t[item] }} {% endfor %} + {% endfor %} + {% if plugin_pages %}{% endif %} {% for item in plugin_pages %} {% set active = plugin_page and plugin_page.name == item.name %}