diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 77472d7d3..87583939e 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -58,6 +58,7 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_LOGGING_ACCESS` | `true` | Log external HTTP and logical MCP request completion | | `POWERCONTEXT_SERVER_METRICS_ENABLED` | `true` | Expose Prometheus metrics at `/metrics` | | `POWERCONTEXT_SERVER_TRACING_ENABLED` | `false` | Enable span recording and OTLP export | +| `POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET` | local persisted key | Shared secret of at least 32 bytes for signing REST pagination cursors | | `POWERCONTEXT_SERVER_DATABASE_KIND` | `sqlite` | Storage backend: `sqlite`, `seekdb`, or `oceanbase` | | `POWERCONTEXT_SERVER_DATABASE_URL` | user data SQLite file | SQLAlchemy async URL for SQLite or OceanBase; do not set for seekDB | | `POWERCONTEXT_SERVER_DATABASE_PATH` | user data `seekdb` directory | Embedded seekDB path; used only when `DATABASE_KIND=seekdb` | @@ -91,6 +92,12 @@ Server settings use the `POWERCONTEXT_SERVER_` prefix. | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | unset | Experience incubation interval; unset disables that job | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | automatic local project targets | JSON override containing the host identity and explicit Agent Skill targets | +When the cursor signing secret is unset, a file-backed SQLite Server creates a private key beside its database; +other persistent backends create one in the PowerContext user data directory. In-memory SQLite uses a process-local +key. Configure the same `POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET` on every replica so a cursor remains valid after +restart or when the next request reaches another replica. Never expose or rotate this value while issued cursors +must remain valid. + Static bearer authentication is disabled by default. When enabled, API and MCP requests must include `Authorization: Bearer `; the liveness and readiness endpoints remain public. Plain HTTP is trusted only on a loopback address (`localhost`, `::1`, or any address in `127.0.0.0/8`). The Server refuses to start when it binds to a diff --git a/docs/zh/docs/reference/configuration.md b/docs/zh/docs/reference/configuration.md index 6c3601422..0991b55ec 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -54,6 +54,7 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_LOGGING_ACCESS` | `true` | 记录外部 HTTP 和逻辑 MCP request completion | | `POWERCONTEXT_SERVER_METRICS_ENABLED` | `true` | 在 `/metrics` 暴露 Prometheus metrics | | `POWERCONTEXT_SERVER_TRACING_ENABLED` | `false` | 启用 span recording 和 OTLP export | +| `POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET` | 本地持久化密钥 | 用于签名 REST 分页 cursor 的共享密钥,至少 32 字节 | | `POWERCONTEXT_SERVER_DATABASE_KIND` | `sqlite` | 存储后端:`sqlite`、`seekdb` 或 `oceanbase` | | `POWERCONTEXT_SERVER_DATABASE_URL` | 用户数据目录下的 SQLite 文件 | SQLite 或 OceanBase 的 SQLAlchemy 异步 URL;seekDB 不设置 | | `POWERCONTEXT_SERVER_DATABASE_PATH` | 用户数据目录下的 `seekdb` 目录 | 嵌入式 seekDB 路径;仅在 `DATABASE_KIND=seekdb` 时使用 | @@ -87,6 +88,11 @@ Server 配置使用 `POWERCONTEXT_SERVER_` 前缀。 | `POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS` | 未设置 | Experience 孵化间隔;未设置即不启用该 job | | `POWERCONTEXT_SERVER_EXTERNAL_SKILLS` | 自动生成本机项目 target | 覆盖默认值的 host identity 和显式 Agent Skill targets JSON object | +未设置 cursor 签名密钥时,使用文件 SQLite 的 Server 会在数据库旁创建权限受限的密钥文件;其他持久化后端会在 +PowerContext 用户数据目录创建密钥。内存 SQLite 使用进程内密钥。多副本部署必须为所有副本配置相同的 +`POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET`,这样重启或下一请求落到其他副本后,已签发 cursor 仍然有效。 +在已签发 cursor 仍需有效期间,不要泄漏或轮换该值。 + 静态 Bearer 鉴权默认关闭。启用后,API 和 MCP 请求必须携带 `Authorization: Bearer `;liveness 和 readiness endpoint 仍然公开。明文 HTTP 仅在 loopback 地址(`localhost`、`::1` 及 `127.0.0.0/8` 网段内的任意 地址)上受信任。当 Server 绑定到非 loopback 地址且鉴权关闭时会拒绝启动;此时应启用鉴权、改回绑定 loopback,或在 diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index e527d310a..6699c7bb1 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -87,490 +87,864 @@ const OPERATIONS = { path: "/health/live", location: null, scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_readiness: { method: "GET", path: "/health/ready", location: null, scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_capabilities: { method: "GET", path: "/v1/capabilities", location: null, scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_scopes: { method: "GET", path: "/v1/scopes", location: null, scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, create_scope: { method: "POST", path: "/v1/scopes", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, publish_artifact: { method: "POST", path: "/v1/artifact-publications", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, get_scope: { method: "GET", path: "/v1/scopes/{scope_id}", location: null, scopeMode: "none", - pathParameters: ["scope_id"] + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, update_scope: { method: "PUT", path: "/v1/scopes/{scope_id}", location: "body", scopeMode: "none", - pathParameters: ["scope_id"] + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_default_scope: { method: "GET", path: "/v1/scopes/default", location: null, scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, set_default_scope: { method: "PUT", path: "/v1/scopes/default", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, resolve_scope_selection: { method: "POST", path: "/v1/scopes/selection/resolve", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, resolve_scope_binding: { method: "POST", path: "/v1/scope-bindings/resolve", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, set_scope_binding: { method: "PUT", path: "/v1/scope-bindings", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, clear_scope_binding: { method: "POST", path: "/v1/scope-bindings/clear", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, capture_content_source: { method: "POST", path: "/v1/sources/content", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, register_source_definition: { method: "POST", path: "/v1/source-definitions/register", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_connector_checkpoint: { method: "POST", path: "/v1/connector-checkpoints/get", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, submit_source_observation: { method: "POST", path: "/v1/source-observations", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, commit_connector_checkpoint: { method: "POST", path: "/v1/connector-checkpoints/commit", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, prepare_context: { method: "POST", path: "/v1/context/prepare", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, create_work_contract: { method: "POST", path: "/v1/work/contracts/create", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, handoff_current_work: { method: "POST", path: "/v1/work/handoffs/prepare-current", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, acknowledge_handoff: { method: "POST", path: "/v1/work/handoffs/acknowledge", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, record_task_outcome: { method: "POST", path: "/v1/work/outcomes/record", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, activate_handoff: { method: "POST", path: "/v1/handoff/activate", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, prepare_handoff: { method: "POST", path: "/v1/handoff/prepare", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, finalize_handoff: { method: "POST", path: "/v1/handoff/finalize", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, commit_handoff: { method: "POST", path: "/v1/handoff/commit", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, continue_handoff: { method: "POST", path: "/v1/handoff/continue", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, flush_memory: { method: "POST", path: "/v1/memory/flush", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, remember_memory: { method: "POST", path: "/v1/memory/remember", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, search_memory: { method: "POST", path: "/v1/memory/search", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_memory_entries: { method: "POST", path: "/v1/memory/entries/list", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_memory_entry: { method: "POST", path: "/v1/memory/entries/get", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, revise_memory_entry: { method: "POST", path: "/v1/memory/entries/revise", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, retire_memory_entry: { method: "POST", path: "/v1/memory/entries/retire", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_memory_changes: { method: "POST", path: "/v1/memory/changes", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, propose_experience: { method: "POST", path: "/v1/experience/propose", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, generate_experience: { method: "POST", path: "/v1/experience/generate", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_experience: { method: "POST", path: "/v1/experience/get", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, propose_skill: { method: "POST", path: "/v1/skill/propose", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, generate_skill: { method: "POST", path: "/v1/skill/generate", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_skill: { method: "POST", path: "/v1/skill/get", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_managed_skills: { method: "POST", path: "/v1/skill/library", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, update_skill_lifecycle: { method: "POST", path: "/v1/skill/lifecycle", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_skill_package_manifest: { method: "POST", path: "/v1/skill/package/manifest", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, download_skill_package: { method: "POST", path: "/v1/skill/package/download", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, propose_skill_package: { method: "POST", path: "/v1/skill/package/propose", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, record_skill_usage: { method: "POST", path: "/v1/skill/usage", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, list_remote_skill_targets: { method: "POST", path: "/v1/skill/remote/targets", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, create_remote_skill_target: { method: "POST", path: "/v1/skill/remote/target/create", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, enroll_remote_skill_target: { method: "POST", path: "/v1/skill/remote/target/enroll", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, rename_remote_skill_target: { method: "POST", path: "/v1/skill/remote/target/rename", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, revoke_remote_skill_target: { method: "POST", path: "/v1/skill/remote/target/revoke", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, publish_remote_skill: { method: "POST", path: "/v1/skill/remote/publication/publish", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, unpublish_remote_skill: { method: "POST", path: "/v1/skill/remote/publication/unpublish", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, reconcile_remote_skills: { method: "POST", path: "/v1/skill/remote/reconcile", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, download_remote_skill_package: { method: "POST", path: "/v1/skill/remote/package/download", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, record_remote_skill_receipt: { method: "POST", path: "/v1/skill/remote/receipt", location: "body", scopeMode: "none", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, scan_external_skills: { method: "POST", path: "/v1/external-skills/scan", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_external_skills: { method: "POST", path: "/v1/external-skills/list", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, resolve_external_skill: { method: "POST", path: "/v1/external-skills/resolve", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, import_external_skill: { method: "POST", path: "/v1/external-skills/import", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_artifact_candidates: { method: "POST", path: "/v1/artifact-candidates/list", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/get", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, approve_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/approve", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, reject_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/reject", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, revise_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/revise", location: "body", scopeMode: "current", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_stats: { method: "POST", path: "/v1/stats", location: "body", scopeMode: "selection", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_handoff_report: { method: "POST", path: "/v1/handoff-reports/get", location: "body", scopeMode: "selection", - pathParameters: [] + pathParameters: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + create_source: { + method: "POST", + path: "/v1/scopes/{scope_id}/sources", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] + }, + get_source: { + method: "GET", + path: "/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "source_type", + "source_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + create_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] + }, + list_artifacts: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}", + location: "query", + scopeMode: "none", + pathParameters: ["scope_id", "family"], + queryParams: ["limit", "cursor"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-None-Match"], + successStatuses: [200, 304], + emptyStatuses: [304] + }, + replace_artifact: { + method: "PUT", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", + location: "body", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact_revision: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id", + "revision" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); @@ -653,28 +1027,48 @@ function queryString(payload) { const encoded = params.toString(); return encoded ? `?${encoded}` : ""; } -function isRedirect(status) { - return status >= 300 && status < 400; +function encodePathSegment(value) { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); } -function bindOperationPath(spec, payload) { - const pathParameters = spec.pathParameters; - if (pathParameters.length === 0) return { - path: spec.path, - payload - }; - const transportPayload = { ...payload }; +function headerPayloadKey(name$1) { + return name$1.toLowerCase().replaceAll("-", "_"); +} +function prepareRequest(spec, payload) { + const remaining = { ...payload ?? {} }; let path = spec.path; - for (const name$1 of pathParameters) { - const value = transportPayload[name$1]; - if (typeof value !== "string" || value.trim().length === 0) throw new TypeError(`operation requires string path parameter ${name$1}`); - path = path.replace(`{${name$1}}`, encodeURIComponent(value)); - delete transportPayload[name$1]; + for (const name$1 of spec.pathParameters) { + const value = remaining[name$1]; + if (value === void 0 || value === null) throw new TypeError(`${spec.method} ${spec.path} requires ${name$1}`); + path = path.replace(`{${name$1}}`, encodePathSegment(value)); + delete remaining[name$1]; + } + const headers = {}; + for (const name$1 of spec.headerParams) { + const alias = headerPayloadKey(name$1); + const value = remaining[name$1] ?? remaining[alias]; + delete remaining[name$1]; + delete remaining[alias]; + if (value !== void 0 && value !== null) headers[name$1] = String(value); + } + const queryPayload = {}; + for (const name$1 of spec.queryParams) { + const value = remaining[name$1]; + delete remaining[name$1]; + if (value !== void 0 && value !== null) queryPayload[name$1] = value; } return { path, - payload: transportPayload + query: queryString(queryPayload), + headers, + body: spec.location === "body" ? remaining : void 0 }; } +function hasStatus(statuses, status) { + return statuses.includes(status); +} +function isRedirect(status) { + return status >= 300 && status < 400; +} var PowerContextClient = class { baseUrl; authorization; @@ -689,25 +1083,22 @@ var PowerContextClient = class { async request(id, payload, signal) { if (!(id in OPERATIONS)) throw new UnknownOperationError(id); const spec = OPERATIONS[id]; - const bound = bindOperationPath(spec, payload); - const url = this.buildUrl(spec, bound.path, bound.payload); + const prepared = prepareRequest(spec, payload); + const url = `${this.baseUrl}${prepared.path}${prepared.query}`; try { - const response = await this.fetchImpl(url, this.buildInit(spec, bound.payload, signal)); + const response = await this.fetchImpl(url, this.buildInit(spec, prepared, signal)); return await this.parseResponse(id, spec, payload, response); } catch (error) { if (error instanceof ServerResponseError || error instanceof InvalidResponseError) throw error; if (error instanceof UnknownOperationError) throw error; - throw this.wrapTransport(bound.path, error); + throw this.wrapTransport(prepared.path, error); } } - buildUrl(spec, path, payload) { - const suffix = spec.location === "query" ? queryString(payload) : ""; - return `${this.baseUrl}${path}${suffix}`; - } - buildInit(spec, payload, signal) { + buildInit(spec, request, signal) { const headers = { Accept: "application/json", - "User-Agent": PLUGIN_USER_AGENT + "User-Agent": PLUGIN_USER_AGENT, + ...request.headers }; if (this.authorization) headers.Authorization = this.authorization; const init = { @@ -718,7 +1109,7 @@ var PowerContextClient = class { }; if (spec.location === "body") { headers["Content-Type"] = "application/json"; - init.body = JSON.stringify(payload ?? {}); + init.body = JSON.stringify(request.body ?? {}); } return init; } @@ -728,10 +1119,20 @@ var PowerContextClient = class { return new UnavailableError(path, error); } async parseResponse(id, spec, payload, response) { - if (isRedirect(response.status)) throw new InvalidResponseError(spec.path); + const success = response.status >= 200 && response.status < 300 || hasStatus(spec.successStatuses, response.status); + if (isRedirect(response.status) && !success) throw new InvalidResponseError(spec.path); const bytes = await readLimitedBody(response); const requestId = response.headers.get(REQUEST_ID_HEADER) ?? void 0; - if (response.status < 200 || response.status >= 300) throw this.httpError(response.status, spec.path, requestId, bytes); + if (!success) throw this.httpError(response.status, spec.path, requestId, bytes); + if (hasStatus(spec.emptyStatuses, response.status)) { + if (bytes.byteLength !== 0) throw new InvalidResponseError(spec.path, requestId); + return { + kind: "json", + value: null, + status: response.status, + requestId + }; + } if (id === "get_handoff_report" && payload?.download === true) return { kind: "bytes", value: bytes, diff --git a/integrations/dsh/plugins/powercontext/src/client.ts b/integrations/dsh/plugins/powercontext/src/client.ts index 82f0a0384..a15d36984 100644 --- a/integrations/dsh/plugins/powercontext/src/client.ts +++ b/integrations/dsh/plugins/powercontext/src/client.ts @@ -123,28 +123,64 @@ function queryString(payload: JsonObject | undefined): string { return encoded ? `?${encoded}` : '' } -function isRedirect(status: number): boolean { - return status >= 300 && status < 400 +interface PreparedRequest { + path: string + query: string + headers: Record + body: JsonObject | undefined +} + +function encodePathSegment(value: unknown): string { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, (character) => ( + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + )) +} + +function headerPayloadKey(name: string): string { + return name.toLowerCase().replaceAll('-', '_') } -function bindOperationPath( - spec: OperationSpec, - payload: JsonObject | undefined, -): { path: string; payload: JsonObject | undefined } { - const pathParameters: readonly string[] = spec.pathParameters - if (pathParameters.length === 0) return { path: spec.path, payload } - - const transportPayload = { ...payload } - let path: string = spec.path - for (const name of pathParameters) { - const value = transportPayload[name] - if (typeof value !== 'string' || value.trim().length === 0) { - throw new TypeError(`operation requires string path parameter ${name}`) +function prepareRequest(spec: OperationSpec, payload: JsonObject | undefined): PreparedRequest { + const remaining = { ...(payload ?? {}) } + let path = spec.path as string + for (const name of spec.pathParameters as readonly string[]) { + const value = remaining[name] + if (value === undefined || value === null) { + throw new TypeError(`${spec.method} ${spec.path} requires ${name}`) } - path = path.replace(`{${name}}`, encodeURIComponent(value)) - delete transportPayload[name] + path = path.replace(`{${name}}`, encodePathSegment(value)) + delete remaining[name] + } + + const headers: Record = {} + for (const name of spec.headerParams as readonly string[]) { + const alias = headerPayloadKey(name) + const value = remaining[name] ?? remaining[alias] + delete remaining[name] + delete remaining[alias] + if (value !== undefined && value !== null) headers[name] = String(value) + } + + const queryPayload: JsonObject = {} + for (const name of spec.queryParams as readonly string[]) { + const value = remaining[name] + delete remaining[name] + if (value !== undefined && value !== null) queryPayload[name] = value } - return { path, payload: transportPayload } + return { + path, + query: queryString(queryPayload), + headers, + body: spec.location === 'body' ? remaining : undefined, + } +} + +function hasStatus(statuses: readonly number[], status: number): boolean { + return statuses.includes(status) +} + +function isRedirect(status: number): boolean { + return status >= 300 && status < 400 } export class PowerContextClient { @@ -167,27 +203,23 @@ export class PowerContextClient { ): Promise { if (!(id in OPERATIONS)) throw new UnknownOperationError(id) const spec = OPERATIONS[id as OperationId] - const bound = bindOperationPath(spec, payload) - const url = this.buildUrl(spec, bound.path, bound.payload) + const prepared = prepareRequest(spec, payload) + const url = `${this.baseUrl}${prepared.path}${prepared.query}` try { - const response = await this.fetchImpl(url, this.buildInit(spec, bound.payload, signal)) + const response = await this.fetchImpl(url, this.buildInit(spec, prepared, signal)) return await this.parseResponse(id, spec, payload, response) } catch (error) { if (error instanceof ServerResponseError || error instanceof InvalidResponseError) throw error if (error instanceof UnknownOperationError) throw error - throw this.wrapTransport(bound.path, error) + throw this.wrapTransport(prepared.path, error) } } - private buildUrl(spec: OperationSpec, path: string, payload: JsonObject | undefined): string { - const suffix = spec.location === 'query' ? queryString(payload) : '' - return `${this.baseUrl}${path}${suffix}` - } - - private buildInit(spec: OperationSpec, payload: JsonObject | undefined, signal?: AbortSignal): RequestInit { + private buildInit(spec: OperationSpec, request: PreparedRequest, signal?: AbortSignal): RequestInit { const headers: Record = { Accept: 'application/json', 'User-Agent': PLUGIN_USER_AGENT, + ...request.headers, } if (this.authorization) headers.Authorization = this.authorization const init: RequestInit = { @@ -198,7 +230,7 @@ export class PowerContextClient { } if (spec.location === 'body') { headers['Content-Type'] = 'application/json' - init.body = JSON.stringify(payload ?? {}) + init.body = JSON.stringify(request.body ?? {}) } return init } @@ -215,12 +247,18 @@ export class PowerContextClient { payload: JsonObject | undefined, response: Response, ): Promise { - if (isRedirect(response.status)) throw new InvalidResponseError(spec.path) + const success = (response.status >= 200 && response.status < 300) + || hasStatus(spec.successStatuses as readonly number[], response.status) + if (isRedirect(response.status) && !success) throw new InvalidResponseError(spec.path) const bytes = await readLimitedBody(response) const requestId = response.headers.get(REQUEST_ID_HEADER) ?? undefined - if (response.status < 200 || response.status >= 300) { + if (!success) { throw this.httpError(response.status, spec.path, requestId, bytes) } + if (hasStatus(spec.emptyStatuses as readonly number[], response.status)) { + if (bytes.byteLength !== 0) throw new InvalidResponseError(spec.path, requestId) + return { kind: 'json', value: null, status: response.status, requestId } + } if (id === 'get_handoff_report' && payload?.download === true) { return { kind: 'bytes', value: bytes, status: response.status, requestId } } diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 962370efc..c0508f7ed 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -17,76 +17,83 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { - get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [] }, - get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [] }, - get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [] }, - list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scopeMode: 'none', pathParameters: [] }, - create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [] }, - publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scopeMode: 'none', pathParameters: [] }, - get_scope: { method: 'GET', path: '/v1/scopes/{scope_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id'] }, - update_scope: { method: 'PUT', path: '/v1/scopes/{scope_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id'] }, - get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scopeMode: 'none', pathParameters: [] }, - set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scopeMode: 'none', pathParameters: [] }, - resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scopeMode: 'none', pathParameters: [] }, - resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scopeMode: 'none', pathParameters: [] }, - set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scopeMode: 'none', pathParameters: [] }, - clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scopeMode: 'none', pathParameters: [] }, - capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scopeMode: 'current', pathParameters: [] }, - register_source_definition: { method: 'POST', path: '/v1/source-definitions/register', location: "body", scopeMode: 'none', pathParameters: [] }, - get_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/get', location: "body", scopeMode: 'none', pathParameters: [] }, - submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [] }, - commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [] }, - prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [] }, - create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [] }, - handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [] }, - acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [] }, - record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scopeMode: 'current', pathParameters: [] }, - activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scopeMode: 'current', pathParameters: [] }, - prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scopeMode: 'current', pathParameters: [] }, - finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scopeMode: 'current', pathParameters: [] }, - commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scopeMode: 'current', pathParameters: [] }, - continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [] }, - flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scopeMode: 'current', pathParameters: [] }, - remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scopeMode: 'current', pathParameters: [] }, - search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scopeMode: 'current', pathParameters: [] }, - list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scopeMode: 'current', pathParameters: [] }, - get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scopeMode: 'current', pathParameters: [] }, - revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scopeMode: 'current', pathParameters: [] }, - retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scopeMode: 'current', pathParameters: [] }, - list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scopeMode: 'current', pathParameters: [] }, - get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scopeMode: 'current', pathParameters: [] }, - get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scopeMode: 'current', pathParameters: [] }, - list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scopeMode: 'current', pathParameters: [] }, - update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scopeMode: 'current', pathParameters: [] }, - get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scopeMode: 'current', pathParameters: [] }, - download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scopeMode: 'current', pathParameters: [] }, - list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scopeMode: 'current', pathParameters: [] }, - create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scopeMode: 'current', pathParameters: [] }, - enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scopeMode: 'none', pathParameters: [] }, - rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scopeMode: 'current', pathParameters: [] }, - revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scopeMode: 'current', pathParameters: [] }, - publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scopeMode: 'current', pathParameters: [] }, - unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scopeMode: 'current', pathParameters: [] }, - reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scopeMode: 'none', pathParameters: [] }, - download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scopeMode: 'none', pathParameters: [] }, - record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scopeMode: 'none', pathParameters: [] }, - scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scopeMode: 'current', pathParameters: [] }, - list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scopeMode: 'current', pathParameters: [] }, - resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scopeMode: 'current', pathParameters: [] }, - import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scopeMode: 'current', pathParameters: [] }, - list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scopeMode: 'current', pathParameters: [] }, - get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scopeMode: 'current', pathParameters: [] }, - approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [] }, - reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [] }, - revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [] }, - get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [] }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [] }, + 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_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + get_scope: { method: 'GET', path: '/v1/scopes/{scope_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_scope: { method: 'PUT', path: '/v1/scopes/{scope_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + register_source_definition: { method: 'POST', path: '/v1/source-definitions/register', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/get', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'source_type', 'source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_artifact: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/dsh/plugins/powercontext/tests/client.spec.ts b/integrations/dsh/plugins/powercontext/tests/client.spec.ts index c29f724bc..b9e838a7c 100644 --- a/integrations/dsh/plugins/powercontext/tests/client.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/client.spec.ts @@ -83,6 +83,61 @@ describe('PowerContextClient', () => { expect(fetchImpl).toHaveBeenCalledOnce() }) + it('encodes scoped paths and separates path, query, header, and PUT body fields', async () => { + let call = 0 + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + call += 1 + const headers = new Headers(init?.headers) + if (call === 1) { + expect(url).toBe( + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory?limit=5', + ) + expect(init?.method).toBe('GET') + expect(init?.body).toBeUndefined() + return jsonResponse(200, { items: [] }) + } + if (call === 2) { + expect(url).toBe( + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory/artifact%2F1', + ) + expect(init?.method).toBe('PUT') + expect(headers.get('If-Match')).toBe('"revision:1"') + expect(JSON.parse(String(init?.body))).toEqual({ content: { title: 'kept' } }) + return jsonResponse(200, { revision: 2 }) + } + if (call === 3) { + expect(init?.method).toBe('GET') + expect(headers.get('If-None-Match')).toBe('"revision:2"') + expect(init?.body).toBeUndefined() + return new Response(null, { status: 304 }) + } + throw new Error('unexpected request') + }) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + requestTimeoutMs: 1000, + fetch: fetchImpl, + }) + const path = { + scope_id: 'scope/team', + family: 'memory', + } + + await client.request('list_artifacts', { ...path, limit: 5, ignored: 'value' }) + await client.request('replace_artifact', { + ...path, + artifact_id: 'artifact/1', + if_match: '"revision:1"', + content: { title: 'kept' }, + }) + await expect(client.request('get_artifact', { + ...path, + artifact_id: 'artifact/1', + if_none_match: '"revision:2"', + })).resolves.toMatchObject({ kind: 'json', value: null, status: 304 }) + expect(fetchImpl).toHaveBeenCalledTimes(3) + }) + it('returns markdown text and raw bytes for get_handoff_report', async () => { const markdownClient = new PowerContextClient({ baseUrl: 'http://127.0.0.1:8000', @@ -142,28 +197,37 @@ describe('PowerContextClient', () => { it('emits the generated method and path for every operationId', async () => { const { OPERATION_IDS, OPERATIONS } = await import('../src/operations.generated.ts') const seen: Array<{ method: string; url: string; hasBody: boolean }> = [] + let operationIndex = 0 const client = new PowerContextClient({ baseUrl: 'http://example.test', requestTimeoutMs: 1000, fetch: async (url, init) => { + const spec = OPERATIONS[OPERATION_IDS[operationIndex++]!] seen.push({ method: String(init?.method), url, hasBody: Boolean(init?.body) }) - return jsonResponse(200, { ok: true }) + const status = spec.successStatuses[0] ?? 200 + return (spec.emptyStatuses as readonly number[]).includes(status) + ? new Response(null, { status }) + : jsonResponse(status, { ok: true }) }, }) for (const id of OPERATION_IDS) { const spec = OPERATIONS[id] - const pathPayload = Object.fromEntries(spec.pathParameters.map((name) => [name, `value:${name}`])) - await client.request(id, { marker: id, ...pathPayload }) + const payload: Record = { marker: id } + for (const name of spec.pathParameters) payload[name] = `value/${name}` + for (const name of spec.queryParams) payload[name] = `value-${name}` + for (const name of spec.headerParams) payload[name] = `value-${name}` + await client.request(id, payload) } expect(seen).toHaveLength(OPERATION_IDS.length) OPERATION_IDS.forEach((id, index) => { const spec = OPERATIONS[id] + let expectedPath = spec.path as string + for (const name of spec.pathParameters) { + expectedPath = expectedPath.replace(`{${name}}`, encodeURIComponent(`value/${name}`)) + } expect(seen[index].method).toBe(spec.method) - const expectedPath = (spec.pathParameters as readonly string[]).reduce( - (path, name) => path.replace(`{${name}}`, encodeURIComponent(`value:${name}`)), - spec.path, - ) expect(seen[index].url.startsWith(`http://example.test${expectedPath}`)).toBe(true) + expect(seen[index].url).not.toContain('{') expect(seen[index].hasBody).toBe(spec.location === 'body') }) }) diff --git a/integrations/opencode/plugins/powercontext/src/client.ts b/integrations/opencode/plugins/powercontext/src/client.ts index c804a66af..8e959556f 100644 --- a/integrations/opencode/plugins/powercontext/src/client.ts +++ b/integrations/opencode/plugins/powercontext/src/client.ts @@ -107,24 +107,64 @@ async function readLimitedBody(response: Response): Promise { return body } -function bindOperationPath( - spec: OperationSpec, - payload: JsonObject | undefined, -): { path: string; payload: JsonObject | undefined } { - const pathParameters: readonly string[] = spec.pathParameters - if (pathParameters.length === 0) return { path: spec.path, payload } - - const transportPayload = { ...payload } - let path: string = spec.path - for (const name of pathParameters) { - const value = transportPayload[name] - if (typeof value !== 'string' || value.trim().length === 0) { - throw new TypeError(`operation requires string path parameter ${name}`) +interface PreparedRequest { + path: string + query: string + headers: Record + body: JsonObject | undefined +} + +function encodePathSegment(value: unknown): string { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, (character) => ( + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + )) +} + +function headerPayloadKey(name: string): string { + return name.toLowerCase().replaceAll('-', '_') +} + +function prepareRequest(spec: OperationSpec, payload: JsonObject | undefined): PreparedRequest { + const remaining = { ...(payload ?? {}) } + let path = spec.path as string + for (const name of spec.pathParameters as readonly string[]) { + const value = remaining[name] + if (value === undefined || value === null) { + throw new TypeError(`${spec.method} ${spec.path} requires ${name}`) } - path = path.replace(`{${name}}`, encodeURIComponent(value)) - delete transportPayload[name] + path = path.replace(`{${name}}`, encodePathSegment(value)) + delete remaining[name] + } + + const headers: Record = {} + for (const name of spec.headerParams as readonly string[]) { + const alias = headerPayloadKey(name) + const value = remaining[name] ?? remaining[alias] + delete remaining[name] + delete remaining[alias] + if (value !== undefined && value !== null) headers[name] = String(value) } - return { path, payload: transportPayload } + + const queryPayload: JsonObject = {} + for (const name of spec.queryParams as readonly string[]) { + const value = remaining[name] + delete remaining[name] + if (value !== undefined && value !== null) queryPayload[name] = value + } + return { + path, + query: queryString(queryPayload), + headers, + body: spec.location === 'body' ? remaining : undefined, + } +} + +function hasStatus(statuses: readonly number[], status: number): boolean { + return statuses.includes(status) +} + +function isRedirect(status: number): boolean { + return status >= 300 && status < 400 } export class PowerContextClient { @@ -137,13 +177,15 @@ export class PowerContextClient { async request(id: string, payload?: JsonObject, signal?: AbortSignal): Promise { if (!(id in OPERATIONS)) throw new UnknownOperationError(id) const spec = OPERATIONS[id as OperationId] - const bound = bindOperationPath(spec, payload) + const prepared = prepareRequest(spec, payload) try { - const response = await this.fetchImpl(this.url(bound.path), this.init(spec, bound.payload, signal)) - if (response.status >= 300 && response.status < 400) throw new InvalidResponseError(spec.path) + const response = await this.fetchImpl(this.url(prepared), this.init(spec, prepared, signal)) + const success = (response.status >= 200 && response.status < 300) + || hasStatus(spec.successStatuses as readonly number[], response.status) + if (isRedirect(response.status) && !success) throw new InvalidResponseError(spec.path) const bytes = await readLimitedBody(response) const requestId = response.headers.get(REQUEST_ID_HEADER) ?? undefined - if (!response.ok) { + if (!success) { let error: { error?: { code?: string; message?: string } } = {} try { error = JSON.parse(Buffer.from(bytes).toString('utf8')) @@ -155,6 +197,10 @@ export class PowerContextClient { message: error.error?.message, }) } + if (hasStatus(spec.emptyStatuses as readonly number[], response.status)) { + if (bytes.byteLength !== 0) throw new InvalidResponseError(spec.path, requestId) + return { kind: 'json', value: null, status: response.status, requestId } + } try { return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId } } catch { @@ -164,23 +210,27 @@ export class PowerContextClient { if (error instanceof ServerResponseError || error instanceof InvalidResponseError || error instanceof UnknownOperationError) { throw error } - throw new UnavailableError(bound.path, error) + throw new UnavailableError(prepared.path, error) } } - private url(path: string): string { - return `${this.options.baseUrl.replace(/\/+$/, '')}${path}` + private url(request: PreparedRequest): string { + return `${this.options.baseUrl.replace(/\/+$/, '')}${request.path}${request.query}` } - private init(spec: OperationSpec, payload: JsonObject | undefined, signal?: AbortSignal): RequestInit { - const headers: Record = { Accept: 'application/json', 'User-Agent': PLUGIN_USER_AGENT } + private init(spec: OperationSpec, request: PreparedRequest, signal?: AbortSignal): RequestInit { + const headers: Record = { + Accept: 'application/json', + 'User-Agent': PLUGIN_USER_AGENT, + ...request.headers, + } if (this.options.authorization) headers.Authorization = this.options.authorization const signals = [createTimeoutSignal(this.options.requestTimeoutMs)] if (signal) signals.push(signal) const init: RequestInit = { method: spec.method, headers, redirect: 'manual', signal: combineSignals(signals) } if (spec.location === 'body') { headers['Content-Type'] = 'application/json' - init.body = JSON.stringify(payload ?? {}) + init.body = JSON.stringify(request.body ?? {}) } return init } diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 962370efc..c0508f7ed 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -17,76 +17,83 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { - get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [] }, - get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [] }, - get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [] }, - list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scopeMode: 'none', pathParameters: [] }, - create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [] }, - publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scopeMode: 'none', pathParameters: [] }, - get_scope: { method: 'GET', path: '/v1/scopes/{scope_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id'] }, - update_scope: { method: 'PUT', path: '/v1/scopes/{scope_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id'] }, - get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scopeMode: 'none', pathParameters: [] }, - set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scopeMode: 'none', pathParameters: [] }, - resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scopeMode: 'none', pathParameters: [] }, - resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scopeMode: 'none', pathParameters: [] }, - set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scopeMode: 'none', pathParameters: [] }, - clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scopeMode: 'none', pathParameters: [] }, - capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scopeMode: 'current', pathParameters: [] }, - register_source_definition: { method: 'POST', path: '/v1/source-definitions/register', location: "body", scopeMode: 'none', pathParameters: [] }, - get_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/get', location: "body", scopeMode: 'none', pathParameters: [] }, - submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [] }, - commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [] }, - prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [] }, - create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [] }, - handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [] }, - acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [] }, - record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scopeMode: 'current', pathParameters: [] }, - activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scopeMode: 'current', pathParameters: [] }, - prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scopeMode: 'current', pathParameters: [] }, - finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scopeMode: 'current', pathParameters: [] }, - commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scopeMode: 'current', pathParameters: [] }, - continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [] }, - flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scopeMode: 'current', pathParameters: [] }, - remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scopeMode: 'current', pathParameters: [] }, - search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scopeMode: 'current', pathParameters: [] }, - list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scopeMode: 'current', pathParameters: [] }, - get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scopeMode: 'current', pathParameters: [] }, - revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scopeMode: 'current', pathParameters: [] }, - retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scopeMode: 'current', pathParameters: [] }, - list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scopeMode: 'current', pathParameters: [] }, - get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scopeMode: 'current', pathParameters: [] }, - get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scopeMode: 'current', pathParameters: [] }, - list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scopeMode: 'current', pathParameters: [] }, - update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scopeMode: 'current', pathParameters: [] }, - get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scopeMode: 'current', pathParameters: [] }, - download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scopeMode: 'current', pathParameters: [] }, - list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scopeMode: 'current', pathParameters: [] }, - create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scopeMode: 'current', pathParameters: [] }, - enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scopeMode: 'none', pathParameters: [] }, - rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scopeMode: 'current', pathParameters: [] }, - revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scopeMode: 'current', pathParameters: [] }, - publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scopeMode: 'current', pathParameters: [] }, - unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scopeMode: 'current', pathParameters: [] }, - reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scopeMode: 'none', pathParameters: [] }, - download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scopeMode: 'none', pathParameters: [] }, - record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scopeMode: 'none', pathParameters: [] }, - scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scopeMode: 'current', pathParameters: [] }, - list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scopeMode: 'current', pathParameters: [] }, - resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scopeMode: 'current', pathParameters: [] }, - import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scopeMode: 'current', pathParameters: [] }, - list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scopeMode: 'current', pathParameters: [] }, - get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scopeMode: 'current', pathParameters: [] }, - approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [] }, - reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [] }, - revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [] }, - get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [] }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [] }, + 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_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + get_scope: { method: 'GET', path: '/v1/scopes/{scope_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_scope: { method: 'PUT', path: '/v1/scopes/{scope_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + register_source_definition: { method: 'POST', path: '/v1/source-definitions/register', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/get', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'source_type', 'source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_artifact: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/opencode/plugins/powercontext/tests/client.spec.ts b/integrations/opencode/plugins/powercontext/tests/client.spec.ts index 1a9b805ed..3b6d1c200 100644 --- a/integrations/opencode/plugins/powercontext/tests/client.spec.ts +++ b/integrations/opencode/plugins/powercontext/tests/client.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { PowerContextClient } from '../src/client.ts' import { MAX_RESPONSE_BYTES } from '../src/errors.ts' @@ -105,3 +105,67 @@ describe('PowerContextClient response limits', () => { expect(result.value).toBe('x'.repeat(MAX_RESPONSE_BYTES - 2)) }) }) + +describe('PowerContextClient generated operation requests', () => { + it('encodes scoped paths and separates path, query, header, and PUT body fields', async () => { + let call = 0 + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + call += 1 + const headers = new Headers(init?.headers) + if (call === 1) { + expect(url).toBe( + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory?limit=5', + ) + expect(init?.method).toBe('GET') + expect(init?.body).toBeUndefined() + return new Response(JSON.stringify({ items: [] }), { status: 200 }) + } + if (call === 2) { + expect(url).toBe( + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory/artifact%2F1', + ) + expect(init?.method).toBe('PUT') + expect(headers.get('If-Match')).toBe('"revision:1"') + expect(JSON.parse(String(init?.body))).toEqual({ content: { title: 'kept' } }) + return new Response(JSON.stringify({ revision: 2 }), { status: 200 }) + } + if (call === 3) { + expect(init?.method).toBe('GET') + expect(headers.get('If-None-Match')).toBe('"revision:2"') + expect(init?.body).toBeUndefined() + return new Response(null, { status: 304 }) + } + throw new Error('unexpected request') + }) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + requestTimeoutMs: 1000, + fetch: fetchImpl, + }) + const path = { scope_id: 'scope/team', family: 'memory' } + + await client.request('list_artifacts', { ...path, limit: 5, ignored: 'value' }) + await client.request('replace_artifact', { + ...path, + artifact_id: 'artifact/1', + if_match: '"revision:1"', + content: { title: 'kept' }, + }) + await expect(client.request('get_artifact', { + ...path, + artifact_id: 'artifact/1', + if_none_match: '"revision:2"', + })).resolves.toMatchObject({ kind: 'json', value: null, status: 304 }) + expect(fetchImpl).toHaveBeenCalledTimes(3) + }) + + it('rejects an undeclared redirect response', async () => { + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + requestTimeoutMs: 1000, + fetch: async () => new Response(null, { status: 302, headers: { Location: 'https://example.invalid' } }), + }) + + await expect(client.request('get_liveness')).rejects.toThrow('violated the API schema') + }) +}) diff --git a/integrations/pi/plugins/powercontext/src/client.ts b/integrations/pi/plugins/powercontext/src/client.ts index 88abae7ba..329e59d4b 100644 --- a/integrations/pi/plugins/powercontext/src/client.ts +++ b/integrations/pi/plugins/powercontext/src/client.ts @@ -112,28 +112,73 @@ function decodeError(bytes: Uint8Array): { code?: string; message?: string } { } } -function isRedirect(status: number): boolean { - return status >= 300 && status < 400 +function queryString(payload: JsonObject | undefined): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(payload ?? {})) { + if (value !== undefined && value !== null) params.set(key, String(value)) + } + const encoded = params.toString() + return encoded ? `?${encoded}` : '' } -function bindOperationPath( - spec: OperationSpec, - payload: JsonObject | undefined, -): { path: string; payload: JsonObject | undefined } { - const pathParameters: readonly string[] = spec.pathParameters - if (pathParameters.length === 0) return { path: spec.path, payload } - - const transportPayload = { ...payload } - let path: string = spec.path - for (const name of pathParameters) { - const value = transportPayload[name] - if (typeof value !== 'string' || value.trim().length === 0) { - throw new TypeError(`operation requires string path parameter ${name}`) +interface PreparedRequest { + path: string + query: string + headers: Record + body: JsonObject | undefined +} + +function encodePathSegment(value: unknown): string { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, (character) => ( + `%${character.charCodeAt(0).toString(16).toUpperCase()}` + )) +} + +function headerPayloadKey(name: string): string { + return name.toLowerCase().replaceAll('-', '_') +} + +function prepareRequest(spec: OperationSpec, payload: JsonObject | undefined): PreparedRequest { + const remaining = { ...(payload ?? {}) } + let path = spec.path as string + for (const name of spec.pathParameters as readonly string[]) { + const value = remaining[name] + if (value === undefined || value === null) { + throw new TypeError(`${spec.method} ${spec.path} requires ${name}`) } - path = path.replace(`{${name}}`, encodeURIComponent(value)) - delete transportPayload[name] + path = path.replace(`{${name}}`, encodePathSegment(value)) + delete remaining[name] + } + + const headers: Record = {} + for (const name of spec.headerParams as readonly string[]) { + const alias = headerPayloadKey(name) + const value = remaining[name] ?? remaining[alias] + delete remaining[name] + delete remaining[alias] + if (value !== undefined && value !== null) headers[name] = String(value) } - return { path, payload: transportPayload } + + const queryPayload: JsonObject = {} + for (const name of spec.queryParams as readonly string[]) { + const value = remaining[name] + delete remaining[name] + if (value !== undefined && value !== null) queryPayload[name] = value + } + return { + path, + query: queryString(queryPayload), + headers, + body: spec.location === 'body' ? remaining : undefined, + } +} + +function hasStatus(statuses: readonly number[], status: number): boolean { + return statuses.includes(status) +} + +function isRedirect(status: number): boolean { + return status >= 300 && status < 400 } export class PowerContextClient { @@ -152,10 +197,10 @@ export class PowerContextClient { async request(id: string, payload?: JsonObject, signal?: AbortSignal): Promise { if (!(id in OPERATIONS)) throw new UnknownOperationError(id) const spec = OPERATIONS[id as OperationId] - const bound = bindOperationPath(spec, payload) - const url = this.buildUrl(bound.path) + const prepared = prepareRequest(spec, payload) + const url = this.buildUrl(prepared) try { - const response = await this.fetchImpl(url, this.buildInit(spec, bound.payload, signal)) + const response = await this.fetchImpl(url, this.buildInit(spec, prepared, signal)) return await this.parseResponse(spec, response) } catch (error) { if ( @@ -165,18 +210,19 @@ export class PowerContextClient { ) { throw error } - throw new UnavailableError(bound.path, error) + throw new UnavailableError(prepared.path, error) } } - private buildUrl(path: string): string { - return `${this.baseUrl}${path}` + private buildUrl(request: PreparedRequest): string { + return `${this.baseUrl}${request.path}${request.query}` } - private buildInit(spec: OperationSpec, payload: JsonObject | undefined, signal?: AbortSignal): RequestInit { + private buildInit(spec: OperationSpec, request: PreparedRequest, signal?: AbortSignal): RequestInit { const headers: Record = { Accept: 'application/json', 'User-Agent': PLUGIN_USER_AGENT, + ...request.headers, } if (this.authorization) headers.Authorization = this.authorization const signals = [createTimeoutSignal(this.requestTimeoutMs)] @@ -189,7 +235,7 @@ export class PowerContextClient { } if (spec.location === 'body') { headers['Content-Type'] = 'application/json' - init.body = JSON.stringify(payload ?? {}) + init.body = JSON.stringify(request.body ?? {}) } return init } @@ -198,12 +244,18 @@ export class PowerContextClient { spec: OperationSpec, response: Response, ): Promise { - if (isRedirect(response.status)) throw new InvalidResponseError(spec.path) + const success = (response.status >= 200 && response.status < 300) + || hasStatus(spec.successStatuses as readonly number[], response.status) + if (isRedirect(response.status) && !success) throw new InvalidResponseError(spec.path) const bytes = await readLimitedBody(response) const requestId = response.headers.get(REQUEST_ID_HEADER) ?? undefined - if (response.status < 200 || response.status >= 300) { + if (!success) { throw this.httpError(response.status, spec.path, requestId, bytes) } + if (hasStatus(spec.emptyStatuses as readonly number[], response.status)) { + if (bytes.byteLength !== 0) throw new InvalidResponseError(spec.path, requestId) + return { kind: 'json', value: null, status: response.status, requestId } + } try { return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId } } catch { diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 962370efc..c0508f7ed 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -17,76 +17,83 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { - get_liveness: { method: 'GET', path: '/health/live', location: null, scopeMode: 'none', pathParameters: [] }, - get_readiness: { method: 'GET', path: '/health/ready', location: null, scopeMode: 'none', pathParameters: [] }, - get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [] }, - list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scopeMode: 'none', pathParameters: [] }, - create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [] }, - publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scopeMode: 'none', pathParameters: [] }, - get_scope: { method: 'GET', path: '/v1/scopes/{scope_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id'] }, - update_scope: { method: 'PUT', path: '/v1/scopes/{scope_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id'] }, - get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scopeMode: 'none', pathParameters: [] }, - set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scopeMode: 'none', pathParameters: [] }, - resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scopeMode: 'none', pathParameters: [] }, - resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scopeMode: 'none', pathParameters: [] }, - set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scopeMode: 'none', pathParameters: [] }, - clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scopeMode: 'none', pathParameters: [] }, - capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scopeMode: 'current', pathParameters: [] }, - register_source_definition: { method: 'POST', path: '/v1/source-definitions/register', location: "body", scopeMode: 'none', pathParameters: [] }, - get_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/get', location: "body", scopeMode: 'none', pathParameters: [] }, - submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [] }, - commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [] }, - prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [] }, - create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [] }, - handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [] }, - acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [] }, - record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scopeMode: 'current', pathParameters: [] }, - activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scopeMode: 'current', pathParameters: [] }, - prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scopeMode: 'current', pathParameters: [] }, - finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scopeMode: 'current', pathParameters: [] }, - commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scopeMode: 'current', pathParameters: [] }, - continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [] }, - flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scopeMode: 'current', pathParameters: [] }, - remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scopeMode: 'current', pathParameters: [] }, - search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scopeMode: 'current', pathParameters: [] }, - list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scopeMode: 'current', pathParameters: [] }, - get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scopeMode: 'current', pathParameters: [] }, - revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scopeMode: 'current', pathParameters: [] }, - retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scopeMode: 'current', pathParameters: [] }, - list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scopeMode: 'current', pathParameters: [] }, - get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scopeMode: 'current', pathParameters: [] }, - get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scopeMode: 'current', pathParameters: [] }, - list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scopeMode: 'current', pathParameters: [] }, - update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scopeMode: 'current', pathParameters: [] }, - get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scopeMode: 'current', pathParameters: [] }, - download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scopeMode: 'current', pathParameters: [] }, - propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scopeMode: 'current', pathParameters: [] }, - record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scopeMode: 'current', pathParameters: [] }, - list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scopeMode: 'current', pathParameters: [] }, - create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scopeMode: 'current', pathParameters: [] }, - enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scopeMode: 'none', pathParameters: [] }, - rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scopeMode: 'current', pathParameters: [] }, - revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scopeMode: 'current', pathParameters: [] }, - publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scopeMode: 'current', pathParameters: [] }, - unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scopeMode: 'current', pathParameters: [] }, - reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scopeMode: 'none', pathParameters: [] }, - download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scopeMode: 'none', pathParameters: [] }, - record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scopeMode: 'none', pathParameters: [] }, - scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scopeMode: 'current', pathParameters: [] }, - list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scopeMode: 'current', pathParameters: [] }, - resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scopeMode: 'current', pathParameters: [] }, - import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scopeMode: 'current', pathParameters: [] }, - list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scopeMode: 'current', pathParameters: [] }, - get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scopeMode: 'current', pathParameters: [] }, - approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [] }, - reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [] }, - revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [] }, - get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [] }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [] }, + 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_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_scope: { method: 'POST', path: '/v1/scopes', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + publish_artifact: { method: 'POST', path: '/v1/artifact-publications', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + get_scope: { method: 'GET', path: '/v1/scopes/{scope_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_scope: { method: 'PUT', path: '/v1/scopes/{scope_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_default_scope: { method: 'GET', path: '/v1/scopes/default', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + set_default_scope: { method: 'PUT', path: '/v1/scopes/default', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_scope_selection: { method: 'POST', path: '/v1/scopes/selection/resolve', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_scope_binding: { method: 'POST', path: '/v1/scope-bindings/resolve', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + set_scope_binding: { method: 'PUT', path: '/v1/scope-bindings', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + clear_scope_binding: { method: 'POST', path: '/v1/scope-bindings/clear', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + register_source_definition: { method: 'POST', path: '/v1/source-definitions/register', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/get', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + submit_source_observation: { method: 'POST', path: '/v1/source-observations', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + commit_connector_checkpoint: { method: 'POST', path: '/v1/connector-checkpoints/commit', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_managed_skills: { method: 'POST', path: '/v1/skill/library', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_skill_lifecycle: { method: 'POST', path: '/v1/skill/lifecycle', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill_package_manifest: { method: 'POST', path: '/v1/skill/package/manifest', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + download_skill_package: { method: 'POST', path: '/v1/skill/package/download', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill_package: { method: 'POST', path: '/v1/skill/package/propose', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + record_skill_usage: { method: 'POST', path: '/v1/skill/usage', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_remote_skill_targets: { method: 'POST', path: '/v1/skill/remote/targets', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/create', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + enroll_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/enroll', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + rename_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/rename', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revoke_remote_skill_target: { method: 'POST', path: '/v1/skill/remote/target/revoke', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + publish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/publish', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + unpublish_remote_skill: { method: 'POST', path: '/v1/skill/remote/publication/unpublish', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reconcile_remote_skills: { method: 'POST', path: '/v1/skill/remote/reconcile', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + download_remote_skill_package: { method: 'POST', path: '/v1/skill/remote/package/download', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_remote_skill_receipt: { method: 'POST', path: '/v1/skill/remote/receipt', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scopeMode: 'current', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_stats: { method: 'POST', path: '/v1/stats', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scopeMode: 'selection', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'source_type', 'source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_artifact: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/tests/client.spec.ts b/integrations/pi/plugins/powercontext/tests/client.spec.ts index 54d012720..2d5de2530 100644 --- a/integrations/pi/plugins/powercontext/tests/client.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/client.spec.ts @@ -90,4 +90,56 @@ describe('PowerContext Pi HTTP client', () => { await expect(client.request('get_liveness')).rejects.toThrow('request to /health/live failed') }) + + it('encodes scoped paths and separates path, query, header, and PUT body fields', async () => { + let call = 0 + const fetch = vi.fn(async (url: string, init?: RequestInit) => { + call += 1 + const headers = new Headers(init?.headers) + if (call === 1) { + expect(url).toBe( + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory?limit=5', + ) + expect(init?.method).toBe('GET') + expect(init?.body).toBeUndefined() + return new Response(JSON.stringify({ items: [] }), { status: 200 }) + } + if (call === 2) { + expect(url).toBe( + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory/artifact%2F1', + ) + expect(init?.method).toBe('PUT') + expect(headers.get('If-Match')).toBe('"revision:1"') + expect(JSON.parse(String(init?.body))).toEqual({ content: { title: 'kept' } }) + return new Response(JSON.stringify({ revision: 2 }), { status: 200 }) + } + if (call === 3) { + expect(init?.method).toBe('GET') + expect(headers.get('If-None-Match')).toBe('"revision:2"') + expect(init?.body).toBeUndefined() + return new Response(null, { status: 304 }) + } + throw new Error('unexpected request') + }) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + requestTimeoutMs: 1000, + fetch, + }) + const path = { scope_id: 'scope/team', family: 'memory' } + + await client.request('list_artifacts', { ...path, limit: 5, ignored: 'value' }) + await client.request('replace_artifact', { + ...path, + artifact_id: 'artifact/1', + if_match: '"revision:1"', + content: { title: 'kept' }, + }) + await expect(client.request('get_artifact', { + ...path, + artifact_id: 'artifact/1', + if_none_match: '"revision:2"', + })).resolves.toMatchObject({ kind: 'json', value: null, status: 304 }) + expect(fetch).toHaveBeenCalledTimes(3) + }) }) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index b7c24f8c9..962aff703 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -2145,6 +2145,386 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/sources: + post: + tags: [sources] + summary: Create a durable Source + description: Persist one Source without synchronously deriving Artifacts. The Server generates source_id. + operationId: create_source + 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/CreateSourceRequest" + responses: + "201": + description: The Source was durably created. + headers: + Location: + $ref: "#/components/headers/Location" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/SourceRecord" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/sources/{source_type}/{source_id}: + get: + tags: [sources] + summary: Get one exact Source + operationId: get_source + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: source_type + in: path + required: true + schema: + type: string + enum: [content] + - name: source_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' + responses: + "200": + description: The exact Source. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/SourceRecord" + "401": + $ref: "#/components/responses/Unauthorized" + "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: + post: + tags: [artifacts] + summary: 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. + operationId: create_artifact + 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/CreateArtifactRequest" + responses: + "201": + description: Artifact revision one was committed. + headers: + Location: + $ref: "#/components/headers/Location" + ETag: + $ref: "#/components/headers/ArtifactETag" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactCreated" + "401": + $ref: "#/components/responses/Unauthorized" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/{family}: + get: + tags: [artifacts] + summary: List current Artifact heads + description: List current heads for exactly one built-in Artifact family. + operationId: list_artifacts + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path + required: true + schema: + type: string + enum: [memory, experience, skill, handoff] + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: cursor + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 4096 + responses: + "200": + description: One stable page of current Artifact heads. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactPage" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "410": + $ref: "#/components/responses/CursorExpired" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}: + get: + tags: [artifacts] + summary: Get the current Artifact head + operationId: get_artifact + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path + required: true + schema: + type: string + enum: [memory, experience, skill, handoff] + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: If-None-Match + in: header + required: false + schema: + type: string + minLength: 1 + responses: + "200": + description: The current visible Artifact head. + headers: + ETag: + $ref: "#/components/headers/ArtifactETag" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactRevision" + "304": + description: If-None-Match identifies the current Artifact head. + headers: + ETag: + $ref: "#/components/headers/ArtifactETag" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [artifacts] + summary: Replace the current Artifact head + description: Commit a complete next revision when If-Match identifies the current head. + operationId: replace_artifact + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path + required: true + schema: + type: string + enum: [memory, experience, skill, handoff] + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: If-Match + in: header + required: true + schema: + type: string + minLength: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReplaceArtifactRequest" + responses: + "200": + description: The complete replacement was committed as the next revision. + headers: + ETag: + $ref: "#/components/headers/ArtifactETag" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactRevision" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "412": + $ref: "#/components/responses/PreconditionFailed" + "422": + $ref: "#/components/responses/InvalidRequest" + "428": + $ref: "#/components/responses/PreconditionRequired" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}: + get: + tags: [artifacts] + summary: Get one exact immutable Artifact revision + operationId: get_artifact_revision + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path + required: true + schema: + type: string + enum: [memory, experience, skill, handoff] + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: revision + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "200": + description: The exact immutable Artifact revision. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactRevision" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" components: securitySchemes: BearerAuth: @@ -2156,6 +2536,11 @@ components: 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: @@ -2165,7 +2550,29 @@ components: 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: + BadRequest: + description: The request query or pagination cursor is invalid. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + CursorExpired: + description: The pagination cursor has expired. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" Unauthorized: description: A valid bearer token is required by this Server deployment. headers: @@ -2186,6 +2593,24 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + PreconditionFailed: + description: If-Match does not identify the current Artifact head. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + PreconditionRequired: + description: A current Artifact ETag is required in If-Match. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" InvalidRequest: description: The request violates the transport or application contract. headers: @@ -2205,7 +2630,7 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" NotFound: - description: The requested immutable Memory value was not found. + description: The requested durable value was not found or is not observable. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -2260,6 +2685,107 @@ components: minimum: 512 maximum: 32768 default: 8000 + ArtifactCollectionItem: + type: object + required: [scope_id, family, artifact_id, revision, sources, artifacts, content_digest] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + family: + $ref: "#/components/schemas/BaseArtifactFamily" + artifact_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + sources: + type: array + items: + $ref: "#/components/schemas/SourceTypeReference" + artifacts: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + ArtifactCreated: + type: object + required: [scope_id, family, artifact_id, revision, sources, artifacts] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + family: + $ref: "#/components/schemas/BaseArtifactFamily" + artifact_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + sources: + type: array + items: + $ref: "#/components/schemas/SourceTypeReference" + artifacts: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + ArtifactPage: + type: object + required: [items, next_cursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ArtifactCollectionItem" + next_cursor: + type: string + nullable: true + ArtifactRevision: + type: object + required: [scope_id, family, artifact_id, revision, content, sources, artifacts, content_digest] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + family: + $ref: "#/components/schemas/BaseArtifactFamily" + artifact_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + revision: + type: integer + minimum: 1 + content: + type: object + additionalProperties: true + sources: + type: array + items: + $ref: "#/components/schemas/SourceTypeReference" + artifacts: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' ArtifactReference: type: object additionalProperties: false @@ -5696,6 +6222,220 @@ components: type: array items: $ref: "#/components/schemas/SearchMemoryHit" + CreateArtifactRequest: + oneOf: + - $ref: "#/components/schemas/CreateMemoryArtifactRequest" + - $ref: "#/components/schemas/CreateExperienceArtifactRequest" + - $ref: "#/components/schemas/CreateSkillArtifactRequest" + - $ref: "#/components/schemas/CreateHandoffArtifactRequest" + discriminator: + propertyName: family + mapping: + memory: "#/components/schemas/CreateMemoryArtifactRequest" + experience: "#/components/schemas/CreateExperienceArtifactRequest" + skill: "#/components/schemas/CreateSkillArtifactRequest" + handoff: "#/components/schemas/CreateHandoffArtifactRequest" + CreateMemoryArtifactRequest: + type: object + additionalProperties: false + required: [family, content] + properties: + family: + type: string + enum: [memory] + content: + $ref: "#/components/schemas/CreateMemoryArtifactContent" + CreateExperienceArtifactRequest: + type: object + additionalProperties: false + required: [family, content] + properties: + family: + type: string + enum: [experience] + content: + $ref: "#/components/schemas/ExperienceProposal" + CreateSkillArtifactRequest: + type: object + additionalProperties: false + required: [family, content] + properties: + family: + type: string + enum: [skill] + content: + $ref: "#/components/schemas/SkillProposal" + CreateHandoffArtifactRequest: + type: object + additionalProperties: false + required: [family, content] + properties: + family: + type: string + enum: [handoff] + content: + $ref: "#/components/schemas/HandoffContent" + CreateMemoryArtifactContent: + type: object + additionalProperties: false + required: [entries] + properties: + entries: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/CreateMemoryArtifactEntry" + CreateMemoryArtifactEntry: + type: object + additionalProperties: false + required: [kind, text] + properties: + kind: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + description: >- + Open application-defined Memory kind. Recommended values are fact, preference, decision, constraint, + and working_note. The Server validates and preserves the supplied value; it never guesses or replaces it. + text: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + description: Durable non-empty Memory entry text used to create the Entry Version and search projection. + CreateSourceRequest: + type: object + additionalProperties: false + required: [content] + properties: + source_type: + type: string + enum: [content] + default: content + content: + description: JSON value persisted by the built-in content Source adapter. + ListArtifactsRequest: + type: object + additionalProperties: false + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + ReplaceArtifactRequest: + oneOf: + - $ref: "#/components/schemas/ReplaceMemoryArtifactRequest" + - $ref: "#/components/schemas/ReplaceExperienceArtifactRequest" + - $ref: "#/components/schemas/ReplaceSkillArtifactRequest" + - $ref: "#/components/schemas/ReplaceHandoffArtifactRequest" + ReplaceMemoryArtifactRequest: + type: object + additionalProperties: false + required: [content] + properties: + content: + $ref: "#/components/schemas/ReplaceMemoryArtifactContent" + ReplaceMemoryArtifactContent: + type: object + additionalProperties: false + required: [entries] + properties: + entries: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/ReplaceMemoryArtifactEntry" + ReplaceMemoryArtifactEntry: + type: object + additionalProperties: false + required: [kind, text] + properties: + entry_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + nullable: true + description: Existing logical entry to revise. Omit to append a new entry. + kind: + type: string + minLength: 1 + maxLength: 128 + pattern: '.*\S.*' + description: Open application-defined kind; the Server preserves the supplied value. + text: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + ReplaceExperienceArtifactRequest: + type: object + additionalProperties: false + required: [content] + properties: + content: + $ref: "#/components/schemas/ExperienceProposal" + ReplaceSkillArtifactRequest: + type: object + additionalProperties: false + required: [content] + properties: + content: + $ref: "#/components/schemas/SkillProposal" + ReplaceHandoffArtifactRequest: + type: object + additionalProperties: false + required: [content] + properties: + content: + $ref: "#/components/schemas/HandoffContent" + SourceRecord: + type: object + required: [scope_id, source_type, source_id, content, position, content_digest] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_type: + type: string + enum: [content] + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' + content: + description: Persisted canonical JSON content. + position: + type: integer + minimum: 1 + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + SourceTypeReference: + type: object + additionalProperties: false + required: [source_type, source_id] + properties: + source_type: + type: string + enum: [content] + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' SourceReference: type: object additionalProperties: false @@ -5709,6 +6449,9 @@ components: CaptureStatus: type: string enum: [accepted] + BaseArtifactFamily: + type: string + enum: [memory, experience, skill, handoff] StatsPeriod: type: string enum: [today, 7d, 30d] diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 3bc865113..e4fdb61a1 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -161,8 +161,13 @@ def _generate_operations( imports.add(request_model[:2]) success_status, success_response = _success_response(operation.responses, path) - response_model = _model_for_json_content(success_response.content, schemas, path) - imports.add(response_model[:2]) + response_model = ( + None + if success_response.content is None + else _model_for_json_content(success_response.content, schemas, path) + ) + if response_model is not None: + imports.add(response_model[:2]) operations.append( _render_operation( constant_name=operation_id.upper(), @@ -171,10 +176,10 @@ def _generate_operations( operation_id=operation_id, request_model=None if request_model is None else request_model[1], request_location=None if request_model is None else request_model[2], + response_model=None if response_model is None else response_model[1], path_parameters=tuple( parameter.name for parameter in parameters if parameter.in_ is ParameterInType.path ), - response_model=response_model[1], success_status=success_status, summary=operation.summary, tags=tuple(operation.tags or ()), @@ -213,8 +218,8 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id: str request_type: type[RequestT] | None request_location: Literal["body", "query"] | None + response_type: type[ResponseT] | None path_parameters: tuple[str, ...] - response_type: type[ResponseT] success_status: int summary: str tags: tuple[str, ...] @@ -388,8 +393,8 @@ def _render_operation( operation_id: str, request_model: str | None, request_location: Literal["body", "query"] | None, + response_model: str | None, path_parameters: tuple[str, ...], - response_model: str, success_status: int, summary: str, tags: tuple[str, ...], @@ -397,14 +402,15 @@ def _render_operation( responses: dict[int | str, dict[str, JsonValue]], ) -> str: request_type = "None" if request_model is None else request_model - return f"""{constant_name} = Operation[{request_type}, {response_model}]( + response_type = "None" if response_model is None else response_model + return f"""{constant_name} = Operation[{request_type}, {response_type}]( method={method!r}, path={path!r}, operation_id={operation_id!r}, request_type={request_type}, request_location={request_location!r}, path_parameters={path_parameters!r}, - response_type={response_model}, + response_type={response_type}, success_status={success_status}, summary={summary!r}, tags={tags!r}, diff --git a/scripts/generate_js_operations.py b/scripts/generate_js_operations.py index 73b599b1d..c2e6f804e 100644 --- a/scripts/generate_js_operations.py +++ b/scripts/generate_js_operations.py @@ -84,6 +84,7 @@ def parse_operations(doc: dict[str, Any]) -> list[dict[str, Any]]: continue parameters = _operation_parameters(doc, path_item, operation) body_schema = _json_body_schema(doc, operation) + success_statuses, empty_statuses = _response_statuses(doc, operation) rows.append({ "operationId": operation["operationId"], "method": method.upper(), @@ -91,6 +92,10 @@ def parse_operations(doc: dict[str, Any]) -> list[dict[str, Any]]: "location": _request_location(body_schema, parameters), "scopeMode": _scope_mode(operation), "pathParameters": _path_parameters(parameters), + "queryParams": _parameter_names(parameters, "query"), + "headerParams": _parameter_names(parameters, "header"), + "successStatuses": success_statuses, + "emptyStatuses": empty_statuses, }) return rows @@ -116,10 +121,17 @@ def _render_row(row: dict[str, Any]) -> str: path_parameters = ", ".join(f"'{name}'" for name in row["pathParameters"]) return ( f" {row['operationId']}: {{ method: '{row['method']}', path: '{row['path']}', " - f"location: {location}, scopeMode: '{row['scopeMode']}', pathParameters: [{path_parameters}] }}," + f"location: {location}, scopeMode: '{row['scopeMode']}', pathParameters: [{path_parameters}], " + f"queryParams: {_render_array(row['queryParams'])}, headerParams: {_render_array(row['headerParams'])}, " + f"successStatuses: {_render_array(row['successStatuses'])}, " + f"emptyStatuses: {_render_array(row['emptyStatuses'])} }}," ) +def _render_array(values: list[str] | list[int]) -> str: + return f"[{','.join(repr(value) for value in values)}]" + + def _resolve_ref(doc: dict[str, Any], ref: str, seen: set[str]) -> Any: if not isinstance(ref, str) or not ref.startswith("#/") or ref in seen: return None @@ -174,6 +186,30 @@ def _request_location(body_schema: Any, parameters: list[dict[str, Any]]) -> str return None +def _parameter_names(parameters: list[dict[str, Any]], location: str) -> list[str]: + return [ + str(parameter["name"]) + for parameter in parameters + if parameter.get("in") == location and isinstance(parameter.get("name"), str) + ] + + +def _response_statuses(doc: dict[str, Any], operation: dict[str, Any]) -> tuple[list[int], list[int]]: + success: list[int] = [] + empty: list[int] = [] + for raw_status, response in (operation.get("responses") or {}).items(): + if not isinstance(raw_status, str) or not raw_status.isdecimal(): + continue + status = int(raw_status) + if not (200 <= status < 300 or status == 304): + continue + success.append(status) + resolved = _deref(doc, response) + if not isinstance(resolved, dict) or not resolved.get("content"): + empty.append(status) + return success, empty + + def _scope_mode(operation: dict[str, Any]) -> str: value = operation.get("x-powercontext-scope-mode", "none") if value not in {"none", "current", "selection"}: diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 4456c20bd..7bd29c241 100644 --- a/src/powercontext/builtin/artifacts/handoff/service.py +++ b/src/powercontext/builtin/artifacts/handoff/service.py @@ -16,7 +16,8 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Sequence +from typing import Protocol, runtime_checkable from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.handoff.errors import ( @@ -36,6 +37,7 @@ HandoffContent, HandoffDraft, HandoffEvidenceCheck, + HandoffGenerationEvidence, HandoffGenerationRequest, HandoffMemoryCitation, HandoffResolution, @@ -53,6 +55,15 @@ from powercontext.sources import SourceRef +@runtime_checkable +class _BatchHandoffEvidenceResolver(Protocol): + async def resolve_many( + self, + citations: Sequence[HandoffCitation], + /, + ) -> tuple[HandoffGenerationEvidence, ...]: ... + + class HandoffService: """Generate, finalize, commit, inspect, and resolve one scope's Handoff lifecycle.""" @@ -79,7 +90,10 @@ async def prepare(self, action: PrepareHandoff, /) -> HandoffDraft: if self._generation_pipeline is None: raise HandoffGenerationUnavailableError - evidence = tuple([await self._evidence_resolver.resolve(citation) for citation in action.evidence]) + if isinstance(self._evidence_resolver, _BatchHandoffEvidenceResolver): + evidence = await self._evidence_resolver.resolve_many(action.evidence) + else: + evidence = tuple([await self._evidence_resolver.resolve(citation) for citation in action.evidence]) draft = await self._generation_pipeline.generate( HandoffGenerationRequest( objective=action.objective, @@ -102,19 +116,26 @@ async def finalize(self, draft: HandoffDraft, /) -> PreparedHandoff: content=content, ) - async def commit(self, prepared: PreparedHandoff, /) -> Handoff: + async def commit( + self, + prepared: PreparedHandoff, + /, + *, + additional_sources: tuple[SourceRef, ...] = (), + force_revision: bool = False, + ) -> Handoff: """Commit an explicit milestone with no-op and optimistic concurrency semantics.""" self._require_prepared(prepared) current = await self._backend.latest(self.artifact_id) - if current is not None and current.content == prepared.content: + if not force_revision and current is not None and current.content == prepared.content: return current self._require_current_base(prepared.base, current) await self._validate_evidence(prepared.content) draft = HandoffArtifactDraft( content=prepared.content, - sources=_source_lineage(prepared.content), + sources=(*additional_sources, *_source_lineage(prepared.content)), artifacts=_artifact_lineage(prepared.content), ) if current is None: diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 0e8fd1ec7..1cc24fdcb 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -16,11 +16,11 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from typing import Any -from pydantic import BaseModel -from sqlalchemy import insert, select, update +from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError +from sqlalchemy import insert, select, tuple_, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection @@ -31,7 +31,7 @@ ArtifactLineage, ArtifactRef, ) -from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes +from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes, validate_json_model from powercontext.builtin.persistence.errors import ( IdentityMismatchError, InvalidPublicationLineageError, @@ -45,6 +45,7 @@ ARTIFACT_PUBLICATIONS_TABLE, ARTIFACTS_TABLE, ) +from powercontext.builtin.source_eligibility import ArtifactLineageTarget, require_source_eligible from powercontext.errors import ( ArtifactFamilyMismatchError, RevisionConflictError, @@ -53,10 +54,27 @@ from powercontext.sources import SourceRef +class RepositoryArtifactDraft(BaseModel): + """A family-validated draft accepted by the shared Artifact repository.""" + + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + family: str + content: BaseModel + sources: tuple[SourceRef, ...] = () + artifacts: tuple[ArtifactRef, ...] = () + + class ArtifactRepository: """Store composed Artifact types in the shared revision schema.""" - def __init__(self, artifact_types: Iterable[type[Artifact[Any]]], /) -> None: + def __init__( + self, + artifact_types: Iterable[type[Artifact[Any]]], + /, + *, + sources: Any | None = None, + ) -> None: self._by_family = {artifact_type.family: artifact_type for artifact_type in artifact_types} self._content_types: dict[str, type[BaseModel]] = {} for family, artifact_type in self._by_family.items(): @@ -64,13 +82,20 @@ def __init__(self, artifact_types: Iterable[type[Artifact[Any]]], /) -> None: if not isinstance(content_type, type) or not issubclass(content_type, BaseModel): raise TypeError(f"{artifact_type.__name__}.content must be a BaseModel") # noqa: TRY003 self._content_types[family] = content_type + self._sources = sources + + @property + def families(self) -> frozenset[str]: + """Return the registered domain Families whose writes require their owning service.""" + + return frozenset(self._by_family) async def create( self, connection: AsyncConnection, scope_id: str, artifact_id: str, - draft: ArtifactDraft[Any], + draft: ArtifactDraft[Any] | RepositoryArtifactDraft, /, ) -> Artifact[Any]: """Create revision one, rejecting an already existing lifecycle.""" @@ -79,6 +104,7 @@ async def create( artifact_type = self._artifact_type(draft.family) self._require_content(draft.family, draft.content) ref = ArtifactRef(family=draft.family, artifact_id=artifact_id, revision=1) + await self._validate_lineage_sources(connection, scope_id, ref, draft.sources) conflict = await self._head_conflict(connection, scope_id, ref, draft) if conflict is not None: raise conflict @@ -114,7 +140,7 @@ async def revise( connection: AsyncConnection, scope_id: str, artifact: Artifact[Any], - draft: ArtifactDraft[Any], + draft: ArtifactDraft[Any] | RepositoryArtifactDraft, /, ) -> Artifact[Any]: """Commit a next revision only when ``artifact`` remains the head.""" @@ -124,6 +150,12 @@ async def revise( if type(artifact) is not artifact_type or draft.family != artifact.family: raise ArtifactFamilyMismatchError(artifact, draft) self._require_content(draft.family, draft.content) + target = ArtifactRef( + family=artifact.family, + artifact_id=artifact.artifact_id, + revision=artifact.revision + 1, + ) + await self._validate_lineage_sources(connection, scope_id, target, draft.sources) locked = await connection.execute( update(ARTIFACT_HEADS_TABLE) @@ -194,6 +226,50 @@ async def get( raise RepositoryNotFoundError("artifact", (scope_id, ref)) return await self._decode_row(connection, row) + async def get_many( + self, + connection: AsyncConnection, + scope_id: str, + refs: Sequence[ArtifactRef], + /, + ) -> tuple[Artifact[Any], ...]: + """Load exact Revisions and ordered lineage with bounded batch queries.""" + + _require_scope(scope_id) + requested = tuple(refs) + if not requested: + return () + keys = tuple(dict.fromkeys((ref.family, ref.artifact_id, ref.revision) for ref in requested)) + for family, _, _ in keys: + self._artifact_type(family) + identity = tuple_( + ARTIFACTS_TABLE.c.family, + ARTIFACTS_TABLE.c.artifact_id, + ARTIFACTS_TABLE.c.revision, + ) + rows = ( + await connection.execute( + select(ARTIFACTS_TABLE).where( + ARTIFACTS_TABLE.c.scope_id == scope_id, + identity.in_(keys), + ) + ) + ).mappings() + by_key = {(str(row["family"]), str(row["artifact_id"]), int(row["revision"])): row for row in rows} + for ref in requested: + key = (ref.family, ref.artifact_id, ref.revision) + if key not in by_key: + raise RepositoryNotFoundError("artifact", (scope_id, ref)) + + lineage = await self._load_lineage_many(connection, scope_id, keys) + return tuple( + self._decode_artifact( + by_key[(ref.family, ref.artifact_id, ref.revision)], + lineage[(ref.family, ref.artifact_id, ref.revision)], + ) + for ref in requested + ) + async def latest( self, connection: AsyncConnection, @@ -347,6 +423,20 @@ async def _decode_row( self, connection: AsyncConnection, row: Mapping[Any, Any], + ) -> Artifact[Any]: + family = str(row["family"]) + ref = ArtifactRef( + family=family, + artifact_id=str(row["artifact_id"]), + revision=int(row["revision"]), + ) + lineage = await self._load_lineage(connection, str(row["scope_id"]), ref) + return self._decode_artifact(row, lineage) + + def _decode_artifact( + self, + row: Mapping[Any, Any], + lineage: ArtifactLineage, ) -> Artifact[Any]: family = str(row["family"]) artifact_type = self._artifact_type(family) @@ -361,7 +451,6 @@ async def _decode_row( artifact_id=str(row["artifact_id"]), revision=int(row["revision"]), ) - lineage = await self._load_lineage(connection, str(row["scope_id"]), ref) artifact = artifact_type( artifact_id=ref.artifact_id, revision=ref.revision, @@ -372,6 +461,68 @@ async def _decode_row( raise IdentityMismatchError("artifact", ref, artifact.as_ref()) return artifact + async def _load_lineage_many( + self, + connection: AsyncConnection, + scope_id: str, + keys: Sequence[tuple[str, str, int]], + ) -> dict[tuple[str, str, int], ArtifactLineage]: + source_identity = tuple_( + ARTIFACT_LINEAGE_SOURCES_TABLE.c.family, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.artifact_id, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.revision, + ) + source_rows = ( + await connection.execute( + select(ARTIFACT_LINEAGE_SOURCES_TABLE) + .where( + ARTIFACT_LINEAGE_SOURCES_TABLE.c.scope_id == scope_id, + source_identity.in_(keys), + ) + .order_by( + ARTIFACT_LINEAGE_SOURCES_TABLE.c.family, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.artifact_id, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.revision, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.ordinal, + ) + ) + ).mappings() + artifact_identity = tuple_( + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.family, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.artifact_id, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.revision, + ) + artifact_rows = ( + await connection.execute( + select(ARTIFACT_LINEAGE_ARTIFACTS_TABLE) + .where( + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.scope_id == scope_id, + artifact_identity.in_(keys), + ) + .order_by( + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.family, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.artifact_id, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.revision, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.ordinal, + ) + ) + ).mappings() + sources: dict[tuple[str, str, int], list[SourceRef]] = {key: [] for key in keys} + artifacts: dict[tuple[str, str, int], list[ArtifactRef]] = {key: [] for key in keys} + for row in source_rows: + key = (str(row["family"]), str(row["artifact_id"]), int(row["revision"])) + sources[key].append(SourceRef(source_type=str(row["source_type"]), source_id=str(row["source_id"]))) + for row in artifact_rows: + key = (str(row["family"]), str(row["artifact_id"]), int(row["revision"])) + artifacts[key].append( + ArtifactRef( + family=str(row["upstream_family"]), + artifact_id=str(row["upstream_artifact_id"]), + revision=int(row["upstream_revision"]), + ) + ) + return {key: ArtifactLineage(sources=tuple(sources[key]), artifacts=tuple(artifacts[key])) for key in keys} + async def _load_lineage( self, connection: AsyncConnection, @@ -484,7 +635,7 @@ async def _head_conflict( connection: AsyncConnection, scope_id: str, ref: ArtifactRef, - draft: ArtifactDraft[Any], + draft: ArtifactDraft[Any] | RepositoryArtifactDraft, ) -> RevisionConflictError | None: """Return the conflict raised by an already committed lifecycle.""" @@ -505,13 +656,12 @@ async def _find_head( family: str, artifact_id: str, ) -> int | None: - value = await connection.scalar( - select(ARTIFACT_HEADS_TABLE.c.revision).where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ) + statement = select(ARTIFACT_HEADS_TABLE.c.revision).where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, ) + value = await connection.scalar(statement) return None if value is None else int(value) def _artifact_type(self, family: str) -> type[Artifact[Any]]: @@ -525,6 +675,53 @@ def _require_content(self, family: str, content: object) -> None: if expected is None or type(content) is not expected: raise ArtifactFamilyMismatchError(family, content) + def draft( + self, + family: str, + content: dict[str, JsonValue], + /, + *, + sources: tuple[SourceRef, ...] = (), + artifacts: tuple[ArtifactRef, ...] = (), + ) -> RepositoryArtifactDraft: + """Validate untrusted JSON with the registered family model.""" + + content_type = self._content_types.get(family) + if content_type is None: + raise RepositoryNotFoundError("artifact-family", family) + try: + validated = validate_json_model(content_type, content) + except ValidationError as error: + raise InvalidRepositoryArgumentError("content", "does not match the Artifact family") from error + return RepositoryArtifactDraft( + family=family, + content=validated, + sources=sources, + artifacts=artifacts, + ) + + async def _validate_lineage_sources( + self, + connection: AsyncConnection, + scope_id: str, + target: ArtifactRef, + sources: tuple[SourceRef, ...], + ) -> None: + if self._sources is None: + return + for source_ref in sources: + stored = await self._sources.get(connection, scope_id, source_ref) + require_source_eligible( + source_ref, + stored.value, + target=ArtifactLineageTarget( + scope_id=scope_id, + family=target.family, + artifact_id=target.artifact_id, + revision=target.revision, + ), + ) + def _require_scope(scope_id: object) -> None: if not isinstance(scope_id, str) or not scope_id.strip() or scope_id != scope_id.strip(): diff --git a/src/powercontext/builtin/persistence/codec.py b/src/powercontext/builtin/persistence/codec.py index 65b82b34b..262c5211c 100644 --- a/src/powercontext/builtin/persistence/codec.py +++ b/src/powercontext/builtin/persistence/codec.py @@ -18,11 +18,12 @@ from typing import TypeVar -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError from powercontext.builtin.persistence.errors import InvalidStoredColumnError, InvalidStoredPayloadError ModelT = TypeVar("ModelT", bound=BaseModel) +_JSON_VALUE_ADAPTER = TypeAdapter(JsonValue) def stored_bytes(value: object, /, *, column: str) -> bytes: @@ -40,6 +41,12 @@ def dump_model(value: BaseModel, /, *, kind: str, name: str) -> bytes: raise InvalidStoredPayloadError(kind, name, "value is not JSON serializable") from error +def validate_json_model(model: type[ModelT], value: JsonValue, /) -> ModelT: + """Validate an already decoded JSON value with JSON-aware strict semantics.""" + + return model.model_validate_json(_JSON_VALUE_ADAPTER.dump_json(value), strict=True) + + def load_model(model: type[ModelT], payload: object, /, *, kind: str, name: str) -> ModelT: if not isinstance(payload, bytes): raise InvalidStoredPayloadError(kind, name, "payload column is not bytes") diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py new file mode 100644 index 000000000..07b360ef6 --- /dev/null +++ b/src/powercontext/builtin/persistence/family_management.py @@ -0,0 +1,545 @@ +# 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. + +"""Family-owned writers used by the foundational Artifact management API.""" + +from __future__ import annotations + +import json +import unicodedata +from collections.abc import Callable, Mapping +from typing import Annotated, Any, Protocol, cast + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, field_validator +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import Artifact, ArtifactLineage +from powercontext.builtin.artifacts.experience import Experience, ExperienceContent +from powercontext.builtin.artifacts.handoff import Handoff, HandoffContent, HandoffService, PreparedHandoff +from powercontext.builtin.artifacts.memory import ( + Memory, + MemoryEntryInput, + MemoryEntryNotFoundError, + MemoryLayerError, + MemoryService, +) +from powercontext.builtin.artifacts.skill import ( + Skill, + SkillContent, + SkillPackageError, + build_instruction_skill_package, +) +from powercontext.builtin.inference import EmbeddingModel +from powercontext.builtin.persistence.artifacts import ArtifactRepository, RepositoryArtifactDraft +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.experience_index import ExperienceIndex +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess +from powercontext.builtin.persistence.handoff import RelationalHandoffBackend, RelationalHandoffEvidenceResolver +from powercontext.builtin.persistence.memory import RelationalMemoryBackend +from powercontext.builtin.persistence.memory_index import MemoryIndex +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.records import ( + ArtifactAlreadyExistsError, + InvalidBaseAccessRequestError, +) +from powercontext.sources import SourceRef + +IdFactory = Callable[[str], str] + + +class _ManagementValue(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class MemoryCreateEntry(_ManagementValue): + """One explicit entry supplied by a direct Memory Create command.""" + + kind: Annotated[str, Field(min_length=1, max_length=128)] + text: Annotated[str, Field(min_length=1, max_length=8192)] + + @field_validator("kind") + @classmethod + def require_canonical_kind(cls, value: str) -> str: + canonical = unicodedata.normalize("NFC", value).strip() + if value != canonical: + raise ValueError("kind must be a non-empty NFC-normalized trimmed string") # noqa: TRY003 + return value + + @field_validator("text") + @classmethod + def require_nonblank_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("text must contain at least one non-whitespace character") # noqa: TRY003 + return value + + +class MemoryCreateContent(_ManagementValue): + """Command content that creates non-empty canonical Memory state.""" + + entries: Annotated[tuple[MemoryCreateEntry, ...], Field(min_length=1, max_length=100)] + + +class MemoryReplaceEntry(MemoryCreateEntry): + """Append a new entry or revise one existing logical entry.""" + + entry_id: Annotated[str | None, Field(min_length=1, max_length=128)] = None + + +class MemoryReplaceContent(_ManagementValue): + """Command content applied by Memory Replace through MemoryService.""" + + entries: Annotated[tuple[MemoryReplaceEntry, ...], Field(min_length=1, max_length=100)] + + +class FamilyManagementWriter(Protocol): + """Validate and atomically maintain one Family's authoritative and derived state.""" + + family: str + + def artifact_id_for_create(self, generated: str, /) -> str: ... + + def validate_create(self, content: Mapping[str, JsonValue]) -> BaseModel: ... + + def validate_replace(self, content: Mapping[str, JsonValue]) -> BaseModel: ... + + async def create( + self, + connection: AsyncConnection, + scope_id: str, + artifact_id: str, + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Artifact[Any]: ... + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + current: Artifact[Any], + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Artifact[Any]: ... + + +class FamilyManagementWriterRegistry: + """Select the owning writer instead of duplicating Family behavior in the REST layer.""" + + def __init__(self, writers: tuple[FamilyManagementWriter, ...], /) -> None: + self._writers = {writer.family: writer for writer in writers} + if len(self._writers) != len(writers): + raise ValueError("Family management writers must have unique families") # noqa: TRY003 + + def get(self, family: str, /) -> FamilyManagementWriter: + try: + return self._writers[family] + except KeyError: + raise InvalidBaseAccessRequestError("family", f"has no management writer: {family}") from None + + +class _RepositoryFamilyWriter: + family: str + content_type: type[BaseModel] + + def __init__(self, artifacts: ArtifactRepository, /) -> None: + self._artifacts = artifacts + + def artifact_id_for_create(self, generated: str, /) -> str: + return generated + + def validate_create(self, content: Mapping[str, JsonValue]) -> BaseModel: + return self._validate(content) + + def validate_replace(self, content: Mapping[str, JsonValue]) -> BaseModel: + return self._validate(content) + + def _validate(self, content: Mapping[str, JsonValue]) -> BaseModel: + try: + return self.content_type.model_validate_json(json.dumps(content), strict=True) + except ValidationError as error: + raise InvalidBaseAccessRequestError("content", f"does not match the {self.family} model") from error + + async def _create_artifact( + self, + connection: AsyncConnection, + scope_id: str, + artifact_id: str, + content: BaseModel, + direct_source: SourceRef, + ) -> Artifact[Any]: + return await self._artifacts.create( + connection, + scope_id, + artifact_id, + RepositoryArtifactDraft(family=self.family, content=content, sources=(direct_source,)), + ) + + async def _revise_artifact( + self, + connection: AsyncConnection, + scope_id: str, + current: Artifact[Any], + content: BaseModel, + direct_source: SourceRef, + ) -> Artifact[Any]: + return await self._artifacts.revise( + connection, + scope_id, + current, + RepositoryArtifactDraft( + family=self.family, + content=content, + sources=(direct_source,), + artifacts=current.lineage.artifacts, + ), + ) + + +class ExperienceManagementWriter(_RepositoryFamilyWriter): + family = Experience.family + content_type = ExperienceContent + + def __init__(self, artifacts: ArtifactRepository, index: ExperienceIndex, /) -> None: + super().__init__(artifacts) + self._index = index + + async def create( + self, + connection: AsyncConnection, + scope_id: str, + artifact_id: str, + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Experience: + artifact = cast( + Experience, await self._create_artifact(connection, scope_id, artifact_id, content, direct_source) + ) + await self._index.replace(connection, scope_id, artifact) + return artifact + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + current: Artifact[Any], + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Experience: + artifact = cast(Experience, await self._revise_artifact(connection, scope_id, current, content, direct_source)) + await self._index.replace(connection, scope_id, artifact) + return artifact + + +class SkillManagementWriter(_RepositoryFamilyWriter): + family = Skill.family + content_type = SkillContent + + def __init__( + self, + artifacts: ArtifactRepository, + index: ExperienceIndex, + packages: SkillPackageRepository, + /, + ) -> None: + super().__init__(artifacts) + self._index = index + self._packages = packages + + async def _canonical_content( + self, + connection: AsyncConnection, + scope_id: str, + content: BaseModel, + ) -> tuple[SkillContent, Any]: + proposal = cast(SkillContent, content) + try: + if proposal.package is None: + package = build_instruction_skill_package(proposal) + await self._packages.add(connection, scope_id, package) + else: + package = await self._packages.get(connection, scope_id, proposal.package) + except (RepositoryNotFoundError, SkillPackageError) as error: + raise InvalidBaseAccessRequestError("content.package", "is unavailable or invalid") from error + canonical = package.as_skill_content() + if proposal.package is not None and canonical != proposal: + raise InvalidBaseAccessRequestError( + "content.package", + "cached Skill fields do not match the exact package", + ) + return canonical, package + + async def create( + self, + connection: AsyncConnection, + scope_id: str, + artifact_id: str, + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Skill: + canonical, package = await self._canonical_content(connection, scope_id, content) + artifact = cast( + Skill, + await self._create_artifact(connection, scope_id, artifact_id, canonical, direct_source), + ) + await self._index.replace_skill(connection, scope_id, artifact, package) + return artifact + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + current: Artifact[Any], + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Skill: + canonical, package = await self._canonical_content(connection, scope_id, content) + artifact = cast( + Skill, + await self._revise_artifact(connection, scope_id, current, canonical, direct_source), + ) + await self._index.replace_skill(connection, scope_id, artifact, package) + return artifact + + +class MemoryManagementWriter: + family = Memory.family + + def __init__( + self, + *, + database: AsyncDatabase, + artifacts: ArtifactRepository, + index: MemoryIndex, + embedding_model: EmbeddingModel | None, + id_factory: IdFactory, + ) -> None: + self._database = database + self._artifacts = artifacts + self._index = index + self._embedding_model = embedding_model + self._id_factory = id_factory + + def artifact_id_for_create(self, generated: str, /) -> str: + return generated + + def validate_create(self, content: Mapping[str, JsonValue]) -> BaseModel: + return _validate(MemoryCreateContent, content, self.family) + + def validate_replace(self, content: Mapping[str, JsonValue]) -> BaseModel: + return _validate(MemoryReplaceContent, content, self.family) + + def _service(self, scope_id: str, artifact_id: str, connection: AsyncConnection) -> MemoryService: + def id_factory(kind: str) -> str: + return artifact_id if kind == "memory" else self._id_factory(kind) + + return MemoryService( + backend=RelationalMemoryBackend( + database=self._database, + scope_id=scope_id, + artifacts=self._artifacts, + index=self._index, + connection=connection, + ), + embedding_model=self._embedding_model, + id_factory=id_factory, + ) + + async def create( + self, + connection: AsyncConnection, + scope_id: str, + artifact_id: str, + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Memory: + command = cast(MemoryCreateContent, content) + service = self._service(scope_id, artifact_id, connection) + plan = await service.plan_remember( + memory=None, + entries=tuple(MemoryEntryInput(kind=item.kind, text=item.text) for item in command.entries), + mode="append", + ) + return await _apply_memory_plan(service, plan, direct_source) + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + current: Artifact[Any], + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Memory: + if type(current) is not Memory: + raise InvalidBaseAccessRequestError("family", "does not identify a Memory Artifact") + memory = current + command = cast(MemoryReplaceContent, content) + service = self._service(scope_id, memory.artifact_id, connection) + current_entries = {entry.entry_id: entry for entry in await service.entries(memory)} + inputs: list[MemoryEntryInput] = [] + for item in command.entries: + existing = None if item.entry_id is None else current_entries.get(item.entry_id) + if item.entry_id is not None and existing is None: + raise InvalidBaseAccessRequestError("content.entries.entry_id", "does not identify a current entry") + inputs.append(MemoryEntryInput(kind=item.kind, text=item.text, entry=existing)) + try: + plan = await service.plan_remember(memory=memory, entries=tuple(inputs), mode="append") + except (MemoryEntryNotFoundError, MemoryLayerError) as error: + raise InvalidBaseAccessRequestError("content.entries", "cannot be applied to the current Memory") from error + return await _apply_memory_plan(service, plan, direct_source) + + +class HandoffManagementWriter: + family = Handoff.family + content_type = HandoffContent + + def __init__( + self, + *, + database: AsyncDatabase, + artifacts: ArtifactRepository, + sources: SourceRepository, + memory_index: MemoryIndex, + id_factory: IdFactory, + memory_artifact_id: str, + handoff_artifact_id: str, + ) -> None: + self._database = database + self._artifacts = artifacts + self._sources = sources + self._memory_index = memory_index + self._id_factory = id_factory + self._memory_artifact_id = memory_artifact_id + self._handoff_artifact_id = handoff_artifact_id + + def artifact_id_for_create(self, _generated: str, /) -> str: + return self._handoff_artifact_id + + def validate_create(self, content: Mapping[str, JsonValue]) -> BaseModel: + return _validate(HandoffContent, content, self.family) + + def validate_replace(self, content: Mapping[str, JsonValue]) -> BaseModel: + return _validate(HandoffContent, content, self.family) + + def _service(self, scope_id: str, connection: AsyncConnection) -> HandoffService: + memory = MemoryService( + backend=RelationalMemoryBackend( + database=self._database, + scope_id=scope_id, + artifacts=self._artifacts, + index=self._memory_index, + connection=connection, + ), + id_factory=self._id_factory, + ) + return HandoffService( + scope_id=scope_id, + artifact_id=self._handoff_artifact_id, + backend=RelationalHandoffBackend( + database=self._database, + scope_id=scope_id, + artifacts=self._artifacts, + connection=connection, + ), + evidence_resolver=RelationalHandoffEvidenceResolver( + database=self._database, + scope_id=scope_id, + sources=GenerationSourceAccess(self._sources), + artifacts=self._artifacts, + memory=memory, + connection=connection, + ), + ) + + async def create( + self, + connection: AsyncConnection, + scope_id: str, + artifact_id: str, + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Handoff: + service = self._service(scope_id, connection) + if artifact_id != self._handoff_artifact_id or await service.latest() is not None: + raise ArtifactAlreadyExistsError(self.family, self._handoff_artifact_id, use_replace=True) + prepared = PreparedHandoff(scope_id=scope_id, base=None, content=cast(HandoffContent, content)) + return await service.commit( + prepared, + additional_sources=(direct_source,), + force_revision=True, + ) + + async def replace( + self, + connection: AsyncConnection, + scope_id: str, + current: Artifact[Any], + content: BaseModel, + direct_source: SourceRef, + /, + ) -> Handoff: + if type(current) is not Handoff or current.artifact_id != self._handoff_artifact_id: + raise InvalidBaseAccessRequestError( + "artifact_id", + "must identify the Scope's configured Handoff singleton", + ) + prepared = PreparedHandoff( + scope_id=scope_id, + base=current.as_ref(), + content=cast(HandoffContent, content), + ) + return await self._service(scope_id, connection).commit( + prepared, + additional_sources=(direct_source,), + force_revision=True, + ) + + +async def _apply_memory_plan(service: MemoryService, plan: Any, direct_source: SourceRef) -> Memory: + if plan.commit is None: + raise InvalidBaseAccessRequestError("content.entries", "does not produce a new Memory Revision") + inherited_artifacts = () if plan.commit.base is None else plan.commit.base.lineage.artifacts + memory = plan.commit.memory.model_copy( + update={"lineage": ArtifactLineage(sources=(direct_source,), artifacts=inherited_artifacts)} + ) + commit = plan.commit.model_copy(update={"memory": memory}) + result = await service.apply(plan.model_copy(update={"result": memory, "commit": commit})) + if type(result) is not Memory: + raise RuntimeError("Memory writer did not commit a Memory Revision") # noqa: TRY003 + return result + + +def _validate(model: type[BaseModel], content: Mapping[str, JsonValue], family: str) -> BaseModel: + try: + return model.model_validate_json(json.dumps(content), strict=True) + except ValidationError as error: + raise InvalidBaseAccessRequestError("content", f"does not match the {family} model") from error + + +__all__ = [ + "ExperienceManagementWriter", + "FamilyManagementWriterRegistry", + "HandoffManagementWriter", + "MemoryManagementWriter", + "SkillManagementWriter", +] diff --git a/src/powercontext/builtin/persistence/generation_sources.py b/src/powercontext/builtin/persistence/generation_sources.py new file mode 100644 index 000000000..11548a56a --- /dev/null +++ b/src/powercontext/builtin/persistence/generation_sources.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Source reads whose purpose is Artifact generation.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.persistence.sources import SourceRepository, StoredSource +from powercontext.builtin.source_eligibility import is_generation_eligible, require_source_eligible +from powercontext.sources import SourceRef + + +class GenerationSourceAccess: + """Keep generation reads and Source eligibility inseparable.""" + + def __init__(self, repository: SourceRepository, /) -> None: + self._repository = repository + + async def require_for_generation( + self, + connection: AsyncConnection, + scope_id: str, + refs: Sequence[SourceRef], + /, + ) -> tuple[StoredSource, ...]: + """Resolve explicit references, rejecting the whole request if any is ineligible.""" + + rows = await self._repository.get_many(connection, scope_id, refs) + for row in rows: + require_source_eligible(row.ref, row.value) + return rows + + async def list_window_for_generation( + self, + connection: AsyncConnection, + scope_id: str, + /, + *, + after: int, + through: int, + ) -> tuple[StoredSource, ...]: + """Resolve one bounded journal window and omit valid lineage-only Sources.""" + + rows = await self._repository.list_window(connection, scope_id, after=after, through=through) + return tuple(row for row in rows if is_generation_eligible(row.value)) + + +__all__ = ["GenerationSourceAccess"] diff --git a/src/powercontext/builtin/persistence/handoff.py b/src/powercontext/builtin/persistence/handoff.py index dcf2a6e0f..7cb757ca3 100644 --- a/src/powercontext/builtin/persistence/handoff.py +++ b/src/powercontext/builtin/persistence/handoff.py @@ -16,8 +16,11 @@ from __future__ import annotations +from collections.abc import Sequence from typing import cast +from sqlalchemy.ext.asyncio import AsyncConnection + from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.handoff import ( Handoff, @@ -40,7 +43,7 @@ from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError -from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.errors import ArtifactNotFoundError @@ -53,13 +56,15 @@ def __init__( database: AsyncDatabase, scope_id: str, artifacts: ArtifactRepository, + connection: AsyncConnection | None = None, ) -> None: self._database = database self._scope_id = scope_id self._artifacts = artifacts + self._bound_connection = connection async def create(self, artifact_id: str, draft: HandoffArtifactDraft, /) -> Handoff: - async with self._database.transaction() as connection: + async with self._database.connection(self._bound_connection) as connection: artifact = await self._artifacts.create( connection, self._scope_id, @@ -69,7 +74,7 @@ async def create(self, artifact_id: str, draft: HandoffArtifactDraft, /) -> Hand return cast(Handoff, artifact) async def revise(self, base: Handoff, draft: HandoffArtifactDraft, /) -> Handoff: - async with self._database.transaction() as connection: + async with self._database.connection(self._bound_connection) as connection: artifact = await self._artifacts.revise( connection, self._scope_id, @@ -80,7 +85,7 @@ async def revise(self, base: Handoff, draft: HandoffArtifactDraft, /) -> Handoff async def get(self, reference: ArtifactRef, /) -> Handoff: try: - async with self._database.transaction() as connection: + async with self._database.connection(self._bound_connection) as connection: artifact = await self._artifacts.get(connection, self._scope_id, reference) except RepositoryNotFoundError: raise ArtifactNotFoundError(reference) from None @@ -88,7 +93,7 @@ async def get(self, reference: ArtifactRef, /) -> Handoff: async def latest(self, artifact_id: str, /) -> Handoff | None: try: - async with self._database.transaction() as connection: + async with self._database.connection(self._bound_connection) as connection: artifact = await self._artifacts.latest( connection, self._scope_id, @@ -100,7 +105,7 @@ async def latest(self, artifact_id: str, /) -> Handoff | None: return cast(Handoff, artifact) async def revisions(self, artifact_id: str, /) -> tuple[Handoff, ...]: - async with self._database.transaction() as connection: + async with self._database.connection(self._bound_connection) as connection: artifacts = await self._artifacts.revisions( connection, self._scope_id, @@ -118,40 +123,86 @@ def __init__( *, database: AsyncDatabase, scope_id: str, - sources: SourceRepository, + sources: GenerationSourceAccess, artifacts: ArtifactRepository, memory: MemoryService, + connection: AsyncConnection | None = None, ) -> None: self._database = database self._scope_id = scope_id self._sources = sources self._artifacts = artifacts self._memory = memory + self._bound_connection = connection async def resolve(self, citation: HandoffCitation, /) -> HandoffGenerationEvidence: + return (await self.resolve_many((citation,)))[0] + + async def resolve_many( + self, + citations: Sequence[HandoffCitation], + /, + ) -> tuple[HandoffGenerationEvidence, ...]: + requested = tuple(citations) + source_refs = tuple( + citation.source_ref for citation in requested if isinstance(citation, HandoffSourceCitation) + ) + artifact_refs = tuple( + citation.artifact_ref for citation in requested if isinstance(citation, HandoffArtifactCitation) + ) try: + async with self._database.connection(self._bound_connection) as connection: + sources = await self._sources.require_for_generation(connection, self._scope_id, source_refs) + artifacts = await self._artifacts.get_many(connection, self._scope_id, artifact_refs) + except RepositoryNotFoundError as error: + unavailable = _missing_citation(requested, error) + raise HandoffEvidenceUnavailableError(unavailable) from error + + source_values = {(row.ref.source_type, row.ref.source_id): row.value for row in sources} + artifact_values = { + (artifact.family, artifact.artifact_id, artifact.revision): artifact for artifact in artifacts + } + evidence: list[HandoffGenerationEvidence] = [] + for citation in requested: if isinstance(citation, HandoffSourceCitation): - async with self._database.transaction() as connection: - source = await self._sources.get(connection, self._scope_id, citation.source_ref) - return HandoffSourceEvidence(citation=citation, source=source.value) - if isinstance(citation, HandoffArtifactCitation): - async with self._database.transaction() as connection: - artifact = await self._artifacts.get(connection, self._scope_id, citation.artifact_ref) - return HandoffArtifactEvidence(citation=citation, artifact=artifact) - if isinstance(citation, HandoffMemoryCitation): - entry = await self._memory.validate_citation(citation.memory_citation) - return HandoffMemoryEvidence(citation=citation, entry=entry) - except ( - ArtifactNotFoundError, - InvalidMemoryCitationError, - MemoryEntryNotFoundError, - RepositoryNotFoundError, - ) as error: - raise HandoffEvidenceUnavailableError(citation) from error - raise TypeError(f"unsupported Handoff citation: {type(citation).__name__}") # noqa: TRY003 + source = source_values[(citation.source_ref.source_type, citation.source_ref.source_id)] + evidence.append(HandoffSourceEvidence(citation=citation, source=source)) + elif isinstance(citation, HandoffArtifactCitation): + reference = citation.artifact_ref + evidence.append( + HandoffArtifactEvidence( + citation=citation, + artifact=artifact_values[(reference.family, reference.artifact_id, reference.revision)], + ) + ) + elif isinstance(citation, HandoffMemoryCitation): + try: + entry = await self._memory.validate_citation(citation.memory_citation) + except (ArtifactNotFoundError, InvalidMemoryCitationError, MemoryEntryNotFoundError) as error: + raise HandoffEvidenceUnavailableError(citation) from error + evidence.append(HandoffMemoryEvidence(citation=citation, entry=entry)) + else: + raise TypeError(f"unsupported Handoff citation: {type(citation).__name__}") # noqa: TRY003 + return tuple(evidence) async def validate(self, citation: HandoffCitation, /) -> None: await self.resolve(citation) +def _missing_citation( + citations: tuple[HandoffCitation, ...], + error: RepositoryNotFoundError, +) -> HandoffCitation: + identity = error.identity + missing = identity[-1] if isinstance(identity, tuple) and identity else None + for citation in citations: + if isinstance(citation, HandoffSourceCitation) and citation.source_ref == missing: + return citation + if isinstance(citation, HandoffArtifactCitation) and citation.artifact_ref == missing: + return citation + if citations: + return citations[0] + raise error + + __all__ = ["RelationalHandoffBackend", "RelationalHandoffEvidenceResolver"] diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py new file mode 100644 index 000000000..dfa08347a --- /dev/null +++ b/src/powercontext/builtin/persistence/records.py @@ -0,0 +1,581 @@ +# 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. + +"""Relational implementation of base Source, Artifact, and Scope access.""" + +from __future__ import annotations + +import base64 +import binascii +import hmac +import secrets +from collections.abc import Callable, Mapping +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from typing import Any, cast +from uuid import uuid4 + +import rfc8785 +from pydantic import JsonValue, TypeAdapter, ValidationError +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import Artifact, ArtifactRef +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import ( + RepositoryNotFoundError, + StoredPayloadConflictError, +) +from powercontext.builtin.persistence.family_management import FamilyManagementWriterRegistry +from powercontext.builtin.persistence.sources import SourceRepository, StoredSource +from powercontext.builtin.persistence.tables import ( + ARTIFACT_HEADS_TABLE, + ARTIFACTS_TABLE, + SOURCE_JOURNAL_HEADS_TABLE, + SOURCES_TABLE, +) +from powercontext.builtin.records import ( + ArtifactCollectionItem, + ArtifactCreated, + ArtifactRecord, + ArtifactRecordPage, + ArtifactRevisionPreconditionError, + ArtifactWrite, + BaseValueConflictError, + BaseValueNotFoundError, + CursorExpiredError, + InvalidBaseAccessRequestError, + InvalidCursorError, + ScopeSummary, + ScopeSummaryPage, + SourceRecord, +) +from powercontext.builtin.sources import ( + CONTENT_SOURCE_ADAPTER, + CONTENT_SOURCE_NAME, + ContentCapture, + ContentSource, + ContentSourceInternal, + ContentSourceTarget, +) +from powercontext.errors import RevisionConflictError +from powercontext.sources import SourceMaterialization, SourceRef + +Clock = Callable[[], datetime] +IdFactory = Callable[[str], str] + +_JSON_OBJECT = TypeAdapter(dict[str, JsonValue]) +_JSON_VALUE = TypeAdapter(JsonValue) +_DEFAULT_CURSOR_TTL_SECONDS = 3_600 + + +class RelationalRecordService: + """Serve fixed Source and Artifact paths over the shared relational tables.""" + + def __init__( + self, + database: AsyncDatabase, + sources: SourceRepository, + artifacts: ArtifactRepository, + family_writers: FamilyManagementWriterRegistry, + /, + *, + clock: Clock | None = None, + id_factory: IdFactory | None = None, + cursor_secret: bytes | None = None, + cursor_ttl_seconds: int = _DEFAULT_CURSOR_TTL_SECONDS, + ) -> None: + if isinstance(cursor_ttl_seconds, bool) or cursor_ttl_seconds < 1: + raise ValueError("cursor_ttl_seconds must be a positive integer") # noqa: TRY003 + if cursor_secret is not None and not cursor_secret: + raise ValueError("cursor_secret must not be empty") # noqa: TRY003 + self._database = database + self._sources = sources + self._artifacts = artifacts + self._family_writers = family_writers + self._clock = _utc_now if clock is None else clock + self._id_factory = _resource_id if id_factory is None else id_factory + self._cursor_secret = secrets.token_bytes(32) if cursor_secret is None else cursor_secret + self._cursor_ttl = timedelta(seconds=cursor_ttl_seconds) + + async def create_source( + self, + scope_id: str, + source_type: str, + content: JsonValue, + /, + ) -> SourceRecord: + self._require_content_source(source_type) + source = ContentSource( + name=self._id_factory("source"), + materialization=SourceMaterialization.CAPTURED, + content=_canonical_source_text(content), + wire_content=_JSON_VALUE.validate_python(content, strict=True), + wire_content_present=True, + ) + return await self._store_source(scope_id, source_type, source) + + async def capture_source( + self, + scope_id: str, + source_type: str, + source_id: str, + content: JsonValue, + metadata: Mapping[str, JsonValue], + /, + ) -> SourceRecord: + """Preserve the caller-stable identity used by the existing capture API.""" + + self._require_content_source(source_type) + try: + capture = ContentCapture.model_validate( + {"source_id": source_id, "content": content, "metadata": dict(metadata)}, + strict=True, + ) + except ValidationError as error: + raise InvalidBaseAccessRequestError("content", "does not match the Source adapter") from error + return await self._store_source(scope_id, source_type, await CONTENT_SOURCE_ADAPTER.resolve(capture)) + + async def _store_source( + self, + scope_id: str, + source_type: str, + source: ContentSource, + ) -> SourceRecord: + try: + async with self._database.transaction() as connection: + stored = await self._sources.add(connection, scope_id, source) + except StoredPayloadConflictError as error: + raise BaseValueConflictError("source", (scope_id, source_type, source.name)) from error + return _source_record(scope_id, stored) + + async def get_source(self, scope_id: str, source_type: str, source_id: str, /) -> SourceRecord: + self._require_content_source(source_type) + ref = SourceRef(source_type=source_type, source_id=source_id) + async with self._database.transaction() as connection: + return _source_record(scope_id, await self._get_source(connection, scope_id, ref)) + + async def create_artifact( + self, + scope_id: str, + family: str, + write: ArtifactWrite, + /, + ) -> ArtifactCreated: + writer = self._family_writers.get(family) + command = writer.validate_create(write.content) + artifact_id = writer.artifact_id_for_create(self._id_factory(family)) + source_id = self._id_factory("source") + canonical_content = cast( + dict[str, JsonValue], + command.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + source = ContentSource( + name=source_id, + materialization=SourceMaterialization.CAPTURED, + content=_canonical_source_text(canonical_content), + wire_content=canonical_content, + wire_content_present=True, + internal=ContentSourceInternal( + role="lineage_only", + operation="artifact_create", + target=ContentSourceTarget( + scope_id=scope_id, + family=cast(Any, family), + artifact_id=artifact_id, + revision=1, + ), + ), + ) + try: + async with self._database.transaction() as connection: + stored = await self._sources.add(connection, scope_id, source) + artifact = await writer.create(connection, scope_id, artifact_id, command, stored.ref) + except (StoredPayloadConflictError, RevisionConflictError) as error: + raise BaseValueConflictError("artifact", (scope_id, family, artifact_id)) from error + return _artifact_created(scope_id, artifact) + + async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> ArtifactRecord: + self._require_family(family) + async with self._database.transaction() as connection: + try: + artifact = await self._artifacts.latest(connection, scope_id, family, artifact_id) + except RepositoryNotFoundError: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) from None + return _artifact_record(scope_id, artifact) + + async def get_artifact_revision( + self, + scope_id: str, + family: str, + artifact_id: str, + revision: int, + /, + ) -> ArtifactRecord: + self._require_family(family) + async with self._database.transaction() as connection: + try: + artifact = await self._artifacts.get( + connection, + scope_id, + ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), + ) + except RepositoryNotFoundError: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id, revision)) from None + return _artifact_record(scope_id, artifact) + + async def query_artifacts( + self, + scope_id: str, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> ArtifactRecordPage: + self._require_family(family) + _require_limit(limit) + expected_cursor = { + "version": 1, + "endpoint": "list_artifacts", + "scope_id": scope_id, + "family": family, + "order": "artifact_id:asc", + } + after = self._cursor_after_text(cursor, expected_cursor) + async with self._database.transaction() as connection: + statement = ( + select( + ARTIFACT_HEADS_TABLE.c.artifact_id, + ARTIFACT_HEADS_TABLE.c.revision, + ) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id > after, + ) + .order_by(ARTIFACT_HEADS_TABLE.c.artifact_id) + .limit(limit + 1) + ) + rows = (await connection.execute(statement)).all() + selected_rows = rows[:limit] + artifacts = await self._artifacts.get_many( + connection, + scope_id, + tuple( + ArtifactRef(family=family, artifact_id=str(row.artifact_id), revision=int(row.revision)) + for row in selected_rows + ), + ) + items = tuple(_artifact_collection_item(scope_id, artifact) for artifact in artifacts) + + next_cursor = None + if len(rows) > limit and selected_rows: + next_cursor = self._encode_cursor(expected_cursor, str(selected_rows[-1].artifact_id)) + return ArtifactRecordPage( + items=items, + next_cursor=next_cursor, + ) + + async def replace_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + expected_etag: str, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: + writer = self._family_writers.get(family) + command = writer.validate_replace(write.content) + async with self._database.transaction() as connection: + try: + current = await self._artifacts.latest(connection, scope_id, family, artifact_id) + except RepositoryNotFoundError: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) from None + current_etag = _artifact_etag(current.revision) + if expected_etag != current_etag: + raise ArtifactRevisionPreconditionError(expected_etag, current_etag) + next_revision = current.revision + 1 + canonical_content = cast( + dict[str, JsonValue], + command.model_dump(mode="json", by_alias=True, exclude_none=True), + ) + source = ContentSource( + name=self._id_factory("source"), + materialization=SourceMaterialization.CAPTURED, + content=_canonical_source_text(canonical_content), + wire_content=canonical_content, + wire_content_present=True, + internal=ContentSourceInternal( + role="lineage_only", + operation="artifact_replace", + target=ContentSourceTarget( + scope_id=scope_id, + family=cast(Any, family), + artifact_id=artifact_id, + revision=next_revision, + ), + ), + ) + try: + stored = await self._sources.add(connection, scope_id, source) + revised = await writer.replace(connection, scope_id, current, command, stored.ref) + except StoredPayloadConflictError as error: + raise BaseValueConflictError("source", (scope_id, CONTENT_SOURCE_NAME, source.name)) from error + except RevisionConflictError: + latest = await self._artifacts.latest(connection, scope_id, family, artifact_id) + raise ArtifactRevisionPreconditionError(expected_etag, _artifact_etag(latest.revision)) from None + return _artifact_record(scope_id, revised) + + async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: + _require_limit(limit) + expected_cursor = {"version": 1, "endpoint": "list_scopes", "order": "scope_id:asc"} + after = self._cursor_after_text(cursor, expected_cursor) + async with self._database.transaction() as connection: + source_scopes = (await connection.execute(select(SOURCE_JOURNAL_HEADS_TABLE.c.scope_id))).scalars() + artifact_scopes = (await connection.execute(select(ARTIFACTS_TABLE.c.scope_id).distinct())).scalars() + scope_ids = sorted({str(value) for value in (*source_scopes, *artifact_scopes) if str(value) > after}) + selected = scope_ids[:limit] + summaries = tuple([await _scope_summary(connection, scope_id) for scope_id in selected]) + next_cursor = None + if len(scope_ids) > limit and selected: + next_cursor = self._encode_cursor(expected_cursor, selected[-1]) + return ScopeSummaryPage(items=summaries, next_cursor=next_cursor) + + def _encode_cursor(self, expected: Mapping[str, JsonValue], after: int | str) -> str: + expires_at = _aware_datetime(self._clock()) + self._cursor_ttl + return _encode_cursor(expected, after, secret=self._cursor_secret, expires_at=expires_at) + + def _cursor_after_text(self, cursor: str | None, expected: Mapping[str, JsonValue]) -> str: + return _cursor_after_text(cursor, expected, secret=self._cursor_secret, now=_aware_datetime(self._clock())) + + def _require_content_source(self, source_type: str) -> None: + if source_type != CONTENT_SOURCE_NAME: + raise InvalidBaseAccessRequestError("source_type", "must be content") + + def _require_family(self, family: str) -> None: + if family not in self._artifacts.families: + raise InvalidBaseAccessRequestError("family", "must be memory, experience, skill, or handoff") + + async def _get_source( + self, + connection: AsyncConnection, + scope_id: str, + ref: SourceRef, + ) -> StoredSource: + try: + return await self._sources.get(connection, scope_id, ref) + except RepositoryNotFoundError as error: + raise BaseValueNotFoundError("source", (scope_id, ref)) from error + + +async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSummary: + source_types = ( + await connection.execute( + select(SOURCES_TABLE.c.source_type) + .where(SOURCES_TABLE.c.scope_id == scope_id) + .distinct() + .order_by(SOURCES_TABLE.c.source_type) + ) + ).scalars() + artifact_families = ( + await connection.execute( + select(ARTIFACT_HEADS_TABLE.c.family) + .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id) + .distinct() + .order_by(ARTIFACT_HEADS_TABLE.c.family) + ) + ).scalars() + source_count = await connection.scalar( + select(func.count()).select_from(SOURCES_TABLE).where(SOURCES_TABLE.c.scope_id == scope_id) + ) + artifact_count = await connection.scalar( + select(func.count()).select_from(ARTIFACT_HEADS_TABLE).where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id) + ) + return ScopeSummary( + scope_id=scope_id, + source_types=tuple(str(value) for value in source_types), + artifact_families=tuple(str(value) for value in artifact_families), + source_count=int(source_count or 0), + artifact_count=int(artifact_count or 0), + ) + + +def _source_record( + scope_id: str, + stored: StoredSource, +) -> SourceRecord: + if not isinstance(stored.value, ContentSource): + raise BaseValueNotFoundError("source", (scope_id, stored.ref)) + return SourceRecord( + scope_id=scope_id, + source_type=cast(Any, stored.ref.source_type), + source_id=stored.ref.source_id, + content=_source_content(stored.value), + position=stored.journal_position, + content_digest=_content_digest(_source_content(stored.value)), + ) + + +def _source_content(source: ContentSource) -> JsonValue: + if source.wire_content_present or source.wire_content is not None: + return source.wire_content + return source.content + + +def _artifact_record( + scope_id: str, + artifact: Artifact[Any], +) -> ArtifactRecord: + content = cast(dict[str, JsonValue], artifact.content.model_dump(mode="json", by_alias=True)) + return ArtifactRecord( + scope_id=scope_id, + family=cast(Any, artifact.family), + artifact_id=artifact.artifact_id, + revision=artifact.revision, + content=content, + sources=artifact.lineage.sources, + artifacts=artifact.lineage.artifacts, + content_digest=_content_digest(content), + ) + + +def _artifact_created(scope_id: str, artifact: Artifact[Any]) -> ArtifactCreated: + return ArtifactCreated( + scope_id=scope_id, + family=cast(Any, artifact.family), + artifact_id=artifact.artifact_id, + revision=artifact.revision, + sources=artifact.lineage.sources, + artifacts=artifact.lineage.artifacts, + ) + + +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)) + return ArtifactCollectionItem( + scope_id=scope_id, + family=cast(Any, artifact.family), + artifact_id=artifact.artifact_id, + revision=artifact.revision, + sources=artifact.lineage.sources, + artifacts=artifact.lineage.artifacts, + content_digest=_content_digest(content), + ) + + +def _artifact_etag(revision: int) -> str: + return f'"revision:{revision}"' + + +def _content_digest(value: JsonValue) -> str: + validated = _JSON_VALUE.validate_python(value, strict=True) + return f"sha256:{sha256(rfc8785.dumps(cast(Any, validated))).hexdigest()}" + + +def _canonical_source_text(value: JsonValue) -> str: + validated = _JSON_VALUE.validate_python(value, strict=True) + if isinstance(validated, str): + return validated + return rfc8785.dumps(cast(Any, validated)).decode("utf-8") + + +def _require_limit(limit: int) -> None: + if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100: + raise InvalidBaseAccessRequestError("limit", "must be between 1 and 100") + + +def _encode_cursor( + expected: Mapping[str, JsonValue], + after: int | str, + *, + secret: bytes, + expires_at: datetime, +) -> str: + payload = {**expected, "after": after, "expires_at": int(expires_at.timestamp())} + encoded = rfc8785.dumps(cast(Any, payload)) + signature = hmac.digest(secret, encoded, "sha256") + return f"{_encode_token_part(encoded)}.{_encode_token_part(signature)}" + + +def _cursor_payload( + cursor: str | None, + expected: Mapping[str, JsonValue], + *, + secret: bytes, + now: datetime, +) -> dict[str, JsonValue] | None: + if cursor is None: + return None + try: + encoded_payload, encoded_signature = cursor.split(".") + decoded = _decode_token_part(encoded_payload) + signature = _decode_token_part(encoded_signature) + payload = _JSON_OBJECT.validate_json(decoded, strict=True) + except (binascii.Error, UnicodeEncodeError, ValueError, ValidationError) as error: + raise InvalidCursorError from error + if not hmac.compare_digest(signature, hmac.digest(secret, decoded, "sha256")): + raise InvalidCursorError + expires_at = payload.get("expires_at") + if not isinstance(expires_at, int) or isinstance(expires_at, bool): + raise InvalidCursorError + if any(payload.get(key) != value for key, value in expected.items()): + raise InvalidCursorError + if set(payload) != {*expected, "after", "expires_at"}: + raise InvalidCursorError + if int(now.timestamp()) >= expires_at: + raise CursorExpiredError + return payload + + +def _cursor_after_text( + cursor: str | None, + expected: Mapping[str, JsonValue], + *, + secret: bytes, + now: datetime, +) -> str: + payload = _cursor_payload(cursor, expected, secret=secret, now=now) + if payload is None: + return "" + after = payload["after"] + if not isinstance(after, str): + raise InvalidCursorError + return after + + +def _encode_token_part(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _decode_token_part(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.b64decode(f"{value}{padding}".encode("ascii"), altchars=b"-_", validate=True) + + +def _aware_datetime(value: object) -> datetime: + if not isinstance(value, datetime): + raise InvalidBaseAccessRequestError("timestamp", "must be a datetime") + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _resource_id(kind: str) -> str: + prefix = "src" if kind == "source" else "art" + return f"{prefix}_{uuid4().hex}" + + +def _utc_now() -> datetime: + return datetime.now(UTC) diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index 4ebb50c1e..7693d315f 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -16,13 +16,13 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from contextlib import suppress from typing import Any, Literal, cast import rfc8785 from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter -from sqlalchemy import func, insert, select, update +from sqlalchemy import func, insert, select, tuple_, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection @@ -139,6 +139,46 @@ async def get( raise RepositoryNotFoundError("source", (scope_id, ref)) return self._decode_row(row) + async def get_many( + self, + connection: AsyncConnection, + scope_id: str, + refs: Sequence[SourceRef], + /, + ) -> tuple[StoredSource, ...]: + """Load exact Sources in first-requested order using one query.""" + + _require_identity("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) + ordered: list[SourceRef] = [] + identities: set[tuple[str, str]] = set() + for ref in refs: + identity = (ref.source_type, ref.source_id) + if identity not in identities: + ordered.append(ref) + identities.add(identity) + if not ordered: + return () + + rows = ( + ( + await connection.execute( + select(SOURCES_TABLE).where( + SOURCES_TABLE.c.scope_id == scope_id, + tuple_(SOURCES_TABLE.c.source_type, SOURCES_TABLE.c.source_id).in_(tuple(identities)), + ) + ) + ) + .mappings() + .all() + ) + decoded = { + (stored.ref.source_type, stored.ref.source_id): stored for stored in (self._decode_row(row) for row in rows) + } + for ref in ordered: + if (ref.source_type, ref.source_id) not in decoded: + raise RepositoryNotFoundError("source", (scope_id, ref)) + return tuple(decoded[(ref.source_type, ref.source_id)] for ref in ordered) + async def list( self, connection: AsyncConnection, @@ -168,6 +208,39 @@ async def list( rows = (await connection.execute(statement)).mappings() return tuple(self._decode_row(row) for row in rows) + async def list_window( + self, + connection: AsyncConnection, + scope_id: str, + /, + *, + after: int, + through: int, + ) -> tuple[StoredSource, ...]: + """Return the exact journal interval ``(after, through]``.""" + + _require_identity("scope_id", scope_id, MAX_SCOPE_ID_LENGTH) + if after < 0: + raise InvalidRepositoryArgumentError("after", "must be non-negative") + if through < after: + raise InvalidRepositoryArgumentError("through", "must not precede after") + rows = ( + ( + await connection.execute( + select(SOURCES_TABLE) + .where( + SOURCES_TABLE.c.scope_id == scope_id, + SOURCES_TABLE.c.journal_position > after, + SOURCES_TABLE.c.journal_position <= through, + ) + .order_by(SOURCES_TABLE.c.journal_position) + ) + ) + .mappings() + .all() + ) + return tuple(self._decode_row(row) for row in rows) + async def journal_position(self, connection: AsyncConnection, scope_id: str, /) -> int: """Return the current high watermark for one scope.""" diff --git a/src/powercontext/builtin/persistence/statistics.py b/src/powercontext/builtin/persistence/statistics.py index 1e6d5aece..a1e97198d 100644 --- a/src/powercontext/builtin/persistence/statistics.py +++ b/src/powercontext/builtin/persistence/statistics.py @@ -83,7 +83,9 @@ async def inventory(self, connection: AsyncConnection, scope_id: str, /) -> Stor artifact_rows = ( await connection.execute( select(ARTIFACT_HEADS_TABLE.c.family, func.count()) - .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope, + ) .group_by(ARTIFACT_HEADS_TABLE.c.family) .order_by(ARTIFACT_HEADS_TABLE.c.family) ) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 2b01ce9d8..a8edf4522 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -226,7 +226,6 @@ def _entry_text_type(): ), ) - ARTIFACT_LINEAGE_SOURCES_TABLE = Table( "pc_artifact_lineage_sources", SHARED_METADATA, diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py new file mode 100644 index 000000000..7a5f8f66d --- /dev/null +++ b/src/powercontext/builtin/records.py @@ -0,0 +1,248 @@ +# 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. + +"""Transport-neutral values for base Source and Artifact access.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, JsonValue + +from powercontext.artifacts import ArtifactRef +from powercontext.sources import SourceRef + +BaseArtifactFamily = Literal["memory", "experience", "skill", "handoff"] + + +class _RecordModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class SourceRecord(_RecordModel): + """One durable Source and its journal position.""" + + scope_id: str + source_type: Literal["content"] + source_id: str + content: JsonValue + position: int + content_digest: str + + +class ArtifactWrite(_RecordModel): + """Complete family-specific content for one Artifact write.""" + + content: dict[str, JsonValue] + + +class ArtifactCreated(_RecordModel): + """Identity and server-owned lineage returned by Artifact Create.""" + + scope_id: str + family: BaseArtifactFamily + artifact_id: str + revision: int + sources: tuple[SourceRef, ...] + artifacts: tuple[ArtifactRef, ...] + + +class ArtifactRecord(_RecordModel): + """One immutable Artifact revision with direct lineage.""" + + scope_id: str + family: BaseArtifactFamily + artifact_id: str + revision: int + content: dict[str, JsonValue] + sources: tuple[SourceRef, ...] + artifacts: tuple[ArtifactRef, ...] + content_digest: str + + +class ArtifactCollectionItem(_RecordModel): + """One active Artifact head without content or lineage.""" + + scope_id: str + family: BaseArtifactFamily + artifact_id: str + revision: int + sources: tuple[SourceRef, ...] + artifacts: tuple[ArtifactRef, ...] + content_digest: str + + +class ArtifactRecordPage(_RecordModel): + """One stable page of current Artifact heads.""" + + items: tuple[ArtifactCollectionItem, ...] + next_cursor: str | None + + +class ScopeSummary(_RecordModel): + """Scope identity plus Source and Artifact activity summaries.""" + + scope_id: str + title: str | None = None + summary: str | None = None + parent_scope_id: str | None = None + version: int | None = None + source_types: tuple[str, ...] = () + artifact_families: tuple[str, ...] = () + source_count: int = 0 + artifact_count: int = 0 + + +class ScopeSummaryPage(_RecordModel): + """One stable page of observable Scopes.""" + + items: tuple[ScopeSummary, ...] + next_cursor: str | None + + +class BaseAccessError(Exception): + """Base error for Source and Artifact access failures.""" + + +class BaseValueNotFoundError(BaseAccessError): + """Report an absent or non-visible Source or Artifact.""" + + def __init__(self, kind: Literal["source", "artifact"], identity: object) -> None: + self.kind = kind + self.identity = identity + super().__init__(f"{kind} was not found") + + +class BaseValueConflictError(BaseAccessError): + """Report an identity that already names different durable state.""" + + def __init__(self, kind: Literal["source", "artifact"], identity: object) -> None: + self.kind = kind + self.identity = identity + super().__init__(f"{kind} identity conflicts with durable state") + + +class ArtifactAlreadyExistsError(BaseAccessError): + """Report a singleton Artifact that must be updated through Replace.""" + + def __init__(self, family: str, artifact_id: str, *, use_replace: bool = False) -> None: + self.family = family + self.artifact_id = artifact_id + self.use_replace = use_replace + super().__init__(f"{family} Artifact {artifact_id} already exists") + + +class BaseOperationNotSupportedError(BaseAccessError): + """Report an operation disabled for one Source type or Artifact family.""" + + def __init__(self, kind: Literal["source_type", "artifact_family"], name: str, operation: str) -> None: + self.kind = kind + self.name = name + self.operation = operation + super().__init__(f"{operation} is not supported for {kind} {name}") + + +class InvalidBaseAccessRequestError(BaseAccessError, ValueError): + """Report a request combination that cannot be interpreted safely.""" + + def __init__(self, field: str, reason: str) -> None: + self.field = field + self.reason = reason + super().__init__(f"{field} {reason}") + + +class InvalidCursorError(InvalidBaseAccessRequestError): + """Report a malformed, tampered, or context-mismatched pagination cursor.""" + + def __init__(self, reason: str = "is invalid") -> None: + super().__init__("cursor", reason) + + +class CursorExpiredError(BaseAccessError): + """Report a valid pagination cursor whose bounded lifetime elapsed.""" + + +class ArtifactRevisionPreconditionError(BaseAccessError): + """Report an Artifact ETag that no longer identifies the current head.""" + + def __init__(self, provided_etag: str, current_etag: str) -> None: + self.provided_etag = provided_etag + self.current_etag = current_etag + super().__init__("Artifact revision precondition failed") + + +class RecordService(Protocol): + """Persistence-backed base Source, Artifact, and Scope operations.""" + + async def create_source( + self, + scope_id: str, + source_type: str, + content: JsonValue, + /, + ) -> SourceRecord: ... + + async def capture_source( + self, + scope_id: str, + source_type: str, + source_id: str, + content: JsonValue, + metadata: Mapping[str, JsonValue], + /, + ) -> SourceRecord: ... + + async def get_source(self, scope_id: str, source_type: str, source_id: str, /) -> SourceRecord: ... + + async def create_artifact( + self, + scope_id: str, + family: str, + write: ArtifactWrite, + /, + ) -> ArtifactCreated: ... + + async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> ArtifactRecord: ... + + async def get_artifact_revision( + self, + scope_id: str, + family: str, + artifact_id: str, + revision: int, + /, + ) -> ArtifactRecord: ... + + async def query_artifacts( + self, + scope_id: str, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> ArtifactRecordPage: ... + + async def replace_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + expected_etag: str, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: ... + + async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: ... diff --git a/src/powercontext/builtin/review/generation.py b/src/powercontext/builtin/review/generation.py index 3d74ebda0..43cd3d0fa 100644 --- a/src/powercontext/builtin/review/generation.py +++ b/src/powercontext/builtin/review/generation.py @@ -33,7 +33,7 @@ from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError -from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.builtin.review.errors import InvalidCandidateError from powercontext.builtin.review.models import ArtifactCandidate from powercontext.builtin.review.service import ReviewService @@ -78,7 +78,7 @@ def __init__( *, database: AsyncDatabase, scope_id: str, - sources: SourceRepository, + sources: GenerationSourceAccess, artifacts: ArtifactRepository, review: ReviewService, experience_generator: ExperienceGenerator | None, @@ -150,9 +150,8 @@ async def _evidence( evidence: list[GenerationEvidence] = [] try: async with self._database.transaction() as connection: - for ref in sources: - row = await self._sources.get(connection, self._scope_id, ref) - evidence.append(_source_evidence(ref, row.value)) + source_rows = await self._sources.require_for_generation(connection, self._scope_id, sources) + evidence.extend(_source_evidence(row.ref, row.value) for row in source_rows) for ref in artifacts: artifact = await self._artifacts.get(connection, self._scope_id, ref) evidence.append(_artifact_evidence(ref, artifact)) diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 230a51a13..12e5397f8 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -29,8 +29,8 @@ from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.experience_index import ExperienceIndex +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.builtin.persistence.skill_packages import SkillPackageRepository -from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.review.errors import ArtifactTargetConflictError, InvalidCandidateError from powercontext.builtin.review.models import ( MAX_CANDIDATE_EVIDENCE, @@ -61,7 +61,7 @@ def __init__( artifacts: ArtifactRepository, experience_index: ExperienceIndex, skill_packages: SkillPackageRepository, - sources: SourceRepository, + sources: GenerationSourceAccess, id_factory: IdFactory, connection: AsyncConnection | None = None, ) -> None: @@ -285,6 +285,7 @@ async def approve( ) ) _validate_approval_lineage(candidate) + await self._validate_evidence(connection, candidate.sources, candidate.artifacts) if isinstance(candidate.proposal, SkillContent) and candidate.proposal.package is not None: await self._canonical_skill_proposal(connection, candidate.proposal) draft = _candidate_draft(candidate) @@ -352,8 +353,7 @@ async def _validate_evidence( if len(sources) + len(artifacts) > MAX_CANDIDATE_EVIDENCE: raise InvalidCandidateError("evidence", f"must not exceed {MAX_CANDIDATE_EVIDENCE} exact references") try: - for source in sources: - await self._sources.get(connection, self._scope_id, source) + await self._sources.require_for_generation(connection, self._scope_id, sources) for artifact in artifacts: await self._artifacts.get(connection, self._scope_id, artifact) except RepositoryNotFoundError as error: diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 4438b31f8..7c28d1349 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -25,7 +25,7 @@ from time import perf_counter from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, JsonValue, ValidationError from powercontext._logging import log_safely from powercontext.artifacts import ArtifactRef @@ -98,6 +98,16 @@ ) from powercontext.builtin.persistence.skill_publications import SkillPublication from powercontext.builtin.publication import ArtifactPublicationApplication +from powercontext.builtin.records import ( + ArtifactCreated, + ArtifactRecord, + ArtifactRecordPage, + ArtifactWrite, + BaseValueConflictError, + RecordService, + ScopeSummaryPage, + SourceRecord, +) from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._scope_cache import ( @@ -171,6 +181,7 @@ from powercontext.builtin.runtime.statistics import RelationalScopedStatistics from powercontext.builtin.scope import ScopeApplication, ScopeDescriptor, ScopeSelection from powercontext.builtin.sources import ( + CONTENT_SOURCE_NAME, ContentCapture, ContentSource, ExternalSkillImportMode, @@ -208,7 +219,7 @@ project_work_continuity, ) from powercontext.context import PowerContext -from powercontext.errors import ArtifactNotFoundError, RevisionConflictError +from powercontext.errors import ArtifactNotFoundError, RevisionConflictError, SourceConflictError from powercontext.sources import ConnectorBinding, SourceDefinitionManifest, SourceRef if TYPE_CHECKING: @@ -265,6 +276,7 @@ def __init__(self, code: str) -> None: "external-skill-registry": "External Skill Registry is not configured", "remote-ingestion": "Remote Source ingestion is not configured", "review": "Candidate Review services are not configured", + "records": "Base Source and Artifact access is not configured", "remote-skill-distribution": "Remote Skill distribution services are not configured", "scheduler": "Built-in Runtime scheduler is already started", "scope": "Scope services are not configured", @@ -286,6 +298,22 @@ def __init__(self, runtime: BuiltinRuntime, scope_id: str) -> None: self.scope_id = validate_scope_id(scope_id) async def capture(self, value: CaptureSource, /) -> SourceReceipt: + if self._runtime._record_service is not None: + try: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + record = await self._runtime._records().capture_source( + self.scope_id, + CONTENT_SOURCE_NAME, + value.source_id, + value.content, + value.metadata, + ) + except BaseValueConflictError as error: + raise SourceConflictError("identity", error.identity) from None + return SourceReceipt( + source_ref=SourceRef(source_type=record.source_type, source_id=record.source_id), + sequence=record.position, + ) async with self._runtime._context(self.scope_id) as context: source, sequence = await context.sources.capture( ContentCapture( @@ -307,6 +335,106 @@ def for_scope(self, scope_id: str, /) -> ScopedSourceApplication: return ScopedSourceApplication(self._runtime, scope_id) +class ScopedRecordApplication: + """Run base Source and Artifact operations in one Scope.""" + + def __init__(self, runtime: BuiltinRuntime, scope_id: str) -> None: + self._runtime = runtime + self.scope_id = validate_scope_id(scope_id) + + async def create_source( + self, + source_type: str, + content: JsonValue, + /, + ) -> SourceRecord: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await self._runtime._records().create_source( + self.scope_id, + source_type, + content, + ) + + async def get_source(self, source_type: str, source_id: str, /) -> SourceRecord: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().get_source(self.scope_id, source_type, source_id) + + async def create_artifact( + self, + family: str, + write: ArtifactWrite, + /, + ) -> ArtifactCreated: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await self._runtime._records().create_artifact(self.scope_id, family, write) + + async def get_artifact(self, family: str, artifact_id: str, /) -> ArtifactRecord: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().get_artifact(self.scope_id, family, artifact_id) + + async def get_artifact_revision( + self, + family: str, + artifact_id: str, + revision: int, + /, + ) -> ArtifactRecord: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().get_artifact_revision( + self.scope_id, + family, + artifact_id, + revision, + ) + + async def query_artifacts( + self, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> ArtifactRecordPage: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().query_artifacts( + self.scope_id, + family, + limit=limit, + cursor=cursor, + ) + + async def replace_artifact( + self, + family: str, + artifact_id: str, + expected_etag: str, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await self._runtime._records().replace_artifact( + self.scope_id, + family, + artifact_id, + expected_etag, + write, + ) + + +class RecordApplication: + """Select scoped base access and list observable Scopes.""" + + def __init__(self, runtime: BuiltinRuntime) -> None: + self._runtime = runtime + + def for_scope(self, scope_id: str, /) -> ScopedRecordApplication: + return ScopedRecordApplication(self._runtime, scope_id) + + async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: + async with self._runtime._operation(): + return await self._runtime._records().list_scopes(limit=limit, cursor=cursor) + + class RemoteIngestionApplication: """Expose worker-owned Definition and observation operations.""" @@ -1694,6 +1822,7 @@ def __init__( skill_publication_service: SkillPublicationServiceFactory | None = None, remote_skill_distribution: RemoteSkillDistributionService | None = None, statistics_service: StatisticsServiceFactory | None = None, + record_service: RecordService | None = None, recall_token_estimator: RecallTokenEstimator | None = None, publication_application: ArtifactPublicationApplication | None = None, scope_application: ScopeApplication | None = None, @@ -1726,6 +1855,7 @@ def __init__( self._skill_publication_service = skill_publication_service self._remote_skill_distribution = remote_skill_distribution self._statistics_service = statistics_service + self._record_service = record_service self._recall_token_estimator = recall_token_estimator self.publications = publication_application self.scopes = scope_application @@ -1755,6 +1885,7 @@ def __init__( self.handoff = HandoffApplication(self) self.work = WorkApplication(self) self.memory = MemoryApplication(self) + self.records = RecordApplication(self) self.review = ReviewApplication(self) self.skill = SkillApplication(self) self.remote_skills = RemoteSkillApplication(self) @@ -1991,6 +2122,11 @@ def _review(self, scope_id: str) -> ReviewService: raise _RuntimeStateError("review") return self._review_service(validate_scope_id(scope_id)) + def _records(self) -> RecordService: + if self._record_service is None: + raise _RuntimeStateError("records") + return self._record_service + def _generation(self, scope_id: str) -> ReviewedGenerationService: if self._generation_service is None: raise _RuntimeStateError("review") diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 4242be3a8..4425853e6 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -177,6 +177,7 @@ async def open_builtin_runtime( scope_cache_observer: ScopeCacheObserver | None = None, tracing: RuntimeTracing | None = None, source_registry: SourceDefinitionRegistry | None = None, + cursor_secret: bytes | None = None, ) -> AsyncIterator[BuiltinRuntime]: """Open the selected database, inference adapters, and built-in runtime.""" @@ -253,6 +254,7 @@ async def open_builtin_runtime( token_estimator=token_estimator, memory_reranker=configured_reranker, source_registry=configured_source_registry, + cursor_secret=cursor_secret, ) ) readiness_probes: dict[str, ReadinessProbeDefinition] = { @@ -306,6 +308,7 @@ async def open_builtin_runtime( skill_publication_service=contexts.skill_publications, remote_skill_distribution=contexts.remote_skill_distribution(), statistics_service=contexts.statistics, + record_service=contexts.records, recall_token_estimator=contexts.estimate_recall_tokens, publication_application=contexts.publications, scope_application=contexts.scopes, @@ -348,6 +351,7 @@ async def open_builtin_contexts( token_estimator: TokenEstimator | None = None, memory_reranker: MemoryReranker | None = None, source_registry: SourceDefinitionRegistry | None = None, + cursor_secret: bytes | None = None, ) -> AsyncIterator[RelationalContexts]: """Open the selected database and expose scope-bound PowerContext providers.""" @@ -383,6 +387,7 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, source_registry=source_registry, + cursor_secret=cursor_secret, ) await contexts.scopes.bootstrap_default() yield contexts @@ -419,6 +424,7 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, source_registry=source_registry, + cursor_secret=cursor_secret, ) await contexts.scopes.bootstrap_default() yield contexts diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index dc997c73c..04ac731a0 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -89,12 +89,21 @@ from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError from powercontext.builtin.persistence.experience_index import ExperienceIndex, NoExperienceIndex from powercontext.builtin.persistence.external_skills import ExternalSkillRepository +from powercontext.builtin.persistence.family_management import ( + ExperienceManagementWriter, + FamilyManagementWriterRegistry, + HandoffManagementWriter, + MemoryManagementWriter, + SkillManagementWriter, +) +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.builtin.persistence.handoff import ( RelationalHandoffBackend, RelationalHandoffEvidenceResolver, ) from powercontext.builtin.persistence.memory import RelationalMemoryBackend from powercontext.builtin.persistence.memory_index import MemoryIndex, NoMemoryIndex +from powercontext.builtin.persistence.records import RelationalRecordService from powercontext.builtin.persistence.skill_packages import SkillPackageRepository from powercontext.builtin.persistence.skill_publications import SkillPublicationRepository from powercontext.builtin.persistence.source_definitions import SourceDefinitionManifestRepository @@ -218,6 +227,9 @@ class _ScopedServices: token_estimator: TokenEstimator | None source_registry: SourceDefinitionRegistry + def generation_sources(self) -> GenerationSourceAccess: + return GenerationSourceAccess(self.repositories.sources) + def sources( self, connection: AsyncConnection | None = None, @@ -267,7 +279,7 @@ def review(self, connection: AsyncConnection | None = None) -> ReviewService: artifacts=self.repositories.artifacts, experience_index=self.experience_index, skill_packages=self.repositories.skill_packages, - sources=self.repositories.sources, + sources=self.generation_sources(), id_factory=self.id_factory, connection=connection, ) @@ -276,7 +288,7 @@ def generation(self) -> ReviewedGenerationService: return ReviewedGenerationService( database=self.database, scope_id=self.scope_id, - sources=self.repositories.sources, + sources=self.generation_sources(), artifacts=self.repositories.artifacts, review=self.review(), experience_generator=self.experience_generator, @@ -295,7 +307,7 @@ def evidence_resolver(scope_id: str) -> RelationalHandoffEvidenceResolver: return RelationalHandoffEvidenceResolver( database=services.database, scope_id=scope_id, - sources=services.repositories.sources, + sources=services.generation_sources(), artifacts=services.repositories.artifacts, memory=services.memory(catalog), ) @@ -311,7 +323,7 @@ def evidence_resolver(scope_id: str) -> RelationalHandoffEvidenceResolver: evidence_resolver=RelationalHandoffEvidenceResolver( database=self.database, scope_id=self.scope_id, - sources=self.repositories.sources, + sources=self.generation_sources(), artifacts=self.repositories.artifacts, memory=memory, ), @@ -378,15 +390,21 @@ def __init__( handoff_artifact_id: str = "handoff", memory_artifact_id: str = "memory", source_registry: SourceDefinitionRegistry | None = None, + cursor_secret: bytes | None = None, ) -> None: self.database = database self.scopes = ScopeApplication(database) self.source_registry = source_registry or BUILTIN_SOURCE_REGISTRY self.index = NoMemoryIndex() if index is None else index self.experience_index = NoExperienceIndex() if experience_index is None else experience_index + source_repository = SourceRepository(self.source_registry) + artifact_repository = ArtifactRepository( + (Handoff, Memory, Experience, Skill), + sources=source_repository, + ) self.repositories = _Repositories( - sources=SourceRepository(self.source_registry), - artifacts=ArtifactRepository((Handoff, Memory, Experience, Skill)), + sources=source_repository, + artifacts=artifact_repository, governance=ArtifactGovernanceRepository(), candidates=CandidateRepository({ Experience.family: ExperienceContent, @@ -401,6 +419,39 @@ def __init__( skill_publications=SkillPublicationRepository(), statistics=StatisticsRepository(), ) + self._id_factory = _scoped_id_factory(memory_artifact_id, id_factory) + family_writers = FamilyManagementWriterRegistry(( + MemoryManagementWriter( + database=database, + artifacts=self.repositories.artifacts, + index=self.index, + embedding_model=embedding_model, + id_factory=self._id_factory, + ), + ExperienceManagementWriter(self.repositories.artifacts, self.experience_index), + SkillManagementWriter( + self.repositories.artifacts, + self.experience_index, + self.repositories.skill_packages, + ), + HandoffManagementWriter( + database=database, + artifacts=self.repositories.artifacts, + sources=self.repositories.sources, + memory_index=self.index, + id_factory=self._id_factory, + memory_artifact_id=memory_artifact_id, + handoff_artifact_id=handoff_artifact_id, + ), + )) + self.records = RelationalRecordService( + database, + self.repositories.sources, + self.repositories.artifacts, + family_writers, + id_factory=id_factory, + cursor_secret=cursor_secret, + ) self.publications = ArtifactPublicationApplication( database, self.repositories.artifacts, @@ -423,7 +474,6 @@ def __init__( self._token_estimator = token_estimator self._memory_reranker = memory_reranker self._memory_rerank_candidate_limit = memory_rerank_candidate_limit - self._id_factory = _scoped_id_factory(memory_artifact_id, id_factory) self._handoff_artifact_id = handoff_artifact_id self._memory_artifact_id = memory_artifact_id self._contexts: dict[ @@ -1065,10 +1115,10 @@ async def activate_handoff(self, request: ActivateHandoff, /) -> HandoffActivati async with self._lock: async with self._services.database.transaction() as connection: try: - source = await self._services.repositories.sources.get( + (source,) = await self._services.generation_sources().require_for_generation( connection, self._services.scope_id, - request.boundary_source, + (request.boundary_source,), ) except RepositoryNotFoundError: raise HandoffEvidenceUnavailableError( @@ -1149,6 +1199,22 @@ async def flush(self, *, limit: int) -> MemoryFlushResult: ) action = transition.actions[0] + if not sources: + async with self._services.database.transaction() as connection: + await self._services.repositories.cursors.save( + connection, + self._services.scope_id, + SOURCE_WINDOW_TRIGGER_NAME, + transition.state, + expected_generation=None if state_row is None else state_row.generation, + ) + return MemoryFlushResult( + previous_cursor=action.after, + high_watermark=high_watermark, + current_cursor=action.through, + source_count=0, + memory_ref=None, + ) prepared = await self._prepare_memory(sources) async with self._services.database.transaction() as connection: _, source_catalog = self._services.sources(connection) @@ -1173,12 +1239,13 @@ async def _sources( connection: AsyncConnection, action: ProcessSourceWindow, ) -> tuple[Source, ...]: - rows = await self._services.repositories.sources.list( + rows = await self._services.generation_sources().list_window_for_generation( connection, self._services.scope_id, after=action.after, + through=action.through, ) - return tuple(row.value for row in rows if row.journal_position <= action.through) + return tuple(row.value for row in rows) async def _prepare_memory(self, sources: tuple[Source, ...]) -> MemoryWritePlan: _, source_catalog = self._services.sources() @@ -1233,9 +1300,25 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: pipeline = self._services.experience_pipeline if pipeline is None: raise RuntimeError("Experience incubation pipeline is not configured") # noqa: TRY003 + action = transition.actions[0] + if not rows: + async with self._services.database.transaction() as connection: + await self._services.repositories.cursors.save( + connection, + self._services.scope_id, + EXPERIENCE_INCUBATION_CURSOR_NAME, + transition.state, + expected_generation=None if state_row is None else state_row.generation, + ) + return ExperienceIncubationResult( + previous_cursor=action.after, + high_watermark=high_watermark, + current_cursor=action.through, + source_count=0, + candidate_count=0, + ) plans = await pipeline.incubate(tuple(row.value for row in rows)) _validate_experience_plans(plans, rows) - action = transition.actions[0] async with self._services.database.transaction() as connection: review = self._services.review(connection) for plan in plans: @@ -1266,11 +1349,11 @@ async def _sources( connection: AsyncConnection, action: ProcessSourceWindow, ) -> tuple[StoredSource, ...]: - return await self._services.repositories.sources.list( + return await self._services.generation_sources().list_window_for_generation( connection, self._services.scope_id, after=action.after, - limit=action.through - action.after, + through=action.through, ) diff --git a/src/powercontext/builtin/source_eligibility.py b/src/powercontext/builtin/source_eligibility.py new file mode 100644 index 000000000..1371634dc --- /dev/null +++ b/src/powercontext/builtin/source_eligibility.py @@ -0,0 +1,68 @@ +# 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 admission rules for Sources used as generation evidence.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from powercontext.builtin.sources import ContentSource +from powercontext.sources import Source, SourceRef + + +@dataclass(frozen=True) +class ArtifactLineageTarget: + """Exact Artifact revision being committed.""" + + scope_id: str + family: str + artifact_id: str + revision: int + + +class SourceNotEligibleError(ValueError): + """Report a Source whose server-owned purpose forbids the requested use.""" + + def __init__(self, source: SourceRef) -> None: + self.source = source + super().__init__("source_not_eligible") + + +def is_generation_eligible(source: Source, /) -> bool: + """Return whether a hydrated Source may enter a model or Candidate.""" + + return not isinstance(source, ContentSource) or source.internal is None + + +def require_source_eligible( + source_ref: SourceRef, + source: Source, + /, + *, + target: ArtifactLineageTarget | None = None, +) -> None: + """Allow ordinary evidence, or a lineage-only Source at its exact bound target.""" + + if not isinstance(source, ContentSource) or source.internal is None: + return + bound = source.internal.target + if target is not None and ( + bound.scope_id, + bound.family, + bound.artifact_id, + bound.revision, + ) == (target.scope_id, target.family, target.artifact_id, target.revision): + return + raise SourceNotEligibleError(source_ref) diff --git a/src/powercontext/builtin/sources/__init__.py b/src/powercontext/builtin/sources/__init__.py index 90c00d76f..1019276a9 100644 --- a/src/powercontext/builtin/sources/__init__.py +++ b/src/powercontext/builtin/sources/__init__.py @@ -21,6 +21,8 @@ ContentCapture, ContentSource, ContentSourceAdapter, + ContentSourceInternal, + ContentSourceTarget, ContentTextEvidenceProjection, ) from powercontext.builtin.sources.external_skill import ( @@ -84,6 +86,8 @@ "ContentCapture", "ContentSource", "ContentSourceAdapter", + "ContentSourceInternal", + "ContentSourceTarget", "ContentTextEvidenceProjection", "ExternalSkillImportMode", "ExternalSkillSnapshotCapture", diff --git a/src/powercontext/builtin/sources/content.py b/src/powercontext/builtin/sources/content.py index 34a7c0157..0f49917f3 100644 --- a/src/powercontext/builtin/sources/content.py +++ b/src/powercontext/builtin/sources/content.py @@ -16,10 +16,11 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Literal from pydantic import BaseModel, Field, JsonValue, field_validator +from powercontext.limits import MAX_ARTIFACT_FAMILY_LENGTH from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, AdapterSourceDefinition, TextEvidence from powercontext.sources.models import Source, SourceMaterialization @@ -42,11 +43,38 @@ def reject_blank_text(cls, value: str) -> str: return value +class ContentSourceTarget(BaseModel): + """Exact Artifact revision to which one system Source is bound.""" + + scope_id: str + family: Annotated[str, Field(min_length=1, max_length=MAX_ARTIFACT_FAMILY_LENGTH)] + artifact_id: str + revision: Annotated[int, Field(ge=1)] = 1 + + @field_validator("family") + @classmethod + def require_canonical_family(cls, value: str) -> str: + if value != value.strip(): + raise ValueError("family must not contain leading or trailing whitespace") # noqa: TRY003 + return value + + +class ContentSourceInternal(BaseModel): + """Server-owned Source purpose data never exposed by the base REST API.""" + + role: Literal["lineage_only"] + operation: Literal["artifact_create", "artifact_replace"] + target: ContentSourceTarget + + class ContentSource(Source): - """Captured text that can be used as Artifact evidence.""" + """Captured content that can be used as Artifact evidence.""" content: str metadata: dict[str, JsonValue] = Field(default_factory=dict) + wire_content: JsonValue | None = None + wire_content_present: bool = False + internal: ContentSourceInternal | None = None class ContentSourceAdapter: @@ -65,6 +93,8 @@ async def resolve(self, value: ContentCapture, /) -> ContentSource: ) async def read(self, source: ContentSource, /) -> ContentCapture: + if not isinstance(source.content, str): + raise TypeError("system Content Sources cannot be read as integration captures") # noqa: TRY003 return ContentCapture( source_id=source.name, content=source.content, diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index f26f99873..d752ad0b3 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -19,7 +19,7 @@ import asyncio from collections.abc import Mapping from types import TracebackType -from typing import Self, TypeVar +from typing import Any, Self, TypeVar, cast from urllib.parse import quote import httpx @@ -33,7 +33,10 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactCreated, + ArtifactPage, ArtifactPublication, + ArtifactRevision, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -44,8 +47,10 @@ CommittedHandoff, ConnectorCheckpointState, ContinueHandoffRequest, + CreateArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, + CreateSourceRequest, CreateWorkContractRequest, DownloadRemoteSkillPackageRequest, EnrollRemoteSkillTargetRequest, @@ -75,6 +80,7 @@ HealthResponse, ImportExternalSkillRequest, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListManagedSkillsRequest, @@ -112,6 +118,7 @@ RemoteSkillTargetCredential, RemoteSkillTargetEnrollment, RenameRemoteSkillTargetRequest, + ReplaceArtifactRequest, ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, @@ -135,6 +142,7 @@ SkillPackageManifest, SourceDefinitionManifest, SourceObservationReceipt, + SourceRecord, SubmitSourceObservationRequest, UnpublishRemoteSkillRequest, UpdateScopeRequest, @@ -150,8 +158,10 @@ COMMIT_CONNECTOR_CHECKPOINT, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ARTIFACT, CREATE_REMOTE_SKILL_TARGET, CREATE_SCOPE, + CREATE_SOURCE, CREATE_WORK_CONTRACT, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, @@ -160,7 +170,9 @@ FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, + GET_ARTIFACT_REVISION, GET_CAPABILITIES, GET_CONNECTOR_CHECKPOINT, GET_DEFAULT_SCOPE, @@ -172,10 +184,12 @@ GET_SCOPE, GET_SKILL, GET_SKILL_PACKAGE_MANIFEST, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, + LIST_ARTIFACTS, LIST_EXTERNAL_SKILLS, LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, @@ -197,6 +211,7 @@ REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -411,6 +426,86 @@ async def capture_content_source(self, request: CaptureContentSourceRequest) -> return await self._request(CAPTURE_CONTENT_SOURCE, request) + async def create_source(self, scope_id: str, request: CreateSourceRequest) -> SourceRecord: + """Create one durable Source without invoking generation.""" + + return await self._request(CREATE_SOURCE, request, path_parameters={"scope_id": scope_id}) + + async def get_source(self, scope_id: str, source_type: str, source_id: str) -> SourceRecord: + """Read one exact Source in a Scope and Source type.""" + + return await self._request( + GET_SOURCE, + path_parameters={"scope_id": scope_id, "source_type": source_type, "source_id": source_id}, + ) + + async def create_artifact(self, scope_id: str, request: CreateArtifactRequest) -> ArtifactCreated: + """Atomically commit revision one and its system provenance Source.""" + + return await self._request(CREATE_ARTIFACT, request, path_parameters={"scope_id": scope_id}) + + async def get_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + *, + if_none_match: str | None = None, + ) -> ArtifactRevision | None: + """Read the current visible Artifact head.""" + + return await self._request( + GET_ARTIFACT, + path_parameters={"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, + extra_headers=None if if_none_match is None else {"If-None-Match": if_none_match}, + ) + + async def get_artifact_revision( + self, + scope_id: str, + family: str, + artifact_id: str, + revision: int, + ) -> ArtifactRevision: + """Read one exact immutable Artifact revision.""" + + return await self._request( + GET_ARTIFACT_REVISION, + path_parameters={ + "scope_id": scope_id, + "family": family, + "artifact_id": artifact_id, + "revision": revision, + }, + ) + + async def list_artifacts(self, scope_id: str, family: str, request: ListArtifactsRequest) -> ArtifactPage: + """List current Artifact heads for one family.""" + + return await self._request( + LIST_ARTIFACTS, + request, + path_parameters={"scope_id": scope_id, "family": family}, + ) + + async def replace_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + request: ReplaceArtifactRequest, + *, + expected_etag: str, + ) -> ArtifactRevision: + """Commit a complete next revision using optimistic concurrency.""" + + return await self._request( + REPLACE_ARTIFACT, + request, + path_parameters={"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, + extra_headers={"If-Match": expected_etag}, + ) + async def register_source_definition(self, request: RegisterSourceDefinitionRequest) -> SourceDefinitionManifest: """Register one immutable worker-owned Source Definition manifest.""" @@ -708,52 +803,45 @@ async def _request( operation: Operation[_RequestT, _ResponseT], request: _RequestT | None = None, *, - path_parameters: Mapping[str, str] | None = None, + path_parameters: Mapping[str, str | int] | None = None, + query_parameters: Mapping[str, Any] | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> _ResponseT: - operation_path = _bind_operation_path(operation, path_parameters) - json_payload = None - query_parameters = None - if request is not None: - if operation.request_type is None: - message = f"{operation.operation_id} does not accept a request" - raise TypeError(message) - payload = TypeAdapter(operation.request_type).dump_python( - request, - mode="json", - by_alias=True, - ) - if operation.request_location == "query": - query_parameters = {key: value for key, value in payload.items() if value is not None} - else: - json_payload = payload + path, json_payload, request_query = _prepare_request( + operation, + request, + path_parameters=path_parameters, + query_parameters=query_parameters, + ) span = ClientSpan.start(operation.operation_id) try: headers = {} if self._headers is None else dict(self._headers) + if extra_headers is not None: + headers.update(extra_headers) span.inject(headers) response = await self._http_client.request( operation.method, - f"{self._base_url}{operation_path}", + f"{self._base_url}{path}", json=json_payload, headers=headers, - params=query_parameters, + params=request_query or None, ) except asyncio.CancelledError as error: span.finish("cancelled", error=error) raise except httpx.HTTPError as exc: span.finish("failure", error=exc) - raise TransportError(operation_path) from exc + raise TransportError(path) from exc except BaseException as error: span.finish("failure", error=error) raise - span.finish( - "success" if response.status_code == operation.success_status else "failure", - status_code=response.status_code, - ) + declared_not_modified = response.status_code == 304 and 304 in operation.responses + succeeded = response.status_code == operation.success_status or declared_not_modified + span.finish("success" if succeeded else "failure", status_code=response.status_code) request_id = response.headers.get(REQUEST_ID_HEADER) - if response.status_code != operation.success_status: + if not succeeded: error = _decode_error(response.content) raise ServerResponseError( status_code=response.status_code, @@ -763,18 +851,48 @@ async def _request( details=None if error is None else error.error.details, ) + if response.status_code in {204, 304} or operation.response_type is None: + return cast(_ResponseT, None) + try: return TypeAdapter(operation.response_type).validate_json(response.content) except ValidationError as exc: raise InvalidResponseError( - operation_path, + path, request_id=request_id, ) from exc +def _prepare_request( + operation: Operation[Any, Any], + request: object | None, + *, + path_parameters: Mapping[str, str | int] | None, + query_parameters: Mapping[str, Any] | None, +) -> tuple[str, dict[str, Any] | None, dict[str, Any] | None]: + json_payload: dict[str, Any] | None = None + request_query: dict[str, Any] = {} + if request is not None: + if operation.request_type is None: + message = f"{operation.operation_id} does not accept a request" + raise TypeError(message) + payload = TypeAdapter(operation.request_type).dump_python(request, mode="json", by_alias=True) + if not isinstance(payload, dict): + message = "Request must serialize to an object." + raise TypeError(message) + if operation.request_location == "query": + request_query.update({key: value for key, value in payload.items() if value is not None}) + else: + json_payload = payload + if query_parameters is not None: + request_query.update({key: value for key, value in query_parameters.items() if value is not None}) + path = _bind_operation_path(operation, path_parameters) + return path, json_payload, request_query or None + + def _bind_operation_path( - operation: Operation[_RequestT, _ResponseT], - path_parameters: Mapping[str, str] | None, + operation: Operation[Any, Any], + path_parameters: Mapping[str, str | int] | None, ) -> str: values = {} if path_parameters is None else dict(path_parameters) expected = set(operation.path_parameters) @@ -792,10 +910,10 @@ def _bind_operation_path( path = operation.path for name in operation.path_parameters: value = values[name] - if not isinstance(value, str): - message = f"{operation.operation_id} path parameter {name} must be a string" + if not isinstance(value, str | int) or isinstance(value, bool): + message = f"{operation.operation_id} path parameter {name} must be a string or integer" raise TypeError(message) - path = path.replace(f"{{{name}}}", quote(value, safe="")) + path = path.replace(f"{{{name}}}", quote(str(value), safe="")) return path diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index f69223518..89ae3d4e4 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -23,10 +23,15 @@ ArtifactAddress, ArtifactCandidate, ArtifactCandidatePage, + ArtifactCollectionItem, + ArtifactCreated, ArtifactInventoryStatistics, + ArtifactPage, ArtifactPublication, ArtifactReference, + ArtifactRevision, AuthorizationNote, + BaseArtifactFamily, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -45,8 +50,16 @@ ConnectorCheckpointState, ContextReference, ContinueHandoffRequest, + CreateArtifactRequest, + CreateExperienceArtifactRequest, + CreateHandoffArtifactRequest, + CreateMemoryArtifactContent, + CreateMemoryArtifactEntry, + CreateMemoryArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, + CreateSkillArtifactRequest, + CreateSourceRequest, CreateWorkContractRequest, CurrentWorkHandoff, DownloadRemoteSkillPackageRequest, @@ -112,6 +125,7 @@ Kind1, Kind2, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListManagedSkillsRequest, @@ -187,6 +201,13 @@ RemoteSkillTargetState, RemoteSkillTargetStatus, RenameRemoteSkillTargetRequest, + ReplaceArtifactRequest, + ReplaceExperienceArtifactRequest, + ReplaceHandoffArtifactRequest, + ReplaceMemoryArtifactContent, + ReplaceMemoryArtifactEntry, + ReplaceMemoryArtifactRequest, + ReplaceSkillArtifactRequest, ReportFormat, ResolvedUsagePeriod, ResolveExternalSkillRequest, @@ -231,7 +252,10 @@ SourceProjectionKey, SourceProjectionManifest, SourceProjectionValue, + SourceRecord, SourceReference, + SourceType, + SourceTypeReference, StatsPeriod, SubmitSourceObservationRequest, SubtreeScopeSelection, @@ -264,10 +288,15 @@ "ArtifactAddress", "ArtifactCandidate", "ArtifactCandidatePage", + "ArtifactCollectionItem", + "ArtifactCreated", "ArtifactInventoryStatistics", + "ArtifactPage", "ArtifactPublication", "ArtifactReference", + "ArtifactRevision", "AuthorizationNote", + "BaseArtifactFamily", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -286,8 +315,16 @@ "ConnectorCheckpointState", "ContextReference", "ContinueHandoffRequest", + "CreateArtifactRequest", + "CreateExperienceArtifactRequest", + "CreateHandoffArtifactRequest", + "CreateMemoryArtifactContent", + "CreateMemoryArtifactEntry", + "CreateMemoryArtifactRequest", "CreateRemoteSkillTargetRequest", "CreateScopeRequest", + "CreateSkillArtifactRequest", + "CreateSourceRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", "DownloadRemoteSkillPackageRequest", @@ -353,6 +390,7 @@ "Kind1", "Kind2", "ListArtifactCandidatesRequest", + "ListArtifactsRequest", "ListExternalSkillsRequest", "ListExternalSkillsResponse", "ListManagedSkillsRequest", @@ -428,6 +466,13 @@ "RemoteSkillTargetState", "RemoteSkillTargetStatus", "RenameRemoteSkillTargetRequest", + "ReplaceArtifactRequest", + "ReplaceExperienceArtifactRequest", + "ReplaceHandoffArtifactRequest", + "ReplaceMemoryArtifactContent", + "ReplaceMemoryArtifactEntry", + "ReplaceMemoryArtifactRequest", + "ReplaceSkillArtifactRequest", "ReportFormat", "ResolveExternalSkillRequest", "ResolveScopeBindingRequest", @@ -472,7 +517,10 @@ "SourceProjectionKey", "SourceProjectionManifest", "SourceProjectionValue", + "SourceRecord", "SourceReference", + "SourceType", + "SourceTypeReference", "StatsPeriod", "SubmitSourceObservationRequest", "SubtreeScopeSelection", @@ -480,6 +528,8 @@ "TaskCheckStatus", "TaskOutcome", "TaskOutcomeStatus", + "TextSearchMode", + "TextSearchUsedMode", "Timezone", "TokenEstimatorProfile", "Trust", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index e144a6ba2..c2722d5c4 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1200,6 +1200,123 @@ class ImportExternalSkillRequest(BaseModel): reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None +class Family(StrEnum): + MEMORY = "memory" + + +class Family1(StrEnum): + EXPERIENCE = "experience" + + +class CreateExperienceArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Literal["experience"] + content: ExperienceProposal + + +class Family2(StrEnum): + SKILL = "skill" + + +class Family3(StrEnum): + HANDOFF = "handoff" + + +class CreateMemoryArtifactEntry(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Annotated[ + StrictStr, + Field( + description="Open application-defined Memory kind. Recommended values are fact, preference, decision, constraint, and working_note. The Server validates and preserves the supplied value; it never guesses or replaces it.", + max_length=128, + min_length=1, + pattern=".*\\S.*", + ), + ] + text: Annotated[ + StrictStr, + Field( + description="Durable non-empty Memory entry text used to create the Entry Version and search projection.", + max_length=8192, + min_length=1, + pattern=".*\\S.*", + ), + ] + + +class SourceType(StrEnum): + CONTENT = "content" + + +class CreateSourceRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + source_type: SourceType = SourceType.CONTENT + content: Annotated[Any, Field(description="JSON value persisted by the built-in content Source adapter.")] + + +class ListArtifactsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class ReplaceMemoryArtifactEntry(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + entry_id: Annotated[ + StrictStr | None, + Field( + description="Existing logical entry to revise. Omit to append a new entry.", + max_length=128, + min_length=1, + pattern="^[\\x21-\\x7E]+$", + ), + ] = None + kind: Annotated[ + StrictStr, + Field( + description="Open application-defined kind; the Server preserves the supplied value.", + max_length=128, + min_length=1, + pattern=".*\\S.*", + ), + ] + text: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] + + +class ReplaceExperienceArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + content: ExperienceProposal + + +class SourceRecord(BaseModel): + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + source_type: SourceType + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] + content: Annotated[Any, Field(description="Persisted canonical JSON content.")] + position: Annotated[StrictInt, Field(ge=1)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + + +class SourceTypeReference(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + source_type: SourceType + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + class SourceReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1212,6 +1329,13 @@ class CaptureStatus(StrEnum): ACCEPTED = "accepted" +class BaseArtifactFamily(StrEnum): + MEMORY = "memory" + EXPERIENCE = "experience" + SKILL = "skill" + HANDOFF = "handoff" + + class StatsPeriod(StrEnum): TODAY = "today" FIELD_7D = "7d" @@ -1324,6 +1448,41 @@ class PreparedHandoffSchema(StrEnum): POWERCONTEXT_PREPARED_HANDOFF_V1 = "powercontext.prepared-handoff.v1" +class ArtifactCollectionItem(BaseModel): + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: BaseArtifactFamily + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + revision: Annotated[StrictInt, Field(ge=1)] + sources: list[SourceTypeReference] + artifacts: list[ArtifactReference] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + + +class ArtifactCreated(BaseModel): + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: BaseArtifactFamily + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + revision: Annotated[StrictInt, Field(ge=1)] + sources: list[SourceTypeReference] + artifacts: list[ArtifactReference] + + +class ArtifactPage(BaseModel): + items: list[ArtifactCollectionItem] + next_cursor: Annotated[StrictStr | None, Field(...)] + + +class ArtifactRevision(BaseModel): + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: BaseArtifactFamily + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + revision: Annotated[StrictInt, Field(ge=1)] + content: dict[str, Any] + sources: list[SourceTypeReference] + artifacts: list[ArtifactReference] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + + class Capabilities(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1890,6 +2049,35 @@ class SearchMemoryResponse(BaseModel): hits: list[SearchMemoryHit] +class CreateSkillArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Literal["skill"] + content: SkillProposal + + +class CreateMemoryArtifactContent(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + entries: Annotated[list[CreateMemoryArtifactEntry], Field(max_length=100, min_length=1)] + + +class ReplaceMemoryArtifactContent(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + entries: Annotated[list[ReplaceMemoryArtifactEntry], Field(max_length=100, min_length=1)] + + +class ReplaceSkillArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + content: SkillProposal + + class ArtifactCandidate(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2029,6 +2217,21 @@ class GeneratedCandidateResponse(BaseModel): candidate: Annotated[ArtifactCandidate | None, Field(...)] +class CreateMemoryArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Literal["memory"] + content: CreateMemoryArtifactContent + + +class ReplaceMemoryArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + content: ReplaceMemoryArtifactContent + + class ActivateHandoffRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2178,6 +2381,21 @@ class PreparedHandoff(BaseModel): content: HandoffContent +class CreateHandoffArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + family: Literal["handoff"] + content: HandoffContent + + +class ReplaceHandoffArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + content: HandoffContent + + class PreparedWorkHandoff(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2254,3 +2472,36 @@ class HandoffActivation(BaseModel): previous_position: Annotated[StrictInt, Field(ge=0)] current_position: Annotated[StrictInt, Field(ge=0)] draft: Annotated[HandoffDraft | None, Field(...)] + + +class CreateArtifactRequest( + RootModel[ + CreateMemoryArtifactRequest + | CreateExperienceArtifactRequest + | CreateSkillArtifactRequest + | CreateHandoffArtifactRequest + ] +): + root: Annotated[ + CreateMemoryArtifactRequest + | CreateExperienceArtifactRequest + | CreateSkillArtifactRequest + | CreateHandoffArtifactRequest, + Field(discriminator="family"), + ] + + +class ReplaceArtifactRequest( + RootModel[ + ReplaceMemoryArtifactRequest + | ReplaceExperienceArtifactRequest + | ReplaceSkillArtifactRequest + | ReplaceHandoffArtifactRequest + ] +): + root: ( + ReplaceMemoryArtifactRequest + | ReplaceExperienceArtifactRequest + | ReplaceSkillArtifactRequest + | ReplaceHandoffArtifactRequest + ) diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index af53a697d..b9db68bb4 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,7 +12,10 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactCreated, + ArtifactPage, ArtifactPublication, + ArtifactRevision, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -23,8 +26,10 @@ CommittedHandoff, ConnectorCheckpointState, ContinueHandoffRequest, + CreateArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, + CreateSourceRequest, CreateWorkContractRequest, DownloadRemoteSkillPackageRequest, EnrollRemoteSkillTargetRequest, @@ -53,6 +58,7 @@ HealthResponse, ImportExternalSkillRequest, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListManagedSkillsRequest, @@ -90,6 +96,7 @@ RemoteSkillTargetCredential, RemoteSkillTargetEnrollment, RenameRemoteSkillTargetRequest, + ReplaceArtifactRequest, ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, @@ -113,6 +120,7 @@ SkillPackageManifest, SourceDefinitionManifest, SourceObservationReceipt, + SourceRecord, SubmitSourceObservationRequest, UnpublishRemoteSkillRequest, UpdateScopeRequest, @@ -135,8 +143,8 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id: str request_type: type[RequestT] | None request_location: Literal["body", "query"] | None + response_type: type[ResponseT] | None path_parameters: tuple[str, ...] - response_type: type[ResponseT] success_status: int summary: str tags: tuple[str, ...] @@ -1793,3 +1801,201 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 500: {"$ref": "#/components/responses/InternalError"}, }, ) + +CREATE_SOURCE = Operation[CreateSourceRequest, SourceRecord]( + method="POST", + path="/v1/scopes/{scope_id}/sources", + operation_id="create_source", + request_type=CreateSourceRequest, + request_location="body", + path_parameters=("scope_id",), + response_type=SourceRecord, + success_status=201, + summary="Create a durable Source", + tags=("sources",), + scope_mode="none", + responses={ + 201: { + "description": "The Source was durably created.", + "headers": { + "Location": {"$ref": "#/components/headers/Location"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +GET_SOURCE = Operation[None, SourceRecord]( + method="GET", + path="/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", + operation_id="get_source", + request_type=None, + request_location=None, + path_parameters=("scope_id", "source_type", "source_id"), + response_type=SourceRecord, + success_status=200, + summary="Get one exact Source", + tags=("sources",), + scope_mode="none", + responses={ + 200: { + "description": "The exact Source.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +CREATE_ARTIFACT = Operation[CreateArtifactRequest, ArtifactCreated]( + method="POST", + path="/v1/scopes/{scope_id}/artifacts", + operation_id="create_artifact", + request_type=CreateArtifactRequest, + request_location="body", + path_parameters=("scope_id",), + response_type=ArtifactCreated, + success_status=201, + summary="Create an Artifact", + tags=("artifacts",), + scope_mode="none", + responses={ + 201: { + "description": "Artifact revision one was committed.", + "headers": { + "Location": {"$ref": "#/components/headers/Location"}, + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +LIST_ARTIFACTS = Operation[ListArtifactsRequest, ArtifactPage]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/{family}", + operation_id="list_artifacts", + request_type=ListArtifactsRequest, + request_location="query", + path_parameters=("scope_id", "family"), + response_type=ArtifactPage, + success_status=200, + summary="List current Artifact heads", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: { + "description": "One stable page of current Artifact heads.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 410: {"$ref": "#/components/responses/CursorExpired"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +GET_ARTIFACT = Operation[None, ArtifactRevision]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", + operation_id="get_artifact", + request_type=None, + request_location=None, + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactRevision, + success_status=200, + summary="Get the current Artifact head", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: { + "description": "The current visible Artifact head.", + "headers": { + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + }, + 304: { + "description": "If-None-Match identifies the current Artifact head.", + "headers": { + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +REPLACE_ARTIFACT = Operation[ReplaceArtifactRequest, ArtifactRevision]( + method="PUT", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", + operation_id="replace_artifact", + request_type=ReplaceArtifactRequest, + request_location="body", + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactRevision, + success_status=200, + summary="Replace the current Artifact head", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: { + "description": "The complete replacement was committed as the next revision.", + "headers": { + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 412: {"$ref": "#/components/responses/PreconditionFailed"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 428: {"$ref": "#/components/responses/PreconditionRequired"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +GET_ARTIFACT_REVISION = Operation[None, ArtifactRevision]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", + operation_id="get_artifact_revision", + request_type=None, + request_location=None, + path_parameters=("scope_id", "family", "artifact_id", "revision"), + response_type=ArtifactRevision, + success_status=200, + summary="Get one exact immutable Artifact revision", + tags=("artifacts",), + scope_mode="none", + responses={ + 200: { + "description": "The exact immutable Artifact revision.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index c6197870f..363361c0f 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1975,6 +1975,328 @@ "x-powercontext-scope-mode": "selection", } }, + "/v1/scopes/{scope_id}/sources": { + "post": { + "tags": ["sources"], + "summary": "Create a durable Source", + "description": "Persist one Source without " + "synchronously deriving " + "Artifacts. The Server " + "generates source_id.", + "operationId": "create_source", + "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/CreateSourceRequest"}}}, + }, + "responses": { + "201": { + "description": "The Source was durably created.", + "headers": { + "Location": {"$ref": "#/components/headers/Location"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceRecord"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, + "/v1/scopes/{scope_id}/sources/{source_type}/{source_id}": { + "get": { + "tags": ["sources"], + "summary": "Get one exact Source", + "operationId": "get_source", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "source_type", + "in": "path", + "required": True, + "schema": {"type": "string", "enum": ["content"]}, + }, + { + "name": "source_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[\\x21-\\x7E]+$"}, + }, + ], + "responses": { + "200": { + "description": "The exact Source.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceRecord"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "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": { + "post": { + "tags": ["artifacts"], + "summary": "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.", + "operationId": "create_artifact", + "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/CreateArtifactRequest"}}}, + }, + "responses": { + "201": { + "description": "Artifact revision one was committed.", + "headers": { + "Location": {"$ref": "#/components/headers/Location"}, + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCreated"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, + "/v1/scopes/{scope_id}/artifacts/{family}": { + "get": { + "tags": ["artifacts"], + "summary": "List current Artifact heads", + "description": "List current heads for exactly one built-in Artifact family.", + "operationId": "list_artifacts", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, + }, + { + "name": "limit", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, + }, + { + "name": "cursor", + "in": "query", + "required": False, + "schema": {"type": "string", "minLength": 1, "maxLength": 4096}, + }, + ], + "responses": { + "200": { + "description": "One stable page of current Artifact heads.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactPage"}}}, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "410": {"$ref": "#/components/responses/CursorExpired"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}": { + "get": { + "tags": ["artifacts"], + "summary": "Get the current Artifact head", + "operationId": "get_artifact", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "If-None-Match", + "in": "header", + "required": False, + "schema": {"type": "string", "minLength": 1}, + }, + ], + "responses": { + "200": { + "description": "The current visible Artifact head.", + "headers": { + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactRevision"}}}, + }, + "304": { + "description": "If-None-Match identifies the current Artifact head.", + "headers": { + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + "put": { + "tags": ["artifacts"], + "summary": "Replace the current Artifact head", + "description": "Commit a complete next revision when If-Match identifies the current head.", + "operationId": "replace_artifact", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "If-Match", + "in": "header", + "required": True, + "schema": {"type": "string", "minLength": 1}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ReplaceArtifactRequest"}} + }, + }, + "responses": { + "200": { + "description": "The complete replacement was committed as the next revision.", + "headers": { + "ETag": {"$ref": "#/components/headers/ArtifactETag"}, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactRevision"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "412": {"$ref": "#/components/responses/PreconditionFailed"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "428": {"$ref": "#/components/responses/PreconditionRequired"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + }, + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}": { + "get": { + "tags": ["artifacts"], + "summary": "Get one exact immutable Artifact revision", + "operationId": "get_artifact_revision", + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + {"name": "revision", "in": "path", "required": True, "schema": {"type": "integer", "minimum": 1}}, + ], + "responses": { + "200": { + "description": "The exact immutable Artifact revision.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactRevision"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, }, "components": { "schemas": { @@ -1995,6 +2317,62 @@ "type": "object", "required": ["scope_id", "boundary_source", "objective"], }, + "ArtifactCollectionItem": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + "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"}, + "artifacts": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "type": "object", + "required": ["scope_id", "family", "artifact_id", "revision", "sources", "artifacts", "content_digest"], + }, + "ArtifactCreated": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + "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"}, + "artifacts": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + }, + "type": "object", + "required": ["scope_id", "family", "artifact_id", "revision", "sources", "artifacts"], + }, + "ArtifactPage": { + "properties": { + "items": {"items": {"$ref": "#/components/schemas/ArtifactCollectionItem"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "type": "object", + "required": ["items", "next_cursor"], + }, + "ArtifactRevision": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "revision": {"type": "integer", "minimum": 1.0}, + "content": {"additionalProperties": True, "type": "object"}, + "sources": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, + "artifacts": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "type": "object", + "required": [ + "scope_id", + "family", + "artifact_id", + "revision", + "content", + "sources", + "artifacts", + "content_digest", + ], + }, "ArtifactReference": { "properties": { "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, @@ -4674,6 +5052,238 @@ "type": "object", "required": ["hits"], }, + "CreateArtifactRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/CreateMemoryArtifactRequest"}, + {"$ref": "#/components/schemas/CreateExperienceArtifactRequest"}, + {"$ref": "#/components/schemas/CreateSkillArtifactRequest"}, + {"$ref": "#/components/schemas/CreateHandoffArtifactRequest"}, + ], + "discriminator": { + "propertyName": "family", + "mapping": { + "memory": "#/components/schemas/CreateMemoryArtifactRequest", + "experience": "#/components/schemas/CreateExperienceArtifactRequest", + "skill": "#/components/schemas/CreateSkillArtifactRequest", + "handoff": "#/components/schemas/CreateHandoffArtifactRequest", + }, + }, + }, + "CreateMemoryArtifactRequest": { + "properties": { + "family": {"type": "string", "enum": ["memory"]}, + "content": {"$ref": "#/components/schemas/CreateMemoryArtifactContent"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "content"], + }, + "CreateExperienceArtifactRequest": { + "properties": { + "family": {"type": "string", "enum": ["experience"]}, + "content": {"$ref": "#/components/schemas/ExperienceProposal"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "content"], + }, + "CreateSkillArtifactRequest": { + "properties": { + "family": {"type": "string", "enum": ["skill"]}, + "content": {"$ref": "#/components/schemas/SkillProposal"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "content"], + }, + "CreateHandoffArtifactRequest": { + "properties": { + "family": {"type": "string", "enum": ["handoff"]}, + "content": {"$ref": "#/components/schemas/HandoffContent"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["family", "content"], + }, + "CreateMemoryArtifactContent": { + "properties": { + "entries": { + "items": {"$ref": "#/components/schemas/CreateMemoryArtifactEntry"}, + "type": "array", + "maxItems": 100, + "minItems": 1, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["entries"], + }, + "CreateMemoryArtifactEntry": { + "properties": { + "kind": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": ".*\\S.*", + "description": "Open " + "application-defined " + "Memory " + "kind. " + "Recommended " + "values " + "are " + "fact, " + "preference, " + "decision, " + "constraint, " + "and " + "working_note. " + "The " + "Server " + "validates " + "and " + "preserves " + "the " + "supplied " + "value; " + "it " + "never " + "guesses " + "or " + "replaces " + "it.", + }, + "text": { + "type": "string", + "maxLength": 8192, + "minLength": 1, + "pattern": ".*\\S.*", + "description": "Durable " + "non-empty " + "Memory " + "entry " + "text " + "used " + "to " + "create " + "the " + "Entry " + "Version " + "and " + "search " + "projection.", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "text"], + }, + "CreateSourceRequest": { + "properties": { + "source_type": {"type": "string", "enum": ["content"], "default": "content"}, + "content": {"description": "JSON value persisted by the built-in content Source adapter."}, + }, + "additionalProperties": False, + "type": "object", + "required": ["content"], + }, + "ListArtifactsRequest": { + "properties": { + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + }, + "ReplaceArtifactRequest": { + "oneOf": [ + {"$ref": "#/components/schemas/ReplaceMemoryArtifactRequest"}, + {"$ref": "#/components/schemas/ReplaceExperienceArtifactRequest"}, + {"$ref": "#/components/schemas/ReplaceSkillArtifactRequest"}, + {"$ref": "#/components/schemas/ReplaceHandoffArtifactRequest"}, + ] + }, + "ReplaceMemoryArtifactRequest": { + "properties": {"content": {"$ref": "#/components/schemas/ReplaceMemoryArtifactContent"}}, + "additionalProperties": False, + "type": "object", + "required": ["content"], + }, + "ReplaceMemoryArtifactContent": { + "properties": { + "entries": { + "items": {"$ref": "#/components/schemas/ReplaceMemoryArtifactEntry"}, + "type": "array", + "maxItems": 100, + "minItems": 1, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["entries"], + }, + "ReplaceMemoryArtifactEntry": { + "properties": { + "entry_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "description": "Existing logical entry to revise. Omit to append a new entry.", + "nullable": True, + }, + "kind": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": ".*\\S.*", + "description": "Open application-defined kind; the Server preserves the supplied value.", + }, + "text": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "text"], + }, + "ReplaceExperienceArtifactRequest": { + "properties": {"content": {"$ref": "#/components/schemas/ExperienceProposal"}}, + "additionalProperties": False, + "type": "object", + "required": ["content"], + }, + "ReplaceSkillArtifactRequest": { + "properties": {"content": {"$ref": "#/components/schemas/SkillProposal"}}, + "additionalProperties": False, + "type": "object", + "required": ["content"], + }, + "ReplaceHandoffArtifactRequest": { + "properties": {"content": {"$ref": "#/components/schemas/HandoffContent"}}, + "additionalProperties": False, + "type": "object", + "required": ["content"], + }, + "SourceRecord": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_type": {"type": "string", "enum": ["content"]}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "content": {"description": "Persisted canonical JSON content."}, + "position": {"type": "integer", "minimum": 1.0}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "type": "object", + "required": ["scope_id", "source_type", "source_id", "content", "position", "content_digest"], + }, + "SourceTypeReference": { + "properties": { + "source_type": {"type": "string", "enum": ["content"]}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["source_type", "source_id"], + }, "SourceReference": { "properties": { "name": {"type": "string", "description": "Stable Source type."}, @@ -4684,6 +5294,7 @@ "required": ["name", "source_id"], }, "CaptureStatus": {"type": "string", "enum": ["accepted"]}, + "BaseArtifactFamily": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, "CandidateFamily": {"type": "string", "enum": ["experience", "skill"]}, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, @@ -4707,6 +5318,16 @@ "PreparedHandoffSchema": {"type": "string", "enum": ["powercontext.prepared-handoff.v1"]}, }, "responses": { + "BadRequest": { + "description": "The request query or pagination cursor is invalid.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, + "CursorExpired": { + "description": "The pagination cursor has expired.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, "Unauthorized": { "description": "A valid bearer token is required by this Server deployment.", "headers": { @@ -4720,6 +5341,16 @@ "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, + "PreconditionFailed": { + "description": "If-Match does not identify the current Artifact head.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, + "PreconditionRequired": { + "description": "A current Artifact ETag is required in If-Match.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, + }, "InvalidRequest": { "description": "The request violates the transport or application contract.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, @@ -4731,7 +5362,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, "NotFound": { - "description": "The requested immutable Memory value was not found.", + "description": "The requested durable value was not found or is not observable.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, @@ -4747,6 +5378,12 @@ }, }, "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"}, @@ -4755,6 +5392,10 @@ "description": "Opaque identifier for correlating one request.", "schema": {"type": "string"}, }, + "Location": { + "description": "URI of the newly created Source or Artifact head.", + "schema": {"type": "string"}, + }, }, "securitySchemes": { "BearerAuth": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index d5810e796..78aa971c3 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -28,18 +28,23 @@ from datetime import UTC, datetime from functools import wraps from time import perf_counter -from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast +from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeVar, cast +from urllib.parse import quote, unquote -from fastapi import Depends, FastAPI, Request, Response, status +from fastapi import Depends, FastAPI, Header, Path, Query, Request, Response, status from fastapi import Path as PathParameter from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute from opentelemetry.trace import SpanKind +from pydantic import JsonValue from pydantic import ValidationError as PydanticValidationError from scalar_fastapi import AgentScalarConfig, get_scalar_api_reference from starlette.middleware import Middleware from starlette.middleware.base import RequestResponseEndpoint -from starlette.types import Lifespan +from starlette.routing import Match +from starlette.types import Lifespan, Scope +from typing_extensions import override from powercontext._logging import log_safely from powercontext.artifacts import ArtifactAddress, ArtifactRef @@ -130,6 +135,34 @@ from powercontext.builtin.publication import ( ArtifactPublicationRequest as DomainArtifactPublicationRequest, ) +from powercontext.builtin.records import ( + ArtifactAlreadyExistsError, + ArtifactRevisionPreconditionError, + BaseAccessError, + BaseValueConflictError, + BaseValueNotFoundError, + CursorExpiredError, + InvalidBaseAccessRequestError, + InvalidCursorError, +) +from powercontext.builtin.records import ( + ArtifactCollectionItem as RuntimeArtifactCollectionItem, +) +from powercontext.builtin.records import ( + ArtifactCreated as RuntimeArtifactCreated, +) +from powercontext.builtin.records import ( + ArtifactRecord as RuntimeArtifactRecord, +) +from powercontext.builtin.records import ( + ArtifactRecordPage as RuntimeArtifactRecordPage, +) +from powercontext.builtin.records import ( + ArtifactWrite as RuntimeArtifactWrite, +) +from powercontext.builtin.records import ( + SourceRecord as RuntimeSourceRecord, +) from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -257,6 +290,7 @@ from powercontext.builtin.scope import ( ScopeSelection as DomainScopeSelection, ) +from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.builtin.sources import ( ObservedInvocation, ObservedOutcome, @@ -294,6 +328,11 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactCollectionItem, + ArtifactCreated, + ArtifactPage, + ArtifactRevision, + BaseArtifactFamily, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -304,8 +343,10 @@ CommittedHandoff, ConnectorCheckpointState, ContinueHandoffRequest, + CreateArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, + CreateSourceRequest, CreateWorkContractRequest, DownloadRemoteSkillPackageRequest, EnrollRemoteSkillTargetRequest, @@ -334,6 +375,7 @@ HealthResponse, ImportExternalSkillRequest, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListManagedSkillsRequest, @@ -373,6 +415,7 @@ RemoteSkillTargetEnrollment, RemoteSkillTargetStatus, RenameRemoteSkillTargetRequest, + ReplaceArtifactRequest, ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, @@ -399,6 +442,8 @@ SkillPackageManifest, SourceDefinitionManifest, SourceObservationReceipt, + SourceRecord, + SourceType, SubmitSourceObservationRequest, UnpublishRemoteSkillRequest, UpdateScopeRequest, @@ -411,6 +456,7 @@ from powercontext.http import ( HandoffActivation as TransportHandoffActivation, ) +from powercontext.http import HandoffContent as TransportHandoffContent from powercontext.http import ( HandoffDraft as TransportHandoffDraft, ) @@ -432,8 +478,10 @@ COMMIT_CONNECTOR_CHECKPOINT, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ARTIFACT, CREATE_REMOTE_SKILL_TARGET, CREATE_SCOPE, + CREATE_SOURCE, CREATE_WORK_CONTRACT, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, @@ -442,7 +490,9 @@ FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, + GET_ARTIFACT_REVISION, GET_CAPABILITIES, GET_CONNECTOR_CHECKPOINT, GET_DEFAULT_SCOPE, @@ -454,10 +504,12 @@ GET_SCOPE, GET_SKILL, GET_SKILL_PACKAGE_MANIFEST, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, + LIST_ARTIFACTS, LIST_EXTERNAL_SKILLS, LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, @@ -480,6 +532,7 @@ REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -534,6 +587,56 @@ class _SourceApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedSourceApplication: ... +class _ScopedRecordApplication(Protocol): + async def create_source( + self, + source_type: str, + content: JsonValue, + /, + ) -> RuntimeSourceRecord: ... + + async def get_source(self, source_type: str, source_id: str, /) -> RuntimeSourceRecord: ... + + async def create_artifact( + self, + family: str, + write: RuntimeArtifactWrite, + /, + ) -> RuntimeArtifactCreated: ... + + async def get_artifact(self, family: str, artifact_id: str, /) -> RuntimeArtifactRecord: ... + + async def get_artifact_revision( + self, + family: str, + artifact_id: str, + revision: int, + /, + ) -> RuntimeArtifactRecord: ... + + async def query_artifacts( + self, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> RuntimeArtifactRecordPage: ... + + async def replace_artifact( + self, + family: str, + artifact_id: str, + expected_etag: str, + write: RuntimeArtifactWrite, + /, + ) -> RuntimeArtifactRecord: ... + + +class _RecordApplication(Protocol): + def for_scope(self, scope_id: str, /) -> _ScopedRecordApplication: ... + + class _RemoteIngestionApplication(Protocol): async def register(self, manifest: RuntimeSourceDefinitionManifest, /) -> RuntimeSourceDefinitionManifest: ... @@ -807,6 +910,7 @@ class ServerApplication(Protocol): scopes: ScopeApplication | None publications: ArtifactPublicationApplication | None sources: _SourceApplication + records: _RecordApplication ingestion: _RemoteIngestionApplication context: _ContextApplication experience: _ExperienceApplication @@ -825,6 +929,10 @@ class _RuntimeNotReadyError(RuntimeError): """Raised when an application operation is called without a Runtime binding.""" +class _PreconditionRequiredError(RuntimeError): + """Raised when an Artifact mutation omits If-Match.""" + + def create_app( *, application: ServerApplication | None = None, @@ -886,14 +994,25 @@ async def invalid_request( request: Request, error: RequestValidationError | PydanticValidationError, ) -> JSONResponse: + errors = _validation_error_details(error) + scoped_resource_syntax_error = request.url.path.startswith("/v1/scopes/") and any( + isinstance(item, dict) + and isinstance(item.get("loc"), (list, tuple)) + and item["loc"] + and item["loc"][0] == "query" + for item in errors + ) return _error_response( - status.HTTP_422_UNPROCESSABLE_CONTENT, + status.HTTP_400_BAD_REQUEST if scoped_resource_syntax_error else status.HTTP_422_UNPROCESSABLE_CONTENT, code="invalid_request", message="The request violates the API contract.", - details={"errors": _validation_error_details(error)}, + details={"errors": errors}, ) @app.exception_handler(_RuntimeNotReadyError) + @app.exception_handler(_PreconditionRequiredError) + @app.exception_handler(BaseAccessError) + @app.exception_handler(SourceNotEligibleError) @app.exception_handler(PowerContextError) @app.exception_handler(PersistenceError) async def application_error(request: Request, error: Exception) -> JSONResponse: @@ -932,6 +1051,13 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, GET_STATS, get_stats) if handoff_report_enabled: _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) + _add_route(app, CREATE_SOURCE, create_source) + _add_route(app, GET_SOURCE, get_source) + _add_route(app, CREATE_ARTIFACT, create_artifact) + _add_route(app, GET_ARTIFACT_REVISION, get_artifact_revision) + _add_route(app, GET_ARTIFACT, get_artifact) + _add_route(app, LIST_ARTIFACTS, list_artifacts) + _add_route(app, REPLACE_ARTIFACT, replace_artifact) _add_route(app, CAPTURE_CONTENT_SOURCE, capture_content_source) _add_route(app, REGISTER_SOURCE_DEFINITION, register_source_definition) _add_route(app, GET_CONNECTOR_CHECKPOINT, get_connector_checkpoint) @@ -1231,6 +1357,206 @@ async def get_handoff_report( return response_payload +async def create_source( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + request: CreateSourceRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SourceRecord: + result = await application.records.for_scope(scope_id).create_source( + request.source_type.value, + request.content, + ) + response.headers["Location"] = _source_location(result) + return _source_record_response(result) + + +async def get_source( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + source_type: Annotated[Literal["content"], Path()], + source_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r"^[\x21-\x7E]+$")], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SourceRecord: + result = await application.records.for_scope(scope_id).get_source(source_type, source_id) + return _source_record_response(result) + + +async def create_artifact( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + request: CreateArtifactRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactCreated: + result = await application.records.for_scope(scope_id).create_artifact( + request.root.family, + _artifact_write(request), + ) + response.headers["Location"] = _artifact_location(result) + response.headers["ETag"] = _artifact_etag(result.revision) + return _artifact_created_response(result) + + +def _list_artifacts_query( + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, +) -> ListArtifactsRequest: + return ListArtifactsRequest(limit=limit, cursor=cursor) + + +async def list_artifacts( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[BaseArtifactFamily, Path()], + request: Annotated[ListArtifactsRequest, Depends(_list_artifacts_query)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactPage: + result = await application.records.for_scope(scope_id).query_artifacts( + family.value, + limit=request.limit, + cursor=request.cursor, + ) + return ArtifactPage( + items=[_artifact_collection_item_response(item) for item in result.items], + next_cursor=result.next_cursor, + ) + + +async def get_artifact( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[BaseArtifactFamily, Path()], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_none_match: Annotated[ + str | None, + Header(alias="If-None-Match", min_length=1), + ] = None, +) -> ArtifactRevision | Response: + result = await application.records.for_scope(scope_id).get_artifact(family.value, artifact_id) + etag = _artifact_etag(result.revision) + if if_none_match == etag: + return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag}) + response.headers["ETag"] = etag + return _artifact_revision_response(result) + + +async def replace_artifact( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[BaseArtifactFamily, Path()], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + request: ReplaceArtifactRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, +) -> ArtifactRevision: + expected_etag = _require_artifact_etag(if_match) + result = await application.records.for_scope(scope_id).replace_artifact( + family.value, + artifact_id, + expected_etag, + _artifact_write(request), + ) + response.headers["ETag"] = _artifact_etag(result.revision) + return _artifact_revision_response(result) + + +async def get_artifact_revision( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[BaseArtifactFamily, Path()], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + revision: Annotated[int, Path(ge=1)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactRevision: + result = await application.records.for_scope(scope_id).get_artifact_revision( + family.value, + artifact_id, + revision, + ) + return _artifact_revision_response(result) + + +def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord: + return SourceRecord( + scope_id=value.scope_id, + source_type=SourceType(value.source_type), + source_id=value.source_id, + content=value.content, + position=value.position, + content_digest=value.content_digest, + ) + + +def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevision: + return ArtifactRevision( + scope_id=value.scope_id, + family=BaseArtifactFamily(value.family), + artifact_id=value.artifact_id, + revision=value.revision, + content=value.content, + sources=[mapping.source_type_reference(ref) for ref in value.sources], + artifacts=[mapping.artifact_reference(ref) for ref in value.artifacts], + content_digest=value.content_digest, + ) + + +def _artifact_created_response(value: RuntimeArtifactCreated) -> ArtifactCreated: + return ArtifactCreated( + scope_id=value.scope_id, + family=BaseArtifactFamily(value.family), + artifact_id=value.artifact_id, + revision=value.revision, + sources=[mapping.source_type_reference(ref) for ref in value.sources], + artifacts=[mapping.artifact_reference(ref) for ref in value.artifacts], + ) + + +def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) -> ArtifactCollectionItem: + return ArtifactCollectionItem( + scope_id=value.scope_id, + family=BaseArtifactFamily(value.family), + artifact_id=value.artifact_id, + revision=value.revision, + sources=[mapping.source_type_reference(ref) for ref in value.sources], + artifacts=[mapping.artifact_reference(ref) for ref in value.artifacts], + content_digest=value.content_digest, + ) + + +def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: + content = value.root.content + if isinstance(content, TransportHandoffContent): + content = mapping.runtime_handoff_content(content) + return RuntimeArtifactWrite( + content=cast( + dict[str, JsonValue], + content.model_dump(mode="json", by_alias=True, exclude_none=True), + ), + ) + + +def _source_location(value: RuntimeSourceRecord) -> str: + source_id = quote(value.source_id, safe="") + scope_id = quote(value.scope_id, safe="") + source_type = quote(value.source_type, safe="") + return f"/v1/scopes/{scope_id}/sources/{source_type}/{source_id}" + + +def _artifact_location(value: RuntimeArtifactCreated) -> str: + artifact_id = quote(value.artifact_id, safe="") + scope_id = quote(value.scope_id, safe="") + family = quote(value.family, safe="") + return f"/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}" + + +def _artifact_etag(revision: int) -> str: + return f'"revision:{revision}"' + + +def _require_artifact_etag(value: str | None) -> str: + if value is None: + raise _PreconditionRequiredError + return value + + def _require_report_size(estimated_bytes: int, report: Any) -> None: if estimated_bytes <= MAX_HANDOFF_REPORT_BYTES: return @@ -2039,7 +2365,7 @@ def _add_route( operation: Operation[_RequestT, _ResponseT], endpoint: Callable[..., Awaitable[_ResponseT | Response]], ) -> None: - app.add_api_route( + app.router.add_api_route( operation.path, _observe_application_operation(app, operation, endpoint), methods=[operation.method], @@ -2049,9 +2375,36 @@ def _add_route( responses=operation.responses, summary=operation.summary, tags=list(operation.tags), + route_class_override=_EncodedPathAPIRoute if operation.path.startswith("/v1/scopes/") else None, ) +class _EncodedPathAPIRoute(APIRoute): + """Match scoped resources against raw paths and decode each identity exactly once.""" + + @override + def matches(self, scope: Scope) -> tuple[Match, Scope]: + raw_path = scope.get("raw_path") + if scope["type"] != "http" or not isinstance(raw_path, bytes): + return super().matches(scope) + try: + encoded_path = raw_path.decode("ascii") + except UnicodeDecodeError: + return super().matches(scope) + encoded_scope = dict(scope) + encoded_scope["path"] = encoded_path + root_path = scope.get("root_path", "") + if root_path: + encoded_scope["root_path"] = quote(str(root_path), safe="/") + match, child_scope = super().matches(cast(Scope, encoded_scope)) + if match is not Match.NONE: + child_scope["path_params"] = { + key: unquote(value) if isinstance(value, str) else value + for key, value in child_scope.get("path_params", {}).items() + } + return match, child_scope + + def _observe_application_operation( app: FastAPI, operation: Operation[_RequestT, _ResponseT], @@ -2190,9 +2543,10 @@ def _error_response( code: str, message: str, details: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> JSONResponse: error = ErrorResponse(error=ErrorDetail(code=code, message=message, details=details)) - return JSONResponse(status_code=response_status, content=error.model_dump(mode="json")) + return JSONResponse(status_code=response_status, content=error.model_dump(mode="json"), headers=headers) def _validation_error_details(error: RequestValidationError | PydanticValidationError) -> list[Any]: @@ -2205,9 +2559,12 @@ def _validation_error_details(error: RequestValidationError | PydanticValidation return details -def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: +def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: # noqa: C901 if isinstance(error, _RuntimeNotReadyError): return status.HTTP_503_SERVICE_UNAVAILABLE, "runtime_not_ready", "The Runtime is not ready.", None + base_access_error = _map_base_access_error(error) + if base_access_error is not None: + return base_access_error external_skill_error = _map_external_skill_error(error) if external_skill_error is not None: return external_skill_error @@ -2285,6 +2642,85 @@ def _map_governance_error(error: Exception) -> tuple[int, str, str, dict[str, An return None +def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, SourceNotEligibleError): + return ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "source_not_eligible", + "The Source cannot be used as Artifact generation evidence.", + {"source_ref": error.source.model_dump(mode="json")}, + ) + if isinstance(error, _PreconditionRequiredError): + return ( + status.HTTP_428_PRECONDITION_REQUIRED, + "precondition_required", + "Artifact mutation requires the current ETag in If-Match.", + None, + ) + if isinstance(error, ArtifactRevisionPreconditionError): + return ( + status.HTTP_412_PRECONDITION_FAILED, + "revision_conflict", + "Artifact ETag does not match the current head.", + { + "provided_etag": error.provided_etag, + "current_etag": error.current_etag, + }, + ) + if isinstance(error, BaseValueNotFoundError): + return ( + status.HTTP_404_NOT_FOUND, + f"{error.kind}_not_found", + f"The requested {error.kind.capitalize()} was not found.", + None, + ) + if isinstance(error, BaseValueConflictError): + return ( + status.HTTP_409_CONFLICT, + "idempotency_conflict", + "The stable identity already names different durable state.", + {"kind": error.kind}, + ) + if isinstance(error, ArtifactAlreadyExistsError): + return ( + status.HTTP_409_CONFLICT, + "artifact_already_exists", + "The Scope already has this singleton Artifact; use Replace Artifact to update it.", + { + "family": error.family, + "artifact_id": error.artifact_id, + "use_replace": error.use_replace, + }, + ) + if isinstance(error, CursorExpiredError): + return ( + status.HTTP_410_GONE, + "cursor_expired", + "The pagination cursor has expired.", + None, + ) + if isinstance(error, InvalidCursorError): + return ( + status.HTTP_400_BAD_REQUEST, + "invalid_cursor", + "The pagination cursor is invalid or does not match this request.", + {"field": error.field, "reason": error.reason}, + ) + if isinstance(error, InvalidBaseAccessRequestError): + response_status = ( + status.HTTP_400_BAD_REQUEST + if error.field in {"If-Match", "limit", "mode"} + else status.HTTP_422_UNPROCESSABLE_CONTENT + ) + return ( + response_status, + "invalid_request", + "The request is invalid.", + {"field": error.field, "reason": error.reason}, + ) + return None + + def _map_scope_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, ArtifactPublicationUnsupportedError): return ( diff --git a/src/powercontext/server/cursor_secret.py b/src/powercontext/server/cursor_secret.py new file mode 100644 index 000000000..66310ae28 --- /dev/null +++ b/src/powercontext/server/cursor_secret.py @@ -0,0 +1,72 @@ +# 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. + +"""Resolve a stable signing secret for stateless REST cursors.""" + +from __future__ import annotations + +import os +import secrets +from pathlib import Path + +from sqlalchemy.engine import make_url + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime.config import DatabaseConfig +from powercontext.paths import powercontext_data_dir + +_CURSOR_SECRET_BYTES = 32 + + +def resolve_cursor_secret(database: DatabaseConfig, configured_secret: str | None, /) -> bytes | None: + """Use an explicit secret, or persist one in the local data location.""" + + if configured_secret is not None: + return configured_secret.encode() + if isinstance(database, SQLiteConfig): + if database.is_in_memory: + return None + database_name = make_url(database.url).database + if database_name: + database_path = Path(database_name).expanduser().resolve(strict=False) + return _load_or_create(database_path.with_name(f".{database_path.name}.cursor-key")) + return _load_or_create(powercontext_data_dir() / "cursor-signing.key") + + +def _load_or_create(path: Path) -> bytes: + path.parent.mkdir(parents=True, exist_ok=True) + try: + return _read(path) + except FileNotFoundError: + pass + + generated = secrets.token_bytes(_CURSOR_SECRET_BYTES) + try: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + return _read(path) + try: + written = os.write(descriptor, generated) + if written != len(generated): + raise OSError(f"failed to write complete cursor signing key: {path}") # noqa: TRY003 + finally: + os.close(descriptor) + return generated + + +def _read(path: Path) -> bytes: + secret = path.read_bytes() + if len(secret) != _CURSOR_SECRET_BYTES: + raise ValueError(f"cursor signing key must contain {_CURSOR_SECRET_BYTES} bytes: {path}") # noqa: TRY003 + return secret diff --git a/src/powercontext/server/factory.py b/src/powercontext/server/factory.py index 7470d6896..3d9e9e1f6 100644 --- a/src/powercontext/server/factory.py +++ b/src/powercontext/server/factory.py @@ -41,6 +41,7 @@ from powercontext.paths import default_scheduler_path from powercontext.server.access import HttpAccessLogMiddleware from powercontext.server.app import create_app +from powercontext.server.cursor_secret import resolve_cursor_secret from powercontext.server.mcp import mount_mcp from powercontext.server.metrics import CONTENT_TYPE_LATEST, HttpMetricsMiddleware, ServerMetrics from powercontext.server.middleware import StaticBearerMiddleware @@ -88,6 +89,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: _log_insecure_remote_http_warning() if isinstance(config.database, SQLiteConfig) and config.database.is_in_memory: _log_in_memory_database_warning() + configured_cursor_secret = ( + None if resolved.cursor_signing_secret is None else resolved.cursor_signing_secret.get_secret_value() + ) + cursor_secret = resolve_cursor_secret(config.database, configured_cursor_secret) async with open_builtin_runtime( config, scheduler_path=default_scheduler_path() if scheduler_path is None else scheduler_path, @@ -101,6 +106,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: instrumentation=resolved_tracing.instrumentation, scope_cache_observer=None if metrics is None else metrics.set_runtime_scopes, tracing=resolved_tracing, + cursor_secret=cursor_secret, ) as runtime: readiness_probe.bind(runtime) app.state.application = runtime diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 266d7b85f..ee27c373b 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -245,6 +245,8 @@ SourceDefinitionManifest, SourceObservationReceipt, SourceReference, + SourceType, + SourceTypeReference, SubmitSourceObservationRequest, TaskCheck, WorkClaim, @@ -959,6 +961,14 @@ def runtime_source_reference(value: SourceReference) -> SourceRef: return SourceRef(source_type=value.name, source_id=value.source_id) +def source_type_reference(value: SourceRef) -> SourceTypeReference: + return SourceTypeReference(source_type=SourceType(value.source_type), source_id=value.source_id) + + +def runtime_source_type_reference(value: SourceTypeReference) -> SourceRef: + return SourceRef(source_type=value.source_type, source_id=value.source_id) + + def runtime_citation(value: TransportMemoryCitation) -> RuntimeMemoryCitation: return RuntimeMemoryCitation( memory_ref=ArtifactRef( diff --git a/src/powercontext/server/settings.py b/src/powercontext/server/settings.py index 56ea3a161..0d58e02fc 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -190,12 +190,20 @@ class ServerSettings(BaseSettings): logging: ServerLoggingConfig = Field(default_factory=ServerLoggingConfig) metrics: MetricsConfig = Field(default_factory=MetricsConfig) tracing: TracingConfig = Field(default_factory=TracingConfig) + cursor_signing_secret: SecretStr | None = Field(default=None, repr=False) runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) database: DatabaseConfig = Field(default_factory=_default_database, discriminator="kind") handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig) inference: InferenceConfig = Field(default_factory=InferenceConfig) external_skills: ExternalSkillsConfig = Field(default_factory=ExternalSkillsConfig) + @field_validator("cursor_signing_secret") + @classmethod + def validate_cursor_signing_secret(cls, value: SecretStr | None) -> SecretStr | None: + if value is not None and len(value.get_secret_value().encode()) < 32: + raise ValueError("cursor signing secret must contain at least 32 bytes") # noqa: TRY003 + return value + @field_validator("workspace") @classmethod def resolve_workspace(cls, value: Path) -> Path: diff --git a/tests/builtin/persistence/test_generation_sources.py b/tests/builtin/persistence/test_generation_sources.py new file mode 100644 index 000000000..32809b983 --- /dev/null +++ b/tests/builtin/persistence/test_generation_sources.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import update + +from powercontext.builtin.persistence import InvalidStoredPayloadError +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.tables import SOURCES_TABLE +from powercontext.builtin.source_eligibility import SourceNotEligibleError +from powercontext.builtin.sources import ( + CONTENT_SOURCE_ADAPTER, + ContentSource, + ContentSourceInternal, + ContentSourceTarget, +) +from powercontext.sources import SourceMaterialization +from tests.builtin.persistence.contract import repository_profile + + +def _content_source(name: str, *, lineage_only: bool = False) -> ContentSource: + internal = ( + ContentSourceInternal( + role="lineage_only", + operation="artifact_create", + target=ContentSourceTarget( + scope_id="scope-a", + family="future-family", + artifact_id="artifact-1", + revision=1, + ), + ) + if lineage_only + else None + ) + return ContentSource( + name=name, + materialization=SourceMaterialization.CAPTURED, + content=name, + internal=internal, + ) + + +def test_generation_source_access_rejects_explicit_refs_but_skips_window_entries() -> None: + async def scenario() -> None: + repository = SourceRepository((CONTENT_SOURCE_ADAPTER,)) + access = GenerationSourceAccess(repository) + async with repository_profile() as (profile, _repositories): + async with profile.database.transaction() as connection: + ordinary = await repository.add(connection, "scope-a", _content_source("ordinary")) + reserved = await repository.add( + connection, + "scope-a", + _content_source("reserved", lineage_only=True), + ) + + with pytest.raises(SourceNotEligibleError) as error: + await access.require_for_generation(connection, "scope-a", (ordinary.ref, reserved.ref)) + window = await access.list_window_for_generation( + connection, + "scope-a", + after=0, + through=reserved.journal_position, + ) + + assert error.value.source == reserved.ref + assert window == (ordinary,) + + asyncio.run(scenario()) + + +def test_generation_window_fails_closed_on_a_malformed_source_payload() -> None: + async def scenario() -> None: + repository = SourceRepository((CONTENT_SOURCE_ADAPTER,)) + access = GenerationSourceAccess(repository) + async with ( + repository_profile() as (profile, _repositories), + profile.database.transaction() as connection, + ): + stored = await repository.add(connection, "scope-a", _content_source("broken")) + await connection.execute( + update(SOURCES_TABLE) + .where( + SOURCES_TABLE.c.scope_id == "scope-a", + SOURCES_TABLE.c.source_type == stored.ref.source_type, + SOURCES_TABLE.c.source_id == stored.ref.source_id, + ) + .values(payload=b"not-json") + ) + + with pytest.raises(InvalidStoredPayloadError): + await access.list_window_for_generation( + connection, + "scope-a", + after=0, + through=stored.journal_position, + ) + + asyncio.run(scenario()) diff --git a/tests/builtin/persistence/test_provider.py b/tests/builtin/persistence/test_provider.py index 01de310d6..ba8c3322f 100644 --- a/tests/builtin/persistence/test_provider.py +++ b/tests/builtin/persistence/test_provider.py @@ -29,6 +29,7 @@ ) from powercontext.builtin.artifacts.memory import MemoryCandidateRequest, MemoryEntryInput from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts from powercontext.builtin.sources import BUILTIN_SOURCE_REGISTRY, ContentCapture, ContentSource, SourceCursor @@ -108,6 +109,30 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_lineage_only_source_remains_readable_but_is_skipped_by_memory_flush() -> None: + async def scenario() -> None: + async with open_builtin_contexts( + BuiltinConfig(database=SQLiteConfig()), + candidate_pipeline=EchoCandidatePipeline(), + ) as contexts: + context = await contexts.get("project") + created = await contexts.records.create_artifact( + "project", + "memory", + ArtifactWrite(content={"entries": [{"kind": "fact", "text": "Directly managed."}]}), + ) + async with contexts.database.transaction() as connection: + stored = await contexts.repositories.sources.get(connection, "project", created.sources[0]) + + assert await context.sources.get(stored.value) == stored.value + flushed = await context.triggers.flush(limit=10) + assert flushed.source_count == 0 + assert flushed.current_cursor == stored.journal_position + assert flushed.memory_ref is None + + asyncio.run(scenario()) + + def test_provider_translates_repository_source_identity_conflicts() -> None: async def scenario() -> None: async with open_builtin_contexts(BuiltinConfig(database=SQLiteConfig())) as contexts: diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py new file mode 100644 index 000000000..79093d6ba --- /dev/null +++ b/tests/builtin/persistence/test_records.py @@ -0,0 +1,457 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from collections import defaultdict + +import pytest +from pydantic import JsonValue +from sqlalchemy import event, func, select +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.artifacts.experience import Experience +from powercontext.builtin.artifacts.handoff import Handoff +from powercontext.builtin.artifacts.memory import Memory +from powercontext.builtin.artifacts.skill import Skill +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.experience_index import ExperienceIndex, NoExperienceIndex +from powercontext.builtin.persistence.family_management import ( + ExperienceManagementWriter, + FamilyManagementWriterRegistry, + HandoffManagementWriter, + MemoryManagementWriter, + SkillManagementWriter, +) +from powercontext.builtin.persistence.memory_index import NoMemoryIndex +from powercontext.builtin.persistence.records import RelationalRecordService +from powercontext.builtin.persistence.skill_packages import SkillPackageRepository +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.tables import ( + ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACTS_TABLE, + BUILTIN_TABLES, + MEMORY_ENTRY_HEADS_TABLE, + MEMORY_ENTRY_VERSIONS_TABLE, + SKILL_PACKAGES_TABLE, + SOURCES_TABLE, +) +from powercontext.builtin.records import ( + ArtifactRevisionPreconditionError, + ArtifactWrite, + InvalidBaseAccessRequestError, +) +from powercontext.builtin.source_eligibility import SourceNotEligibleError +from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER, ContentSource + + +class _FailingExperienceIndex(NoExperienceIndex): + async def replace( + self, + _connection: AsyncConnection, + _scope_id: str, + _experience: Experience, + /, + ) -> None: + raise RuntimeError("projection update failed") # noqa: TRY003 + + +def _memory_content() -> dict[str, JsonValue]: + return {"entries": [{"kind": "preference", "text": "用户偏好使用中文回答"}]} + + +def _handoff_content(objective: str = "Transfer the API test result.") -> dict[str, JsonValue]: + return { + "schema": "powercontext.handoff.v1", + "objective": objective, + "state": [ + { + "text": "The Source and Artifact API passed live HTTP tests.", + "citations": [ + { + "kind": "source", + "source_ref": {"source_type": "content", "source_id": "source-evidence"}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + } + + +def _services( + profile: SQLiteProfile, + *, + experience_index: ExperienceIndex | None = None, +) -> tuple[RelationalRecordService, ArtifactRepository, SourceRepository]: + counters: defaultdict[str, int] = defaultdict(int) + + def new_id(kind: str) -> str: + counters[kind] += 1 + prefixes = {"source": "src", "memory": "mem", "experience": "exp", "skill": "skill"} + return f"{prefixes.get(kind, kind)}-{counters[kind]}" + + sources = SourceRepository((CONTENT_SOURCE_ADAPTER,)) + artifacts = ArtifactRepository((Handoff, Memory, Experience, Skill), sources=sources) + memory_index = NoMemoryIndex() + selected_experience_index = NoExperienceIndex() if experience_index is None else experience_index + packages = SkillPackageRepository() + writers = FamilyManagementWriterRegistry(( + MemoryManagementWriter( + database=profile.database, + artifacts=artifacts, + index=memory_index, + embedding_model=None, + id_factory=new_id, + ), + ExperienceManagementWriter(artifacts, selected_experience_index), + SkillManagementWriter(artifacts, selected_experience_index, packages), + HandoffManagementWriter( + database=profile.database, + artifacts=artifacts, + sources=sources, + memory_index=memory_index, + id_factory=new_id, + memory_artifact_id="memory", + handoff_artifact_id="handoff", + ), + )) + records = RelationalRecordService( + profile.database, + sources, + artifacts, + writers, + id_factory=new_id, + cursor_secret=b"record-test-secret", + ) + return records, artifacts, sources + + +def test_source_create_persists_json_without_public_internal_fields() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + created = await records.create_source("scope-a", "content", {"fact": True}) + loaded = await records.get_source("scope-a", "content", "src-1") + null_source = await records.create_source("scope-a", "content", None) + + assert loaded == created + assert created.content == {"fact": True} + assert null_source.content is None + assert (await records.get_source("scope-a", "content", "src-2")).content is None + assert set(created.model_dump()) == { + "scope_id", + "source_type", + "source_id", + "content", + "position", + "content_digest", + } + with pytest.raises(InvalidBaseAccessRequestError): + await records.create_source("scope-a", "private", "not public") + + asyncio.run(scenario()) + + +def test_artifact_create_is_atomic_and_binds_its_system_source() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, artifacts, sources = _services(profile) + created = await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) + + assert (created.family, created.artifact_id, created.revision) == ("memory", "mem-1", 1) + assert created.artifacts == () + assert len(created.sources) == 1 + assert created.sources[0].source_id == "src-1" + + loaded_source = await records.get_source("scope-a", "content", "src-1") + assert loaded_source.content == _memory_content() + async with profile.database.transaction() as connection: + stored = await sources.get(connection, "scope-a", created.sources[0]) + assert isinstance(stored.value, ContentSource) + assert stored.value.internal is not None + assert stored.value.internal.target.model_dump() == { + "scope_id": "scope-a", + "family": "memory", + "artifact_id": "mem-1", + "revision": 1, + } + lineage = (await connection.execute(select(ARTIFACT_LINEAGE_SOURCES_TABLE))).mappings().one() + assert lineage["ordinal"] == 0 + entry = (await connection.execute(select(MEMORY_ENTRY_VERSIONS_TABLE))).mappings().one() + assert (entry["kind"], entry["text"]) == ("preference", "用户偏好使用中文回答") + projection = (await connection.execute(select(MEMORY_ENTRY_HEADS_TABLE))).mappings().one() + assert projection["searchable_text"] + + async with profile.database.transaction() as connection: + assert await connection.scalar(select(func.count()).select_from(SOURCES_TABLE)) == 1 + assert await connection.scalar(select(func.count()).select_from(ARTIFACTS_TABLE)) == 1 + assert await connection.scalar(select(func.count()).select_from(ARTIFACT_HEADS_TABLE)) == 1 + stored_memory = await records.get_artifact("scope-a", "memory", created.artifact_id) + foreign = artifacts.draft("memory", stored_memory.content, sources=created.sources) + with pytest.raises(SourceNotEligibleError): + await artifacts.create(connection, "scope-a", "mem-foreign", foreign) + + asyncio.run(scenario()) + + +def test_artifact_get_list_replace_use_family_models_and_opaque_etags() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, sources = _services(profile) + created = await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) + head = await records.get_artifact("scope-a", "memory", created.artifact_id) + page = await records.query_artifacts("scope-a", "memory", limit=10, cursor=None) + + assert head.revision == 1 + assert head.sources == created.sources + assert [item.artifact_id for item in page.items] == [created.artifact_id] + assert "content" not in page.items[0].model_dump() + replaced = await records.replace_artifact( + "scope-a", + "memory", + created.artifact_id, + '"revision:1"', + ArtifactWrite(content={"entries": [{"kind": "working_note", "text": "继续验证 API"}]}), + ) + assert replaced.revision == 2 + assert replaced.sources[0].source_id == "src-2" + original = await records.get_artifact_revision("scope-a", "memory", created.artifact_id, 1) + assert original.sources == created.sources + async with profile.database.transaction() as connection: + replacement_source = await sources.get(connection, "scope-a", replaced.sources[0]) + versions = ( + ( + await connection.execute( + select(MEMORY_ENTRY_VERSIONS_TABLE).order_by(MEMORY_ENTRY_VERSIONS_TABLE.c.entry_version_id) + ) + ) + .mappings() + .all() + ) + projections = (await connection.execute(select(MEMORY_ENTRY_HEADS_TABLE))).mappings().all() + assert isinstance(replacement_source.value, ContentSource) + assert replacement_source.value.internal is not None + assert replacement_source.value.internal.operation == "artifact_replace" + assert replacement_source.value.internal.target.revision == 2 + assert [(row["kind"], row["text"]) for row in versions] == [ + ("preference", "用户偏好使用中文回答"), + ("working_note", "继续验证 API"), + ] + assert len(projections) == 2 + + with pytest.raises(ArtifactRevisionPreconditionError): + await records.replace_artifact( + "scope-a", + "memory", + created.artifact_id, + '"opaque-stale"', + ArtifactWrite(content=_memory_content()), + ) + with pytest.raises(InvalidBaseAccessRequestError): + await records.create_artifact("scope-a", "document", ArtifactWrite(content={})) + with pytest.raises(InvalidBaseAccessRequestError): + await records.create_artifact("scope-a", "memory", ArtifactWrite(content={"invalid": True})) + + asyncio.run(scenario()) + + +def test_artifact_create_and_replace_validate_handoff_as_json() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + evidence = await records.create_source("scope-a", "content", "verified API test") + created = await records.create_artifact( + "scope-a", + "handoff", + ArtifactWrite( + content=_handoff_content().copy() + | { + "state": [ + { + "text": "verified", + "citations": [ + { + "kind": "source", + "source_ref": evidence.model_dump(include={"source_type", "source_id"}), + } + ], + } + ] + } + ), + ) + + loaded = await records.get_artifact("scope-a", "handoff", created.artifact_id) + assert loaded.artifact_id == "handoff" + assert loaded.sources[0] == created.sources[0] + assert loaded.sources[1].source_id == evidence.source_id + + replacement = loaded.content | {"objective": "Transfer the verified API test result."} + replaced = await records.replace_artifact( + "scope-a", + "handoff", + created.artifact_id, + '"revision:1"', + ArtifactWrite(content=replacement), + ) + assert replaced.revision == 2 + assert replaced.sources[0].source_id != created.sources[0].source_id + assert replaced.sources[1].source_id == evidence.source_id + assert replaced.content == replacement + + asyncio.run(scenario()) + + +def test_artifact_list_batches_revision_and_lineage_reads() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + for _ in range(3): + await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) + + statements: list[str] = [] + + def record_statement(*args: object) -> None: + statements.append(str(args[2])) + + event.listen(profile.database.engine.sync_engine, "before_cursor_execute", record_statement) + try: + page = await records.query_artifacts("scope-a", "memory", limit=10, cursor=None) + finally: + event.remove(profile.database.engine.sync_engine, "before_cursor_execute", record_statement) + + assert len(page.items) == 3 + assert len([statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]) == 4 + + asyncio.run(scenario()) + + +def test_experience_and_skill_writers_update_owned_search_projections() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + index = SQLiteExperienceFTSIndex() + async with profile.database.transaction() as connection: + await index.initialize(connection) + records, _, _ = _services(profile, experience_index=index) + + experience = await records.create_artifact( + "scope-a", + "experience", + ArtifactWrite( + content={ + "situation": "发布前发现兼容性问题", + "action": "增加跨版本测试", + "outcome": "避免线上回归", + "lesson": "公共接口变更需要覆盖兼容性测试", + } + ), + ) + skill = await records.create_artifact( + "scope-a", + "skill", + ArtifactWrite( + content={ + "name": "compatibility-check", + "description": "Check compatibility before release", + "instructions": "Run cross-version compatibility tests.", + "validation": ["Compatibility tests pass"], + } + ), + ) + replaced_experience = await records.replace_artifact( + "scope-a", + "experience", + experience.artifact_id, + '"revision:1"', + ArtifactWrite( + content={ + "situation": "A rollback was required", + "action": "Rebuild the compatibility matrix", + "outcome": "The release became safe", + "lesson": "Keep compatibility evidence current", + } + ), + ) + replaced_skill = await records.replace_artifact( + "scope-a", + "skill", + skill.artifact_id, + '"revision:1"', + ArtifactWrite( + content={ + "name": "compatibility-check", + "description": "Check compatibility before every release", + "instructions": "Run the complete compatibility matrix.", + "validation": ["Compatibility matrix passes"], + } + ), + ) + + async with profile.database.transaction() as connection: + experience_hits = await index.search(connection, "scope-a", "rollback", 10) + skill_hits = await index.search_skills(connection, "scope-a", "matrix", 10) + package_count = await connection.scalar(select(func.count()).select_from(SKILL_PACKAGES_TABLE)) + assert [hit.artifact_ref for hit in experience_hits] == [ + ArtifactRef( + family="experience", artifact_id=experience.artifact_id, revision=replaced_experience.revision + ) + ] + assert [hit.artifact_ref for hit in skill_hits] == [ + ArtifactRef(family="skill", artifact_id=skill.artifact_id, revision=replaced_skill.revision) + ] + assert package_count == 2 + + asyncio.run(scenario()) + + +def test_family_projection_failure_rolls_back_source_and_artifact() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile, experience_index=_FailingExperienceIndex()) + + with pytest.raises(RuntimeError, match="projection update failed"): + await records.create_artifact( + "scope-a", + "experience", + ArtifactWrite( + content={ + "situation": "A projection cannot be updated", + "action": "Abort the management write", + "outcome": "No partial state remains", + "lesson": "Derived state belongs to the transaction", + } + ), + ) + + async with profile.database.transaction() as connection: + assert await connection.scalar(select(func.count()).select_from(SOURCES_TABLE)) == 0 + assert await connection.scalar(select(func.count()).select_from(ARTIFACTS_TABLE)) == 0 + assert await connection.scalar(select(func.count()).select_from(ARTIFACT_HEADS_TABLE)) == 0 + + asyncio.run(scenario()) + + +def test_base_access_reuses_existing_tables_without_lifecycle_columns() -> None: + assert "created_at" not in SOURCES_TABLE.c + assert "created_at" not in ARTIFACTS_TABLE.c + assert "deleted_at" not in ARTIFACT_HEADS_TABLE.c + assert ArtifactRef(family="memory", artifact_id="memory-1", revision=1).revision == 1 diff --git a/tests/builtin/persistence/test_sources.py b/tests/builtin/persistence/test_sources.py index 19289926f..22e080499 100644 --- a/tests/builtin/persistence/test_sources.py +++ b/tests/builtin/persistence/test_sources.py @@ -22,6 +22,7 @@ from powercontext import InvalidSourceDefinitionError from powercontext.builtin.persistence import ( InvalidRepositoryArgumentError, + RepositoryNotFoundError, StoredPayloadConflictError, ) from powercontext.builtin.persistence.codec import dump_model @@ -98,6 +99,56 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_repository_batch_reads_deduplicate_refs_and_bound_the_journal_window() -> None: + async def scenario() -> None: + async with repository_profile() as (profile, repositories): + first_source = NoteSource( + name="note-1", + materialization=SourceMaterialization.CAPTURED, + body="first", + ) + second_source = NoteSource( + name="note-2", + materialization=SourceMaterialization.CAPTURED, + body="second", + ) + third_source = NoteSource( + name="note-3", + materialization=SourceMaterialization.CAPTURED, + body="third", + ) + async with profile.database.transaction() as connection: + first = await repositories.sources.add(connection, "scope-a", first_source) + second = await repositories.sources.add(connection, "scope-a", second_source) + third = await repositories.sources.add(connection, "scope-a", third_source) + + loaded = await repositories.sources.get_many( + connection, + "scope-a", + (second.ref, first.ref, second.ref), + ) + window = await repositories.sources.list_window( + connection, + "scope-a", + after=first.journal_position, + through=second.journal_position, + ) + + assert loaded == (second, first) + assert window == (second,) + assert third.journal_position > second.journal_position + with pytest.raises(RepositoryNotFoundError): + await repositories.sources.get_many( + connection, + "scope-a", + (SourceRef(source_type="note", source_id="missing"),), + ) + with pytest.raises(InvalidRepositoryArgumentError): + await repositories.sources.list_window(connection, "scope-a", after=2, through=1) + + asyncio.run(scenario()) + + def test_repository_rejects_a_scope_outside_the_relational_identity_baseline() -> None: async def scenario() -> None: async with repository_profile() as (profile, repositories): diff --git a/tests/builtin/review/test_generation.py b/tests/builtin/review/test_generation.py index 03a0defc0..93a30e6fa 100644 --- a/tests/builtin/review/test_generation.py +++ b/tests/builtin/review/test_generation.py @@ -22,6 +22,7 @@ from powercontext.builtin.artifacts.generation import ArtifactGenerationInput from powercontext.builtin.artifacts.skill import SkillContent from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.review import InvalidCandidateError from powercontext.builtin.review.generation import GenerationCapabilityUnavailableError from powercontext.builtin.runtime import ( @@ -36,6 +37,7 @@ open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft +from powercontext.builtin.source_eligibility import SourceNotEligibleError async def _create_scope(runtime: BuiltinRuntime, idempotency_key: str = "project") -> str: @@ -150,6 +152,30 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_explicit_generation_rejects_a_lineage_only_source_before_model_use() -> None: + async def scenario() -> None: + generator = _ExperienceGenerator(_experience()) + async with open_builtin_runtime( + BuiltinConfig(database=SQLiteConfig()), + experience_generator=generator, + ) as runtime: + scope_id = await _create_scope(runtime) + created = await runtime.records.for_scope(scope_id).create_artifact( + "memory", + ArtifactWrite(content={"entries": [{"kind": "fact", "text": "Direct input."}]}), + ) + + with pytest.raises(SourceNotEligibleError) as error: + await runtime.experience.for_scope(scope_id).generate( + GenerateExperienceRequest(sources=(created.sources[0],)) + ) + + assert error.value.source == created.sources[0] + assert generator.inputs == [] + + asyncio.run(scenario()) + + def test_experience_generation_targets_an_exact_approved_revision() -> None: async def scenario() -> None: generator = _ExperienceGenerator(_experience()) diff --git a/tests/builtin/review/test_service.py b/tests/builtin/review/test_service.py index 1b4734f7b..5d38a2ff8 100644 --- a/tests/builtin/review/test_service.py +++ b/tests/builtin/review/test_service.py @@ -27,6 +27,7 @@ from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE, BUILTIN_TABLES +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -53,6 +54,7 @@ ) from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.scope import ScopeDraft +from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.builtin.sources import ContentCapture @@ -191,6 +193,48 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_approval_rechecks_sources_saved_by_an_older_candidate_path() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + contexts = RelationalContexts(database=profile.database) + await contexts.get("project") + created = await contexts.records.create_artifact( + "project", + "memory", + ArtifactWrite(content={"entries": [{"kind": "fact", "text": "Direct input."}]}), + ) + async with profile.database.transaction() as connection: + candidate = await contexts.repositories.candidates.create( + connection, + "project", + "candidate-legacy", + "experience", + _proposal(), + sources=created.sources, + artifacts=(), + target=None, + reason=None, + ) + + with pytest.raises(SourceNotEligibleError): + await contexts.review("project").approve(candidate.candidate_id, candidate.version) + + current = await contexts.review("project").get_candidate(candidate.candidate_id) + async with profile.database.transaction() as connection: + experiences = await connection.scalar( + select(func.count()) + .select_from(ARTIFACTS_TABLE) + .where( + ARTIFACTS_TABLE.c.scope_id == "project", + ARTIFACTS_TABLE.c.family == Experience.family, + ) + ) + assert current.status == "pending" + assert experiences == 0 + + asyncio.run(scenario()) + + def test_experience_candidate_revise_approve_and_retrieval_gate() -> None: async def scenario() -> None: async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: diff --git a/tests/builtin/runtime/test_experience_incubation.py b/tests/builtin/runtime/test_experience_incubation.py index 34d360190..447009c4d 100644 --- a/tests/builtin/runtime/test_experience_incubation.py +++ b/tests/builtin/runtime/test_experience_incubation.py @@ -21,6 +21,7 @@ from powercontext.builtin.artifacts.experience import ExperienceCandidateInput, ExperienceContent from powercontext.builtin.inference import InferenceUnavailableError from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import ( ApproveArtifactCandidateRequest, BuiltinConfig, @@ -188,6 +189,29 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_incubation_skips_lineage_only_sources_but_advances_the_full_window() -> None: + async def scenario() -> None: + pipeline = _TaskOutcomePipeline() + async with open_builtin_runtime( + BuiltinConfig(database=SQLiteConfig()), + experience_pipeline=pipeline, + ) as runtime: + scope = await _create_scope(runtime, "lineage-only") + created = await runtime.records.for_scope(scope).create_artifact( + "memory", + ArtifactWrite(content={"entries": [{"kind": "working_note", "text": "Do not incubate direct writes"}]}), + ) + result = await runtime.experience.for_scope(scope).incubate() + + assert created.revision == 1 + assert result.current_cursor == 1 + assert result.source_count == 0 + assert result.candidate_count == 0 + assert pipeline.calls == [] + + asyncio.run(scenario()) + + def test_scheduled_incubation_requires_a_configured_pipeline(tmp_path) -> None: async def scenario() -> None: with pytest.raises(BuiltinConfigurationError, match="Experience incubation"): diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py new file mode 100644 index 000000000..10b2170a9 --- /dev/null +++ b/tests/e2e/test_base_access_api.py @@ -0,0 +1,317 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from pathlib import Path +from urllib.parse import quote + +import httpx +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.client import PowerContextClient, ServerResponseError +from powercontext.http import ( + ArtifactReference, + ContinueHandoffRequest, + CreateArtifactRequest, + CreateSourceRequest, + GetExperienceRequest, + GetSkillPackageRequest, + GetSkillRequest, + HandoffSelection, + ListArtifactsRequest, + ReplaceArtifactRequest, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings + + +def _memory_content() -> dict[str, object]: + return { + "entries": [{"kind": "preference", "text": "用户偏好使用中文回答"}], + } + + +def _handoff_content(source_id: str, objective: str = "Transfer the API test result.") -> dict[str, object]: + return { + "schema": "powercontext.handoff.v1", + "objective": objective, + "state": [ + { + "text": "The Source and Artifact API passed live HTTP tests.", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": source_id}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + } + + +def test_source_and_artifact_api_round_trip(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'base-access.db'}"), + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope_id = (await client.get_default_scope()).scope_id + encoded_scope = quote(scope_id, safe="") + source = await client.create_source( + scope_id, + CreateSourceRequest(content={"statement": "Keep the public Source immutable."}), + ) + assert await client.get_source(scope_id, "content", source.source_id) == source + assert source.content == {"statement": "Keep the public Source immutable."} + assert set(source.model_dump()) == { + "scope_id", + "source_type", + "source_id", + "content", + "position", + "content_digest", + } + null_source = await client.create_source(scope_id, CreateSourceRequest(content=None)) + assert null_source.content is None + assert (await client.get_source(scope_id, "content", null_source.source_id)).content is None + + invalid_source_type = await transport.get( + f"/v1/scopes/{encoded_scope}/sources/private/{quote(source.source_id, safe='')}" + ) + assert invalid_source_type.status_code == 422 + invalid_family = await transport.post( + f"/v1/scopes/{encoded_scope}/artifacts", + json={"family": "document", "content": {}}, + ) + assert invalid_family.status_code == 422 + + created = await client.create_artifact( + scope_id, + CreateArtifactRequest.model_validate({"family": "memory", "content": _memory_content()}), + ) + assert created.revision == 1 + assert len(created.sources) == 1 + assert created.artifacts == [] + assert "content" not in created.model_dump() + + system_source = await client.get_source( + scope_id, + created.sources[0].source_type.value, + created.sources[0].source_id, + ) + assert system_source.content == _memory_content() + assert "internal" not in system_source.model_dump() + + head_path = f"/v1/scopes/{encoded_scope}/artifacts/memory/{quote(created.artifact_id, safe='')}" + raw_head = await transport.get(head_path) + assert raw_head.status_code == 200 + etag = raw_head.headers["ETag"] + loaded = await client.get_artifact(scope_id, "memory", created.artifact_id) + assert loaded is not None + assert loaded.sources == created.sources + assert len(loaded.content["manifest"]["entries"]) == 1 + assert loaded.content["changes"][0]["op"] == "add" + not_modified = await client.get_artifact( + scope_id, + "memory", + created.artifact_id, + if_none_match=etag, + ) + assert not_modified is None + + listed = await client.list_artifacts(scope_id, "memory", ListArtifactsRequest()) + assert [item.artifact_id for item in listed.items] == [created.artifact_id] + assert "content" not in listed.items[0].model_dump() + assert listed.items[0].sources == created.sources + + replaced = await client.replace_artifact( + scope_id, + "memory", + created.artifact_id, + ReplaceArtifactRequest.model_validate({ + "content": {"entries": [{"kind": "working_note", "text": "继续验证基础 API"}]} + }), + expected_etag=etag, + ) + assert replaced.revision == 2 + assert len(replaced.sources) == 1 + assert replaced.sources != created.sources + replacement_source = await client.get_source( + scope_id, + replaced.sources[0].source_type.value, + replaced.sources[0].source_id, + ) + assert replacement_source.content == {"entries": [{"kind": "working_note", "text": "继续验证基础 API"}]} + exact_first = await client.get_artifact_revision(scope_id, "memory", created.artifact_id, 1) + assert exact_first.revision == 1 + assert exact_first.sources == created.sources + + with pytest.raises(ServerResponseError) as stale: + await client.replace_artifact( + scope_id, + "memory", + created.artifact_id, + ReplaceArtifactRequest.model_validate({ + "content": {"entries": [{"kind": "working_note", "text": "不能覆盖并发更新"}]} + }), + expected_etag=etag, + ) + assert stale.value.status_code == 412 + assert stale.value.code == "revision_conflict" + + asyncio.run(scenario()) + + +def test_handoff_artifact_round_trip_accepts_json_arrays(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'handoff-base-access.db'}"), + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope_id = (await client.get_default_scope()).scope_id + evidence = await client.create_source(scope_id, CreateSourceRequest(content="verified API test")) + created = await client.create_artifact( + scope_id, + CreateArtifactRequest.model_validate({ + "family": "handoff", + "content": _handoff_content(evidence.source_id), + }), + ) + assert created.artifact_id == "handoff" + loaded = await client.get_artifact(scope_id, "handoff", created.artifact_id) + assert loaded is not None + assert loaded.sources[0] == created.sources[0] + assert loaded.sources[1].source_id == evidence.source_id + assert loaded.content["objective"] == _handoff_content(evidence.source_id)["objective"] + assert loaded.content["state"][0]["citations"][0]["source_ref"] == { + "source_type": "content", + "source_id": evidence.source_id, + } + continued = await client.continue_handoff( + ContinueHandoffRequest(scope_id=scope_id, selection=HandoffSelection.LATEST) + ) + assert continued.content is not None + assert continued.content.objective == "Transfer the API test result." + + with pytest.raises(ServerResponseError) as duplicate: + await client.create_artifact( + scope_id, + CreateArtifactRequest.model_validate({ + "family": "handoff", + "content": _handoff_content(evidence.source_id), + }), + ) + assert duplicate.value.status_code == 409 + assert duplicate.value.code == "artifact_already_exists" + assert duplicate.value.details == {"family": "handoff", "artifact_id": "handoff", "use_replace": True} + + replaced = await client.replace_artifact( + scope_id, + "handoff", + created.artifact_id, + ReplaceArtifactRequest.model_validate({ + "content": _handoff_content(evidence.source_id, "Transfer the verified API test result.") + }), + expected_etag='"revision:1"', + ) + assert replaced.revision == 2 + assert replaced.sources[0].source_id != created.sources[0].source_id + assert replaced.sources[1].source_id == evidence.source_id + assert replaced.content["objective"] == "Transfer the verified API test result." + + asyncio.run(scenario()) + + +def test_experience_and_skill_base_writes_are_visible_through_existing_apis(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'family-base-access.db'}"), + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + ) + + async def scenario() -> None: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, + ): + client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + scope_id = (await client.get_default_scope()).scope_id + experience_content = { + "situation": "A compatibility issue was found before release", + "action": "Add cross-version tests", + "outcome": "Avoided a production regression", + "lesson": "Public API changes require compatibility coverage", + } + experience = await client.create_artifact( + scope_id, + CreateArtifactRequest.model_validate({"family": "experience", "content": experience_content}), + ) + experience_ref = ArtifactReference( + family="experience", + artifact_id=experience.artifact_id, + revision=experience.revision, + ) + loaded_experience = await client.get_experience( + GetExperienceRequest(scope_id=scope_id, artifact=experience_ref) + ) + assert loaded_experience.content.model_dump() == experience_content + + skill = await client.create_artifact( + scope_id, + CreateArtifactRequest.model_validate({ + "family": "skill", + "content": { + "name": "compatibility-check", + "description": "Check compatibility before release", + "instructions": "Run cross-version compatibility tests.", + "validation": ["Compatibility tests pass"], + }, + }), + ) + skill_ref = ArtifactReference(family="skill", artifact_id=skill.artifact_id, revision=skill.revision) + loaded_skill = await client.get_skill(GetSkillRequest(scope_id=scope_id, artifact=skill_ref)) + assert loaded_skill.content.package is not None + package = await client.get_skill_package_manifest( + GetSkillPackageRequest(scope_id=scope_id, artifact=skill_ref) + ) + assert package.name == "compatibility-check" + assert [item.path for item in package.files] == ["SKILL.md"] + + asyncio.run(scenario()) diff --git a/tests/e2e/test_handoff_runtime.py b/tests/e2e/test_handoff_runtime.py index 5aee16b84..c0e8c8f8c 100644 --- a/tests/e2e/test_handoff_runtime.py +++ b/tests/e2e/test_handoff_runtime.py @@ -23,6 +23,7 @@ from powercontext.builtin.artifacts.handoff import HandoffScopeMismatchError from powercontext.builtin.artifacts.memory import MemoryEntryInput from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import ( ActivateHandoff, BuiltinConfig, @@ -38,6 +39,7 @@ open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft +from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.errors import RevisionConflictError @@ -105,6 +107,33 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_handoff_activation_rejects_a_lineage_only_boundary_source() -> None: + async def scenario() -> None: + pipeline = _EchoHandoffPipeline() + async with open_builtin_runtime( + BuiltinConfig(database=SQLiteConfig()), + handoff_pipeline=pipeline, + ) as runtime: + assert runtime.scopes is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Project", summary="Reserved boundary", idempotency_key="reserved-boundary") + ) + created = await runtime.records.for_scope(scope.scope_id).create_artifact( + "memory", + ArtifactWrite(content={"entries": [{"kind": "fact", "text": "Direct input."}]}), + ) + + with pytest.raises(SourceNotEligibleError): + await runtime.handoff.for_scope(scope.scope_id).activate( + ActivateHandoff( + boundary_source=created.sources[0], + objective="Do not regenerate from management provenance.", + ) + ) + + asyncio.run(scenario()) + + def test_handoff_trigger_state_survives_runtime_restart(tmp_path: Path) -> None: async def scenario() -> None: config = BuiltinConfig( diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 7bf60ea23..1cee522af 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -23,12 +23,19 @@ ActivateHandoffRequest, ApproveArtifactCandidateRequest, ArtifactCandidate, + ArtifactCreated, ArtifactReference, + ArtifactRevision, CaptureContentSourceRequest, CaptureContentSourceResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateArtifactRequest, + CreateMemoryArtifactContent, + CreateMemoryArtifactEntry, + CreateMemoryArtifactRequest, + CreateSourceRequest, CreateWorkContractRequest, ExternalSkillResolution, FinalizeHandoffRequest, @@ -43,6 +50,7 @@ HandoffDraft, HandoffResolution, ImportExternalSkillRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListMemoryEntriesRequest, @@ -62,6 +70,9 @@ SearchMemoryRequest, SkillProposal, SkillValidationItem, + SourceRecord, + SourceType, + SourceTypeReference, StatsPeriod, WorkSourceReceipt, ) @@ -72,7 +83,9 @@ CAPTURE_CONTENT_SOURCE, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ARTIFACT, CREATE_REMOTE_SKILL_TARGET, + CREATE_SOURCE, CREATE_WORK_CONTRACT, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, @@ -81,16 +94,20 @@ FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, + GET_ARTIFACT_REVISION, GET_EXPERIENCE, GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, GET_SKILL_PACKAGE_MANIFEST, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, + LIST_ARTIFACTS, LIST_EXTERNAL_SKILLS, LIST_MANAGED_SKILLS, LIST_MEMORY_CHANGES, @@ -109,6 +126,7 @@ REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, RENAME_REMOTE_SKILL_TARGET, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, @@ -577,6 +595,165 @@ def test_generated_transport_rejects_values_outside_openapi( model.model_validate(value) +def test_base_access_contract_uses_only_the_seven_scoped_operations() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + paths = contract["paths"] + + expected_operations = { + ("/v1/scopes/{scope_id}/sources", "post"): "create_source", + ("/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", "get"): "get_source", + ("/v1/scopes/{scope_id}/artifacts", "post"): "create_artifact", + ("/v1/scopes/{scope_id}/artifacts/{family}", "get"): "list_artifacts", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", "get"): "get_artifact", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", "put"): "replace_artifact", + ( + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", + "get", + ): "get_artifact_revision", + } + actual_operations = { + (path, method): operation["operationId"] + for path, path_item in paths.items() + if path.startswith("/v1/scopes/{scope_id}/sources") or path.startswith("/v1/scopes/{scope_id}/artifacts") + for method, operation in path_item.items() + } + assert actual_operations == expected_operations + assert not any( + operation_id in {"list_sources", "search_sources", "search_artifacts", "delete_artifact", "list_scopes"} + for operation_id in actual_operations.values() + ) + assert not any("search-results" in path for path in paths) + + +def test_base_access_create_requests_leave_identity_generation_to_the_server() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + schemas = contract["components"]["schemas"] + + 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"]["default"] == "content" + + artifact = schemas["CreateArtifactRequest"] + assert len(artifact["oneOf"]) == 4 + assert artifact["discriminator"]["propertyName"] == "family" + for name in ( + "CreateMemoryArtifactRequest", + "CreateExperienceArtifactRequest", + "CreateSkillArtifactRequest", + "CreateHandoffArtifactRequest", + ): + 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( + root=CreateMemoryArtifactRequest( + family="memory", + content=CreateMemoryArtifactContent( + entries=[CreateMemoryArtifactEntry(kind="preference", text="Use Chinese")] + ), + ) + ).root.family + == "memory" + ) + memory_entry = schemas["CreateMemoryArtifactEntry"]["properties"] + assert memory_entry["kind"]["minLength"] == 1 + assert memory_entry["kind"]["maxLength"] == 128 + for recommended in ("fact", "preference", "decision", "constraint", "working_note"): + assert recommended in memory_entry["kind"]["description"] + assert ( + CreateMemoryArtifactEntry(kind="business_specific", text="Keep the caller's kind").kind == "business_specific" + ) + for invalid_kind in (" ", "x" * 129): + with pytest.raises(ValidationError): + CreateMemoryArtifactEntry(kind=invalid_kind, text="invalid") + for model, payload in ( + (CreateSourceRequest, {"scope_id": "scope", "content": "evidence"}), + (CreateSourceRequest, {"source_id": "source", "content": "evidence"}), + (CreateArtifactRequest, {"artifact_id": "artifact", "family": "memory", "content": {}}), + ): + with pytest.raises(ValidationError): + model.model_validate(payload) + + +def test_artifact_collection_only_accepts_pagination() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + paths = contract["paths"] + + assert "/v1/scopes/{scope_id}/sources/{source_type}" not in paths + parameters = paths["/v1/scopes/{scope_id}/artifacts/{family}"]["get"]["parameters"] + assert [parameter["name"] for parameter in parameters if parameter["in"] == "query"] == ["limit", "cursor"] + assert ListArtifactsRequest().model_dump() == {"limit": 50, "cursor": None} + + +def test_base_access_uses_a_dedicated_source_type_reference() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + schemas = contract["components"]["schemas"] + + assert set(schemas["SourceReference"]["properties"]) == {"name", "source_id"} + assert set(schemas["SourceTypeReference"]["properties"]) == {"source_type", "source_id"} + for schema_name in ("ArtifactCreated", "ArtifactRevision", "ArtifactCollectionItem"): + assert schemas[schema_name]["properties"]["sources"]["items"] == { + "$ref": "#/components/schemas/SourceTypeReference" + } + for request_name in ( + "CreateMemoryArtifactRequest", + "CreateExperienceArtifactRequest", + "CreateSkillArtifactRequest", + "CreateHandoffArtifactRequest", + "ReplaceMemoryArtifactRequest", + "ReplaceExperienceArtifactRequest", + "ReplaceSkillArtifactRequest", + "ReplaceHandoffArtifactRequest", + ): + assert "sources" not in schemas[request_name]["properties"] + assert SourceTypeReference(source_type=SourceType.CONTENT, source_id="source").source_type is SourceType.CONTENT + + +def test_base_access_operations_describe_create_and_conditional_get() -> None: + assert CREATE_SOURCE.request_type is CreateSourceRequest + assert CREATE_ARTIFACT.request_type is CreateArtifactRequest + assert CREATE_ARTIFACT.response_type is ArtifactCreated + assert LIST_ARTIFACTS.request_type is ListArtifactsRequest + assert GET_SOURCE.request_type is None + assert GET_ARTIFACT.request_type is None + assert GET_ARTIFACT_REVISION.request_type is None + assert REPLACE_ARTIFACT.response_type is ArtifactRevision + assert 304 in GET_ARTIFACT.responses + + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + item_path = contract["paths"]["/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}"] + get_parameters = {parameter["name"]: parameter for parameter in item_path["get"]["parameters"]} + assert get_parameters["If-None-Match"]["required"] is False + assert "content" not in item_path["get"]["responses"]["304"] + + parameters = {item["name"]: item for item in item_path["put"]["parameters"]} + assert parameters["If-Match"]["required"] is True + assert parameters["If-Match"]["schema"] == {"type": "string", "minLength": 1} + assert set(item_path["put"]["responses"]) >= {"412", "428"} + + +def test_generated_response_models_ignore_unknown_fields() -> None: + response = SourceRecord.model_validate({ + "scope_id": "scope", + "source_type": "content", + "source_id": "source", + "content": "evidence", + "metadata": {}, + "created_at": "2026-09-02T12:00:00Z", + "position": 1, + "content_digest": f"sha256:{'0' * 64}", + "future_optional_field": "ignored", + }) + + assert "future_optional_field" not in response.model_dump() + + def test_server_publishes_the_canonical_openapi_schema() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) diff --git a/tests/test_api_generation.py b/tests/test_api_generation.py new file mode 100644 index 000000000..ab16bb7e6 --- /dev/null +++ b/tests/test_api_generation.py @@ -0,0 +1,70 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _load_generator(): + spec = importlib.util.spec_from_file_location( + "generate_api", + REPO_ROOT / "scripts" / "generate_api.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_path_and_header_parameters_do_not_create_a_query_model_for_no_content_success() -> None: + generator = _load_generator() + contract = generator.OpenAPI.model_validate({ + "openapi": "3.0.3", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/widgets/{widget_id}": { + "delete": { + "summary": "Delete a widget", + "operationId": "delete_widget", + "parameters": [ + { + "name": "widget_id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "If-Match", + "in": "header", + "required": True, + "schema": {"type": "string"}, + }, + ], + "responses": {"204": {"description": "Deleted."}}, + } + } + }, + }) + + source = generator._generate_operations(contract, {}) + + assert "DELETE_WIDGET = Operation[None, None](" in source + assert "request_type=None" in source + assert "request_location=None" in source + assert "response_type=None" in source + assert "success_status=204" in source diff --git a/tests/test_client.py b/tests/test_client.py index 2947b7296..b8bb94fc4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -25,6 +25,11 @@ CaptureContentSourceRequest, ExactScopeSelection, GetHandoffReportRequest, + ListArtifactsRequest, + ReplaceArtifactRequest, + ReplaceMemoryArtifactContent, + ReplaceMemoryArtifactEntry, + ReplaceMemoryArtifactRequest, ReportFormat, ScopeId, ScopeSelection, @@ -228,6 +233,116 @@ def respond(request: httpx.Request) -> httpx.Response: asyncio.run(scenario()) +def test_client_serializes_scoped_artifact_paths_and_list_query() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "items": [ + { + "scope_id": "scope one", + "family": "memory", + "artifact_id": "memory-1", + "revision": 1, + "sources": [], + "artifacts": [], + "content_digest": f"sha256:{'0' * 64}", + } + ], + "next_cursor": None, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + page = await client.list_artifacts( + "scope one", + "memory", + ListArtifactsRequest(limit=7, cursor="cursor-1"), + ) + + assert [item.artifact_id for item in page.items] == ["memory-1"] + assert len(requests) == 1 + assert requests[0].url.path == "/v1/scopes/scope one/artifacts/memory" + assert dict(requests[0].url.params) == {"limit": "7", "cursor": "cursor-1"} + + asyncio.run(scenario()) + + +def test_client_decodes_declared_not_modified_without_a_body() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(304, headers={"ETag": '"revision:2"'}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + artifact = await client.get_artifact( + "scope-a", + "document", + "artifact-1", + if_none_match='"revision:2"', + ) + + assert artifact is None + assert len(requests) == 1 + assert requests[0].url.path == "/v1/scopes/scope-a/artifacts/document/artifact-1" + assert requests[0].headers["If-None-Match"] == '"revision:2"' + + asyncio.run(scenario()) + + +def test_client_sends_opaque_replace_precondition_verbatim() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json={ + "scope_id": "scope-a", + "family": "memory", + "artifact_id": "artifact-1", + "revision": 4, + "content": {"manifest": {}}, + "sources": [], + "artifacts": [], + "content_digest": f"sha256:{'0' * 64}", + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + result = await client.replace_artifact( + "scope-a", + "memory", + "artifact-1", + ReplaceArtifactRequest( + root=ReplaceMemoryArtifactRequest( + content=ReplaceMemoryArtifactContent( + entries=[ReplaceMemoryArtifactEntry(kind="preference", text="Use Chinese")] + ) + ) + ), + expected_etag='"opaque-v4"', + ) + + assert result.revision == 4 + assert len(requests) == 1 + assert requests[0].method == "PUT" + assert requests[0].url.path == "/v1/scopes/scope-a/artifacts/memory/artifact-1" + assert requests[0].headers["If-Match"] == '"opaque-v4"' + + asyncio.run(scenario()) + + @pytest.mark.parametrize( "server_url", [ diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index 4f100e7fc..bbb6f95ed 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -360,8 +360,7 @@ def test_validate_accepts_multiline_quoted_dashboard_scopes(tmp_path: Path) -> N content = f"{content}\n{multiline}\n" environment.write_text(content, encoding="utf-8") - with patch.object(config_cli, "_validate_provider_models"): - result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) + result = CliRunner().invoke(config_cli.app, ["validate", "--env-file", str(environment)]) assert result.exit_code == 0 assert "Configuration is valid" in result.output diff --git a/tests/test_cursor_secret.py b/tests/test_cursor_secret.py new file mode 100644 index 000000000..613813b37 --- /dev/null +++ b/tests/test_cursor_secret.py @@ -0,0 +1,58 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import stat + +from pydantic import SecretStr + +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.cursor_secret import resolve_cursor_secret + + +def test_file_backed_sqlite_reuses_private_cursor_secret(tmp_path) -> None: + database_path = tmp_path / "powercontext.db" + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path}") + + first = resolve_cursor_secret(config, None) + second = resolve_cursor_secret(config, None) + + key_path = tmp_path / ".powercontext.db.cursor-key" + assert first == second + assert first is not None and len(first) == 32 + assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 + + +def test_explicit_cursor_secret_overrides_database_storage(tmp_path) -> None: + database_path = tmp_path / "powercontext.db" + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{database_path}") + + secret = resolve_cursor_secret(config, "configured-secret-with-at-least-32-bytes") + + assert secret == b"configured-secret-with-at-least-32-bytes" + assert not (tmp_path / ".powercontext.db.cursor-key").exists() + + +def test_remote_database_without_override_reuses_local_persisted_secret(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path)) + config = OceanBaseConfig(url=SecretStr("mysql+aoceanbase://user:password@127.0.0.1:2881/test?charset=utf8mb4")) + + first = resolve_cursor_secret(config, None) + second = resolve_cursor_secret(config, None) + + assert first == second + assert first is not None and len(first) == 32 + assert stat.S_IMODE((tmp_path / "cursor-signing.key").stat().st_mode) == 0o600 diff --git a/tests/test_js_operations.py b/tests/test_js_operations.py index f75b66117..c9935c4bb 100644 --- a/tests/test_js_operations.py +++ b/tests/test_js_operations.py @@ -51,17 +51,43 @@ def test_js_operations_record_method_path_location_and_scope() -> None: generator = _load_generator() doc = yaml.safe_load(generator.CONTRACT_PATH.read_text(encoding="utf-8")) by_id = {row["operationId"]: row for row in generator.parse_operations(doc)} - assert {key: by_id["get_liveness"][key] for key in ("operationId", "method", "path", "location", "scopeMode")} == { + expected_keys = ( + "operationId", + "method", + "path", + "location", + "scopeMode", + "pathParameters", + "queryParams", + "headerParams", + "successStatuses", + "emptyStatuses", + ) + assert {key: by_id["get_liveness"][key] for key in expected_keys} == { "operationId": "get_liveness", "method": "GET", "path": "/health/live", "location": None, "scopeMode": "none", + "pathParameters": [], + "queryParams": [], + "headerParams": [], + "successStatuses": [200], + "emptyStatuses": [], } assert by_id["get_stats"]["location"] == "body" assert by_id["get_stats"]["scopeMode"] == "selection" assert by_id["remember_memory"]["location"] == "body" assert by_id["remember_memory"]["scopeMode"] == "current" + assert by_id["create_source"]["location"] == "body" + assert by_id["get_artifact"]["location"] is None + assert by_id["get_artifact"]["pathParameters"] == ["scope_id", "family", "artifact_id"] + assert by_id["get_artifact"]["headerParams"] == ["If-None-Match"] + assert by_id["get_artifact"]["successStatuses"] == [200, 304] + assert by_id["get_artifact"]["emptyStatuses"] == [304] + assert by_id["replace_artifact"]["headerParams"] == ["If-Match"] + assert "delete_artifact" not in by_id + assert "list_sources" not in by_id assert by_id["set_scope_binding"]["scopeMode"] == "none" diff --git a/tests/test_server.py b/tests/test_server.py index ab19ba2f5..88bfc84b4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -104,6 +104,7 @@ def test_settings_load_server_environment(monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_MEMORY_RERANK_ENABLED", "true") monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_MEMORY_RERANK_CANDIDATE_LIMIT", "40") monkeypatch.setenv("POWERCONTEXT_SERVER_RUNTIME_EXPERIENCE_SCHEDULE_SECONDS", "45") + monkeypatch.setenv("POWERCONTEXT_SERVER_CURSOR_SIGNING_SECRET", "cursor-secret-with-at-least-thirty-two-bytes") monkeypatch.setenv("POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL", " test ") monkeypatch.setenv( "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL_SETTINGS", @@ -140,6 +141,7 @@ def test_settings_load_server_environment(monkeypatch) -> None: assert settings.runtime.memory_rerank_enabled is True assert settings.runtime.memory_rerank_candidate_limit == 40 assert settings.runtime.experience_schedule_seconds == 45 + assert settings.cursor_signing_secret == SecretStr("cursor-secret-with-at-least-thirty-two-bytes") assert settings.inference.generation_model == "test" assert settings.inference.generation_model_settings == { "extra_body": {"chat_template_kwargs": {"enable_thinking": False}} @@ -178,6 +180,39 @@ def test_server_settings_configure_default_project_skill_targets(tmp_path, monke assert all(target.allow_managed_publish for target in settings.external_skills.targets) +def test_server_reuses_file_backed_cursor_secret_across_restarts(tmp_path) -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}") + settings = ServerSettings( + database=database, + auth=BearerAuthConfig(enabled=False), + mcp=McpConfig(enabled=False), + ) + first_app = create_server_app(settings=settings) + with TestClient(first_app) as client: + scope_id = client.get("/v1/scopes/default").json()["scope_id"] + content = {"entries": [{"kind": "working_note", "text": "Verify cursor reuse after restart"}]} + for _ in range(2): + response = client.post( + f"/v1/scopes/{scope_id}/artifacts", + json={"family": "memory", "content": content}, + ) + assert response.status_code == 201 + first_page = client.get(f"/v1/scopes/{scope_id}/artifacts/memory", params={"limit": 1}) + assert first_page.status_code == 200 + cursor = first_page.json()["next_cursor"] + assert cursor is not None + + second_app = create_server_app(settings=settings) + with TestClient(second_app) as client: + second_page = client.get( + f"/v1/scopes/{scope_id}/artifacts/memory", + params={"limit": 1, "cursor": cursor}, + ) + + assert second_page.status_code == 200 + assert len(second_page.json()["items"]) == 1 + + def test_server_settings_use_configured_workspace_for_default_skill_targets(tmp_path, monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_SERVER_WORKSPACE", str(tmp_path)) monkeypatch.delenv("POWERCONTEXT_SERVER_EXTERNAL_SKILLS", raising=False)