From 596e99f012f38d3b135a570adf4e0444e2a480a2 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Wed, 2 Sep 2026 10:56:16 +0800 Subject: [PATCH 1/9] feat(api): add source and artifact access --- .../powercontext/src/operations.generated.ts | 11 + .../powercontext/src/operations.generated.ts | 11 + .../powercontext/src/operations.generated.ts | 11 + openapi/powercontext.yaml | 2852 ++++++++++++----- .../builtin/persistence/records.py | 968 ++++++ .../builtin/persistence/tables.py | 64 + src/powercontext/builtin/records.py | 274 ++ .../builtin/runtime/application.py | 201 +- .../builtin/runtime/composition.py | 1 + .../builtin/runtime/relational.py | 2 + src/powercontext/client/client.py | 206 +- src/powercontext/http/__init__.py | 48 + src/powercontext/http/_generated/models.py | 244 ++ .../http/_generated/operations.py | 290 ++ src/powercontext/http/_generated/schema.py | 786 ++++- src/powercontext/server/app.py | 539 +++- tests/builtin/persistence/test_records.py | 218 ++ tests/e2e/test_base_access_api.py | 154 + tests/test_api_contract.py | 36 + 19 files changed, 6022 insertions(+), 894 deletions(-) create mode 100644 src/powercontext/builtin/persistence/records.py create mode 100644 src/powercontext/builtin/records.py create mode 100644 tests/builtin/persistence/test_records.py create mode 100644 tests/e2e/test_base_access_api.py diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 092c7108a..898d53832 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -70,6 +70,17 @@ export const OPERATIONS = { get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + search_sources: { method: 'GET', path: '/v1/sources', location: "query", scope: true }, + create_source: { method: 'POST', path: '/v1/sources', location: "body", scope: true }, + get_source: { method: 'GET', path: '/v1/sources/{source_id}', location: "query", scope: true }, + list_artifacts: { method: 'GET', path: '/v1/artifacts', location: "query", scope: true }, + create_artifact: { method: 'POST', path: '/v1/artifacts', location: "body", scope: true }, + get_artifact: { method: 'GET', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, + replace_artifact: { method: 'PUT', path: '/v1/artifacts/{artifact_id}', location: "body", scope: true }, + delete_artifact: { method: 'DELETE', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, + get_artifact_revision: { method: 'GET', path: '/v1/artifacts/{artifact_id}/revisions/{revision}', location: "query", scope: true }, + search_artifacts: { method: 'GET', path: '/v1/artifact-search-results', location: "query", scope: true }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 092c7108a..898d53832 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -70,6 +70,17 @@ export const OPERATIONS = { get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + search_sources: { method: 'GET', path: '/v1/sources', location: "query", scope: true }, + create_source: { method: 'POST', path: '/v1/sources', location: "body", scope: true }, + get_source: { method: 'GET', path: '/v1/sources/{source_id}', location: "query", scope: true }, + list_artifacts: { method: 'GET', path: '/v1/artifacts', location: "query", scope: true }, + create_artifact: { method: 'POST', path: '/v1/artifacts', location: "body", scope: true }, + get_artifact: { method: 'GET', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, + replace_artifact: { method: 'PUT', path: '/v1/artifacts/{artifact_id}', location: "body", scope: true }, + delete_artifact: { method: 'DELETE', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, + get_artifact_revision: { method: 'GET', path: '/v1/artifacts/{artifact_id}/revisions/{revision}', location: "query", scope: true }, + search_artifacts: { method: 'GET', path: '/v1/artifact-search-results', location: "query", scope: true }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 092c7108a..898d53832 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -70,6 +70,17 @@ export const OPERATIONS = { get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, + search_sources: { method: 'GET', path: '/v1/sources', location: "query", scope: true }, + create_source: { method: 'POST', path: '/v1/sources', location: "body", scope: true }, + get_source: { method: 'GET', path: '/v1/sources/{source_id}', location: "query", scope: true }, + list_artifacts: { method: 'GET', path: '/v1/artifacts', location: "query", scope: true }, + create_artifact: { method: 'POST', path: '/v1/artifacts', location: "body", scope: true }, + get_artifact: { method: 'GET', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, + replace_artifact: { method: 'PUT', path: '/v1/artifacts/{artifact_id}', location: "body", scope: true }, + delete_artifact: { method: 'DELETE', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, + get_artifact_revision: { method: 'GET', path: '/v1/artifacts/{artifact_id}/revisions/{revision}', location: "query", scope: true }, + search_artifacts: { method: 'GET', path: '/v1/artifact-search-results', location: "query", scope: true }, + list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scope: false }, } as const export type OperationId = keyof typeof OPERATIONS diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index a38ba8922..79f9b5da2 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1688,6 +1688,604 @@ paths: $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" + /v1/sources: + post: + tags: [sources] + summary: Create a durable Source + description: Create one idempotent Source without synchronously deriving Artifacts. + operationId: create_source + 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" + "405": + $ref: "#/components/responses/OperationNotSupported" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + get: + tags: [sources] + summary: List or search Sources + description: List when type is omitted or list; search when type is search and q is nonblank. + operationId: search_sources + parameters: + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: source_type + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: type + in: query + required: false + schema: + $ref: "#/components/schemas/SourceQueryType" + default: list + - name: q + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 8192 + - name: mode + in: query + required: false + schema: + $ref: "#/components/schemas/TextSearchMode" + - 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 Sources or Source search results. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/SourcePage" + "401": + $ref: "#/components/responses/Unauthorized" + "405": + $ref: "#/components/responses/OperationNotSupported" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/sources/{source_id}: + get: + tags: [sources] + summary: Get one exact Source + operationId: get_source + parameters: + - name: source_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: source_type + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + 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" + "405": + $ref: "#/components/responses/OperationNotSupported" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/artifacts: + post: + tags: [artifacts] + summary: Create an Artifact + description: Commit revision one for a family that permits direct creation. + operationId: create_artifact + 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/ArtifactRevision" + "401": + $ref: "#/components/responses/Unauthorized" + "405": + $ref: "#/components/responses/OperationNotSupported" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + get: + tags: [artifacts] + summary: List current Artifact heads + operationId: list_artifacts + parameters: + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/artifacts/{artifact_id}: + get: + tags: [artifacts] + summary: Get the current Artifact head + operationId: get_artifact + parameters: + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + 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" + "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: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: If-Match + in: header + required: true + schema: + type: string + pattern: '^"revision:[1-9][0-9]*"$' + 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" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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" + delete: + tags: [artifacts] + summary: Delete the current Artifact head + description: Record a tombstone without deleting immutable historical revisions. + operationId: delete_artifact + parameters: + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: If-Match + in: header + required: true + schema: + type: string + pattern: '^"revision:[1-9][0-9]*"$' + responses: + "200": + description: The Artifact head is deleted and immutable history is retained. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactDeletionStatus" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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/artifacts/{artifact_id}/revisions/{revision}: + get: + tags: [artifacts] + summary: Get one exact immutable Artifact revision + operationId: get_artifact_revision + parameters: + - name: artifact_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + - name: revision + in: path + required: true + schema: + type: integer + minimum: 1 + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + 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" + /v1/artifact-search-results: + get: + tags: [artifacts] + summary: Search current Artifact heads + operationId: search_artifacts + parameters: + - name: scope_id + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + - name: mode + in: query + required: false + schema: + $ref: "#/components/schemas/TextSearchMode" + default: auto + - 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 matching current Artifact heads. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactSearchResultPage" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes: + get: + tags: [scopes] + summary: List observable Scopes + operationId: list_scopes + parameters: + - 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 Scopes observable by the caller. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ScopePage" + "401": + $ref: "#/components/responses/Unauthorized" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" components: securitySchemes: BearerAuth: @@ -1695,6 +2293,11 @@ components: scheme: bearer description: Static bearer token used when local Server authentication is enabled. headers: + ArtifactETag: + description: Strong validator for the current immutable Artifact revision. + schema: + type: string + pattern: '^"revision:[1-9][0-9]*"$' BearerChallenge: description: Authentication scheme required by the Server. schema: @@ -1704,6 +2307,10 @@ 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: Unauthorized: description: A valid bearer token is required by this Server deployment. @@ -1725,6 +2332,33 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + OperationNotSupported: + description: The Source type or Artifact family does not permit this operation. + 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: @@ -1744,7 +2378,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" @@ -1774,31 +2408,126 @@ components: ActivateHandoffRequest: type: object additionalProperties: false - required: [scope_id, boundary_source, objective] + required: [scope_id, boundary_source, objective] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + boundary_source: + $ref: "#/components/schemas/SourceReference" + objective: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + evidence: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffCitation" + default: [] + max_bytes: + type: integer + minimum: 512 + maximum: 32768 + default: 8000 + ArtifactDeletionState: + type: string + enum: [deleted] + ArtifactDeletionStatus: + type: object + additionalProperties: false + required: [artifact_ref, status, deleted_at] + properties: + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + status: + $ref: "#/components/schemas/ArtifactDeletionState" + deleted_at: + type: string + format: date-time + ArtifactPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ArtifactRevision" + next_cursor: + type: string + nullable: true + ArtifactRevision: + type: object + additionalProperties: false + required: + [scope_id, artifact_ref, schema_version, metadata, content, source_refs, artifact_refs, created_at, content_digest] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + schema_version: + type: integer + minimum: 1 + metadata: + type: object + additionalProperties: true + content: + type: object + additionalProperties: true + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + created_at: + type: string + format: date-time + nullable: true + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + ArtifactSearchHit: + type: object + additionalProperties: false + required: [artifact, score, snippets] + properties: + artifact: + $ref: "#/components/schemas/ArtifactRevision" + score: + type: number + minimum: 0 + maximum: 1 + snippets: + type: array + items: + type: string + ArtifactSearchResultPage: + type: object + additionalProperties: false + required: [query, mode, hits, next_cursor] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - boundary_source: - $ref: "#/components/schemas/SourceReference" - objective: + query: type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - evidence: + mode: + $ref: "#/components/schemas/TextSearchUsedMode" + hits: type: array - maxItems: 32 items: - $ref: "#/components/schemas/HandoffCitation" - default: [] - max_bytes: - type: integer - minimum: 512 - maximum: 32768 - default: 8000 + $ref: "#/components/schemas/ArtifactSearchHit" + next_cursor: + type: string + nullable: true ArtifactReference: type: object additionalProperties: false @@ -3159,37 +3888,296 @@ components: $ref: "#/components/schemas/ExternalSkillRegistration" skipped: type: integer - minimum: 0 - ListExternalSkillsResponse: + minimum: 0 + ListExternalSkillsResponse: + type: object + additionalProperties: false + required: [skills] + properties: + skills: + type: array + items: + $ref: "#/components/schemas/ExternalSkillResolution" + ErrorDetail: + type: object + additionalProperties: false + required: [code, message, details] + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + nullable: true + ErrorResponse: + type: object + additionalProperties: false + required: [error] + properties: + error: + $ref: "#/components/schemas/ErrorDetail" + FlushMemoryRequest: + type: object + additionalProperties: false + required: [scope_id] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + FlushMemoryResponse: + type: object + additionalProperties: false + required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] + properties: + status: + $ref: "#/components/schemas/FlushStatus" + previous_cursor: + type: integer + minimum: 0 + current_cursor: + type: integer + minimum: 0 + high_watermark: + type: integer + minimum: 0 + processed_source_count: + type: integer + minimum: 0 + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + GetMemoryEntryRequest: + type: object + additionalProperties: false + required: [scope_id, citation] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/MemoryCitation" + GetArtifactCandidateRequest: + type: object + additionalProperties: false + required: [scope_id, candidate_id] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + candidate_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + GetExperienceRequest: + type: object + additionalProperties: false + required: [scope_id, artifact] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + GetSkillRequest: + type: object + additionalProperties: false + required: [scope_id, artifact] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact: + $ref: "#/components/schemas/ArtifactReference" + CreateHandoffReportProjectRequest: + type: object + additionalProperties: false + required: [project_key, title] + properties: + project_key: + type: string + minLength: 1 + maxLength: 64 + title: + type: string + minLength: 1 + maxLength: 256 + description: + type: string + maxLength: 2000 + nullable: true + default_locale: + $ref: "#/components/schemas/ReportLocale" + default: zh-CN + timezone: + type: string + minLength: 1 + maxLength: 256 + default: UTC + ListHandoffReportProjectsRequest: + type: object + additionalProperties: false + properties: + cursor: + type: string + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + include_archived: + type: boolean + default: false + GetHandoffReportProjectRequest: + type: object + additionalProperties: false + required: [project_id] + properties: + project_id: + type: string + minLength: 1 + maxLength: 256 + UpdateHandoffReportProjectRequest: + type: object + additionalProperties: false + required: [project, expected_version] + properties: + project: + $ref: "#/components/schemas/ProjectDescriptor" + expected_version: + type: integer + minimum: 1 + RegisterHandoffReportWorkstreamRequest: + type: object + additionalProperties: false + required: [project_id, scope_id, title, kind] + properties: + project_id: + type: string + minLength: 1 + maxLength: 256 + scope_id: + type: string + minLength: 1 + maxLength: 256 + key: + type: string + minLength: 1 + maxLength: 64 + nullable: true + title: + type: string + minLength: 1 + maxLength: 256 + kind: + $ref: "#/components/schemas/WorkstreamKind" + catalog_state: + $ref: "#/components/schemas/ReportCatalogState" + default: included + external_refs: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffReportExternalReference" + default: [] + labels: + type: array + maxItems: 32 + items: + type: string + minLength: 1 + maxLength: 128 + default: [] + ListHandoffReportWorkstreamsRequest: + type: object + additionalProperties: false + required: [project_id] + properties: + project_id: + type: string + minLength: 1 + maxLength: 256 + cursor: + type: string + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + include_archived: + type: boolean + default: false + UpdateHandoffReportWorkstreamRequest: type: object additionalProperties: false - required: [skills] + required: [workstream, expected_version] properties: - skills: - type: array - items: - $ref: "#/components/schemas/ExternalSkillResolution" - ErrorDetail: + workstream: + $ref: "#/components/schemas/WorkstreamDescriptor" + expected_version: + type: integer + minimum: 1 + GetHandoffReportRequest: type: object additionalProperties: false - required: [code, message, details] + required: [scope_id] properties: - code: + scope_id: type: string - message: + minLength: 1 + maxLength: 256 + project_id: type: string - details: - type: object - additionalProperties: true + minLength: 1 + maxLength: 256 nullable: true - ErrorResponse: + deprecated: true + description: Retained for wire compatibility and ignored when generating a scope report. + locale: + $ref: "#/components/schemas/ReportLocale" + nullable: true + include_evidence_checks: + type: boolean + default: true + format: + $ref: "#/components/schemas/ReportFormat" + default: markdown + include_archived: + type: boolean + default: false + download: + type: boolean + default: false + period: + $ref: "#/components/schemas/HandoffReportPeriodRequest" + nullable: true + ListHandoffReportKnownScopesRequest: type: object additionalProperties: false - required: [error] properties: - error: - $ref: "#/components/schemas/ErrorDetail" - FlushMemoryRequest: + cursor: + type: string + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + KnownHandoffScope: type: object additionalProperties: false required: [scope_id] @@ -3198,144 +4186,159 @@ components: type: string minLength: 1 maxLength: 256 - pattern: '.*\S.*' - FlushMemoryResponse: + KnownHandoffScopePage: type: object additionalProperties: false - required: [status, previous_cursor, current_cursor, high_watermark, processed_source_count] + required: [items] properties: - status: - $ref: "#/components/schemas/FlushStatus" - previous_cursor: - type: integer - minimum: 0 - current_cursor: - type: integer - minimum: 0 - high_watermark: - type: integer - minimum: 0 - processed_source_count: - type: integer - minimum: 0 - memory: - $ref: "#/components/schemas/ArtifactReference" + items: + type: array + items: + $ref: "#/components/schemas/KnownHandoffScope" + next_cursor: + type: string nullable: true - GetMemoryEntryRequest: + HandoffReportPeriodRequest: type: object additionalProperties: false - required: [scope_id, citation] + required: [start, end] properties: - scope_id: + start: + type: string + format: date-time + end: + type: string + format: date-time + timezone: type: string minLength: 1 maxLength: 256 - pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - GetArtifactCandidateRequest: + nullable: true + compare_to_previous_period: + type: boolean + default: false + HandoffReportResponse: type: object additionalProperties: false - required: [scope_id, candidate_id] + required: [format, report, markdown, selection_digest, report_digest] properties: - scope_id: + format: + $ref: "#/components/schemas/ReportFormat" + report: + type: object + additionalProperties: true + nullable: true + markdown: + type: string + nullable: true + selection_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + report_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + ReportActivitySource: + type: string + enum: [handoff_observation, git_commit, git_worktree, coding_session, other] + ReportTimeBasis: + type: string + enum: [source_reported, host_observed, first_seen, current_only, unknown] + HandoffReportActivityAgent: + type: object + additionalProperties: false + properties: + provider: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - candidate_id: + maxLength: 64 + nullable: true + label: type: string minLength: 1 maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetExperienceRequest: + nullable: true + HandoffReportActivityVcsContext: type: object additionalProperties: false - required: [scope_id, artifact] properties: - scope_id: + branch: type: string minLength: 1 maxLength: 256 - pattern: '.*\S.*' - artifact: - $ref: "#/components/schemas/ArtifactReference" - GetSkillRequest: - type: object - additionalProperties: false - required: [scope_id, artifact] - properties: - scope_id: + nullable: true + head_revision: type: string minLength: 1 maxLength: 256 - pattern: '.*\S.*' - artifact: - $ref: "#/components/schemas/ArtifactReference" - CreateHandoffReportProjectRequest: + nullable: true + RecordHandoffReportActivityRequest: type: object additionalProperties: false - required: [project_key, title] + required: [project_id, source, source_event_id, time_basis] properties: - project_key: + project_id: type: string minLength: 1 - maxLength: 64 - title: + maxLength: 256 + scope_id: type: string minLength: 1 maxLength: 256 - description: + nullable: true + source: + $ref: "#/components/schemas/ReportActivitySource" + source_event_id: type: string - maxLength: 2000 + minLength: 1 + maxLength: 256 + source_ref: + $ref: "#/components/schemas/HandoffReportExternalReference" nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - default: zh-CN - timezone: + occurred_at: + type: string + format: date-time + nullable: true + time_basis: + $ref: "#/components/schemas/ReportTimeBasis" + title: type: string minLength: 1 maxLength: 256 - default: UTC - ListHandoffReportProjectsRequest: - type: object - additionalProperties: false - properties: - cursor: + nullable: true + summary: type: string + minLength: 1 + maxLength: 2000 nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - include_archived: - type: boolean - default: false - GetHandoffReportProjectRequest: - type: object - additionalProperties: false - required: [project_id] - properties: - project_id: + agent: + $ref: "#/components/schemas/HandoffReportActivityAgent" + nullable: true + session_id: type: string minLength: 1 maxLength: 256 - UpdateHandoffReportProjectRequest: - type: object - additionalProperties: false - required: [project, expected_version] - properties: - project: - $ref: "#/components/schemas/ProjectDescriptor" - expected_version: - type: integer - minimum: 1 - RegisterHandoffReportWorkstreamRequest: + nullable: true + vcs_context: + $ref: "#/components/schemas/HandoffReportActivityVcsContext" + nullable: true + evidence_refs: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffReportExternalReference" + default: [] + HandoffReportActivity: type: object additionalProperties: false - required: [project_id, scope_id, title, kind] + required: [schema, event_id, project_id, scope_id, source, source_event_id, source_ref, occurred_at, observed_at, time_basis, title, summary, agent, session_id, vcs_context, evidence_refs, trust] properties: + schema: + type: string + enum: [powercontext.handoff-report-activity.v1] + event_id: + type: string + minLength: 1 + maxLength: 256 project_id: type: string minLength: 1 @@ -3344,35 +4347,65 @@ components: type: string minLength: 1 maxLength: 256 - key: + nullable: true + source: + $ref: "#/components/schemas/ReportActivitySource" + source_event_id: type: string minLength: 1 - maxLength: 64 + maxLength: 256 + source_ref: + $ref: "#/components/schemas/HandoffReportExternalReference" + nullable: true + occurred_at: + type: string + format: date-time nullable: true + observed_at: + type: string + format: date-time + time_basis: + $ref: "#/components/schemas/ReportTimeBasis" title: type: string minLength: 1 maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - default: included - external_refs: + nullable: true + summary: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + agent: + $ref: "#/components/schemas/HandoffReportActivityAgent" + nullable: true + session_id: + type: string + minLength: 1 + maxLength: 256 + nullable: true + vcs_context: + $ref: "#/components/schemas/HandoffReportActivityVcsContext" + nullable: true + evidence_refs: type: array maxItems: 32 items: $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - labels: - type: array - maxItems: 32 - items: - type: string - minLength: 1 - maxLength: 128 - default: [] - ListHandoffReportWorkstreamsRequest: + trust: + type: string + enum: [untrusted_observation] + StoredHandoffReportActivity: + type: object + additionalProperties: false + required: [cursor, event] + properties: + cursor: + type: integer + minimum: 1 + event: + $ref: "#/components/schemas/HandoffReportActivity" + ListHandoffReportActivitiesRequest: type: object additionalProperties: false required: [project_id] @@ -3381,232 +4414,217 @@ components: type: string minLength: 1 maxLength: 256 - cursor: + period_start: type: string + format: date-time + nullable: true + period_end: + type: string + format: date-time + nullable: true + sources: + type: array + maxItems: 5 + items: + $ref: "#/components/schemas/ReportActivitySource" + nullable: true + after_cursor: + type: integer + minimum: 0 + default: 0 + through_cursor: + type: integer + minimum: 0 nullable: true limit: type: integer minimum: 1 maximum: 100 default: 50 - include_archived: - type: boolean - default: false - UpdateHandoffReportWorkstreamRequest: + HandoffReportActivityPage: type: object additionalProperties: false - required: [workstream, expected_version] + required: [items, next_cursor, high_watermark] properties: - workstream: - $ref: "#/components/schemas/WorkstreamDescriptor" - expected_version: + items: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/HandoffReportActivity" + next_cursor: type: integer minimum: 1 - GetHandoffReportRequest: + nullable: true + high_watermark: + type: integer + minimum: 0 + PurgeHandoffReportActivitiesRequest: type: object additionalProperties: false - required: [scope_id] + required: [project_id, observed_before] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 project_id: type: string minLength: 1 maxLength: 256 - nullable: true - deprecated: true - description: Retained for wire compatibility and ignored when generating a scope report. - locale: - $ref: "#/components/schemas/ReportLocale" - nullable: true - include_evidence_checks: - type: boolean - default: true - format: - $ref: "#/components/schemas/ReportFormat" - default: markdown - include_archived: - type: boolean - default: false - download: - type: boolean - default: false - period: - $ref: "#/components/schemas/HandoffReportPeriodRequest" - nullable: true - ListHandoffReportKnownScopesRequest: + observed_before: + type: string + format: date-time + PurgeHandoffReportActivitiesResponse: type: object additionalProperties: false + required: [deleted_count] properties: - cursor: - type: string - nullable: true - limit: + deleted_count: type: integer - minimum: 1 - maximum: 100 - default: 50 - KnownHandoffScope: + minimum: 0 + HandoffReportRepositoryRef: type: object additionalProperties: false - required: [scope_id] + required: [provider, repository_id, normalized_remote, subpath] properties: - scope_id: + provider: + type: string + enum: [github, gitlab, local, other] + repository_id: type: string minLength: 1 maxLength: 256 - KnownHandoffScopePage: - type: object - additionalProperties: false - required: [items] - properties: - items: - type: array - items: - $ref: "#/components/schemas/KnownHandoffScope" - next_cursor: + nullable: true + normalized_remote: type: string + minLength: 1 + maxLength: 2048 nullable: true - HandoffReportPeriodRequest: + subpath: + type: string + minLength: 1 + maxLength: 1024 + nullable: true + HandoffReportWorkspaceBinding: type: object additionalProperties: false - required: [start, end] + required: [schema, workspace_instance_id, project_id, repository_ref, state, confirmed_at, version] properties: - start: - type: string - format: date-time - end: + schema: type: string - format: date-time - timezone: + enum: [powercontext.workspace-binding.v1] + workspace_instance_id: type: string minLength: 1 maxLength: 256 - nullable: true - compare_to_previous_period: - type: boolean - default: false - HandoffReportResponse: - type: object - additionalProperties: false - required: [format, report, markdown, selection_digest, report_digest] - properties: - format: - $ref: "#/components/schemas/ReportFormat" - report: - type: object - additionalProperties: true - nullable: true - markdown: + project_id: type: string - nullable: true - selection_digest: + minLength: 1 + maxLength: 256 + repository_ref: + $ref: "#/components/schemas/HandoffReportRepositoryRef" + state: type: string - pattern: '^sha256:[0-9a-f]{64}$' - report_digest: + enum: [confirmed, detached] + confirmed_at: type: string - pattern: '^sha256:[0-9a-f]{64}$' - ReportActivitySource: - type: string - enum: [handoff_observation, git_commit, git_worktree, coding_session, other] - ReportTimeBasis: - type: string - enum: [source_reported, host_observed, first_seen, current_only, unknown] - HandoffReportActivityAgent: + format: date-time + version: + type: integer + minimum: 1 + GetHandoffReportWorkspaceRequest: type: object additionalProperties: false + required: [workspace_instance_id] properties: - provider: - type: string - minLength: 1 - maxLength: 64 - nullable: true - label: + workspace_instance_id: type: string minLength: 1 - maxLength: 128 - nullable: true - HandoffReportActivityVcsContext: + maxLength: 256 + AttachHandoffReportWorkspaceRequest: type: object additionalProperties: false + required: [workspace_instance_id, project_id, repository_ref, expected_version] properties: - branch: + workspace_instance_id: type: string minLength: 1 maxLength: 256 - nullable: true - head_revision: + project_id: type: string minLength: 1 maxLength: 256 + repository_ref: + $ref: "#/components/schemas/HandoffReportRepositoryRef" + expected_version: + type: integer + minimum: 1 nullable: true - RecordHandoffReportActivityRequest: + DetachHandoffReportWorkspaceRequest: type: object additionalProperties: false - required: [project_id, source, source_event_id, time_basis] + required: [workspace_instance_id, expected_version] properties: - project_id: + workspace_instance_id: type: string minLength: 1 maxLength: 256 - scope_id: + expected_version: + type: integer + minimum: 1 + ProjectDescriptor: + type: object + additionalProperties: false + required: [schema, project_id, project_key, title, description, default_locale, timezone, catalog_state, version] + properties: + schema: type: string - minLength: 1 - maxLength: 256 - nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + enum: [powercontext.project.v1] + project_id: type: string minLength: 1 maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + project_key: type: string - format: date-time - nullable: true - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" + minLength: 1 + maxLength: 64 title: type: string minLength: 1 maxLength: 256 - nullable: true - summary: + description: type: string - minLength: 1 maxLength: 2000 nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + default_locale: + $ref: "#/components/schemas/ReportLocale" + timezone: type: string minLength: 1 maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: + catalog_state: + $ref: "#/components/schemas/ReportCatalogState" + version: + type: integer + minimum: 1 + ProjectPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: type: array - maxItems: 32 + maxItems: 100 items: - $ref: "#/components/schemas/HandoffReportExternalReference" - default: [] - HandoffReportActivity: + $ref: "#/components/schemas/ProjectDescriptor" + next_cursor: + type: string + nullable: true + WorkstreamDescriptor: type: object additionalProperties: false - required: [schema, event_id, project_id, scope_id, source, source_event_id, source_ref, occurred_at, observed_at, time_basis, title, summary, agent, session_id, vcs_context, evidence_refs, trust] + required: [schema, scope_id, project_id, key, title, kind, catalog_state, external_refs, labels, version] properties: schema: type: string - enum: [powercontext.handoff-report-activity.v1] - event_id: + enum: [powercontext.workstream.v1] + scope_id: type: string minLength: 1 maxLength: 256 @@ -3614,538 +4632,574 @@ components: type: string minLength: 1 maxLength: 256 - scope_id: + key: type: string - minLength: 1 - maxLength: 256 + maxLength: 64 nullable: true - source: - $ref: "#/components/schemas/ReportActivitySource" - source_event_id: + title: type: string minLength: 1 maxLength: 256 - source_ref: - $ref: "#/components/schemas/HandoffReportExternalReference" - nullable: true - occurred_at: + kind: + $ref: "#/components/schemas/WorkstreamKind" + catalog_state: + $ref: "#/components/schemas/ReportCatalogState" + external_refs: + type: array + maxItems: 32 + items: + $ref: "#/components/schemas/HandoffReportExternalReference" + labels: + type: array + maxItems: 32 + items: + type: string + minLength: 1 + maxLength: 128 + version: + type: integer + minimum: 1 + WorkstreamPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + maxItems: 100 + items: + $ref: "#/components/schemas/WorkstreamDescriptor" + next_cursor: type: string - format: date-time nullable: true - observed_at: - type: string - format: date-time - time_basis: - $ref: "#/components/schemas/ReportTimeBasis" - title: + HandoffReportExternalReference: + type: object + additionalProperties: false + required: [kind, provider, external_id, url] + properties: + kind: type: string - minLength: 1 - maxLength: 256 - nullable: true - summary: + enum: [issue, task, pull_request, branch, feature, release, program, other] + provider: type: string minLength: 1 - maxLength: 2000 - nullable: true - agent: - $ref: "#/components/schemas/HandoffReportActivityAgent" - nullable: true - session_id: + maxLength: 64 + external_id: type: string minLength: 1 maxLength: 256 - nullable: true - vcs_context: - $ref: "#/components/schemas/HandoffReportActivityVcsContext" - nullable: true - evidence_refs: - type: array - maxItems: 32 - items: - $ref: "#/components/schemas/HandoffReportExternalReference" - trust: + url: type: string - enum: [untrusted_observation] - StoredHandoffReportActivity: + maxLength: 2048 + nullable: true + ReportLocale: + type: string + enum: [zh-CN, en] + ReportFormat: + type: string + enum: [json, markdown] + ReportCatalogState: + type: string + enum: [included, archived] + WorkstreamKind: + type: string + enum: [feature, bug, refactor, operations, research, other] + HealthResponse: type: object additionalProperties: false - required: [cursor, event] + required: [status] properties: - cursor: - type: integer - minimum: 1 - event: - $ref: "#/components/schemas/HandoffReportActivity" - ListHandoffReportActivitiesRequest: + status: + type: string + ListMemoryChangesRequest: type: object additionalProperties: false - required: [project_id] + required: [scope_id] properties: - project_id: + scope_id: type: string minLength: 1 maxLength: 256 - period_start: - type: string - format: date-time - nullable: true - period_end: - type: string - format: date-time - nullable: true - sources: - type: array - maxItems: 5 - items: - $ref: "#/components/schemas/ReportActivitySource" - nullable: true - after_cursor: - type: integer - minimum: 0 - default: 0 - through_cursor: + pattern: '.*\S.*' + since_revision: type: integer minimum: 0 nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - HandoffReportActivityPage: + description: >- + Exclusive lower bound; 0 requests complete history from Revision 1. + Positive nonexistent revisions are errors. + ListMemoryChangesResponse: type: object additionalProperties: false - required: [items, next_cursor, high_watermark] + required: [revisions] properties: - items: + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + revisions: type: array - maxItems: 100 items: - $ref: "#/components/schemas/HandoffReportActivity" - next_cursor: - type: integer - minimum: 1 - nullable: true - high_watermark: - type: integer - minimum: 0 - PurgeHandoffReportActivitiesRequest: + $ref: "#/components/schemas/MemoryRevisionChanges" + ListMemoryEntriesRequest: type: object additionalProperties: false - required: [project_id, observed_before] + required: [scope_id] properties: - project_id: + scope_id: type: string minLength: 1 maxLength: 256 - observed_before: - type: string - format: date-time - PurgeHandoffReportActivitiesResponse: + pattern: '.*\S.*' + include_inactive: + type: boolean + default: false + description: Include inactive entries from the current Memory head for explicit audit. + ListMemoryEntriesResponse: type: object additionalProperties: false - required: [deleted_count] + required: [entries] properties: - deleted_count: - type: integer - minimum: 0 - HandoffReportRepositoryRef: + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + entries: + type: array + items: + $ref: "#/components/schemas/MemoryEntry" + ListArtifactCandidatesRequest: type: object additionalProperties: false - required: [provider, repository_id, normalized_remote, subpath] + required: [scope_id] properties: - provider: - type: string - enum: [github, gitlab, local, other] - repository_id: + scope_id: type: string minLength: 1 maxLength: 256 + pattern: '.*\S.*' + status: + $ref: "#/components/schemas/CandidateStatus" + default: pending + family: + $ref: "#/components/schemas/CandidateFamily" nullable: true - normalized_remote: - type: string - minLength: 1 - maxLength: 2048 - nullable: true - subpath: + cursor: type: string minLength: 1 - maxLength: 1024 + maxLength: 128 nullable: true - HandoffReportWorkspaceBinding: + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + ListExternalSkillsRequest: type: object additionalProperties: false - required: [schema, workspace_instance_id, project_id, repository_ref, state, confirmed_at, version] + required: [scope_id] properties: - schema: - type: string - enum: [powercontext.workspace-binding.v1] - workspace_instance_id: - type: string - minLength: 1 - maxLength: 256 - project_id: + scope_id: type: string minLength: 1 maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - state: - type: string - enum: [confirmed, detached] - confirmed_at: - type: string - format: date-time + pattern: '.*\S.*' + include_unavailable: + type: boolean + default: false + MemoryEntry: + type: object + additionalProperties: false + required: + - citation + - version + - kind + - text + - state + - source_refs + - artifact_refs + properties: + citation: + $ref: "#/components/schemas/MemoryCitation" version: type: integer minimum: 1 - GetHandoffReportWorkspaceRequest: + kind: + type: string + text: + type: string + state: + $ref: "#/components/schemas/MemoryEntryState" + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + MemoryMutationResponse: type: object additionalProperties: false - required: [workspace_instance_id] + required: [memory] properties: - workspace_instance_id: - type: string - minLength: 1 - maxLength: 256 - AttachHandoffReportWorkspaceRequest: + memory: + $ref: "#/components/schemas/ArtifactReference" + entry: + $ref: "#/components/schemas/MemoryEntry" + nullable: true + MemoryCitation: type: object additionalProperties: false - required: [workspace_instance_id, project_id, repository_ref, expected_version] + required: [memory_ref, entry_id, entry_version_id] properties: - workspace_instance_id: + memory_ref: + $ref: "#/components/schemas/ArtifactReference" + entry_id: type: string minLength: 1 - maxLength: 256 - project_id: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + entry_version_id: type: string minLength: 1 - maxLength: 256 - repository_ref: - $ref: "#/components/schemas/HandoffReportRepositoryRef" - expected_version: - type: integer - minimum: 1 - nullable: true - DetachHandoffReportWorkspaceRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + MemoryRevisionChanges: type: object additionalProperties: false - required: [workspace_instance_id, expected_version] + required: [memory_ref, changes] properties: - workspace_instance_id: + memory_ref: + $ref: "#/components/schemas/ArtifactReference" + changes: + type: array + items: + $ref: "#/components/schemas/EntryChange" + PrepareContextRequest: + type: object + additionalProperties: false + required: [scope_id, query] + properties: + scope_id: type: string minLength: 1 maxLength: 256 - expected_version: + pattern: '.*\S.*' + query: + type: string + minLength: 1 + maxLength: 8192 + pattern: '.*\S.*' + max_bytes: type: integer - minimum: 1 - ProjectDescriptor: + minimum: 512 + maximum: 32768 + default: 8000 + ProposeExperienceRequest: type: object additionalProperties: false - required: [schema, project_id, project_key, title, description, default_locale, timezone, catalog_state, version] + required: [scope_id, proposal, source_refs, artifact_refs] properties: - schema: - type: string - enum: [powercontext.project.v1] - project_id: + scope_id: type: string minLength: 1 maxLength: 256 - project_key: + pattern: '.*\S.*' + proposal: + $ref: "#/components/schemas/ExperienceProposal" + source_refs: + type: array + maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. + items: + $ref: "#/components/schemas/ArtifactReference" + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + reason: type: string minLength: 1 - maxLength: 64 - title: + maxLength: 2000 + nullable: true + GenerateExperienceRequest: + type: object + additionalProperties: false + required: [scope_id, source_refs, artifact_refs] + properties: + scope_id: type: string minLength: 1 maxLength: 256 - description: - type: string - maxLength: 2000 + pattern: '.*\S.*' + source_refs: + type: array + maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: + type: array + maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. + items: + $ref: "#/components/schemas/ArtifactReference" + target: + $ref: "#/components/schemas/ArtifactReference" nullable: true - default_locale: - $ref: "#/components/schemas/ReportLocale" - timezone: + reason: type: string minLength: 1 - maxLength: 256 - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - version: - type: integer - minimum: 1 - ProjectPage: + maxLength: 2000 + nullable: true + ProposeSkillRequest: type: object additionalProperties: false - required: [items, next_cursor] + required: [scope_id, proposal, source_refs, artifact_refs] properties: - items: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + proposal: + $ref: "#/components/schemas/SkillProposal" + source_refs: + type: array + maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. + items: + $ref: "#/components/schemas/SourceReference" + artifact_refs: type: array - maxItems: 100 + maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: - $ref: "#/components/schemas/ProjectDescriptor" - next_cursor: + $ref: "#/components/schemas/ArtifactReference" + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + reason: type: string + minLength: 1 + maxLength: 2000 nullable: true - WorkstreamDescriptor: + SkillGenerationOrigin: + type: string + enum: [experience, source, usage] + description: The operation-specific direct provenance shape required for managed Skill generation. + GenerateSkillRequest: type: object additionalProperties: false - required: [schema, scope_id, project_id, key, title, kind, catalog_state, external_refs, labels, version] + required: [scope_id, origin, source_refs, artifact_refs] properties: - schema: - type: string - enum: [powercontext.workstream.v1] scope_id: type: string minLength: 1 maxLength: 256 - project_id: - type: string - minLength: 1 - maxLength: 256 - key: - type: string - maxLength: 64 - nullable: true - title: - type: string - minLength: 1 - maxLength: 256 - kind: - $ref: "#/components/schemas/WorkstreamKind" - catalog_state: - $ref: "#/components/schemas/ReportCatalogState" - external_refs: + pattern: '.*\S.*' + origin: + $ref: "#/components/schemas/SkillGenerationOrigin" + source_refs: type: array maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: - $ref: "#/components/schemas/HandoffReportExternalReference" - labels: + $ref: "#/components/schemas/SourceReference" + artifact_refs: type: array maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: - type: string - minLength: 1 - maxLength: 128 - version: - type: integer - minimum: 1 - WorkstreamPage: - type: object - additionalProperties: false - required: [items, next_cursor] - properties: - items: - type: array - maxItems: 100 - items: - $ref: "#/components/schemas/WorkstreamDescriptor" - next_cursor: + $ref: "#/components/schemas/ArtifactReference" + target: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + reason: type: string + minLength: 1 + maxLength: 2000 nullable: true - HandoffReportExternalReference: + GeneratedCandidateStatus: + type: string + enum: [pending, no_op] + GeneratedCandidateResponse: type: object additionalProperties: false - required: [kind, provider, external_id, url] + required: [status, candidate] properties: - kind: - type: string - enum: [issue, task, pull_request, branch, feature, release, program, other] - provider: - type: string - minLength: 1 - maxLength: 64 - external_id: - type: string - minLength: 1 - maxLength: 256 - url: - type: string - maxLength: 2048 + status: + $ref: "#/components/schemas/GeneratedCandidateStatus" + candidate: + $ref: "#/components/schemas/ArtifactCandidate" nullable: true - ReportLocale: - type: string - enum: [zh-CN, en] - ReportFormat: - type: string - enum: [json, markdown] - ReportCatalogState: - type: string - enum: [included, archived] - WorkstreamKind: - type: string - enum: [feature, bug, refactor, operations, research, other] - HealthResponse: + ReadinessResponse: type: object additionalProperties: false - required: [status] + required: [status, checks] properties: status: - type: string - ListMemoryChangesRequest: + $ref: "#/components/schemas/ReadinessStatus" + checks: + type: object + additionalProperties: + type: string + ReadinessStatus: + type: string + enum: [ready, degraded, not_ready] + RememberMemoryRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, kind, text] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - since_revision: - type: integer - minimum: 0 + kind: + type: string + minLength: 1 + maxLength: 128 + text: + type: string + minLength: 1 + description: Must not exceed 8192 UTF-8 bytes after normalization. + reason: + type: string + maxLength: 512 nullable: true - description: >- - Exclusive lower bound; 0 requests complete history from Revision 1. - Positive nonexistent revisions are errors. - ListMemoryChangesResponse: - type: object - additionalProperties: false - required: [revisions] - properties: - memory: - $ref: "#/components/schemas/ArtifactReference" + expected_revision: + type: integer + minimum: 1 nullable: true - revisions: - type: array - items: - $ref: "#/components/schemas/MemoryRevisionChanges" - ListMemoryEntriesRequest: + RetireMemoryEntryRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, citation] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - include_inactive: - type: boolean - default: false - description: Include inactive entries from the current Memory head for explicit audit. - ListMemoryEntriesResponse: - type: object - additionalProperties: false - required: [entries] - properties: - memory: - $ref: "#/components/schemas/ArtifactReference" + citation: + $ref: "#/components/schemas/MemoryCitation" + reason: + type: string + maxLength: 512 nullable: true - entries: - type: array - items: - $ref: "#/components/schemas/MemoryEntry" - ListArtifactCandidatesRequest: + RejectArtifactCandidateRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, candidate_id, expected_version, reason] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - status: - $ref: "#/components/schemas/CandidateStatus" - default: pending - family: - $ref: "#/components/schemas/CandidateFamily" - nullable: true - cursor: + candidate_id: type: string minLength: 1 maxLength: 128 - nullable: true - limit: + pattern: '^[\x21-\x7E]+$' + expected_version: type: integer minimum: 1 - maximum: 100 - default: 50 - ListExternalSkillsRequest: + reason: + type: string + minLength: 1 + maxLength: 2000 + pattern: '.*\S.*' + ReviseArtifactCandidateRequest: type: object additionalProperties: false - required: [scope_id] + required: [scope_id, candidate_id, expected_version, proposal, source_refs, artifact_refs] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - include_unavailable: - type: boolean - default: false - MemoryEntry: - type: object - additionalProperties: false - required: - - citation - - version - - kind - - text - - state - - source_refs - - artifact_refs - properties: - citation: - $ref: "#/components/schemas/MemoryCitation" - version: + candidate_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + expected_version: type: integer minimum: 1 - kind: - type: string - text: - type: string - state: - $ref: "#/components/schemas/MemoryEntryState" + proposal: + oneOf: + - $ref: "#/components/schemas/ExperienceProposal" + - $ref: "#/components/schemas/SkillProposal" source_refs: type: array + maxItems: 32 + description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/SourceReference" artifact_refs: type: array + maxItems: 32 + description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/ArtifactReference" - MemoryMutationResponse: - type: object - additionalProperties: false - required: [memory] - properties: - memory: + target: $ref: "#/components/schemas/ArtifactReference" - entry: - $ref: "#/components/schemas/MemoryEntry" nullable: true - MemoryCitation: + reason: + type: string + minLength: 1 + maxLength: 2000 + nullable: true + ReviseMemoryEntryRequest: type: object additionalProperties: false - required: [memory_ref, entry_id, entry_version_id] + required: [scope_id, citation, kind, text] properties: - memory_ref: - $ref: "#/components/schemas/ArtifactReference" - entry_id: + scope_id: type: string minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - entry_version_id: + maxLength: 256 + pattern: '.*\S.*' + citation: + $ref: "#/components/schemas/MemoryCitation" + kind: type: string minLength: 1 maxLength: 128 - pattern: '^[\x21-\x7E]+$' - MemoryRevisionChanges: + text: + type: string + minLength: 1 + description: Must not exceed 8192 UTF-8 bytes after normalization. + reason: + type: string + maxLength: 512 + nullable: true + SearchMemoryHit: type: object additionalProperties: false - required: [memory_ref, changes] + required: [citation, text, score, matched_by] properties: - memory_ref: - $ref: "#/components/schemas/ArtifactReference" - changes: + citation: + $ref: "#/components/schemas/MemoryCitation" + text: + type: string + score: + type: number + minimum: 0 + maximum: 1 + matched_by: type: array items: - $ref: "#/components/schemas/EntryChange" - PrepareContextRequest: + $ref: "#/components/schemas/MemoryMatchedBy" + SearchMemoryRequest: type: object additionalProperties: false required: [scope_id, query] @@ -4159,411 +5213,461 @@ components: type: string minLength: 1 maxLength: 8192 - pattern: '.*\S.*' - max_bytes: + limit: type: integer - minimum: 512 - maximum: 32768 - default: 8000 - ProposeExperienceRequest: + minimum: 1 + maximum: 50 + default: 10 + mode: + $ref: "#/components/schemas/MemorySearchMode" + default: auto + ScanExternalSkillsRequest: type: object additionalProperties: false - required: [scope_id, proposal, source_refs, artifact_refs] + required: [scope_id] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - proposal: - $ref: "#/components/schemas/ExperienceProposal" - source_refs: - type: array - maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - GenerateExperienceRequest: + ResolveExternalSkillRequest: type: object additionalProperties: false - required: [scope_id, source_refs, artifact_refs] + required: [scope_id, external_skill_id, fingerprint] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_refs: - type: array - maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: + external_skill_id: type: string minLength: 1 - maxLength: 2000 - nullable: true - ProposeSkillRequest: + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + ExternalSkillImportMode: + type: string + enum: [import, fork] + ImportExternalSkillRequest: type: object additionalProperties: false - required: [scope_id, proposal, source_refs, artifact_refs] + required: [scope_id, external_skill_id, fingerprint, mode] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - proposal: - $ref: "#/components/schemas/SkillProposal" - source_refs: - type: array - maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/SourceReference" - artifact_refs: - type: array - maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. - items: - $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true + external_skill_id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + fingerprint: + type: string + pattern: '^[0-9a-f]{64}$' + description: Exact package fingerprint captured into Source lineage. + mode: + $ref: "#/components/schemas/ExternalSkillImportMode" reason: type: string minLength: 1 maxLength: 2000 nullable: true - SkillGenerationOrigin: - type: string - enum: [experience, source, usage] - description: The operation-specific direct provenance shape required for managed Skill generation. - GenerateSkillRequest: + SearchMemoryResponse: type: object additionalProperties: false - required: [scope_id, origin, source_refs, artifact_refs] + required: [hits] + properties: + memory: + $ref: "#/components/schemas/ArtifactReference" + nullable: true + mode: + $ref: "#/components/schemas/MemoryUsedSearchMode" + nullable: true + hits: + type: array + items: + $ref: "#/components/schemas/SearchMemoryHit" + CreateArtifactRequest: + type: object + additionalProperties: false + required: [scope_id, family, schema_version, content] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - origin: - $ref: "#/components/schemas/SkillGenerationOrigin" + family: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + artifact_id: + type: string + minLength: 1 + maxLength: 128 + nullable: true + schema_version: + type: integer + minimum: 1 + metadata: + type: object + additionalProperties: true + default: {} + content: + type: object + additionalProperties: true source_refs: type: array maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/SourceReference" + default: [] artifact_refs: type: array maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: - type: string - minLength: 1 - maxLength: 2000 - nullable: true - GeneratedCandidateStatus: - type: string - enum: [pending, no_op] - GeneratedCandidateResponse: + default: [] + CreateSourceRequest: type: object additionalProperties: false - required: [status, candidate] + required: [scope_id, source_type, source_id, content] properties: - status: - $ref: "#/components/schemas/GeneratedCandidateStatus" - candidate: - $ref: "#/components/schemas/ArtifactCandidate" - nullable: true - ReadinessResponse: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_type: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + source_id: + type: string + minLength: 1 + maxLength: 256 + content: + type: string + minLength: 1 + maxLength: 200000 + pattern: '.*\S.*' + metadata: + type: object + additionalProperties: true + default: {} + DeleteArtifactRequest: type: object additionalProperties: false - required: [status, checks] + required: [scope_id, family] properties: - status: - $ref: "#/components/schemas/ReadinessStatus" - checks: - type: object - additionalProperties: - type: string - ReadinessStatus: - type: string - enum: [ready, degraded, not_ready] - RememberMemoryRequest: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + family: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + GetArtifactRequest: type: object additionalProperties: false - required: [scope_id, kind, text] + required: [scope_id, family] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - kind: + family: type: string minLength: 1 maxLength: 128 - text: - type: string - minLength: 1 - description: Must not exceed 8192 UTF-8 bytes after normalization. - reason: - type: string - maxLength: 512 - nullable: true - expected_revision: - type: integer - minimum: 1 - nullable: true - RetireMemoryEntryRequest: + pattern: '^[\x21-\x7E]+$' + GetArtifactRevisionRequest: type: object additionalProperties: false - required: [scope_id, citation] + required: [scope_id, family] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - reason: + family: type: string - maxLength: 512 - nullable: true - RejectArtifactCandidateRequest: + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + GetSourceRequest: type: object additionalProperties: false - required: [scope_id, candidate_id, expected_version, reason] + required: [scope_id, source_type] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - candidate_id: + source_type: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - expected_version: - type: integer - minimum: 1 - reason: - type: string - minLength: 1 - maxLength: 2000 - pattern: '.*\S.*' - ReviseArtifactCandidateRequest: + ListArtifactsRequest: type: object additionalProperties: false - required: [scope_id, candidate_id, expected_version, proposal, source_refs, artifact_refs] + required: [scope_id, family] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - candidate_id: + family: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - expected_version: + limit: type: integer minimum: 1 - proposal: - oneOf: - - $ref: "#/components/schemas/ExperienceProposal" - - $ref: "#/components/schemas/SkillProposal" + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + ListScopesRequest: + type: object + additionalProperties: false + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + ReplaceArtifactRequest: + type: object + additionalProperties: false + required: [schema_version, content] + properties: + schema_version: + type: integer + minimum: 1 + metadata: + type: object + additionalProperties: true + default: {} + content: + type: object + additionalProperties: true source_refs: type: array maxItems: 32 - description: Exact Source evidence. Counted with artifact_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/SourceReference" + default: [] artifact_refs: type: array maxItems: 32 - description: Exact Artifact evidence. Counted with source_refs toward a combined maximum of 32 references. items: $ref: "#/components/schemas/ArtifactReference" - target: - $ref: "#/components/schemas/ArtifactReference" - nullable: true - reason: + default: [] + ScopePage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + items: + $ref: "#/components/schemas/ScopeSummary" + next_cursor: type: string - minLength: 1 - maxLength: 2000 nullable: true - ReviseMemoryEntryRequest: + ScopeSummary: type: object additionalProperties: false - required: [scope_id, citation, kind, text] + required: + [scope_id, title, summary, parent_scope_id, version, source_types, artifact_families, source_count, artifact_count] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - citation: - $ref: "#/components/schemas/MemoryCitation" - kind: - type: string - minLength: 1 - maxLength: 128 - text: + title: type: string - minLength: 1 - description: Must not exceed 8192 UTF-8 bytes after normalization. - reason: + nullable: true + summary: type: string - maxLength: 512 nullable: true - SearchMemoryHit: - type: object - additionalProperties: false - required: [citation, text, score, matched_by] - properties: - citation: - $ref: "#/components/schemas/MemoryCitation" - text: + parent_scope_id: type: string - score: - type: number - minimum: 0 - maximum: 1 - matched_by: + nullable: true + version: + type: integer + minimum: 1 + nullable: true + source_types: type: array items: - $ref: "#/components/schemas/MemoryMatchedBy" - SearchMemoryRequest: + type: string + artifact_families: + type: array + items: + type: string + source_count: + type: integer + minimum: 0 + artifact_count: + type: integer + minimum: 0 + SearchArtifactsRequest: type: object additionalProperties: false - required: [scope_id, query] + required: [scope_id, family, q] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - query: + family: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + q: type: string minLength: 1 maxLength: 8192 + pattern: '.*\S.*' + mode: + $ref: "#/components/schemas/TextSearchMode" + default: auto limit: type: integer minimum: 1 - maximum: 50 - default: 10 - mode: - $ref: "#/components/schemas/MemorySearchMode" - default: auto - ScanExternalSkillsRequest: - type: object - additionalProperties: false - required: [scope_id] - properties: - scope_id: + maximum: 100 + default: 50 + cursor: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - ResolveExternalSkillRequest: + maxLength: 4096 + nullable: true + SearchSourcesRequest: type: object additionalProperties: false - required: [scope_id, external_skill_id, fingerprint] + required: [scope_id, source_type] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - external_skill_id: + source_type: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - fingerprint: + type: + $ref: "#/components/schemas/SourceQueryType" + default: list + q: type: string - pattern: '^[0-9a-f]{64}$' - ExternalSkillImportMode: + minLength: 1 + maxLength: 8192 + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchMode" + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + SourcePage: + type: object + additionalProperties: false + required: [query, mode, items, next_cursor] + properties: + query: + type: string + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchUsedMode" + nullable: true + items: + type: array + items: + $ref: "#/components/schemas/SourceRecord" + next_cursor: + type: string + nullable: true + SourceQueryType: type: string - enum: [import, fork] - ImportExternalSkillRequest: + enum: [list, search] + SourceRecord: type: object additionalProperties: false - required: [scope_id, external_skill_id, fingerprint, mode] + required: + [scope_id, source_ref, content, metadata, created_at, position, content_digest, score, snippets] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - external_skill_id: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - fingerprint: + source_ref: + $ref: "#/components/schemas/SourceReference" + content: type: string - pattern: '^[0-9a-f]{64}$' - description: Exact package fingerprint captured into Source lineage. - mode: - $ref: "#/components/schemas/ExternalSkillImportMode" - reason: + metadata: + type: object + additionalProperties: true + created_at: type: string - minLength: 1 - maxLength: 2000 - nullable: true - SearchMemoryResponse: - type: object - additionalProperties: false - required: [hits] - properties: - memory: - $ref: "#/components/schemas/ArtifactReference" + format: date-time nullable: true - mode: - $ref: "#/components/schemas/MemoryUsedSearchMode" + position: + type: integer + minimum: 1 + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + score: + type: number + minimum: 0 + maximum: 1 nullable: true - hits: + snippets: type: array items: - $ref: "#/components/schemas/SearchMemoryHit" + type: string SourceReference: type: object additionalProperties: false @@ -4580,6 +5684,12 @@ components: StatsPeriod: type: string enum: [today, 7d, 30d] + TextSearchMode: + type: string + enum: [auto, keyword] + TextSearchUsedMode: + type: string + enum: [keyword] CandidateFamily: type: string enum: [experience, skill] diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py new file mode 100644 index 000000000..8fd27f276 --- /dev/null +++ b/src/powercontext/builtin/persistence/records.py @@ -0,0 +1,968 @@ +# 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 json +from collections.abc import Callable, Mapping +from contextlib import suppress +from datetime import UTC, datetime +from hashlib import sha256 +from typing import Any, cast +from uuid import uuid4 + +import rfc8785 +from pydantic import JsonValue, TypeAdapter, ValidationError +from sqlalchemy import delete, func, insert, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.persistence.codec import stored_bytes +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.sources import SourceRepository, StoredSource +from powercontext.builtin.persistence.tables import ( + ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_REVISION_RECORDS_TABLE, + ARTIFACT_TOMBSTONES_TABLE, + ARTIFACTS_TABLE, + SOURCE_JOURNAL_HEADS_TABLE, + SOURCE_RECORDS_TABLE, + SOURCES_TABLE, +) +from powercontext.builtin.records import ( + ArtifactDeletion, + ArtifactRecord, + ArtifactRecordPage, + ArtifactRevisionPreconditionError, + ArtifactSearchHit, + ArtifactSearchPage, + ArtifactWrite, + BaseOperationNotSupportedError, + BaseValueConflictError, + BaseValueNotFoundError, + InvalidBaseAccessRequestError, + ScopeSummary, + ScopeSummaryPage, + SourceQueryType, + SourceRecord, + SourceRecordPage, + TextSearchMode, +) +from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER, CONTENT_SOURCE_NAME, ContentCapture, ContentSource +from powercontext.sources import SourceRef + +Clock = Callable[[], datetime] + +_JSON_OBJECT = TypeAdapter(dict[str, JsonValue]) +_PROTECTED_ARTIFACT_FAMILIES = frozenset({"experience", "handoff", "memory", "skill"}) + + +class RelationalRecordService: + """Serve fixed Source and Artifact paths over the shared relational tables.""" + + def __init__( + self, + database: AsyncDatabase, + sources: SourceRepository, + /, + *, + clock: Clock | None = None, + ) -> None: + self._database = database + self._sources = sources + self._clock = _utc_now if clock is None else clock + + async def create_source( + self, + scope_id: str, + source_type: str, + source_id: str, + content: str, + metadata: Mapping[str, JsonValue], + /, + ) -> SourceRecord: + self._require_content_source(source_type, "create") + capture = ContentCapture(source_id=source_id, content=content, metadata=dict(metadata)) + source = await CONTENT_SOURCE_ADAPTER.resolve(capture) + digest = _content_digest({"content": source.content, "metadata": source.metadata}) + created_at = _aware_datetime(self._clock()) + try: + async with self._database.transaction() as connection: + stored = await self._sources.add(connection, scope_id, source) + record = await _source_projection(connection, scope_id, stored.ref) + if record is None: + with suppress(IntegrityError): + await connection.execute( + insert(SOURCE_RECORDS_TABLE).values( + scope_id=scope_id, + source_type=stored.ref.source_type, + source_id=stored.ref.source_id, + created_at=created_at, + content_digest=digest, + ) + ) + record = await _source_projection(connection, scope_id, stored.ref) + projected_at = created_at if record is None else _aware_datetime(record.created_at) + projected_digest = digest if record is None else str(record.content_digest) + return _source_record( + scope_id, + stored, + created_at=projected_at, + content_digest=projected_digest, + ) + except StoredPayloadConflictError as error: + raise BaseValueConflictError("source", (scope_id, source_type, source_id)) from error + + async def get_source(self, scope_id: str, source_type: str, source_id: str, /) -> SourceRecord: + self._require_content_source(source_type, "get") + ref = SourceRef(source_type=source_type, source_id=source_id) + async with self._database.transaction() as connection: + stored = await self._get_source(connection, scope_id, ref) + projection = await _source_projection(connection, scope_id, ref) + return _source_record( + scope_id, + stored, + created_at=None if projection is None else _aware_datetime(projection.created_at), + content_digest=(_source_digest(stored) if projection is None else str(projection.content_digest)), + ) + + async def query_sources( + self, + scope_id: str, + source_type: str, + query_type: SourceQueryType, + /, + *, + query: str | None, + mode: TextSearchMode | None, + limit: int, + cursor: str | None, + ) -> SourceRecordPage: + self._require_content_source(source_type, query_type) + normalized_query = _validate_source_query(query_type, query, mode) + expected_cursor = { + "kind": "source", + "scope_id": scope_id, + "source_type": source_type, + "type": query_type, + "q": normalized_query, + "mode": None if mode is None else str(mode), + } + after = _cursor_after_int(cursor, expected_cursor) + async with self._database.transaction() as connection: + statement = ( + select(SOURCES_TABLE.c.source_id, SOURCES_TABLE.c.journal_position) + .where( + SOURCES_TABLE.c.scope_id == scope_id, + SOURCES_TABLE.c.source_type == source_type, + SOURCES_TABLE.c.journal_position > after, + ) + .order_by(SOURCES_TABLE.c.journal_position) + ) + if query_type == "list": + statement = statement.limit(limit + 1) + rows = (await connection.execute(statement)).all() + matches: list[tuple[int, SourceRecord]] = [] + for row in rows: + ref = SourceRef(source_type=source_type, source_id=str(row.source_id)) + stored = await self._get_source(connection, scope_id, ref) + projection = await _source_projection(connection, scope_id, ref) + record = _source_record( + scope_id, + stored, + created_at=None if projection is None else _aware_datetime(projection.created_at), + content_digest=(_source_digest(stored) if projection is None else str(projection.content_digest)), + ) + if normalized_query is not None: + if not _matches(record.content, record.metadata, normalized_query): + continue + record = record.model_copy( + update={"score": 1.0, "snippets": (_snippet(record.content, normalized_query),)} + ) + matches.append((int(row.journal_position), record)) + if len(matches) > limit: + break + + has_more = len(matches) > limit + selected = matches[:limit] + next_cursor = None + if has_more and selected: + next_cursor = _encode_cursor(expected_cursor, selected[-1][0]) + return SourceRecordPage( + query=normalized_query, + mode=None if normalized_query is None else "keyword", + items=tuple(record for _, record in selected), + next_cursor=next_cursor, + ) + + async def create_artifact( + self, + scope_id: str, + family: str, + artifact_id: str | None, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: + self._require_direct_family(family, "create") + identity = f"art_{uuid4().hex}" if artifact_id is None else artifact_id + ref = ArtifactRef(family=family, artifact_id=identity, revision=1) + now = _aware_datetime(self._clock()) + try: + async with self._database.transaction() as connection: + await self._require_new_artifact(connection, scope_id, ref) + await self._validate_lineage(connection, scope_id, write) + await _insert_artifact_revision(connection, scope_id, ref, write, now) + await connection.execute( + insert(ARTIFACT_HEADS_TABLE).values( + scope_id=scope_id, + family=family, + artifact_id=identity, + revision=1, + searchable_text=_searchable_text(write.content, write.metadata), + ) + ) + except IntegrityError as error: + raise BaseValueConflictError("artifact", (scope_id, family, identity)) from error + return _artifact_record(scope_id, ref, write, now) + + async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> ArtifactRecord: + ArtifactRef(family=family, artifact_id=artifact_id, revision=1) + async with self._database.transaction() as connection: + revision = 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, + ) + ) + if revision is None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + return await _load_artifact(connection, scope_id, family, artifact_id, int(revision)) + + async def get_artifact_revision( + self, + scope_id: str, + family: str, + artifact_id: str, + revision: int, + /, + ) -> ArtifactRecord: + ArtifactRef(family=family, artifact_id=artifact_id, revision=revision) + async with self._database.transaction() as connection: + return await _load_artifact(connection, scope_id, family, artifact_id, revision) + + async def list_artifacts( + self, + scope_id: str, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> ArtifactRecordPage: + ArtifactRef(family=family, artifact_id="validation", revision=1) + expected_cursor = {"kind": "artifact-list", "scope_id": scope_id, "family": family} + after = _cursor_after_text(cursor, expected_cursor) + async with self._database.transaction() as connection: + rows = ( + await connection.execute( + 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) + ) + ).all() + selected = rows[:limit] + items = tuple([ + await _load_artifact( + connection, + scope_id, + family, + str(row.artifact_id), + int(row.revision), + ) + for row in selected + ]) + next_cursor = None + if len(rows) > limit and selected: + next_cursor = _encode_cursor(expected_cursor, str(selected[-1].artifact_id)) + return ArtifactRecordPage(items=items, next_cursor=next_cursor) + + async def search_artifacts( + self, + scope_id: str, + family: str, + query: str, + /, + *, + mode: TextSearchMode, + limit: int, + cursor: str | None, + ) -> ArtifactSearchPage: + ArtifactRef(family=family, artifact_id="validation", revision=1) + normalized_query = _require_query(query) + if mode not in {"auto", "keyword"}: + raise InvalidBaseAccessRequestError("mode", "must be auto or keyword") + expected_cursor = { + "kind": "artifact-search", + "scope_id": scope_id, + "family": family, + "q": normalized_query, + "mode": mode, + } + after = _cursor_after_text(cursor, expected_cursor) + async with self._database.transaction() as connection: + rows = ( + await connection.execute( + 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) + ) + ).all() + matches: list[tuple[str, ArtifactSearchHit]] = [] + for row in rows: + artifact = await _load_artifact( + connection, + scope_id, + family, + str(row.artifact_id), + int(row.revision), + ) + text = _searchable_text(artifact.content, artifact.metadata) + if not _matches_text(text, normalized_query): + continue + matches.append(( + artifact.artifact_ref.artifact_id, + ArtifactSearchHit( + artifact=artifact, + score=1.0, + snippets=(_snippet(text, normalized_query),), + ), + )) + if len(matches) > limit: + break + + has_more = len(matches) > limit + selected_hits = matches[:limit] + next_cursor = None + if has_more and selected_hits: + next_cursor = _encode_cursor(expected_cursor, selected_hits[-1][0]) + return ArtifactSearchPage( + query=normalized_query, + mode="keyword", + hits=tuple(hit for _, hit in selected_hits), + next_cursor=next_cursor, + ) + + async def replace_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + expected_revision: int, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: + self._require_direct_family(family, "replace") + ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision) + now = _aware_datetime(self._clock()) + try: + async with self._database.transaction() as connection: + current = await _head_revision(connection, scope_id, family, artifact_id) + if current is None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + if current != expected_revision: + raise ArtifactRevisionPreconditionError(expected_revision, current) + locked = await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_HEADS_TABLE.c.revision == expected_revision, + ) + .values(revision=ARTIFACT_HEADS_TABLE.c.revision) + ) + if locked.rowcount != 1: + latest = await _head_revision(connection, scope_id, family, artifact_id) + raise ArtifactRevisionPreconditionError(expected_revision, latest or expected_revision) + await self._validate_lineage(connection, scope_id, write) + ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision + 1) + await _insert_artifact_revision(connection, scope_id, ref, write, now) + advanced = await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_HEADS_TABLE.c.revision == expected_revision, + ) + .values( + revision=ref.revision, + searchable_text=_searchable_text(write.content, write.metadata), + ) + ) + if advanced.rowcount != 1: + latest = await _head_revision(connection, scope_id, family, artifact_id) + raise ArtifactRevisionPreconditionError(expected_revision, latest or expected_revision) + except IntegrityError as error: + raise BaseValueConflictError("artifact", (scope_id, family, artifact_id)) from error + return _artifact_record(scope_id, ref, write, now) + + async def delete_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + expected_revision: int, + /, + ) -> ArtifactDeletion: + self._require_direct_family(family, "delete") + ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision) + async with self._database.transaction() as connection: + tombstone = ( + await connection.execute( + select( + ARTIFACT_TOMBSTONES_TABLE.c.revision, + ARTIFACT_TOMBSTONES_TABLE.c.deleted_at, + ).where( + ARTIFACT_TOMBSTONES_TABLE.c.scope_id == scope_id, + ARTIFACT_TOMBSTONES_TABLE.c.family == family, + ARTIFACT_TOMBSTONES_TABLE.c.artifact_id == artifact_id, + ) + ) + ).one_or_none() + if tombstone is not None: + revision = int(tombstone.revision) + if revision != expected_revision: + raise ArtifactRevisionPreconditionError(expected_revision, revision) + return ArtifactDeletion( + artifact_ref=ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), + deleted_at=_aware_datetime(tombstone.deleted_at), + ) + + current = await _head_revision(connection, scope_id, family, artifact_id) + if current is None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + if current != expected_revision: + raise ArtifactRevisionPreconditionError(expected_revision, current) + removed = await connection.execute( + delete(ARTIFACT_HEADS_TABLE).where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_HEADS_TABLE.c.revision == expected_revision, + ) + ) + if removed.rowcount != 1: + latest = await _head_revision(connection, scope_id, family, artifact_id) + raise ArtifactRevisionPreconditionError(expected_revision, latest or expected_revision) + deleted_at = _aware_datetime(self._clock()) + await connection.execute( + insert(ARTIFACT_TOMBSTONES_TABLE).values( + scope_id=scope_id, + family=family, + artifact_id=artifact_id, + revision=expected_revision, + deleted_at=deleted_at, + ) + ) + return ArtifactDeletion( + artifact_ref=ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision), + deleted_at=deleted_at, + ) + + async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: + expected_cursor = {"kind": "scope-list"} + after = _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 = _encode_cursor(expected_cursor, selected[-1]) + return ScopeSummaryPage(items=summaries, next_cursor=next_cursor) + + def _require_content_source(self, source_type: str, operation: str) -> None: + if source_type != CONTENT_SOURCE_NAME: + raise BaseOperationNotSupportedError("source_type", source_type, operation) + + def _require_direct_family(self, family: str, operation: str) -> None: + if family in _PROTECTED_ARTIFACT_FAMILIES: + raise BaseOperationNotSupportedError("artifact_family", family, operation) + + 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 _require_new_artifact( + self, + connection: AsyncConnection, + scope_id: str, + ref: ArtifactRef, + ) -> None: + existing = await connection.scalar( + select(ARTIFACTS_TABLE.c.revision) + .where( + ARTIFACTS_TABLE.c.scope_id == scope_id, + ARTIFACTS_TABLE.c.family == ref.family, + ARTIFACTS_TABLE.c.artifact_id == ref.artifact_id, + ) + .limit(1) + ) + if existing is not None: + raise BaseValueConflictError("artifact", (scope_id, ref.family, ref.artifact_id)) + + async def _validate_lineage( + self, + connection: AsyncConnection, + scope_id: str, + write: ArtifactWrite, + ) -> None: + for source in write.source_refs: + exists = await connection.scalar( + select(func.count()) + .select_from(SOURCES_TABLE) + .where( + SOURCES_TABLE.c.scope_id == scope_id, + SOURCES_TABLE.c.source_type == source.source_type, + SOURCES_TABLE.c.source_id == source.source_id, + ) + ) + if not exists: + raise InvalidBaseAccessRequestError("source_refs", "must identify same-Scope Sources") + for artifact in write.artifact_refs: + exists = await connection.scalar( + select(func.count()) + .select_from(ARTIFACTS_TABLE) + .where( + ARTIFACTS_TABLE.c.scope_id == scope_id, + ARTIFACTS_TABLE.c.family == artifact.family, + ARTIFACTS_TABLE.c.artifact_id == artifact.artifact_id, + ARTIFACTS_TABLE.c.revision == artifact.revision, + ) + ) + if not exists: + raise InvalidBaseAccessRequestError( + "artifact_refs", + "must identify exact same-Scope Artifact revisions", + ) + + +async def _insert_artifact_revision( + connection: AsyncConnection, + scope_id: str, + ref: ArtifactRef, + write: ArtifactWrite, + created_at: datetime, +) -> None: + content = _canonical_object(write.content) + metadata = _canonical_object(write.metadata) + await connection.execute( + insert(ARTIFACTS_TABLE).values( + scope_id=scope_id, + family=ref.family, + artifact_id=ref.artifact_id, + revision=ref.revision, + content=content, + ) + ) + await connection.execute( + insert(ARTIFACT_REVISION_RECORDS_TABLE).values( + scope_id=scope_id, + family=ref.family, + artifact_id=ref.artifact_id, + revision=ref.revision, + schema_version=write.schema_version, + metadata=metadata, + created_at=created_at, + content_digest=_content_digest(write.content), + ) + ) + if write.source_refs: + await connection.execute( + insert(ARTIFACT_LINEAGE_SOURCES_TABLE), + [ + { + "scope_id": scope_id, + "family": ref.family, + "artifact_id": ref.artifact_id, + "revision": ref.revision, + "ordinal": ordinal, + "source_type": source.source_type, + "source_id": source.source_id, + } + for ordinal, source in enumerate(write.source_refs) + ], + ) + if write.artifact_refs: + await connection.execute( + insert(ARTIFACT_LINEAGE_ARTIFACTS_TABLE), + [ + { + "scope_id": scope_id, + "family": ref.family, + "artifact_id": ref.artifact_id, + "revision": ref.revision, + "ordinal": ordinal, + "upstream_family": artifact.family, + "upstream_artifact_id": artifact.artifact_id, + "upstream_revision": artifact.revision, + } + for ordinal, artifact in enumerate(write.artifact_refs) + ], + ) + + +async def _load_artifact( + connection: AsyncConnection, + scope_id: str, + family: str, + artifact_id: str, + revision: int, +) -> ArtifactRecord: + row = ( + await connection.execute( + select(ARTIFACTS_TABLE.c.content).where( + ARTIFACTS_TABLE.c.scope_id == scope_id, + ARTIFACTS_TABLE.c.family == family, + ARTIFACTS_TABLE.c.artifact_id == artifact_id, + ARTIFACTS_TABLE.c.revision == revision, + ) + ) + ).one_or_none() + if row is None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id, revision)) + content = _decode_object(row.content, "content") + projection = ( + await connection.execute( + select( + ARTIFACT_REVISION_RECORDS_TABLE.c.schema_version, + ARTIFACT_REVISION_RECORDS_TABLE.c.metadata, + ARTIFACT_REVISION_RECORDS_TABLE.c.created_at, + ARTIFACT_REVISION_RECORDS_TABLE.c.content_digest, + ).where( + ARTIFACT_REVISION_RECORDS_TABLE.c.scope_id == scope_id, + ARTIFACT_REVISION_RECORDS_TABLE.c.family == family, + ARTIFACT_REVISION_RECORDS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_REVISION_RECORDS_TABLE.c.revision == revision, + ) + ) + ).one_or_none() + sources = ( + await connection.execute( + select( + ARTIFACT_LINEAGE_SOURCES_TABLE.c.source_type, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.source_id, + ) + .where( + ARTIFACT_LINEAGE_SOURCES_TABLE.c.scope_id == scope_id, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.family == family, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.artifact_id == artifact_id, + ARTIFACT_LINEAGE_SOURCES_TABLE.c.revision == revision, + ) + .order_by(ARTIFACT_LINEAGE_SOURCES_TABLE.c.ordinal) + ) + ).all() + artifacts = ( + await connection.execute( + select( + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.upstream_family, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.upstream_artifact_id, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.upstream_revision, + ) + .where( + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.scope_id == scope_id, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.family == family, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.revision == revision, + ) + .order_by(ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.ordinal) + ) + ).all() + write = ArtifactWrite( + schema_version=1 if projection is None else int(projection.schema_version), + metadata={} if projection is None else _decode_object(projection.metadata, "metadata"), + content=content, + source_refs=tuple( + SourceRef(source_type=str(source.source_type), source_id=str(source.source_id)) for source in sources + ), + artifact_refs=tuple( + ArtifactRef( + family=str(artifact.upstream_family), + artifact_id=str(artifact.upstream_artifact_id), + revision=int(artifact.upstream_revision), + ) + for artifact in artifacts + ), + ) + ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=revision) + return ArtifactRecord( + scope_id=scope_id, + artifact_ref=ref, + schema_version=write.schema_version, + metadata=write.metadata, + content=write.content, + source_refs=write.source_refs, + artifact_refs=write.artifact_refs, + created_at=None if projection is None else _aware_datetime(projection.created_at), + content_digest=(_content_digest(content) if projection is None else str(projection.content_digest)), + ) + + +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), + ) + + +async def _source_projection(connection: AsyncConnection, scope_id: str, ref: SourceRef): + return ( + await connection.execute( + select( + SOURCE_RECORDS_TABLE.c.created_at, + SOURCE_RECORDS_TABLE.c.content_digest, + ).where( + SOURCE_RECORDS_TABLE.c.scope_id == scope_id, + SOURCE_RECORDS_TABLE.c.source_type == ref.source_type, + SOURCE_RECORDS_TABLE.c.source_id == ref.source_id, + ) + ) + ).one_or_none() + + +async def _head_revision( + connection: AsyncConnection, + scope_id: str, + 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, + ) + ) + return None if value is None else int(value) + + +def _source_record( + scope_id: str, + stored: StoredSource, + *, + created_at: datetime | None, + content_digest: str, +) -> SourceRecord: + if not isinstance(stored.value, ContentSource): + raise BaseOperationNotSupportedError("source_type", stored.ref.source_type, "read") + return SourceRecord( + scope_id=scope_id, + source_ref=stored.ref, + content=stored.value.content, + metadata=stored.value.metadata, + created_at=created_at, + position=stored.journal_position, + content_digest=content_digest, + ) + + +def _source_digest(stored: StoredSource) -> str: + if not isinstance(stored.value, ContentSource): + raise BaseOperationNotSupportedError("source_type", stored.ref.source_type, "read") + return _content_digest({"content": stored.value.content, "metadata": stored.value.metadata}) + + +def _artifact_record( + scope_id: str, + ref: ArtifactRef, + write: ArtifactWrite, + created_at: datetime, +) -> ArtifactRecord: + return ArtifactRecord( + scope_id=scope_id, + artifact_ref=ref, + schema_version=write.schema_version, + metadata=write.metadata, + content=write.content, + source_refs=write.source_refs, + artifact_refs=write.artifact_refs, + created_at=created_at, + content_digest=_content_digest(write.content), + ) + + +def _canonical_object(value: Mapping[str, JsonValue]) -> bytes: + validated = _JSON_OBJECT.validate_python(dict(value), strict=True) + return rfc8785.dumps(cast(Any, validated)) + + +def _decode_object(value: object, column: str) -> dict[str, JsonValue]: + try: + decoded = json.loads(stored_bytes(value, column=column)) + return _JSON_OBJECT.validate_python(decoded, strict=True) + except (UnicodeDecodeError, json.JSONDecodeError, ValidationError) as error: + raise InvalidBaseAccessRequestError(column, "contains invalid stored JSON") from error + + +def _content_digest(value: Mapping[str, JsonValue]) -> str: + return f"sha256:{sha256(_canonical_object(value)).hexdigest()}" + + +def _searchable_text(content: Mapping[str, JsonValue], metadata: Mapping[str, JsonValue]) -> str: + return json.dumps({"content": content, "metadata": metadata}, ensure_ascii=False, sort_keys=True) + + +def _matches(content: str, metadata: Mapping[str, JsonValue], query: str) -> bool: + return _matches_text(f"{content}\n{json.dumps(metadata, ensure_ascii=False, sort_keys=True)}", query) + + +def _matches_text(text: str, query: str) -> bool: + searchable = text.casefold() + return all(token in searchable for token in query.casefold().split()) + + +def _snippet(text: str, query: str, *, maximum: int = 240) -> str: + folded = text.casefold() + first = query.casefold().split()[0] + position = folded.find(first) + if position < 0: + return text[:maximum] + start = max(position - maximum // 3, 0) + return text[start : start + maximum] + + +def _validate_source_query( + query_type: SourceQueryType, + query: str | None, + mode: TextSearchMode | None, +) -> str | None: + if query_type == "list": + if query is not None or mode is not None: + raise InvalidBaseAccessRequestError("type", "list cannot include q or mode") + return None + if query_type != "search": + raise InvalidBaseAccessRequestError("type", "must be list or search") + if mode not in {None, "auto", "keyword"}: + raise InvalidBaseAccessRequestError("mode", "must be auto or keyword") + return _require_query(query) + + +def _require_query(query: str | None) -> str: + if query is None or not query.strip(): + raise InvalidBaseAccessRequestError("q", "must contain non-whitespace text") + return query.strip() + + +def _encode_cursor(expected: Mapping[str, JsonValue], after: int | str) -> str: + payload = {**expected, "after": after} + encoded = rfc8785.dumps(cast(Any, payload)) + return base64.urlsafe_b64encode(encoded).decode("ascii").rstrip("=") + + +def _cursor_payload(cursor: str | None, expected: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None: + if cursor is None: + return None + try: + padding = "=" * (-len(cursor) % 4) + decoded = base64.urlsafe_b64decode(f"{cursor}{padding}") + payload = _JSON_OBJECT.validate_json(decoded, strict=True) + except (ValueError, ValidationError) as error: + raise InvalidBaseAccessRequestError("cursor", "is invalid") from error + if any(payload.get(key) != value for key, value in expected.items()): + raise InvalidBaseAccessRequestError("cursor", "does not match the query") + if set(payload) != {*expected, "after"}: + raise InvalidBaseAccessRequestError("cursor", "is invalid") + return payload + + +def _cursor_after_int(cursor: str | None, expected: Mapping[str, JsonValue]) -> int: + payload = _cursor_payload(cursor, expected) + if payload is None: + return 0 + after = payload["after"] + if not isinstance(after, int) or isinstance(after, bool) or after < 0: + raise InvalidBaseAccessRequestError("cursor", "contains an invalid position") + return after + + +def _cursor_after_text(cursor: str | None, expected: Mapping[str, JsonValue]) -> str: + payload = _cursor_payload(cursor, expected) + if payload is None: + return "" + after = payload["after"] + if not isinstance(after, str): + raise InvalidBaseAccessRequestError("cursor", "contains an invalid identity") + return after + + +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 _utc_now() -> datetime: + return datetime.now(UTC) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 99e1013a6..835edcf27 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -20,6 +20,7 @@ CheckConstraint, Column, Date, + DateTime, ForeignKeyConstraint, Integer, LargeBinary, @@ -94,6 +95,21 @@ def _entry_text_type(): CheckConstraint("position >= 0", name="ck_pc_source_journal_heads_position_nonnegative"), ) +SOURCE_RECORDS_TABLE = Table( + "pc_source_records", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("source_type", identity_string(MAX_SOURCE_TYPE_LENGTH), primary_key=True), + Column("source_id", identity_string(MAX_SOURCE_ID_LENGTH), primary_key=True), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("content_digest", identity_string(71), nullable=False), + ForeignKeyConstraint( + ("scope_id", "source_type", "source_id"), + ("pc_sources.scope_id", "pc_sources.source_type", "pc_sources.source_id"), + ondelete="CASCADE", + ), +) + ARTIFACTS_TABLE = Table( "pc_artifacts", SHARED_METADATA, @@ -125,6 +141,51 @@ def _entry_text_type(): CheckConstraint("revision > 0", name="ck_pc_artifact_heads_revision_positive"), ) +ARTIFACT_REVISION_RECORDS_TABLE = Table( + "pc_artifact_revision_records", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("revision", Integer, primary_key=True), + Column("schema_version", Integer, nullable=False), + Column("metadata", _canonical_payload_type(), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("content_digest", identity_string(71), nullable=False), + ForeignKeyConstraint( + ("scope_id", "family", "artifact_id", "revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="CASCADE", + ), + CheckConstraint("schema_version > 0", name="ck_pc_artifact_revision_records_schema_version_positive"), +) + +ARTIFACT_TOMBSTONES_TABLE = Table( + "pc_artifact_tombstones", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("revision", Integer, nullable=False), + Column("deleted_at", DateTime(timezone=True), nullable=False), + ForeignKeyConstraint( + ("scope_id", "family", "artifact_id", "revision"), + ( + "pc_artifacts.scope_id", + "pc_artifacts.family", + "pc_artifacts.artifact_id", + "pc_artifacts.revision", + ), + ondelete="RESTRICT", + ), + CheckConstraint("revision > 0", name="ck_pc_artifact_tombstones_revision_positive"), +) + ARTIFACT_LINEAGE_SOURCES_TABLE = Table( "pc_artifact_lineage_sources", @@ -350,8 +411,11 @@ def _entry_text_type(): SHARED_TABLES = ( SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, + SOURCE_RECORDS_TABLE, ARTIFACTS_TABLE, ARTIFACT_HEADS_TABLE, + ARTIFACT_REVISION_RECORDS_TABLE, + ARTIFACT_TOMBSTONES_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE, diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py new file mode 100644 index 000000000..e978f8a97 --- /dev/null +++ b/src/powercontext/builtin/records.py @@ -0,0 +1,274 @@ +# 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 datetime import datetime +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, JsonValue + +from powercontext.artifacts import ArtifactRef +from powercontext.sources import SourceRef + +SourceQueryType = Literal["list", "search"] +TextSearchMode = Literal["auto", "keyword"] + + +class _RecordModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class SourceRecord(_RecordModel): + """One durable captured-text Source and its journal position.""" + + scope_id: str + source_ref: SourceRef + content: str + metadata: dict[str, JsonValue] + created_at: datetime | None + position: int + content_digest: str + score: float | None = None + snippets: tuple[str, ...] = () + + +class SourceRecordPage(_RecordModel): + """One stable page for either Source listing or keyword search.""" + + query: str | None + mode: Literal["keyword"] | None + items: tuple[SourceRecord, ...] + next_cursor: str | None + + +class ArtifactWrite(_RecordModel): + """Complete content and direct lineage for one Artifact revision.""" + + schema_version: int + metadata: dict[str, JsonValue] + content: dict[str, JsonValue] + source_refs: tuple[SourceRef, ...] = () + artifact_refs: tuple[ArtifactRef, ...] = () + + +class ArtifactRecord(_RecordModel): + """One immutable Artifact revision with direct lineage.""" + + scope_id: str + artifact_ref: ArtifactRef + schema_version: int + metadata: dict[str, JsonValue] + content: dict[str, JsonValue] + source_refs: tuple[SourceRef, ...] + artifact_refs: tuple[ArtifactRef, ...] + created_at: datetime | None + content_digest: str + + +class ArtifactRecordPage(_RecordModel): + """One stable page of current Artifact heads.""" + + items: tuple[ArtifactRecord, ...] + next_cursor: str | None + + +class ArtifactSearchHit(_RecordModel): + """One matching current Artifact head.""" + + artifact: ArtifactRecord + score: float + snippets: tuple[str, ...] + + +class ArtifactSearchPage(_RecordModel): + """One stable page of current Artifact-head search results.""" + + query: str + mode: Literal["keyword"] + hits: tuple[ArtifactSearchHit, ...] + next_cursor: str | None + + +class ArtifactDeletion(_RecordModel): + """The durable deletion state for one Artifact lifecycle.""" + + artifact_ref: ArtifactRef + deleted_at: datetime + + +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 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 ArtifactRevisionPreconditionError(BaseAccessError): + """Report an Artifact ETag that no longer identifies the current head.""" + + def __init__(self, provided_revision: int, current_revision: int) -> None: + self.provided_revision = provided_revision + self.current_revision = current_revision + 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, + source_id: str, + content: str, + metadata: Mapping[str, JsonValue], + /, + ) -> SourceRecord: ... + + async def get_source(self, scope_id: str, source_type: str, source_id: str, /) -> SourceRecord: ... + + async def query_sources( + self, + scope_id: str, + source_type: str, + query_type: SourceQueryType, + /, + *, + query: str | None, + mode: TextSearchMode | None, + limit: int, + cursor: str | None, + ) -> SourceRecordPage: ... + + async def create_artifact( + self, + scope_id: str, + family: str, + artifact_id: str | None, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: ... + + 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 list_artifacts( + self, + scope_id: str, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> ArtifactRecordPage: ... + + async def search_artifacts( + self, + scope_id: str, + family: str, + query: str, + /, + *, + mode: TextSearchMode, + limit: int, + cursor: str | None, + ) -> ArtifactSearchPage: ... + + async def replace_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + expected_revision: int, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: ... + + async def delete_artifact( + self, + scope_id: str, + family: str, + artifact_id: str, + expected_revision: int, + /, + ) -> ArtifactDeletion: ... + + async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: ... diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 750b7d20e..09294dc8e 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 @@ -71,6 +71,20 @@ from powercontext.builtin.context import BuiltinArtifacts, BuiltinSources from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter +from powercontext.builtin.records import ( + ArtifactDeletion, + ArtifactRecord, + ArtifactRecordPage, + ArtifactSearchPage, + ArtifactWrite, + BaseValueConflictError, + RecordService, + ScopeSummaryPage, + SourceQueryType, + SourceRecord, + SourceRecordPage, + TextSearchMode, +) from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._scope_cache import ( @@ -134,6 +148,7 @@ ) from powercontext.builtin.runtime.statistics import RelationalScopedStatistics from powercontext.builtin.sources import ( + CONTENT_SOURCE_NAME, ContentCapture, ContentSource, ExternalSkillImportMode, @@ -169,7 +184,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 SourceRef if TYPE_CHECKING: @@ -215,6 +230,7 @@ def __init__(self, code: str) -> None: "experience-incubation": "Experience incubation is not configured", "external-skill-registry": "External Skill Registry is not configured", "review": "Candidate Review services are not configured", + "records": "Base Source and Artifact access is not configured", "scheduler": "Built-in Runtime scheduler is already started", "statistics": "Statistics services are not configured", } @@ -229,6 +245,17 @@ 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: + record = await self._runtime.records.for_scope(self.scope_id).create_source( + 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=record.source_ref, sequence=record.position) async with self._runtime._context(self.scope_id) as context: source, sequence = await context.sources.capture( ContentCapture( @@ -250,6 +277,168 @@ 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, + source_id: str, + content: str, + metadata: Mapping[str, 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, + source_id, + content, + metadata, + ) + + 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 query_sources( + self, + source_type: str, + query_type: SourceQueryType, + /, + *, + query: str | None, + mode: TextSearchMode | None, + limit: int, + cursor: str | None, + ) -> SourceRecordPage: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().query_sources( + self.scope_id, + source_type, + query_type, + query=query, + mode=mode, + limit=limit, + cursor=cursor, + ) + + async def create_artifact( + self, + family: str, + artifact_id: str | None, + write: ArtifactWrite, + /, + ) -> ArtifactRecord: + 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, artifact_id, 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 list_artifacts( + self, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> ArtifactRecordPage: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().list_artifacts( + self.scope_id, + family, + limit=limit, + cursor=cursor, + ) + + async def search_artifacts( + self, + family: str, + query: str, + /, + *, + mode: TextSearchMode, + limit: int, + cursor: str | None, + ) -> ArtifactSearchPage: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().search_artifacts( + self.scope_id, + family, + query, + mode=mode, + limit=limit, + cursor=cursor, + ) + + async def replace_artifact( + self, + family: str, + artifact_id: str, + expected_revision: int, + 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_revision, + write, + ) + + async def delete_artifact( + self, + family: str, + artifact_id: str, + expected_revision: int, + /, + ) -> ArtifactDeletion: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await self._runtime._records().delete_artifact( + self.scope_id, + family, + artifact_id, + expected_revision, + ) + + +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 ScopedStatisticsApplication: """Read product statistics and record model usage for one scope.""" @@ -1226,6 +1415,7 @@ def __init__( external_skill_registry: ExternalSkillRegistryFactory | None = None, external_skill_importer: ExternalSkillImporter | None = None, statistics_service: StatisticsServiceFactory | None = None, + record_service: RecordService | None = None, recall_token_estimator: RecallTokenEstimator | None = None, readiness: RuntimeReadinessChecks | None = None, clock: Clock | None = None, @@ -1244,6 +1434,7 @@ def __init__( self._external_skill_registry = external_skill_registry self._external_skill_importer = external_skill_importer self._statistics_service = statistics_service + self._record_service = record_service self._recall_token_estimator = recall_token_estimator self._readiness = RuntimeReadinessChecks() if readiness is None else readiness self._clock = _utc_now if clock is None else clock @@ -1270,6 +1461,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.statistics = StatisticsApplication(self) @@ -1502,6 +1694,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 3867a28e9..99752a3b5 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -278,6 +278,7 @@ async def open_builtin_runtime( external_skill_registry=contexts.external_skills if contexts.external_skill_registry else None, external_skill_importer=contexts.import_external_skill if contexts.external_skill_registry else None, statistics_service=contexts.statistics, + record_service=contexts.records, recall_token_estimator=contexts.estimate_recall_tokens, readiness=RuntimeReadinessChecks(readiness_probes), tracing=tracing, diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 534a828f5..6ff5b55c9 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -74,6 +74,7 @@ ) 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.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.statistics import StatisticsRepository from powercontext.builtin.persistence.tables import ARTIFACT_HEADS_TABLE, SOURCE_JOURNAL_HEADS_TABLE @@ -318,6 +319,7 @@ def __init__( external_skills=ExternalSkillRepository(), statistics=StatisticsRepository(), ) + self.records = RelationalRecordService(database, self.repositories.sources) self._candidate_pipeline = candidate_pipeline self.memory_extraction = candidate_pipeline is not None self._experience_pipeline = experience_pipeline diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 2c1618f2f..547d82b93 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -17,8 +17,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from types import TracebackType -from typing import Self, TypeVar +from typing import Any, Self, TypeVar +from urllib.parse import quote import httpx from pydantic import TypeAdapter, ValidationError @@ -31,6 +33,10 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactDeletionStatus, + ArtifactPage, + ArtifactRevision, + ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -38,8 +44,11 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateArtifactRequest, CreateHandoffReportProjectRequest, + CreateSourceRequest, CreateWorkContractRequest, + DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, ErrorResponse, ExperienceArtifact, @@ -51,12 +60,15 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetArtifactRequest, + GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, + GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, @@ -70,6 +82,7 @@ ImportExternalSkillRequest, KnownHandoffScopePage, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListHandoffReportActivitiesRequest, @@ -80,6 +93,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListScopesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -99,6 +113,7 @@ RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + ReplaceArtifactRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -106,9 +121,14 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopePage, + SearchArtifactsRequest, SearchMemoryRequest, SearchMemoryResponse, + SearchSourcesRequest, SkillArtifact, + SourcePage, + SourceRecord, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, @@ -124,14 +144,19 @@ CAPTURE_CONTENT_SOURCE, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ARTIFACT, CREATE_HANDOFF_REPORT_PROJECT, + CREATE_SOURCE, CREATE_WORK_CONTRACT, + DELETE_ARTIFACT, DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, + GET_ARTIFACT_REVISION, GET_CAPABILITIES, GET_EXPERIENCE, GET_HANDOFF_REPORT, @@ -141,10 +166,12 @@ GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, + LIST_ARTIFACTS, LIST_EXTERNAL_SKILLS, LIST_HANDOFF_REPORT_ACTIVITIES, LIST_HANDOFF_REPORT_KNOWN_SCOPES, @@ -152,6 +179,7 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SCOPES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -162,12 +190,15 @@ REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, + SEARCH_ARTIFACTS, SEARCH_MEMORY, + SEARCH_SOURCES, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, Operation, @@ -423,6 +454,94 @@ async def capture_content_source(self, request: CaptureContentSourceRequest) -> return await self._request(CAPTURE_CONTENT_SOURCE, request) + async def create_source(self, request: CreateSourceRequest) -> SourceRecord: + """Create one durable Source without invoking generation.""" + + return await self._request(CREATE_SOURCE, request) + + async def get_source(self, source_id: str, request: GetSourceRequest) -> SourceRecord: + """Read one exact Source in a Scope and Source type.""" + + return await self._request(GET_SOURCE, request, path_parameters={"source_id": source_id}) + + async def search_sources(self, request: SearchSourcesRequest) -> SourcePage: + """List Sources or search them according to request.type.""" + + return await self._request(SEARCH_SOURCES, request) + + async def create_artifact(self, request: CreateArtifactRequest) -> ArtifactRevision: + """Commit revision one for a direct Artifact family.""" + + return await self._request(CREATE_ARTIFACT, request) + + async def get_artifact(self, artifact_id: str, request: GetArtifactRequest) -> ArtifactRevision: + """Read the current visible Artifact head.""" + + return await self._request(GET_ARTIFACT, request, path_parameters={"artifact_id": artifact_id}) + + async def get_artifact_revision( + self, + artifact_id: str, + revision: int, + request: GetArtifactRevisionRequest, + ) -> ArtifactRevision: + """Read one exact immutable Artifact revision.""" + + return await self._request( + GET_ARTIFACT_REVISION, + request, + path_parameters={"artifact_id": artifact_id, "revision": revision}, + ) + + async def list_artifacts(self, request: ListArtifactsRequest) -> ArtifactPage: + """List current visible Artifact heads in one family.""" + + return await self._request(LIST_ARTIFACTS, request) + + async def search_artifacts(self, request: SearchArtifactsRequest) -> ArtifactSearchResultPage: + """Search current visible Artifact heads in one family.""" + + return await self._request(SEARCH_ARTIFACTS, request) + + async def replace_artifact( + self, + artifact_id: str, + selector: GetArtifactRequest, + request: ReplaceArtifactRequest, + *, + expected_revision: int, + ) -> ArtifactRevision: + """Commit a complete next revision using optimistic concurrency.""" + + return await self._request( + REPLACE_ARTIFACT, + request, + path_parameters={"artifact_id": artifact_id}, + query_parameters=_query_parameters(selector), + extra_headers={"If-Match": _artifact_etag(expected_revision)}, + ) + + async def delete_artifact( + self, + artifact_id: str, + request: DeleteArtifactRequest, + *, + expected_revision: int, + ) -> ArtifactDeletionStatus: + """Delete an Artifact head while preserving immutable revisions.""" + + return await self._request( + DELETE_ARTIFACT, + request, + path_parameters={"artifact_id": artifact_id}, + extra_headers={"If-Match": _artifact_etag(expected_revision)}, + ) + + async def list_scopes(self, request: ListScopesRequest) -> ScopePage: + """List Scopes observable by the current Server identity.""" + + return await self._request(LIST_SCOPES, request) + async def create_work_contract(self, request: CreateWorkContractRequest) -> WorkSourceReceipt: """Create one grounded delegation baseline as durable Source evidence.""" @@ -592,40 +711,37 @@ async def _request( self, operation: Operation[_RequestT, _ResponseT], request: _RequestT | 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: - 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 @@ -654,6 +770,58 @@ async def _request( ) from exc +def _query_parameters(value: GetArtifactRequest) -> dict[str, Any]: + return value.model_dump(mode="json", by_alias=True, exclude_none=True) + + +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 = _operation_path(operation, path_parameters) + return path, json_payload, request_query or None + + +def _operation_path( + operation: Operation[Any, Any], + path_parameters: Mapping[str, str | int] | None, +) -> str: + path = operation.path + for name, value in (path_parameters or {}).items(): + placeholder = f"{{{name}}}" + if placeholder not in path: + raise ValueError(f"{operation.operation_id} has no {name} path parameter") # noqa: TRY003 + path = path.replace(placeholder, quote(str(value), safe="")) + if "{" in path or "}" in path: + raise ValueError(f"{operation.operation_id} requires path parameters") # noqa: TRY003 + return path + + +def _artifact_etag(revision: int) -> str: + if isinstance(revision, bool) or revision < 1: + raise ValueError("expected_revision must be positive") # noqa: TRY003 + return f'"revision:{revision}"' + + def _decode_error(content: bytes) -> ErrorResponse | None: try: return ErrorResponse.model_validate_json(content) diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 56c05bf89..1d82afbfc 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -20,8 +20,14 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactDeletionState, + ArtifactDeletionStatus, ArtifactInventoryStatistics, + ArtifactPage, ArtifactReference, + ArtifactRevision, + ArtifactSearchHit, + ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, CandidateFamily, CandidateFamilyCount, @@ -34,9 +40,12 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateArtifactRequest, CreateHandoffReportProjectRequest, + CreateSourceRequest, CreateWorkContractRequest, CurrentWorkHandoff, + DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, EntryChange, EntryChangeOperation, @@ -59,12 +68,15 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetArtifactRequest, + GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, + GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, @@ -102,6 +114,7 @@ KnownHandoffScope, KnownHandoffScopePage, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListHandoffReportActivitiesRequest, @@ -112,6 +125,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListScopesRequest, MemoryCitation, MemoryEntry, MemoryEntryInventoryStatistics, @@ -151,6 +165,7 @@ RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + ReplaceArtifactRequest, ReportActivitySource, ReportCatalogState, ReportFormat, @@ -164,14 +179,21 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopePage, + ScopeSummary, + SearchArtifactsRequest, SearchMemoryHit, SearchMemoryRequest, SearchMemoryResponse, + SearchSourcesRequest, SkillArtifact, SkillGenerationOrigin, SkillProposal, SkillValidationItem, SourceInventoryStatistics, + SourcePage, + SourceQueryType, + SourceRecord, SourceReference, StatsPeriod, StoredHandoffReportActivity, @@ -179,6 +201,8 @@ TaskCheckStatus, TaskOutcome, TaskOutcomeStatus, + TextSearchMode, + TextSearchUsedMode, TokenEstimatorProfile, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, @@ -199,8 +223,14 @@ "ApproveArtifactCandidateRequest", "ArtifactCandidate", "ArtifactCandidatePage", + "ArtifactDeletionState", + "ArtifactDeletionStatus", "ArtifactInventoryStatistics", + "ArtifactPage", "ArtifactReference", + "ArtifactRevision", + "ArtifactSearchHit", + "ArtifactSearchResultPage", "AttachHandoffReportWorkspaceRequest", "CandidateFamily", "CandidateFamilyCount", @@ -213,9 +243,12 @@ "CommitHandoffRequest", "CommittedHandoff", "ContinueHandoffRequest", + "CreateArtifactRequest", "CreateHandoffReportProjectRequest", + "CreateSourceRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", + "DeleteArtifactRequest", "DetachHandoffReportWorkspaceRequest", "EntryChange", "EntryChangeOperation", @@ -238,12 +271,15 @@ "GeneratedCandidateResponse", "GeneratedCandidateStatus", "GetArtifactCandidateRequest", + "GetArtifactRequest", + "GetArtifactRevisionRequest", "GetExperienceRequest", "GetHandoffReportProjectRequest", "GetHandoffReportRequest", "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", "GetSkillRequest", + "GetSourceRequest", "GetStatsRequest", "HandoffAcknowledgement", "HandoffActivation", @@ -281,6 +317,7 @@ "KnownHandoffScope", "KnownHandoffScopePage", "ListArtifactCandidatesRequest", + "ListArtifactsRequest", "ListExternalSkillsRequest", "ListExternalSkillsResponse", "ListHandoffReportActivitiesRequest", @@ -291,6 +328,7 @@ "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", + "ListScopesRequest", "MemoryCitation", "MemoryEntry", "MemoryEntryInventoryStatistics", @@ -330,6 +368,7 @@ "RegisterHandoffReportWorkstreamRequest", "RejectArtifactCandidateRequest", "RememberMemoryRequest", + "ReplaceArtifactRequest", "ReportActivitySource", "ReportCatalogState", "ReportFormat", @@ -342,15 +381,22 @@ "ReviseMemoryEntryRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", + "ScopePage", + "ScopeSummary", "ScopedStats", + "SearchArtifactsRequest", "SearchMemoryHit", "SearchMemoryRequest", "SearchMemoryResponse", + "SearchSourcesRequest", "SkillArtifact", "SkillGenerationOrigin", "SkillProposal", "SkillValidationItem", "SourceInventoryStatistics", + "SourcePage", + "SourceQueryType", + "SourceRecord", "SourceReference", "StatsPeriod", "StoredHandoffReportActivity", @@ -358,6 +404,8 @@ "TaskCheckStatus", "TaskOutcome", "TaskOutcomeStatus", + "TextSearchMode", + "TextSearchUsedMode", "TokenEstimatorProfile", "UpdateHandoffReportProjectRequest", "UpdateHandoffReportWorkstreamRequest", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index ad2a598c1..89f8ad4e3 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -21,6 +21,10 @@ ) +class ArtifactDeletionState(StrEnum): + DELETED = "deleted" + + class ArtifactReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -778,6 +782,87 @@ class ImportExternalSkillRequest(BaseModel): reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None +class CreateSourceRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + content: Annotated[StrictStr, Field(max_length=200000, min_length=1, pattern=".*\\S.*")] + metadata: dict[str, Any] = {} + + +class DeleteArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + +class GetArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + +class GetArtifactRevisionRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + +class GetSourceRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + + +class ListArtifactsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class ListScopesRequest(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 ScopeSummary(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + title: Annotated[StrictStr | None, Field(...)] + summary: Annotated[StrictStr | None, Field(...)] + parent_scope_id: Annotated[StrictStr | None, Field(...)] + version: Annotated[StrictInt | None, Field(ge=1)] + source_types: list[StrictStr] + artifact_families: list[StrictStr] + source_count: Annotated[StrictInt, Field(ge=0)] + artifact_count: Annotated[StrictInt, Field(ge=0)] + + +class SourceQueryType(StrEnum): + LIST = "list" + SEARCH = "search" + + class SourceReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -796,6 +881,15 @@ class StatsPeriod(StrEnum): FIELD_30D = "30d" +class TextSearchMode(StrEnum): + AUTO = "auto" + KEYWORD = "keyword" + + +class TextSearchUsedMode(StrEnum): + KEYWORD = "keyword" + + class CandidateFamily(StrEnum): EXPERIENCE = "experience" SKILL = "skill" @@ -902,6 +996,49 @@ class PreparedHandoffSchema(StrEnum): POWERCONTEXT_PREPARED_HANDOFF_V1 = "powercontext.prepared-handoff.v1" +class ArtifactDeletionStatus(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact_ref: ArtifactReference + status: ArtifactDeletionState + deleted_at: AwareDatetime + + +class ArtifactRevision(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + artifact_ref: ArtifactReference + schema_version: Annotated[StrictInt, Field(ge=1)] + metadata: dict[str, Any] + content: dict[str, Any] + source_refs: list[SourceReference] + artifact_refs: list[ArtifactReference] + created_at: Annotated[AwareDatetime | None, Field(...)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + + +class ArtifactSearchHit(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + artifact: ArtifactRevision + score: Annotated[StrictFloat, Field(ge=0.0, le=1.0)] + snippets: list[StrictStr] + + +class ArtifactSearchResultPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + query: StrictStr + mode: TextSearchUsedMode + hits: list[ArtifactSearchHit] + next_cursor: Annotated[StrictStr | None, Field(...)] + + class Capabilities(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1555,6 +1692,103 @@ class SearchMemoryResponse(BaseModel): hits: list[SearchMemoryHit] +class CreateArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + artifact_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None + schema_version: Annotated[StrictInt, Field(ge=1)] + metadata: dict[str, Any] = {} + content: dict[str, Any] + source_refs: Annotated[list[SourceReference], Field(max_length=32, validate_default=True)] = [] + artifact_refs: Annotated[list[ArtifactReference], Field(max_length=32, validate_default=True)] = [] + + @model_validator(mode="after") + def _reject_excess_candidate_evidence(self): + if len(self.source_refs) + len(self.artifact_refs) > 32: + raise ValueError( # noqa: TRY003 + "source_refs and artifact_refs together must not exceed 32 references" + ) + return self + + +class ReplaceArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + schema_version: Annotated[StrictInt, Field(ge=1)] + metadata: dict[str, Any] = {} + content: dict[str, Any] + source_refs: Annotated[list[SourceReference], Field(max_length=32, validate_default=True)] = [] + artifact_refs: Annotated[list[ArtifactReference], Field(max_length=32, validate_default=True)] = [] + + @model_validator(mode="after") + def _reject_excess_candidate_evidence(self): + if len(self.source_refs) + len(self.artifact_refs) > 32: + raise ValueError( # noqa: TRY003 + "source_refs and artifact_refs together must not exceed 32 references" + ) + return self + + +class ScopePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[ScopeSummary] + next_cursor: Annotated[StrictStr | None, Field(...)] + + +class SearchArtifactsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + q: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] + mode: TextSearchMode = TextSearchMode.AUTO + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class SearchSourcesRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + type: SourceQueryType = SourceQueryType.LIST + q: Annotated[StrictStr | None, Field(max_length=8192, min_length=1)] = None + mode: TextSearchMode | None = None + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class SourceRecord(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + source_ref: SourceReference + content: StrictStr + metadata: dict[str, Any] + created_at: Annotated[AwareDatetime | None, Field(...)] + position: Annotated[StrictInt, Field(ge=1)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + score: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] + snippets: list[StrictStr] + + +class ArtifactPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[ArtifactRevision] + next_cursor: Annotated[StrictStr | None, Field(...)] + + class ArtifactCandidate(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1692,6 +1926,16 @@ class GeneratedCandidateResponse(BaseModel): candidate: Annotated[ArtifactCandidate | None, Field(...)] +class SourcePage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + query: Annotated[StrictStr | None, Field(...)] + mode: Annotated[TextSearchUsedMode | None, Field(...)] + items: list[SourceRecord] + next_cursor: Annotated[StrictStr | None, Field(...)] + + class ActivateHandoffRequest(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 2044f5352..154f0e05c 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,6 +12,10 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactDeletionStatus, + ArtifactPage, + ArtifactRevision, + ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -19,8 +23,11 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateArtifactRequest, CreateHandoffReportProjectRequest, + CreateSourceRequest, CreateWorkContractRequest, + DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, ExperienceArtifact, ExternalSkillResolution, @@ -31,12 +38,15 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetArtifactRequest, + GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, + GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, @@ -50,6 +60,7 @@ ImportExternalSkillRequest, KnownHandoffScopePage, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListHandoffReportActivitiesRequest, @@ -60,6 +71,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListScopesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -79,6 +91,7 @@ RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + ReplaceArtifactRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -86,9 +99,14 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopePage, + SearchArtifactsRequest, SearchMemoryRequest, SearchMemoryResponse, + SearchSourcesRequest, SkillArtifact, + SourcePage, + SourceRecord, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, @@ -1348,3 +1366,275 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 500: {"$ref": "#/components/responses/InternalError"}, }, ) + +SEARCH_SOURCES = Operation[SearchSourcesRequest, SourcePage]( + method="GET", + path="/v1/sources", + operation_id="search_sources", + request_type=SearchSourcesRequest, + request_location="query", + response_type=SourcePage, + success_status=200, + summary="List or search Sources", + tags=("sources",), + responses={ + 200: { + "description": "One stable page of Sources or Source search results.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +CREATE_SOURCE = Operation[CreateSourceRequest, SourceRecord]( + method="POST", + path="/v1/sources", + operation_id="create_source", + request_type=CreateSourceRequest, + request_location="body", + response_type=SourceRecord, + success_status=201, + summary="Create a durable Source", + tags=("sources",), + 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"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +GET_SOURCE = Operation[GetSourceRequest, SourceRecord]( + method="GET", + path="/v1/sources/{source_id}", + operation_id="get_source", + request_type=GetSourceRequest, + request_location="query", + response_type=SourceRecord, + success_status=200, + summary="Get one exact Source", + tags=("sources",), + 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"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +LIST_ARTIFACTS = Operation[ListArtifactsRequest, ArtifactPage]( + method="GET", + path="/v1/artifacts", + operation_id="list_artifacts", + request_type=ListArtifactsRequest, + request_location="query", + response_type=ArtifactPage, + success_status=200, + summary="List current Artifact heads", + tags=("artifacts",), + responses={ + 200: { + "description": "One stable page of current Artifact heads.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +CREATE_ARTIFACT = Operation[CreateArtifactRequest, ArtifactRevision]( + method="POST", + path="/v1/artifacts", + operation_id="create_artifact", + request_type=CreateArtifactRequest, + request_location="body", + response_type=ArtifactRevision, + success_status=201, + summary="Create an Artifact", + tags=("artifacts",), + 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"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 409: {"$ref": "#/components/responses/Conflict"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +GET_ARTIFACT = Operation[GetArtifactRequest, ArtifactRevision]( + method="GET", + path="/v1/artifacts/{artifact_id}", + operation_id="get_artifact", + request_type=GetArtifactRequest, + request_location="query", + response_type=ArtifactRevision, + success_status=200, + summary="Get the current Artifact head", + tags=("artifacts",), + responses={ + 200: { + "description": "The current visible 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/artifacts/{artifact_id}", + operation_id="replace_artifact", + request_type=ReplaceArtifactRequest, + request_location="body", + response_type=ArtifactRevision, + success_status=200, + summary="Replace the current Artifact head", + tags=("artifacts",), + 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"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 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"}, + }, +) + +DELETE_ARTIFACT = Operation[DeleteArtifactRequest, ArtifactDeletionStatus]( + method="DELETE", + path="/v1/artifacts/{artifact_id}", + operation_id="delete_artifact", + request_type=DeleteArtifactRequest, + request_location="query", + response_type=ArtifactDeletionStatus, + success_status=200, + summary="Delete the current Artifact head", + tags=("artifacts",), + responses={ + 200: { + "description": "The Artifact head is deleted and immutable history is retained.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 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[GetArtifactRevisionRequest, ArtifactRevision]( + method="GET", + path="/v1/artifacts/{artifact_id}/revisions/{revision}", + operation_id="get_artifact_revision", + request_type=GetArtifactRevisionRequest, + request_location="query", + response_type=ArtifactRevision, + success_status=200, + summary="Get one exact immutable Artifact revision", + tags=("artifacts",), + 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"}, + }, +) + +SEARCH_ARTIFACTS = Operation[SearchArtifactsRequest, ArtifactSearchResultPage]( + method="GET", + path="/v1/artifact-search-results", + operation_id="search_artifacts", + request_type=SearchArtifactsRequest, + request_location="query", + response_type=ArtifactSearchResultPage, + success_status=200, + summary="Search current Artifact heads", + tags=("artifacts",), + responses={ + 200: { + "description": "One stable page of matching current Artifact heads.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, +) + +LIST_SCOPES = Operation[ListScopesRequest, ScopePage]( + method="GET", + path="/v1/scopes", + operation_id="list_scopes", + request_type=ListScopesRequest, + request_location="query", + response_type=ScopePage, + success_status=200, + summary="List observable Scopes", + tags=("scopes",), + responses={ + 200: { + "description": "One stable page of Scopes observable by the caller.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + }, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 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 be81ba103..09fc26bdf 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1505,6 +1505,484 @@ }, } }, + "/v1/sources": { + "post": { + "tags": ["sources"], + "summary": "Create a durable Source", + "description": "Create one idempotent Source without synchronously deriving Artifacts.", + "operationId": "create_source", + "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"}, + "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + "get": { + "tags": ["sources"], + "summary": "List or search Sources", + "description": "List when type is omitted or list; search when type is search and q is nonblank.", + "operationId": "search_sources", + "parameters": [ + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "source_type", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "type", + "in": "query", + "required": False, + "schema": {"$ref": "#/components/schemas/SourceQueryType", "default": "list"}, + }, + { + "name": "q", + "in": "query", + "required": False, + "schema": {"type": "string", "minLength": 1, "maxLength": 8192}, + }, + { + "name": "mode", + "in": "query", + "required": False, + "schema": {"$ref": "#/components/schemas/TextSearchMode"}, + }, + { + "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 Sources or Source search results.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourcePage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + }, + "/v1/sources/{source_id}": { + "get": { + "tags": ["sources"], + "summary": "Get one exact Source", + "operationId": "get_source", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "source_type", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "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"}, + "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, + "/v1/artifacts": { + "post": { + "tags": ["artifacts"], + "summary": "Create an Artifact", + "description": "Commit revision one for a family that permits direct creation.", + "operationId": "create_artifact", + "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/ArtifactRevision"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "409": {"$ref": "#/components/responses/Conflict"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + "get": { + "tags": ["artifacts"], + "summary": "List current Artifact heads", + "operationId": "list_artifacts", + "parameters": [ + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "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"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + }, + "/v1/artifacts/{artifact_id}": { + "get": { + "tags": ["artifacts"], + "summary": "Get the current Artifact head", + "operationId": "get_artifact", + "parameters": [ + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + ], + "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"}}}, + }, + "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": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "If-Match", + "in": "header", + "required": True, + "schema": {"type": "string", "pattern": '^"revision:[1-9][0-9]*"$'}, + }, + ], + "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"}, + "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "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"}, + }, + }, + "delete": { + "tags": ["artifacts"], + "summary": "Delete the current Artifact head", + "description": "Record a tombstone without deleting immutable historical revisions.", + "operationId": "delete_artifact", + "parameters": [ + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "If-Match", + "in": "header", + "required": True, + "schema": {"type": "string", "pattern": '^"revision:[1-9][0-9]*"$'}, + }, + ], + "responses": { + "200": { + "description": "The Artifact head is deleted and immutable history is retained.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactDeletionStatus"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "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/artifacts/{artifact_id}/revisions/{revision}": { + "get": { + "tags": ["artifacts"], + "summary": "Get one exact immutable Artifact revision", + "operationId": "get_artifact_revision", + "parameters": [ + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + {"name": "revision", "in": "path", "required": True, "schema": {"type": "integer", "minimum": 1}}, + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + ], + "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"}, + }, + } + }, + "/v1/artifact-search-results": { + "get": { + "tags": ["artifacts"], + "summary": "Search current Artifact heads", + "operationId": "search_artifacts", + "parameters": [ + { + "name": "scope_id", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + }, + { + "name": "family", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + }, + { + "name": "q", + "in": "query", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 8192, "pattern": ".*\\S.*"}, + }, + { + "name": "mode", + "in": "query", + "required": False, + "schema": {"$ref": "#/components/schemas/TextSearchMode", "default": "auto"}, + }, + { + "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 matching current Artifact heads.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactSearchResultPage"}} + }, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, + "/v1/scopes": { + "get": { + "tags": ["scopes"], + "summary": "List observable Scopes", + "operationId": "list_scopes", + "parameters": [ + { + "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 Scopes observable by the caller.", + "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopePage"}}}, + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + } + }, }, "components": { "schemas": { @@ -1525,6 +2003,73 @@ "type": "object", "required": ["scope_id", "boundary_source", "objective"], }, + "ArtifactDeletionState": {"type": "string", "enum": ["deleted"]}, + "ArtifactDeletionStatus": { + "properties": { + "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, + "status": {"$ref": "#/components/schemas/ArtifactDeletionState"}, + "deleted_at": {"type": "string", "format": "date-time"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["artifact_ref", "status", "deleted_at"], + }, + "ArtifactPage": { + "properties": { + "items": {"items": {"$ref": "#/components/schemas/ArtifactRevision"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor"], + }, + "ArtifactRevision": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, + "schema_version": {"type": "integer", "minimum": 1.0}, + "metadata": {"additionalProperties": True, "type": "object"}, + "content": {"additionalProperties": True, "type": "object"}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, + "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "scope_id", + "artifact_ref", + "schema_version", + "metadata", + "content", + "source_refs", + "artifact_refs", + "created_at", + "content_digest", + ], + }, + "ArtifactSearchHit": { + "properties": { + "artifact": {"$ref": "#/components/schemas/ArtifactRevision"}, + "score": {"type": "number", "maximum": 1.0, "minimum": 0.0}, + "snippets": {"items": {"type": "string"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["artifact", "score", "snippets"], + }, + "ArtifactSearchResultPage": { + "properties": { + "query": {"type": "string"}, + "mode": {"$ref": "#/components/schemas/TextSearchUsedMode"}, + "hits": {"items": {"$ref": "#/components/schemas/ArtifactSearchHit"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["query", "mode", "hits", "next_cursor"], + }, "ArtifactReference": { "properties": { "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, @@ -3690,6 +4235,220 @@ "type": "object", "required": ["hits"], }, + "CreateArtifactRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, + "schema_version": {"type": "integer", "minimum": 1.0}, + "metadata": {"additionalProperties": True, "type": "object", "default": {}}, + "content": {"additionalProperties": True, "type": "object"}, + "source_refs": { + "items": {"$ref": "#/components/schemas/SourceReference"}, + "type": "array", + "maxItems": 32, + "default": [], + }, + "artifact_refs": { + "items": {"$ref": "#/components/schemas/ArtifactReference"}, + "type": "array", + "maxItems": 32, + "default": [], + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "family", "schema_version", "content"], + }, + "CreateSourceRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "content": {"type": "string", "maxLength": 200000, "minLength": 1, "pattern": ".*\\S.*"}, + "metadata": {"additionalProperties": True, "type": "object", "default": {}}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "source_type", "source_id", "content"], + }, + "DeleteArtifactRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "family"], + }, + "GetArtifactRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "family"], + }, + "GetArtifactRevisionRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "family"], + }, + "GetSourceRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "source_type"], + }, + "ListArtifactsRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "family"], + }, + "ListScopesRequest": { + "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": { + "properties": { + "schema_version": {"type": "integer", "minimum": 1.0}, + "metadata": {"additionalProperties": True, "type": "object", "default": {}}, + "content": {"additionalProperties": True, "type": "object"}, + "source_refs": { + "items": {"$ref": "#/components/schemas/SourceReference"}, + "type": "array", + "maxItems": 32, + "default": [], + }, + "artifact_refs": { + "items": {"$ref": "#/components/schemas/ArtifactReference"}, + "type": "array", + "maxItems": 32, + "default": [], + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["schema_version", "content"], + }, + "ScopePage": { + "properties": { + "items": {"items": {"$ref": "#/components/schemas/ScopeSummary"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor"], + }, + "ScopeSummary": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "title": {"type": "string", "nullable": True}, + "summary": {"type": "string", "nullable": True}, + "parent_scope_id": {"type": "string", "nullable": True}, + "version": {"type": "integer", "minimum": 1.0, "nullable": True}, + "source_types": {"items": {"type": "string"}, "type": "array"}, + "artifact_families": {"items": {"type": "string"}, "type": "array"}, + "source_count": {"type": "integer", "minimum": 0.0}, + "artifact_count": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "scope_id", + "title", + "summary", + "parent_scope_id", + "version", + "source_types", + "artifact_families", + "source_count", + "artifact_count", + ], + }, + "SearchArtifactsRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "q": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, + "mode": {"$ref": "#/components/schemas/TextSearchMode", "default": "auto"}, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "family", "q"], + }, + "SearchSourcesRequest": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "type": {"$ref": "#/components/schemas/SourceQueryType", "default": "list"}, + "q": {"type": "string", "maxLength": 8192, "minLength": 1, "nullable": True}, + "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchMode"}], "nullable": True}, + "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, + "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "source_type"], + }, + "SourcePage": { + "properties": { + "query": {"type": "string", "nullable": True}, + "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchUsedMode"}], "nullable": True}, + "items": {"items": {"$ref": "#/components/schemas/SourceRecord"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["query", "mode", "items", "next_cursor"], + }, + "SourceQueryType": {"type": "string", "enum": ["list", "search"]}, + "SourceRecord": { + "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, + "source_ref": {"$ref": "#/components/schemas/SourceReference"}, + "content": {"type": "string"}, + "metadata": {"additionalProperties": True, "type": "object"}, + "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "position": {"type": "integer", "minimum": 1.0}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "score": {"type": "number", "maximum": 1.0, "minimum": 0.0, "nullable": True}, + "snippets": {"items": {"type": "string"}, "type": "array"}, + }, + "additionalProperties": False, + "type": "object", + "required": [ + "scope_id", + "source_ref", + "content", + "metadata", + "created_at", + "position", + "content_digest", + "score", + "snippets", + ], + }, "SourceReference": { "properties": { "name": {"type": "string", "description": "Stable Source type."}, @@ -3701,6 +4460,8 @@ }, "CaptureStatus": {"type": "string", "enum": ["accepted"]}, "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, + "TextSearchMode": {"type": "string", "enum": ["auto", "keyword"]}, + "TextSearchUsedMode": {"type": "string", "enum": ["keyword"]}, "CandidateFamily": {"type": "string", "enum": ["experience", "skill"]}, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, "ExternalSkillResolutionStatus": {"type": "string", "enum": ["available", "unavailable"]}, @@ -3736,6 +4497,21 @@ "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, + "OperationNotSupported": { + "description": "The Source type or Artifact family does not permit this operation.", + "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"}}, @@ -3747,7 +4523,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"}}}, }, @@ -3763,6 +4539,10 @@ }, }, "headers": { + "ArtifactETag": { + "description": "Strong validator for the current immutable Artifact revision.", + "schema": {"type": "string", "pattern": '^"revision:[1-9][0-9]*"$'}, + }, "BearerChallenge": { "description": "Authentication scheme required by the Server.", "schema": {"type": "string", "example": "Bearer"}, @@ -3771,6 +4551,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 ebe042b4f..9f1c02405 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -19,6 +19,7 @@ import asyncio import json import logging +import re from collections.abc import Awaitable, Callable, Sequence from contextlib import suppress from copy import deepcopy @@ -26,9 +27,10 @@ from functools import wraps from time import perf_counter from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast +from urllib.parse import quote from uuid import uuid4 -from fastapi import Depends, FastAPI, Query, Request, Response, status +from fastapi import Depends, FastAPI, Header, Path, Query, Request, Response, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from opentelemetry.trace import SpanKind @@ -97,6 +99,44 @@ InvalidActivityRepositoryArgumentError, ) from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError +from powercontext.builtin.records import ( + ArtifactDeletion as RuntimeArtifactDeletion, +) +from powercontext.builtin.records import ( + ArtifactRecord as RuntimeArtifactRecord, +) +from powercontext.builtin.records import ( + ArtifactRecordPage as RuntimeArtifactRecordPage, +) +from powercontext.builtin.records import ( + ArtifactRevisionPreconditionError, + BaseAccessError, + BaseOperationNotSupportedError, + BaseValueConflictError, + BaseValueNotFoundError, + InvalidBaseAccessRequestError, +) +from powercontext.builtin.records import ( + ArtifactSearchPage as RuntimeArtifactSearchPage, +) +from powercontext.builtin.records import ( + ArtifactWrite as RuntimeArtifactWrite, +) +from powercontext.builtin.records import ( + ScopeSummaryPage as RuntimeScopeSummaryPage, +) +from powercontext.builtin.records import ( + SourceQueryType as RuntimeSourceQueryType, +) +from powercontext.builtin.records import ( + SourceRecord as RuntimeSourceRecord, +) +from powercontext.builtin.records import ( + SourceRecordPage as RuntimeSourceRecordPage, +) +from powercontext.builtin.records import ( + TextSearchMode as RuntimeTextSearchMode, +) from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -220,6 +260,12 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactDeletionState, + ArtifactDeletionStatus, + ArtifactPage, + ArtifactRevision, + ArtifactSearchHit, + ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -227,8 +273,11 @@ CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateArtifactRequest, CreateHandoffReportProjectRequest, + CreateSourceRequest, CreateWorkContractRequest, + DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, ErrorDetail, ErrorResponse, @@ -241,12 +290,15 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, + GetArtifactRequest, + GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, + GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffCurrentWorkRequest, @@ -260,6 +312,7 @@ KnownHandoffScope, KnownHandoffScopePage, ListArtifactCandidatesRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListHandoffReportActivitiesRequest, @@ -270,6 +323,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, + ListScopesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -289,6 +343,7 @@ RegisterHandoffReportWorkstreamRequest, RejectArtifactCandidateRequest, RememberMemoryRequest, + ReplaceArtifactRequest, ResolveExternalSkillRequest, RetireMemoryEntryRequest, ReviseArtifactCandidateRequest, @@ -296,10 +351,19 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, + ScopePage, + ScopeSummary, + SearchArtifactsRequest, SearchMemoryRequest, SearchMemoryResponse, + SearchSourcesRequest, SkillArtifact, + SourcePage, + SourceQueryType, + SourceRecord, StoredHandoffReportActivity, + TextSearchMode, + TextSearchUsedMode, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, WorkSourceReceipt, @@ -329,14 +393,19 @@ CAPTURE_CONTENT_SOURCE, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ARTIFACT, CREATE_HANDOFF_REPORT_PROJECT, + CREATE_SOURCE, CREATE_WORK_CONTRACT, + DELETE_ARTIFACT, DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, + GET_ARTIFACT_REVISION, GET_CAPABILITIES, GET_EXPERIENCE, GET_HANDOFF_REPORT, @@ -346,10 +415,12 @@ GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, + LIST_ARTIFACTS, LIST_EXTERNAL_SKILLS, LIST_HANDOFF_REPORT_ACTIVITIES, LIST_HANDOFF_REPORT_KNOWN_SCOPES, @@ -357,6 +428,7 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SCOPES, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, @@ -368,12 +440,15 @@ REGISTER_HANDOFF_REPORT_WORKSTREAM, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, + SEARCH_ARTIFACTS, SEARCH_MEMORY, + SEARCH_SOURCES, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, Operation, @@ -412,6 +487,92 @@ class _SourceApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedSourceApplication: ... +class _ScopedRecordApplication(Protocol): + async def create_source( + self, + source_type: str, + source_id: str, + content: str, + metadata: dict[str, Any], + /, + ) -> RuntimeSourceRecord: ... + + async def get_source(self, source_type: str, source_id: str, /) -> RuntimeSourceRecord: ... + + async def query_sources( + self, + source_type: str, + query_type: RuntimeSourceQueryType, + /, + *, + query: str | None, + mode: RuntimeTextSearchMode | None, + limit: int, + cursor: str | None, + ) -> RuntimeSourceRecordPage: ... + + async def create_artifact( + self, + family: str, + artifact_id: str | None, + write: RuntimeArtifactWrite, + /, + ) -> RuntimeArtifactRecord: ... + + 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 list_artifacts( + self, + family: str, + /, + *, + limit: int, + cursor: str | None, + ) -> RuntimeArtifactRecordPage: ... + + async def search_artifacts( + self, + family: str, + query: str, + /, + *, + mode: RuntimeTextSearchMode, + limit: int, + cursor: str | None, + ) -> RuntimeArtifactSearchPage: ... + + async def replace_artifact( + self, + family: str, + artifact_id: str, + expected_revision: int, + write: RuntimeArtifactWrite, + /, + ) -> RuntimeArtifactRecord: ... + + async def delete_artifact( + self, + family: str, + artifact_id: str, + expected_revision: int, + /, + ) -> RuntimeArtifactDeletion: ... + + +class _RecordApplication(Protocol): + def for_scope(self, scope_id: str, /) -> _ScopedRecordApplication: ... + + async def list_scopes(self, *, limit: int, cursor: str | None) -> RuntimeScopeSummaryPage: ... + + class _ScopedContextApplication(Protocol): async def prepare(self, request: RuntimePrepareContextRequest, /) -> RuntimePreparedContext: ... @@ -540,6 +701,7 @@ def for_scope(self, scope_id: str, /) -> _ScopedStatisticsApplication: ... class ServerApplication(Protocol): sources: _SourceApplication + records: _RecordApplication context: _ContextApplication experience: _ExperienceApplication external_skills: _ExternalSkillApplication @@ -556,6 +718,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, @@ -619,6 +785,8 @@ async def invalid_request(request: Request, error: RequestValidationError) -> JS ) @app.exception_handler(_RuntimeNotReadyError) + @app.exception_handler(_PreconditionRequiredError) + @app.exception_handler(BaseAccessError) @app.exception_handler(PowerContextError) async def application_error(request: Request, error: Exception) -> JSONResponse: response_status, code, message, details = _map_error(error) @@ -656,6 +824,17 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, ATTACH_HANDOFF_REPORT_WORKSPACE, attach_handoff_report_workspace) _add_route(app, DETACH_HANDOFF_REPORT_WORKSPACE, detach_handoff_report_workspace) _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) + _add_route(app, CREATE_SOURCE, create_source) + _add_route(app, SEARCH_SOURCES, search_sources) + _add_route(app, GET_SOURCE, get_source) + _add_route(app, CREATE_ARTIFACT, create_artifact) + _add_route(app, LIST_ARTIFACTS, list_artifacts) + _add_route(app, GET_ARTIFACT, get_artifact) + _add_route(app, REPLACE_ARTIFACT, replace_artifact) + _add_route(app, DELETE_ARTIFACT, delete_artifact) + _add_route(app, GET_ARTIFACT_REVISION, get_artifact_revision) + _add_route(app, SEARCH_ARTIFACTS, search_artifacts) + _add_route(app, LIST_SCOPES, list_scopes) _add_route(app, CAPTURE_CONTENT_SOURCE, capture_content_source) _add_route(app, FLUSH_MEMORY, flush_memory) _add_route(app, REMEMBER_MEMORY, remember_memory) @@ -1002,6 +1181,312 @@ async def get_handoff_report( return response_payload +async def create_source( + request: CreateSourceRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SourceRecord: + result = await application.records.for_scope(request.scope_id).create_source( + request.source_type, + request.source_id, + request.content, + request.metadata, + ) + response.headers["Location"] = _source_location(result) + return _source_record_response(result) + + +def _search_sources_query( + scope_id: Annotated[str, Query(min_length=1, max_length=256, pattern=r".*\S.*")], + source_type: Annotated[str, Query(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + query_type: Annotated[SourceQueryType, Query(alias="type")] = SourceQueryType.LIST, + q: Annotated[str | None, Query(min_length=1, max_length=8192)] = None, + mode: Annotated[TextSearchMode | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, +) -> SearchSourcesRequest: + return SearchSourcesRequest( + scope_id=scope_id, + source_type=source_type, + type=query_type, + q=q, + mode=mode, + limit=limit, + cursor=cursor, + ) + + +async def search_sources( + request: Annotated[SearchSourcesRequest, Depends(_search_sources_query)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SourcePage: + result = await application.records.for_scope(request.scope_id).query_sources( + request.source_type, + request.type.value, + query=request.q, + mode=None if request.mode is None else request.mode.value, + limit=request.limit, + cursor=request.cursor, + ) + return SourcePage( + query=result.query, + mode=None if result.mode is None else TextSearchUsedMode(result.mode), + items=[_source_record_response(item) for item in result.items], + next_cursor=result.next_cursor, + ) + + +async def get_source( + source_id: Annotated[str, Path(min_length=1, max_length=256)], + request: Annotated[GetSourceRequest, Query()], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> SourceRecord: + result = await application.records.for_scope(request.scope_id).get_source(request.source_type, source_id) + return _source_record_response(result) + + +async def create_artifact( + request: CreateArtifactRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactRevision: + result = await application.records.for_scope(request.scope_id).create_artifact( + request.family, + request.artifact_id, + _artifact_write(request), + ) + response.headers["Location"] = _artifact_location(result) + response.headers["ETag"] = _artifact_etag(result.artifact_ref.revision) + return _artifact_revision_response(result) + + +def _list_artifacts_query( + scope_id: Annotated[str, Query(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Query(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, +) -> ListArtifactsRequest: + return ListArtifactsRequest(scope_id=scope_id, family=family, limit=limit, cursor=cursor) + + +async def list_artifacts( + request: Annotated[ListArtifactsRequest, Depends(_list_artifacts_query)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactPage: + result = await application.records.for_scope(request.scope_id).list_artifacts( + request.family, + limit=request.limit, + cursor=request.cursor, + ) + return ArtifactPage( + items=[_artifact_revision_response(item) for item in result.items], + next_cursor=result.next_cursor, + ) + + +async def get_artifact( + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + request: Annotated[GetArtifactRequest, Query()], + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactRevision: + result = await application.records.for_scope(request.scope_id).get_artifact(request.family, artifact_id) + response.headers["ETag"] = _artifact_etag(result.artifact_ref.revision) + return _artifact_revision_response(result) + + +async def replace_artifact( + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + request: ReplaceArtifactRequest, + selector: Annotated[GetArtifactRequest, Query()], + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, +) -> ArtifactRevision: + expected_revision = _require_artifact_etag(if_match) + result = await application.records.for_scope(selector.scope_id).replace_artifact( + selector.family, + artifact_id, + expected_revision, + _artifact_write(request), + ) + response.headers["ETag"] = _artifact_etag(result.artifact_ref.revision) + return _artifact_revision_response(result) + + +async def delete_artifact( + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + request: Annotated[DeleteArtifactRequest, Query()], + application: Annotated[ServerApplication, Depends(_require_application)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, +) -> ArtifactDeletionStatus: + expected_revision = _require_artifact_etag(if_match) + result = await application.records.for_scope(request.scope_id).delete_artifact( + request.family, + artifact_id, + expected_revision, + ) + return ArtifactDeletionStatus( + artifact_ref=mapping.artifact_reference(result.artifact_ref), + status=ArtifactDeletionState.DELETED, + deleted_at=result.deleted_at, + ) + + +async def get_artifact_revision( + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + revision: Annotated[int, Path(ge=1)], + request: Annotated[GetArtifactRevisionRequest, Query()], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactRevision: + result = await application.records.for_scope(request.scope_id).get_artifact_revision( + request.family, + artifact_id, + revision, + ) + return _artifact_revision_response(result) + + +def _search_artifacts_query( + scope_id: Annotated[str, Query(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Query(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + q: Annotated[str, Query(min_length=1, max_length=8192, pattern=r".*\S.*")], + mode: Annotated[TextSearchMode, Query()] = TextSearchMode.AUTO, + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, +) -> SearchArtifactsRequest: + return SearchArtifactsRequest( + scope_id=scope_id, + family=family, + q=q, + mode=mode, + limit=limit, + cursor=cursor, + ) + + +async def search_artifacts( + request: Annotated[SearchArtifactsRequest, Depends(_search_artifacts_query)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactSearchResultPage: + result = await application.records.for_scope(request.scope_id).search_artifacts( + request.family, + request.q, + mode=request.mode.value, + limit=request.limit, + cursor=request.cursor, + ) + return ArtifactSearchResultPage( + query=result.query, + mode=TextSearchUsedMode(result.mode), + hits=[ + ArtifactSearchHit( + artifact=_artifact_revision_response(hit.artifact), + score=hit.score, + snippets=list(hit.snippets), + ) + for hit in result.hits + ], + next_cursor=result.next_cursor, + ) + + +async def list_scopes( + request: Annotated[ListScopesRequest, Depends(_list_scopes_query)], + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ScopePage: + result = await application.records.list_scopes(limit=request.limit, cursor=request.cursor) + return ScopePage( + items=[ + ScopeSummary( + scope_id=item.scope_id, + title=item.title, + summary=item.summary, + parent_scope_id=item.parent_scope_id, + version=item.version, + source_types=list(item.source_types), + artifact_families=list(item.artifact_families), + source_count=item.source_count, + artifact_count=item.artifact_count, + ) + for item in result.items + ], + next_cursor=result.next_cursor, + ) + + +def _list_scopes_query( + limit: Annotated[int, Query(ge=1, le=100)] = 50, + cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, +) -> ListScopesRequest: + return ListScopesRequest(limit=limit, cursor=cursor) + + +def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord: + return SourceRecord( + scope_id=value.scope_id, + source_ref=mapping.source_reference(value.source_ref), + content=value.content, + metadata=value.metadata, + created_at=value.created_at, + position=value.position, + content_digest=value.content_digest, + score=value.score, + snippets=list(value.snippets), + ) + + +def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevision: + return ArtifactRevision( + scope_id=value.scope_id, + artifact_ref=mapping.artifact_reference(value.artifact_ref), + schema_version=value.schema_version, + metadata=value.metadata, + content=value.content, + source_refs=[mapping.source_reference(ref) for ref in value.source_refs], + artifact_refs=[mapping.artifact_reference(ref) for ref in value.artifact_refs], + created_at=value.created_at, + content_digest=value.content_digest, + ) + + +def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: + return RuntimeArtifactWrite( + schema_version=value.schema_version, + metadata=value.metadata, + content=value.content, + source_refs=tuple(mapping.runtime_source_reference(ref) for ref in value.source_refs), + artifact_refs=tuple(mapping.runtime_artifact_reference(ref) for ref in value.artifact_refs), + ) + + +def _source_location(value: RuntimeSourceRecord) -> str: + source_id = quote(value.source_ref.source_id, safe="") + scope_id = quote(value.scope_id, safe="") + source_type = quote(value.source_ref.source_type, safe="") + return f"/v1/sources/{source_id}?scope_id={scope_id}&source_type={source_type}" + + +def _artifact_location(value: RuntimeArtifactRecord) -> str: + artifact_id = quote(value.artifact_ref.artifact_id, safe="") + scope_id = quote(value.scope_id, safe="") + family = quote(value.artifact_ref.family, safe="") + return f"/v1/artifacts/{artifact_id}?scope_id={scope_id}&family={family}" + + +def _artifact_etag(revision: int) -> str: + return f'"revision:{revision}"' + + +def _require_artifact_etag(value: str | None) -> int: + if value is None: + raise _PreconditionRequiredError + match = re.fullmatch(r'"revision:([1-9][0-9]*)"', value) + if match is None: + raise InvalidBaseAccessRequestError("If-Match", "must be an Artifact revision ETag") + return int(match.group(1)) + + def _require_report_size(estimated_bytes: int, report: Any) -> None: if estimated_bytes <= MAX_HANDOFF_REPORT_BYTES: return @@ -1519,6 +2004,9 @@ def _validation_error_details(error: RequestValidationError) -> list[Any]: def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: 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 if isinstance(error, ExternalSkillRegistryUnavailableError): return ( status.HTTP_503_SERVICE_UNAVAILABLE, @@ -1554,6 +2042,55 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: return _map_domain_error(error) +def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + 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": _artifact_etag(error.provided_revision), + "current_etag": _artifact_etag(error.current_revision), + }, + ) + if isinstance(error, BaseOperationNotSupportedError): + return ( + status.HTTP_405_METHOD_NOT_ALLOWED, + "operation_not_supported", + "The Source type or Artifact family does not permit this operation.", + {"kind": error.kind, "name": error.name, "operation": error.operation}, + ) + 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, InvalidBaseAccessRequestError): + return ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "invalid_request", + "The request is invalid.", + {"field": error.field, "reason": error.reason}, + ) + return None + + def _map_candidate_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: if isinstance(error, CandidateNotFoundError): return status.HTTP_404_NOT_FOUND, "candidate_not_found", "The requested Candidate was not found.", None diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py new file mode 100644 index 000000000..c65aa9473 --- /dev/null +++ b/tests/builtin/persistence/test_records.py @@ -0,0 +1,218 @@ +# 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 powercontext.builtin.persistence.records import RelationalRecordService +from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import SHARED_TABLES +from powercontext.builtin.records import ( + ArtifactRevisionPreconditionError, + ArtifactWrite, + BaseOperationNotSupportedError, + BaseValueConflictError, + BaseValueNotFoundError, + InvalidBaseAccessRequestError, +) +from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER +from powercontext.sources import SourceRef + + +def test_source_records_support_create_get_list_search_and_bound_cursors() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: + records = RelationalRecordService( + profile.database, + SourceRepository((CONTENT_SOURCE_ADAPTER,)), + ) + first = await records.create_source( + "scope-a", + "content", + "source-1", + "Keep the OpenAPI contract authoritative.", + {"channel": "test"}, + ) + repeated = await records.create_source( + "scope-a", + "content", + "source-1", + "Keep the OpenAPI contract authoritative.", + {"channel": "test"}, + ) + second = await records.create_source( + "scope-a", + "content", + "source-2", + "Preserve immutable artifact revisions.", + {}, + ) + + assert repeated == first + assert first.position == 1 + assert second.position == 2 + assert (await records.get_source("scope-a", "content", "source-1")) == first + + first_page = await records.query_sources( + "scope-a", + "content", + "list", + query=None, + mode=None, + limit=1, + cursor=None, + ) + assert [item.source_ref.source_id for item in first_page.items] == ["source-1"] + assert first_page.next_cursor is not None + second_page = await records.query_sources( + "scope-a", + "content", + "list", + query=None, + mode=None, + limit=1, + cursor=first_page.next_cursor, + ) + assert [item.source_ref.source_id for item in second_page.items] == ["source-2"] + + found = await records.query_sources( + "scope-a", + "content", + "search", + query="OpenAPI authoritative", + mode="auto", + limit=10, + cursor=None, + ) + assert [item.source_ref.source_id for item in found.items] == ["source-1"] + assert found.mode == "keyword" + assert found.items[0].score == 1.0 + + with pytest.raises(BaseValueConflictError): + await records.create_source("scope-a", "content", "source-1", "different", {}) + with pytest.raises(InvalidBaseAccessRequestError): + await records.query_sources( + "scope-a", + "content", + "search", + query="OpenAPI", + mode="auto", + limit=1, + cursor=first_page.next_cursor, + ) + with pytest.raises(BaseOperationNotSupportedError): + await records.get_source("scope-a", "git", "source-1") + + asyncio.run(scenario()) + + +def test_artifact_records_preserve_revisions_and_delete_only_the_head() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: + records = RelationalRecordService( + profile.database, + SourceRepository((CONTENT_SOURCE_ADAPTER,)), + ) + source = await records.create_source( + "scope-a", + "content", + "source-1", + "The reviewed API design.", + {}, + ) + first = await records.create_artifact( + "scope-a", + "document", + "guide-1", + ArtifactWrite( + schema_version=1, + metadata={"title": "API Guide"}, + content={"body": "Use a complete replacement."}, + source_refs=(source.source_ref,), + ), + ) + + assert first.artifact_ref.revision == 1 + assert first.source_refs == (SourceRef(source_type="content", source_id="source-1"),) + assert (await records.get_artifact("scope-a", "document", "guide-1")) == first + assert (await records.list_artifacts("scope-a", "document", limit=10, cursor=None)).items == (first,) + found = await records.search_artifacts( + "scope-a", + "document", + "complete replacement", + mode="auto", + limit=10, + cursor=None, + ) + assert [hit.artifact.artifact_ref.artifact_id for hit in found.hits] == ["guide-1"] + + second = await records.replace_artifact( + "scope-a", + "document", + "guide-1", + 1, + ArtifactWrite( + schema_version=2, + metadata={"title": "API Guide"}, + content={"body": "Use If-Match for replacement."}, + source_refs=(source.source_ref,), + artifact_refs=(first.artifact_ref,), + ), + ) + assert second.artifact_ref.revision == 2 + assert (await records.get_artifact_revision("scope-a", "document", "guide-1", 1)) == first + with pytest.raises(ArtifactRevisionPreconditionError): + await records.replace_artifact( + "scope-a", + "document", + "guide-1", + 1, + ArtifactWrite(schema_version=2, metadata={}, content={"body": "stale"}), + ) + + scopes = await records.list_scopes(limit=10, cursor=None) + assert [(item.scope_id, item.source_count, item.artifact_count) for item in scopes.items] == [ + ("scope-a", 1, 1) + ] + assert scopes.items[0].source_types == ("content",) + assert scopes.items[0].artifact_families == ("document",) + + deleted = await records.delete_artifact("scope-a", "document", "guide-1", 2) + assert deleted.artifact_ref == second.artifact_ref + assert await records.delete_artifact("scope-a", "document", "guide-1", 2) == deleted + with pytest.raises(BaseValueNotFoundError): + await records.get_artifact("scope-a", "document", "guide-1") + assert (await records.get_artifact_revision("scope-a", "document", "guide-1", 2)) == second + assert (await records.list_artifacts("scope-a", "document", limit=10, cursor=None)).items == () + + with pytest.raises(BaseValueConflictError): + await records.create_artifact( + "scope-a", + "document", + "guide-1", + ArtifactWrite(schema_version=1, metadata={}, content={"body": "reused identity"}), + ) + with pytest.raises(BaseOperationNotSupportedError): + await records.create_artifact( + "scope-a", + "memory", + "memory-1", + ArtifactWrite(schema_version=1, metadata={}, content={"body": "protected"}), + ) + + asyncio.run(scenario()) diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py new file mode 100644 index 000000000..5c1e856d3 --- /dev/null +++ b/tests/e2e/test_base_access_api.py @@ -0,0 +1,154 @@ +# 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 + +import httpx +import pytest + +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.client import PowerContextClient, ServerResponseError +from powercontext.http import ( + CreateArtifactRequest, + CreateSourceRequest, + DeleteArtifactRequest, + GetArtifactRequest, + GetArtifactRevisionRequest, + GetSourceRequest, + ListArtifactsRequest, + ListScopesRequest, + ReplaceArtifactRequest, + SearchArtifactsRequest, + SearchSourcesRequest, + SourceQueryType, +) +from powercontext.server.factory import create_server_app +from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings + + +def test_source_artifact_and_scope_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) + source = await client.create_source( + CreateSourceRequest( + scope_id="scope-a", + source_type="content", + source_id="source-1", + content="Keep Source writes separate from Memory generation.", + metadata={"channel": "e2e"}, + ) + ) + loaded_source = await client.get_source( + "source-1", + GetSourceRequest(scope_id="scope-a", source_type="content"), + ) + listed_sources = await client.search_sources( + SearchSourcesRequest(scope_id="scope-a", source_type="content") + ) + found_sources = await client.search_sources( + SearchSourcesRequest( + scope_id="scope-a", + source_type="content", + type=SourceQueryType.SEARCH, + q="Memory generation", + ) + ) + + first = await client.create_artifact( + CreateArtifactRequest( + scope_id="scope-a", + family="document", + artifact_id="guide-1", + schema_version=1, + metadata={"title": "Base API"}, + content={"body": "Use full replacement updates."}, + source_refs=[source.source_ref], + ) + ) + selector = GetArtifactRequest(scope_id="scope-a", family="document") + listed_artifacts = await client.list_artifacts(ListArtifactsRequest(scope_id="scope-a", family="document")) + found_artifacts = await client.search_artifacts( + SearchArtifactsRequest(scope_id="scope-a", family="document", q="replacement updates") + ) + second = await client.replace_artifact( + "guide-1", + selector, + ReplaceArtifactRequest( + schema_version=2, + metadata={"title": "Base API"}, + content={"body": "Use If-Match with full replacement updates."}, + source_refs=[source.source_ref], + artifact_refs=[first.artifact_ref], + ), + expected_revision=1, + ) + exact_first = await client.get_artifact_revision( + "guide-1", + 1, + GetArtifactRevisionRequest(scope_id="scope-a", family="document"), + ) + scopes = await client.list_scopes(ListScopesRequest()) + + with pytest.raises(ServerResponseError) as stale: + await client.replace_artifact( + "guide-1", + selector, + ReplaceArtifactRequest(schema_version=2, content={"body": "stale"}), + expected_revision=1, + ) + assert stale.value.status_code == 412 + assert stale.value.code == "revision_conflict" + + deleted = await client.delete_artifact( + "guide-1", + DeleteArtifactRequest(scope_id="scope-a", family="document"), + expected_revision=2, + ) + with pytest.raises(ServerResponseError) as missing: + await client.get_artifact("guide-1", selector) + assert missing.value.status_code == 404 + + assert source == loaded_source + assert [item.source_ref.source_id for item in listed_sources.items] == ["source-1"] + assert [item.source_ref.source_id for item in found_sources.items] == ["source-1"] + assert listed_sources.query is None + assert found_sources.query == "Memory generation" + assert found_sources.mode == "keyword" + assert [item.artifact_ref.artifact_id for item in listed_artifacts.items] == ["guide-1"] + assert [hit.artifact.artifact_ref.artifact_id for hit in found_artifacts.hits] == ["guide-1"] + assert second.artifact_ref.revision == 2 + assert exact_first == first + assert [(item.scope_id, item.source_count, item.artifact_count) for item in scopes.items] == [("scope-a", 1, 1)] + assert deleted.status == "deleted" + assert deleted.artifact_ref == second.artifact_ref + + asyncio.run(scenario()) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 537e827ef..27fbb25e2 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -481,6 +481,42 @@ def test_generated_transport_rejects_values_outside_openapi( model.model_validate(value) +def test_base_access_contract_uses_fixed_source_artifact_and_scope_paths() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + paths = contract["paths"] + + expected_operations = { + ("/v1/sources", "post"): "create_source", + ("/v1/sources", "get"): "search_sources", + ("/v1/sources/{source_id}", "get"): "get_source", + ("/v1/artifacts", "post"): "create_artifact", + ("/v1/artifacts", "get"): "list_artifacts", + ("/v1/artifacts/{artifact_id}", "get"): "get_artifact", + ("/v1/artifacts/{artifact_id}", "put"): "replace_artifact", + ("/v1/artifacts/{artifact_id}", "delete"): "delete_artifact", + ("/v1/artifacts/{artifact_id}/revisions/{revision}", "get"): "get_artifact_revision", + ("/v1/artifact-search-results", "get"): "search_artifacts", + ("/v1/scopes", "get"): "list_scopes", + } + assert { + (path, method): paths[path][method]["operationId"] for path, method in expected_operations + } == expected_operations + assert not any(path.startswith("/v1/resources") or "derivation" in path for path in paths) + + source_query = contract["components"]["schemas"]["SourceQueryType"] + assert source_query == {"type": "string", "enum": ["list", "search"]} + source_parameters = {item["name"]: item for item in paths["/v1/sources"]["get"]["parameters"]} + assert source_parameters["type"]["schema"] == { + "$ref": "#/components/schemas/SourceQueryType", + "default": "list", + } + + for method in ("put", "delete"): + parameters = {item["name"]: item for item in paths["/v1/artifacts/{artifact_id}"][method]["parameters"]} + assert parameters["If-Match"]["required"] is True + assert set(paths["/v1/artifacts/{artifact_id}"][method]["responses"]) >= {"200", "412", "428"} + + def test_server_publishes_the_canonical_openapi_schema() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) From d0b21c01e7224571be1f9ea416fa9f8074a1ef90 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 3 Sep 2026 01:01:49 +0800 Subject: [PATCH 2/9] feat(api): align source and artifact access with RFC --- .../dsh/plugins/powercontext/lib/index.js | 572 +++++++++-- .../powercontext/openapi/powercontext.yaml | 896 +++++++++++++++++- .../powercontext/scripts/openapi-ops.mjs | 32 +- .../dsh/plugins/powercontext/src/client.ts | 83 +- .../powercontext/src/operations.generated.ts | 126 ++- .../plugins/powercontext/tests/client.spec.ts | 85 +- .../tests/operations-coverage.spec.ts | 35 +- .../plugins/powercontext/src/client.ts | 90 +- .../powercontext/src/operations.generated.ts | 126 ++- .../plugins/powercontext/tests/client.spec.ts | 74 +- .../pi/plugins/powercontext/src/client.ts | 83 +- .../powercontext/src/operations.generated.ts | 126 ++- .../plugins/powercontext/tests/client.spec.ts | 60 ++ openapi/powercontext.yaml | 708 +++++--------- scripts/generate_api.py | 39 +- scripts/generate_js_operations.py | 43 +- .../builtin/persistence/artifacts.py | 30 + .../builtin/persistence/records.py | 653 +++++++------ .../builtin/persistence/schema.py | 39 +- .../builtin/persistence/sources.py | 18 +- .../builtin/persistence/statistics.py | 5 +- .../builtin/persistence/tables.py | 67 +- src/powercontext/builtin/records.py | 96 +- .../builtin/runtime/application.py | 56 +- .../builtin/runtime/relational.py | 7 +- src/powercontext/client/client.py | 120 +-- src/powercontext/http/__init__.py | 36 +- src/powercontext/http/_generated/models.py | 223 +---- .../http/_generated/operations.py | 184 ++-- src/powercontext/http/_generated/schema.py | 514 ++++------ src/powercontext/server/app.py | 381 ++++---- src/powercontext/server/mapping.py | 9 + tests/builtin/persistence/test_records.py | 262 ++++- tests/e2e/test_base_access_api.py | 156 +-- tests/test_api_contract.py | 167 +++- tests/test_api_generation.py | 70 ++ tests/test_client.py | 107 +++ tests/test_js_operations.py | 16 + 38 files changed, 4145 insertions(+), 2249 deletions(-) create mode 100644 tests/test_api_generation.py diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index f863ed06a..99202c24d 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -87,319 +87,714 @@ const OPERATIONS = { method: "GET", path: "/health/live", location: null, - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_readiness: { method: "GET", path: "/health/ready", location: null, - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_capabilities: { method: "GET", path: "/v1/capabilities", location: null, - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, capture_content_source: { method: "POST", path: "/v1/sources/content", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, prepare_context: { method: "POST", path: "/v1/context/prepare", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, create_work_contract: { method: "POST", path: "/v1/work/contracts/create", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, handoff_current_work: { method: "POST", path: "/v1/work/handoffs/prepare-current", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, acknowledge_handoff: { method: "POST", path: "/v1/work/handoffs/acknowledge", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, record_task_outcome: { method: "POST", path: "/v1/work/outcomes/record", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [202], + emptyStatuses: [] }, activate_handoff: { method: "POST", path: "/v1/handoff/activate", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, prepare_handoff: { method: "POST", path: "/v1/handoff/prepare", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, finalize_handoff: { method: "POST", path: "/v1/handoff/finalize", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, commit_handoff: { method: "POST", path: "/v1/handoff/commit", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, continue_handoff: { method: "POST", path: "/v1/handoff/continue", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, flush_memory: { method: "POST", path: "/v1/memory/flush", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, remember_memory: { method: "POST", path: "/v1/memory/remember", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, search_memory: { method: "POST", path: "/v1/memory/search", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_memory_entries: { method: "POST", path: "/v1/memory/entries/list", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_memory_entry: { method: "POST", path: "/v1/memory/entries/get", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, revise_memory_entry: { method: "POST", path: "/v1/memory/entries/revise", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, retire_memory_entry: { method: "POST", path: "/v1/memory/entries/retire", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_memory_changes: { method: "POST", path: "/v1/memory/changes", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, propose_experience: { method: "POST", path: "/v1/experience/propose", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, generate_experience: { method: "POST", path: "/v1/experience/generate", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_experience: { method: "POST", path: "/v1/experience/get", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, propose_skill: { method: "POST", path: "/v1/skill/propose", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, generate_skill: { method: "POST", path: "/v1/skill/generate", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_skill: { method: "POST", path: "/v1/skill/get", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, scan_external_skills: { method: "POST", path: "/v1/external-skills/scan", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_external_skills: { method: "POST", path: "/v1/external-skills/list", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, resolve_external_skill: { method: "POST", path: "/v1/external-skills/resolve", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, import_external_skill: { method: "POST", path: "/v1/external-skills/import", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_artifact_candidates: { method: "POST", path: "/v1/artifact-candidates/list", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/get", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, approve_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/approve", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, reject_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/reject", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, revise_artifact_candidate: { method: "POST", path: "/v1/artifact-candidates/revise", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_stats: { method: "GET", path: "/v1/stats", location: "query", - scope: true + scope: true, + pathParams: [], + queryParams: ["scope_id", "period"], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, create_handoff_report_project: { method: "POST", path: "/v1/handoff-reports/projects/create", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, list_handoff_report_projects: { method: "POST", path: "/v1/handoff-reports/projects/list", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, list_handoff_report_known_scopes: { method: "POST", path: "/v1/handoff-reports/scopes/list-known", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_handoff_report_project: { method: "POST", path: "/v1/handoff-reports/projects/get", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, update_handoff_report_project: { method: "POST", path: "/v1/handoff-reports/projects/update", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, register_handoff_report_workstream: { method: "POST", path: "/v1/handoff-reports/workstreams/register", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, list_handoff_report_workstreams: { method: "POST", path: "/v1/handoff-reports/workstreams/list", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, update_handoff_report_workstream: { method: "POST", path: "/v1/handoff-reports/workstreams/update", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_handoff_report: { method: "POST", path: "/v1/handoff-reports/get", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, record_handoff_report_activity: { method: "POST", path: "/v1/handoff-reports/activities/record", location: "body", - scope: true + scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] }, list_handoff_report_activities: { method: "POST", path: "/v1/handoff-reports/activities/list", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, purge_handoff_report_activities: { method: "POST", path: "/v1/handoff-reports/activities/purge", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, get_handoff_report_workspace: { method: "POST", path: "/v1/handoff-reports/workspace-bindings/get", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, attach_handoff_report_workspace: { method: "POST", path: "/v1/handoff-reports/workspace-bindings/attach", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] }, detach_handoff_report_workspace: { method: "POST", path: "/v1/handoff-reports/workspace-bindings/detach", location: "body", - scope: false + scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + create_source: { + method: "POST", + path: "/v1/scopes/{scope_id}/sources", + location: "body", + scope: true, + pathParams: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] + }, + list_sources: { + method: "GET", + path: "/v1/scopes/{scope_id}/sources/{source_type}", + location: "query", + scope: true, + pathParams: ["scope_id", "source_type"], + queryParams: [ + "query", + "mode", + "limit", + "cursor" + ], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_source: { + method: "GET", + path: "/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", + location: null, + scope: true, + pathParams: [ + "scope_id", + "source_type", + "source_id" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + create_artifact: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifacts", + location: "body", + scope: true, + pathParams: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [201], + emptyStatuses: [] + }, + list_artifacts: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}", + location: "query", + scope: true, + pathParams: ["scope_id", "family"], + queryParams: [ + "query", + "mode", + "limit", + "cursor" + ], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, + get_artifact: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", + location: null, + scope: true, + pathParams: [ + "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", + scope: true, + pathParams: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [200], + emptyStatuses: [] + }, + delete_artifact: { + method: "DELETE", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", + location: null, + scope: true, + pathParams: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [204], + emptyStatuses: [204] + }, + get_artifact_revision: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", + location: null, + scope: true, + pathParams: [ + "scope_id", + "family", + "artifact_id", + "revision" + ], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] } }; const OPERATION_IDS = Object.keys(OPERATIONS); @@ -482,6 +877,45 @@ function queryString(payload) { const encoded = params.toString(); return encoded ? `?${encoded}` : ""; } +function encodePathSegment(value) { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); +} +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 spec.pathParams) { + 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, + 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; } @@ -499,9 +933,10 @@ var PowerContextClient = class { async request(id, payload, signal) { if (!(id in OPERATIONS)) throw new UnknownOperationError(id); const spec = OPERATIONS[id]; - const url = this.buildUrl(spec, payload); + const prepared = prepareRequest(spec, payload); + const url = `${this.baseUrl}${prepared.path}${prepared.query}`; try { - const response = await this.fetchImpl(url, this.buildInit(spec, 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; @@ -509,14 +944,11 @@ var PowerContextClient = class { throw this.wrapTransport(spec.path, error); } } - buildUrl(spec, payload) { - const suffix = spec.location === "query" ? queryString(payload) : ""; - return `${this.baseUrl}${spec.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 = { @@ -525,9 +957,9 @@ var PowerContextClient = class { redirect: "manual", signal: combineSignals([timeoutSignal(this.requestTimeoutMs), ...signal ? [signal] : []]) }; - if (spec.method === "POST" && spec.location === "body") { + if (spec.location === "body") { headers["Content-Type"] = "application/json"; - init.body = JSON.stringify(payload ?? {}); + init.body = JSON.stringify(request.body ?? {}); } return init; } @@ -537,10 +969,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/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index a38ba8922..fc62c82b1 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -1688,6 +1688,543 @@ paths: $ref: "#/components/responses/InvalidRequest" "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" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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}: + get: + tags: [sources] + summary: List or search Sources + description: List when query is absent or blank; search when query is nonblank. + operationId: list_sources + 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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: query + in: query + required: false + schema: + type: string + maxLength: 8192 + - name: mode + in: query + required: false + schema: + $ref: "#/components/schemas/TextSearchMode" + - 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 Sources or Source search results. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/SourcePage" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "405": + $ref: "#/components/responses/OperationNotSupported" + "410": + $ref: "#/components/responses/CursorExpired" + "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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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: Commit revision one for a family that permits direct creation. The Server generates artifact_id. + 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/ArtifactRevision" + "401": + $ref: "#/components/responses/Unauthorized" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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 or search current Artifact heads + description: List when query is absent or blank; search when query is nonblank. + 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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: query + in: query + required: false + schema: + type: string + maxLength: 8192 + - name: mode + in: query + required: false + schema: + $ref: "#/components/schemas/TextSearchMode" + - 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 or search results. + 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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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 + pattern: '^"revision:[1-9][0-9]*"$' + 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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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 + pattern: '^"revision:[1-9][0-9]*"$' + 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" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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" + delete: + tags: [artifacts] + summary: Delete the current Artifact head + description: Record a tombstone without deleting immutable historical revisions. + operationId: delete_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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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 + pattern: '^"revision:[1-9][0-9]*"$' + responses: + "204": + description: The Artifact head is deleted and immutable history is retained. + headers: + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "405": + $ref: "#/components/responses/OperationNotSupported" + "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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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: @@ -1695,6 +2232,11 @@ components: scheme: bearer description: Static bearer token used when local Server authentication is enabled. headers: + ArtifactETag: + description: Strong validator for the current immutable Artifact revision. + schema: + type: string + pattern: '^"revision:[1-9][0-9]*"$' BearerChallenge: description: Authentication scheme required by the Server. schema: @@ -1704,20 +2246,74 @@ 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: - WWW-Authenticate: - $ref: "#/components/headers/BearerChallenge" + WWW-Authenticate: + $ref: "#/components/headers/BearerChallenge" + X-PowerContext-Request-ID: + $ref: "#/components/headers/RequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: The command conflicts with current immutable state. + headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - Conflict: - description: The command conflicts with current immutable state. + OperationNotSupported: + description: The Source type or Artifact family does not permit this operation. + headers: + Allow: + description: HTTP methods supported by the selected Source type or Artifact family. + required: true + schema: + type: string + 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" @@ -1744,7 +2340,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" @@ -1799,6 +2395,80 @@ components: minimum: 512 maximum: 32768 default: 8000 + ArtifactCollectionItem: + type: object + required: [scope_id, artifact_ref, created_at, content_digest, score, snippets] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + created_at: + type: string + format: date-time + nullable: true + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + score: + type: number + minimum: 0 + maximum: 1 + nullable: true + snippets: + type: array + items: + type: string + ArtifactPage: + type: object + required: [query, mode, items, next_cursor] + properties: + query: + type: string + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchUsedMode" + nullable: true + items: + type: array + items: + $ref: "#/components/schemas/ArtifactCollectionItem" + next_cursor: + type: string + nullable: true + ArtifactRevision: + type: object + required: [scope_id, artifact_ref, content, source_refs, artifact_refs, created_at, content_digest] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + artifact_ref: + $ref: "#/components/schemas/ArtifactReference" + content: + type: object + additionalProperties: true + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceTypeReference" + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + created_at: + type: string + format: date-time + nullable: true + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' ArtifactReference: type: object additionalProperties: false @@ -4564,6 +5234,216 @@ components: type: array items: $ref: "#/components/schemas/SearchMemoryHit" + CreateArtifactRequest: + type: object + additionalProperties: false + required: [family, content] + properties: + family: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + content: + type: object + additionalProperties: true + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceTypeReference" + default: [] + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + default: [] + CreateSourceRequest: + type: object + additionalProperties: false + required: [content] + properties: + source_type: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + default: content + content: + description: JSON value validated by the selected Source adapter. + metadata: + type: object + additionalProperties: true + default: {} + ListArtifactsRequest: + type: object + additionalProperties: false + properties: + query: + type: string + maxLength: 8192 + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchMode" + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + ListSourcesRequest: + type: object + additionalProperties: false + properties: + query: + type: string + maxLength: 8192 + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchMode" + nullable: true + limit: + type: integer + minimum: 1 + maximum: 100 + default: 50 + cursor: + type: string + minLength: 1 + maxLength: 4096 + nullable: true + ReplaceArtifactRequest: + type: object + additionalProperties: false + required: [content] + properties: + content: + type: object + additionalProperties: true + source_refs: + type: array + items: + $ref: "#/components/schemas/SourceTypeReference" + default: [] + artifact_refs: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" + default: [] + SourceCollectionItem: + type: object + required: [scope_id, source_type, source_id, metadata, created_at, position, content_digest, score, snippets] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_type: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + nullable: true + position: + type: integer + minimum: 1 + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + score: + type: number + minimum: 0 + maximum: 1 + nullable: true + snippets: + type: array + items: + type: string + SourcePage: + type: object + required: [query, mode, items, next_cursor] + properties: + query: + type: string + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchUsedMode" + nullable: true + items: + type: array + items: + $ref: "#/components/schemas/SourceCollectionItem" + next_cursor: + type: string + nullable: true + SourceRecord: + type: object + required: [scope_id, source_type, source_id, content, metadata, created_at, position, content_digest] + properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' + source_type: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' + content: + description: Persisted canonical JSON content. + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + nullable: true + 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 + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' SourceReference: type: object additionalProperties: false @@ -4580,6 +5460,12 @@ components: StatsPeriod: type: string enum: [today, 7d, 30d] + TextSearchMode: + type: string + enum: [auto, keyword] + TextSearchUsedMode: + type: string + enum: [keyword] CandidateFamily: type: string enum: [experience, skill] diff --git a/integrations/dsh/plugins/powercontext/scripts/openapi-ops.mjs b/integrations/dsh/plugins/powercontext/scripts/openapi-ops.mjs index 1afad5217..a8ab8b020 100644 --- a/integrations/dsh/plugins/powercontext/scripts/openapi-ops.mjs +++ b/integrations/dsh/plugins/powercontext/scripts/openapi-ops.mjs @@ -66,7 +66,27 @@ function requestLocation(bodySchema, parameters) { function operationHasScope(doc, bodySchema, parameters) { if (bodySchema && schemaHasScope(doc, bodySchema)) return true - return parameters.some((parameter) => parameter.in === 'query' && parameter.name === 'scope_id') + return parameters.some((parameter) => ['path', 'query'].includes(parameter.in) && parameter.name === 'scope_id') +} + +function parameterNames(parameters, location) { + return parameters + .filter((parameter) => parameter.in === location && typeof parameter.name === 'string') + .map((parameter) => parameter.name) +} + +function responseStatuses(doc, operation) { + const successStatuses = [] + const emptyStatuses = [] + for (const [rawStatus, response] of Object.entries(operation.responses ?? {})) { + if (!/^\d+$/.test(rawStatus)) continue + const status = Number(rawStatus) + if (!((status >= 200 && status < 300) || status === 304)) continue + successStatuses.push(status) + const resolved = deref(doc, response) + if (!resolved?.content || Object.keys(resolved.content).length === 0) emptyStatuses.push(status) + } + return { successStatuses, emptyStatuses } } export function parseOperations(doc) { @@ -78,12 +98,17 @@ export function parseOperations(doc) { if (!operation?.operationId) continue const parameters = operationParameters(doc, pathItem, operation) const bodySchema = jsonBodySchema(doc, operation) + const statuses = responseStatuses(doc, operation) rows.push({ operationId: operation.operationId, method: method.toUpperCase(), path, location: requestLocation(bodySchema, parameters), scope: operationHasScope(doc, bodySchema, parameters), + pathParams: parameterNames(parameters, 'path'), + queryParams: parameterNames(parameters, 'query'), + headerParams: parameterNames(parameters, 'header'), + ...statuses, }) } } @@ -94,7 +119,10 @@ export function renderOperationsSource(rows) { const body = rows .map((row) => { const location = row.location === null ? 'null' : `"${row.location}"` - return ` ${row.operationId}: { method: '${row.method}', path: '${row.path}', location: ${location}, scope: ${row.scope} },` + const pathParams = JSON.stringify(row.pathParams).replaceAll('"', "'") + const queryParams = JSON.stringify(row.queryParams).replaceAll('"', "'") + const headerParams = JSON.stringify(row.headerParams).replaceAll('"', "'") + return ` ${row.operationId}: { method: '${row.method}', path: '${row.path}', location: ${location}, scope: ${row.scope}, pathParams: ${pathParams}, queryParams: ${queryParams}, headerParams: ${headerParams}, successStatuses: ${JSON.stringify(row.successStatuses)}, emptyStatuses: ${JSON.stringify(row.emptyStatuses)} },` }) .join('\n') return `/* diff --git a/integrations/dsh/plugins/powercontext/src/client.ts b/integrations/dsh/plugins/powercontext/src/client.ts index 1bbdf1655..abdac3484 100644 --- a/integrations/dsh/plugins/powercontext/src/client.ts +++ b/integrations/dsh/plugins/powercontext/src/client.ts @@ -123,6 +123,62 @@ function queryString(payload: JsonObject | undefined): string { return encoded ? `?${encoded}` : '' } +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.pathParams 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}}`, 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, + 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 } @@ -147,9 +203,10 @@ export class PowerContextClient { ): Promise { if (!(id in OPERATIONS)) throw new UnknownOperationError(id) const spec = OPERATIONS[id as OperationId] - const url = this.buildUrl(spec, payload) + const prepared = prepareRequest(spec, payload) + const url = `${this.baseUrl}${prepared.path}${prepared.query}` try { - const response = await this.fetchImpl(url, this.buildInit(spec, 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 @@ -158,15 +215,11 @@ export class PowerContextClient { } } - private buildUrl(spec: OperationSpec, payload: JsonObject | undefined): string { - const suffix = spec.location === 'query' ? queryString(payload) : '' - return `${this.baseUrl}${spec.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 = { @@ -175,9 +228,9 @@ export class PowerContextClient { redirect: 'manual', signal: combineSignals([timeoutSignal(this.requestTimeoutMs), ...signal ? [signal] : []]), } - if (spec.method === 'POST' && spec.location === 'body') { + if (spec.location === 'body') { headers['Content-Type'] = 'application/json' - init.body = JSON.stringify(payload ?? {}) + init.body = JSON.stringify(request.body ?? {}) } return init } @@ -194,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 898d53832..157c61567 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -17,70 +17,68 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { - get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, - get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, - get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, - capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, - prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, - create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, - handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scope: true }, - acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scope: true }, - record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scope: true }, - activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scope: true }, - prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scope: true }, - finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scope: true }, - commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scope: true }, - continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scope: true }, - flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scope: true }, - remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scope: true }, - search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scope: true }, - list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scope: true }, - get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scope: true }, - revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scope: true }, - retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scope: true }, - list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scope: true }, - propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scope: true }, - generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scope: true }, - get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scope: true }, - propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, - generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, - get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, - scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, - list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, - resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, - import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scope: true }, - list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scope: true }, - get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scope: true }, - approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, - reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, - revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, - search_sources: { method: 'GET', path: '/v1/sources', location: "query", scope: true }, - create_source: { method: 'POST', path: '/v1/sources', location: "body", scope: true }, - get_source: { method: 'GET', path: '/v1/sources/{source_id}', location: "query", scope: true }, - list_artifacts: { method: 'GET', path: '/v1/artifacts', location: "query", scope: true }, - create_artifact: { method: 'POST', path: '/v1/artifacts', location: "body", scope: true }, - get_artifact: { method: 'GET', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, - replace_artifact: { method: 'PUT', path: '/v1/artifacts/{artifact_id}', location: "body", scope: true }, - delete_artifact: { method: 'DELETE', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, - get_artifact_revision: { method: 'GET', path: '/v1/artifacts/{artifact_id}/revisions/{revision}', location: "query", scope: true }, - search_artifacts: { method: 'GET', path: '/v1/artifact-search-results', location: "query", scope: true }, - list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scope: false }, + get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true, pathParams: [], queryParams: ['scope_id','period'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}', location: "query", scope: true, pathParams: ['scope_id','source_type'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scope: true, pathParams: ['scope_id','source_type','source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['scope_id','family'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['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", scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + delete_artifact: { method: 'DELETE', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [204], emptyStatuses: [204] }, + get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scope: true, pathParams: ['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 835e15920..2ba7d24b9 100644 --- a/integrations/dsh/plugins/powercontext/tests/client.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/client.spec.ts @@ -77,6 +77,69 @@ 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/decision%20records?query=recent+work&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/decision%20records/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 }) + } + expect(init?.method).toBe('DELETE') + expect(headers.get('If-Match')).toBe('"revision:2"') + expect(init?.body).toBeUndefined() + return new Response(null, { status: 204 }) + }) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + requestTimeoutMs: 1000, + fetch: fetchImpl, + }) + const path = { + scope_id: 'scope/team', + family: 'decision records', + } + + await client.request('list_artifacts', { ...path, query: 'recent work', 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 }) + await expect(client.request('delete_artifact', { + ...path, + artifact_id: 'artifact/1', + if_match: '"revision:2"', + })).resolves.toMatchObject({ kind: 'json', value: null, status: 204 }) + expect(fetchImpl).toHaveBeenCalledTimes(4) + }) + it('returns markdown text and raw bytes for get_handoff_report', async () => { const markdownClient = new PowerContextClient({ baseUrl: 'http://127.0.0.1:8000', @@ -135,24 +198,38 @@ 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] - await client.request(id, spec.location === 'query' ? { scope_id: 's' } : { marker: id }) + const payload: Record = { marker: id } + for (const name of spec.pathParams) 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.pathParams) { + expectedPath = expectedPath.replace(`{${name}}`, encodeURIComponent(`value/${name}`)) + } expect(seen[index].method).toBe(spec.method) - expect(seen[index].url.startsWith(`http://example.test${spec.path}`)).toBe(true) - expect(seen[index].hasBody).toBe(spec.method === 'POST' && spec.location === 'body') + 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/dsh/plugins/powercontext/tests/operations-coverage.spec.ts b/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts index 61ce821ff..433ba2140 100644 --- a/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts @@ -32,36 +32,67 @@ describe('operations coverage', () => { expect(generated).toEqual(fromYaml) }) - it('records method, path, and location for each operation', () => { + it('records request and response metadata for each operation', () => { expect(OPERATIONS.get_liveness).toEqual({ method: 'GET', path: '/health/live', location: null, scope: false, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [], }) expect(OPERATIONS.get_stats).toEqual({ method: 'GET', path: '/v1/stats', location: 'query', scope: true, + pathParams: [], + queryParams: ['scope_id', 'period'], + headerParams: [], + successStatuses: [200], + emptyStatuses: [], }) expect(OPERATIONS.remember_memory).toEqual({ method: 'POST', path: '/v1/memory/remember', location: 'body', scope: true, + pathParams: [], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [], }) expect(OPERATIONS.get_handoff_report.scope).toBe(true) expect(OPERATIONS.get_capabilities.location).toBeNull() + expect(OPERATIONS.get_artifact).toMatchObject({ + pathParams: ['scope_id', 'family', 'artifact_id'], + headerParams: ['If-None-Match'], + successStatuses: [200, 304], + emptyStatuses: [304], + }) + expect(OPERATIONS.delete_artifact).toMatchObject({ + headerParams: ['If-Match'], + successStatuses: [204], + emptyStatuses: [204], + }) }) - it('matches generated method, path, location, and scope for every operation', () => { + it('matches every generated operation field', () => { for (const row of parseOperations(loadYamlDoc())) { expect(OPERATIONS[row.operationId]).toEqual({ method: row.method, path: row.path, location: row.location, scope: row.scope, + pathParams: row.pathParams, + queryParams: row.queryParams, + headerParams: row.headerParams, + successStatuses: row.successStatuses, + emptyStatuses: row.emptyStatuses, }) } }) diff --git a/integrations/opencode/plugins/powercontext/src/client.ts b/integrations/opencode/plugins/powercontext/src/client.ts index 061072c61..eec5c400b 100644 --- a/integrations/opencode/plugins/powercontext/src/client.ts +++ b/integrations/opencode/plugins/powercontext/src/client.ts @@ -116,6 +116,66 @@ function queryString(payload: JsonObject | undefined): string { return encoded ? `?${encoded}` : '' } +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.pathParams 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}}`, 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, + 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 { private readonly fetchImpl: FetchFn @@ -126,12 +186,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 prepared = prepareRequest(spec, payload) try { - const response = await this.fetchImpl(this.url(spec, payload), this.init(spec, 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')) @@ -143,6 +206,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 { @@ -156,20 +223,23 @@ export class PowerContextClient { } } - private url(spec: OperationSpec, payload: JsonObject | undefined): string { - const query = spec.location === 'query' ? queryString(payload) : '' - return `${this.options.baseUrl.replace(/\/+$/, '')}${spec.path}${query}` + 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.method === 'POST' && spec.location === 'body') { + 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 898d53832..157c61567 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -17,70 +17,68 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { - get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, - get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, - get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, - capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, - prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, - create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, - handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scope: true }, - acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scope: true }, - record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scope: true }, - activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scope: true }, - prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scope: true }, - finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scope: true }, - commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scope: true }, - continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scope: true }, - flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scope: true }, - remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scope: true }, - search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scope: true }, - list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scope: true }, - get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scope: true }, - revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scope: true }, - retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scope: true }, - list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scope: true }, - propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scope: true }, - generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scope: true }, - get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scope: true }, - propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, - generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, - get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, - scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, - list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, - resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, - import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scope: true }, - list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scope: true }, - get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scope: true }, - approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, - reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, - revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, - search_sources: { method: 'GET', path: '/v1/sources', location: "query", scope: true }, - create_source: { method: 'POST', path: '/v1/sources', location: "body", scope: true }, - get_source: { method: 'GET', path: '/v1/sources/{source_id}', location: "query", scope: true }, - list_artifacts: { method: 'GET', path: '/v1/artifacts', location: "query", scope: true }, - create_artifact: { method: 'POST', path: '/v1/artifacts', location: "body", scope: true }, - get_artifact: { method: 'GET', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, - replace_artifact: { method: 'PUT', path: '/v1/artifacts/{artifact_id}', location: "body", scope: true }, - delete_artifact: { method: 'DELETE', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, - get_artifact_revision: { method: 'GET', path: '/v1/artifacts/{artifact_id}/revisions/{revision}', location: "query", scope: true }, - search_artifacts: { method: 'GET', path: '/v1/artifact-search-results', location: "query", scope: true }, - list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scope: false }, + get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true, pathParams: [], queryParams: ['scope_id','period'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}', location: "query", scope: true, pathParams: ['scope_id','source_type'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scope: true, pathParams: ['scope_id','source_type','source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['scope_id','family'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['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", scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + delete_artifact: { method: 'DELETE', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [204], emptyStatuses: [204] }, + get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scope: true, pathParams: ['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 c097e2de8..19e9430a3 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' @@ -78,3 +78,75 @@ 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/decision%20records?query=recent+work&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/decision%20records/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 }) + } + expect(init?.method).toBe('DELETE') + expect(headers.get('If-Match')).toBe('"revision:2"') + expect(init?.body).toBeUndefined() + return new Response(null, { status: 204 }) + }) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + requestTimeoutMs: 1000, + fetch: fetchImpl, + }) + const path = { scope_id: 'scope/team', family: 'decision records' } + + await client.request('list_artifacts', { ...path, query: 'recent work', 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 }) + await expect(client.request('delete_artifact', { + ...path, + artifact_id: 'artifact/1', + if_match: '"revision:2"', + })).resolves.toMatchObject({ kind: 'json', value: null, status: 204 }) + expect(fetchImpl).toHaveBeenCalledTimes(4) + }) + + 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 302a73472..933e10431 100644 --- a/integrations/pi/plugins/powercontext/src/client.ts +++ b/integrations/pi/plugins/powercontext/src/client.ts @@ -121,6 +121,62 @@ function queryString(payload: JsonObject | undefined): string { return encoded ? `?${encoded}` : '' } +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.pathParams 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}}`, 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, + 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 } @@ -141,9 +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 url = this.buildUrl(spec, payload) + const prepared = prepareRequest(spec, payload) + const url = this.buildUrl(prepared) try { - const response = await this.fetchImpl(url, this.buildInit(spec, payload, signal)) + const response = await this.fetchImpl(url, this.buildInit(spec, prepared, signal)) return await this.parseResponse(spec, response) } catch (error) { if ( @@ -157,15 +214,15 @@ export class PowerContextClient { } } - private buildUrl(spec: OperationSpec, payload: JsonObject | undefined): string { - const suffix = spec.location === 'query' ? queryString(payload) : '' - return `${this.baseUrl}${spec.path}${suffix}` + 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)] @@ -176,9 +233,9 @@ export class PowerContextClient { redirect: 'manual', signal: combineSignals(signals), } - if (spec.method === 'POST' && spec.location === 'body') { + if (spec.location === 'body') { headers['Content-Type'] = 'application/json' - init.body = JSON.stringify(payload ?? {}) + init.body = JSON.stringify(request.body ?? {}) } return init } @@ -187,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 898d53832..157c61567 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -17,70 +17,68 @@ // generated from openapi/powercontext.yaml; do not edit. export const OPERATIONS = { - get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false }, - get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false }, - get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false }, - capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true }, - prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true }, - create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true }, - handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scope: true }, - acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scope: true }, - record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scope: true }, - activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scope: true }, - prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scope: true }, - finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scope: true }, - commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scope: true }, - continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scope: true }, - flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scope: true }, - remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scope: true }, - search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scope: true }, - list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scope: true }, - get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scope: true }, - revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scope: true }, - retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scope: true }, - list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scope: true }, - propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scope: true }, - generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scope: true }, - get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scope: true }, - propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true }, - generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true }, - get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true }, - scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true }, - list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true }, - resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true }, - import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scope: true }, - list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scope: true }, - get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scope: true }, - approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true }, - reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true }, - revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true }, - get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true }, - create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false }, - list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false }, - list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false }, - get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false }, - update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false }, - register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true }, - list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false }, - update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false }, - get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true }, - record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true }, - list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false }, - purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false }, - get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false }, - attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false }, - detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false }, - search_sources: { method: 'GET', path: '/v1/sources', location: "query", scope: true }, - create_source: { method: 'POST', path: '/v1/sources', location: "body", scope: true }, - get_source: { method: 'GET', path: '/v1/sources/{source_id}', location: "query", scope: true }, - list_artifacts: { method: 'GET', path: '/v1/artifacts', location: "query", scope: true }, - create_artifact: { method: 'POST', path: '/v1/artifacts', location: "body", scope: true }, - get_artifact: { method: 'GET', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, - replace_artifact: { method: 'PUT', path: '/v1/artifacts/{artifact_id}', location: "body", scope: true }, - delete_artifact: { method: 'DELETE', path: '/v1/artifacts/{artifact_id}', location: "query", scope: true }, - get_artifact_revision: { method: 'GET', path: '/v1/artifacts/{artifact_id}/revisions/{revision}', location: "query", scope: true }, - search_artifacts: { method: 'GET', path: '/v1/artifact-search-results', location: "query", scope: true }, - list_scopes: { method: 'GET', path: '/v1/scopes', location: "query", scope: false }, + get_liveness: { method: 'GET', path: '/health/live', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_readiness: { method: 'GET', path: '/health/ready', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_capabilities: { method: 'GET', path: '/v1/capabilities', location: null, scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + capture_content_source: { method: 'POST', path: '/v1/sources/content', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + prepare_context: { method: 'POST', path: '/v1/context/prepare', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_work_contract: { method: 'POST', path: '/v1/work/contracts/create', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + handoff_current_work: { method: 'POST', path: '/v1/work/handoffs/prepare-current', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + acknowledge_handoff: { method: 'POST', path: '/v1/work/handoffs/acknowledge', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_task_outcome: { method: 'POST', path: '/v1/work/outcomes/record', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [202], emptyStatuses: [] }, + activate_handoff: { method: 'POST', path: '/v1/handoff/activate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + prepare_handoff: { method: 'POST', path: '/v1/handoff/prepare', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + finalize_handoff: { method: 'POST', path: '/v1/handoff/finalize', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + commit_handoff: { method: 'POST', path: '/v1/handoff/commit', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + continue_handoff: { method: 'POST', path: '/v1/handoff/continue', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + flush_memory: { method: 'POST', path: '/v1/memory/flush', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + remember_memory: { method: 'POST', path: '/v1/memory/remember', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + search_memory: { method: 'POST', path: '/v1/memory/search', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_entries: { method: 'POST', path: '/v1/memory/entries/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry: { method: 'POST', path: '/v1/memory/entries/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_memory_entry: { method: 'POST', path: '/v1/memory/entries/revise', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + retire_memory_entry: { method: 'POST', path: '/v1/memory/entries/retire', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_memory_changes: { method: 'POST', path: '/v1/memory/changes', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_experience: { method: 'POST', path: '/v1/experience/propose', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_experience: { method: 'POST', path: '/v1/experience/generate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_experience: { method: 'POST', path: '/v1/experience/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + propose_skill: { method: 'POST', path: '/v1/skill/propose', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + generate_skill: { method: 'POST', path: '/v1/skill/generate', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_skill: { method: 'POST', path: '/v1/skill/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + scan_external_skills: { method: 'POST', path: '/v1/external-skills/scan', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_external_skills: { method: 'POST', path: '/v1/external-skills/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + resolve_external_skill: { method: 'POST', path: '/v1/external-skills/resolve', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + import_external_skill: { method: 'POST', path: '/v1/external-skills/import', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifact_candidates: { method: 'POST', path: '/v1/artifact-candidates/list', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + approve_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/approve', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + reject_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/reject', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + revise_artifact_candidate: { method: 'POST', path: '/v1/artifact-candidates/revise', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_stats: { method: 'GET', path: '/v1/stats', location: "query", scope: true, pathParams: [], queryParams: ['scope_id','period'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/create', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_projects: { method: 'POST', path: '/v1/handoff-reports/projects/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_handoff_report_known_scopes: { method: 'POST', path: '/v1/handoff-reports/scopes/list-known', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/get', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_handoff_report_project: { method: 'POST', path: '/v1/handoff-reports/projects/update', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + register_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/register', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_workstreams: { method: 'POST', path: '/v1/handoff-reports/workstreams/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + update_handoff_report_workstream: { method: 'POST', path: '/v1/handoff-reports/workstreams/update', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report: { method: 'POST', path: '/v1/handoff-reports/get', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + record_handoff_report_activity: { method: 'POST', path: '/v1/handoff-reports/activities/record', location: "body", scope: true, pathParams: [], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/list', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + purge_handoff_report_activities: { method: 'POST', path: '/v1/handoff-reports/activities/purge', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/get', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}', location: "query", scope: true, pathParams: ['scope_id','source_type'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scope: true, pathParams: ['scope_id','source_type','source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['scope_id','family'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + get_artifact: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['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", scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + delete_artifact: { method: 'DELETE', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [204], emptyStatuses: [204] }, + get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scope: true, pathParams: ['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 2c3ce2dc0..ecb51fdb1 100644 --- a/integrations/pi/plugins/powercontext/tests/client.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/client.spec.ts @@ -63,4 +63,64 @@ 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/decision%20records?query=recent+work&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/decision%20records/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 }) + } + expect(init?.method).toBe('DELETE') + expect(headers.get('If-Match')).toBe('"revision:2"') + expect(init?.body).toBeUndefined() + return new Response(null, { status: 204 }) + }) + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000/', + requestTimeoutMs: 1000, + fetch, + }) + const path = { scope_id: 'scope/team', family: 'decision records' } + + await client.request('list_artifacts', { ...path, query: 'recent work', 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 }) + await expect(client.request('delete_artifact', { + ...path, + artifact_id: 'artifact/1', + if_match: '"revision:2"', + })).resolves.toMatchObject({ kind: 'json', value: null, status: 204 }) + expect(fetch).toHaveBeenCalledTimes(4) + }) }) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 79f9b5da2..fc62c82b1 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1688,12 +1688,21 @@ paths: $ref: "#/components/responses/InvalidRequest" "500": $ref: "#/components/responses/InternalError" - /v1/sources: + /v1/scopes/{scope_id}/sources: post: tags: [sources] summary: Create a durable Source - description: Create one idempotent Source without synchronously deriving Artifacts. + 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: @@ -1724,14 +1733,15 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/sources/{source_type}: get: tags: [sources] summary: List or search Sources - description: List when type is omitted or list; search when type is search and q is nonblank. - operationId: search_sources + description: List when query is absent or blank; search when query is nonblank. + operationId: list_sources parameters: - name: scope_id - in: query + in: path required: true schema: type: string @@ -1739,25 +1749,18 @@ paths: maxLength: 256 pattern: '.*\S.*' - name: source_type - in: query + in: path required: true schema: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - - name: type - in: query - required: false - schema: - $ref: "#/components/schemas/SourceQueryType" - default: list - - name: q + - name: query in: query required: false schema: type: string - minLength: 1 maxLength: 8192 - name: mode in: query @@ -1789,44 +1792,49 @@ paths: application/json: schema: $ref: "#/components/schemas/SourcePage" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "405": $ref: "#/components/responses/OperationNotSupported" + "410": + $ref: "#/components/responses/CursorExpired" "422": $ref: "#/components/responses/InvalidRequest" "503": $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/sources/{source_id}: + /v1/scopes/{scope_id}/sources/{source_type}/{source_id}: get: tags: [sources] summary: Get one exact Source operationId: get_source parameters: - - name: source_id + - name: scope_id in: path required: true schema: type: string minLength: 1 maxLength: 256 - - name: scope_id - in: query + pattern: '.*\S.*' + - name: source_type + in: path required: true schema: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: source_type - in: query + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: source_id + in: path required: true schema: type: string minLength: 1 - maxLength: 128 + maxLength: 256 pattern: '^[\x21-\x7E]+$' responses: "200": @@ -1850,12 +1858,21 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifacts: + /v1/scopes/{scope_id}/artifacts: post: tags: [artifacts] summary: Create an Artifact - description: Commit revision one for a family that permits direct creation. + description: Commit revision one for a family that permits direct creation. The Server generates artifact_id. operationId: create_artifact + parameters: + - name: scope_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' requestBody: required: true content: @@ -1888,13 +1905,15 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/{family}: get: tags: [artifacts] - summary: List current Artifact heads + summary: List or search current Artifact heads + description: List when query is absent or blank; search when query is nonblank. operationId: list_artifacts parameters: - name: scope_id - in: query + in: path required: true schema: type: string @@ -1902,13 +1921,24 @@ paths: maxLength: 256 pattern: '.*\S.*' - name: family - in: query + in: path required: true schema: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' + - name: query + in: query + required: false + schema: + type: string + maxLength: 8192 + - name: mode + in: query + required: false + schema: + $ref: "#/components/schemas/TextSearchMode" - name: limit in: query required: false @@ -1926,7 +1956,7 @@ paths: maxLength: 4096 responses: "200": - description: One stable page of current Artifact heads. + description: One stable page of current Artifact heads or search results. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -1934,43 +1964,54 @@ paths: 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/artifacts/{artifact_id}: + /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}: get: tags: [artifacts] summary: Get the current Artifact head operationId: get_artifact parameters: - - name: artifact_id + - name: scope_id in: path required: true schema: type: string minLength: 1 - maxLength: 128 - - name: scope_id - in: query + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path required: true schema: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: family - in: query + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - 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 + pattern: '^"revision:[1-9][0-9]*"$' responses: "200": description: The current visible Artifact head. @@ -1983,6 +2024,13 @@ paths: 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": @@ -1999,23 +2047,24 @@ paths: description: Commit a complete next revision when If-Match identifies the current head. operationId: replace_artifact parameters: - - name: artifact_id + - name: scope_id in: path required: true schema: type: string minLength: 1 - maxLength: 128 - - name: scope_id - in: query + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path required: true schema: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: family - in: query + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: artifact_id + in: path required: true schema: type: string @@ -2068,23 +2117,24 @@ paths: description: Record a tombstone without deleting immutable historical revisions. operationId: delete_artifact parameters: - - name: artifact_id + - name: scope_id in: path required: true schema: type: string minLength: 1 - maxLength: 128 - - name: scope_id - in: query + maxLength: 256 + pattern: '.*\S.*' + - name: family + in: path required: true schema: type: string minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: family - in: query + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + - name: artifact_id + in: path required: true schema: type: string @@ -2098,15 +2148,11 @@ paths: type: string pattern: '^"revision:[1-9][0-9]*"$' responses: - "200": + "204": description: The Artifact head is deleted and immutable history is retained. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ArtifactDeletionStatus" "401": $ref: "#/components/responses/Unauthorized" "404": @@ -2123,27 +2169,14 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/artifacts/{artifact_id}/revisions/{revision}: + /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: artifact_id - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 128 - - name: revision - in: path - required: true - schema: - type: integer - minimum: 1 - name: scope_id - in: query + in: path required: true schema: type: string @@ -2151,135 +2184,41 @@ paths: maxLength: 256 pattern: '.*\S.*' - name: family - in: query + in: path required: true schema: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - 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" - /v1/artifact-search-results: - get: - tags: [artifacts] - summary: Search current Artifact heads - operationId: search_artifacts - parameters: - - name: scope_id - in: query - required: true - schema: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - - name: family - in: query + - name: artifact_id + in: path required: true schema: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - - name: q - in: query + - name: revision + in: path required: true - schema: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - - name: mode - in: query - required: false - schema: - $ref: "#/components/schemas/TextSearchMode" - default: auto - - 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 matching current Artifact heads. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ArtifactSearchResultPage" - "401": - $ref: "#/components/responses/Unauthorized" - "422": - $ref: "#/components/responses/InvalidRequest" - "503": - $ref: "#/components/responses/Unavailable" - "500": - $ref: "#/components/responses/InternalError" - /v1/scopes: - get: - tags: [scopes] - summary: List observable Scopes - operationId: list_scopes - parameters: - - 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 Scopes observable by the caller. + description: The exact immutable Artifact revision. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: application/json: schema: - $ref: "#/components/schemas/ScopePage" + $ref: "#/components/schemas/ArtifactRevision" "401": $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -2312,6 +2251,24 @@ components: 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: @@ -2335,6 +2292,11 @@ components: OperationNotSupported: description: The Source type or Artifact family does not permit this operation. headers: + Allow: + description: HTTP methods supported by the selected Source type or Artifact family. + required: true + schema: + type: string X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" content: @@ -2433,38 +2395,54 @@ components: minimum: 512 maximum: 32768 default: 8000 - ArtifactDeletionState: - type: string - enum: [deleted] - ArtifactDeletionStatus: + ArtifactCollectionItem: type: object - additionalProperties: false - required: [artifact_ref, status, deleted_at] + required: [scope_id, artifact_ref, created_at, content_digest, score, snippets] properties: + scope_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '.*\S.*' artifact_ref: $ref: "#/components/schemas/ArtifactReference" - status: - $ref: "#/components/schemas/ArtifactDeletionState" - deleted_at: + created_at: type: string format: date-time + nullable: true + content_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + score: + type: number + minimum: 0 + maximum: 1 + nullable: true + snippets: + type: array + items: + type: string ArtifactPage: type: object - additionalProperties: false - required: [items, next_cursor] + required: [query, mode, items, next_cursor] properties: + query: + type: string + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchUsedMode" + nullable: true items: type: array items: - $ref: "#/components/schemas/ArtifactRevision" + $ref: "#/components/schemas/ArtifactCollectionItem" next_cursor: type: string nullable: true ArtifactRevision: type: object - additionalProperties: false - required: - [scope_id, artifact_ref, schema_version, metadata, content, source_refs, artifact_refs, created_at, content_digest] + required: [scope_id, artifact_ref, content, source_refs, artifact_refs, created_at, content_digest] properties: scope_id: type: string @@ -2473,19 +2451,13 @@ components: pattern: '.*\S.*' artifact_ref: $ref: "#/components/schemas/ArtifactReference" - schema_version: - type: integer - minimum: 1 - metadata: - type: object - additionalProperties: true content: type: object additionalProperties: true source_refs: type: array items: - $ref: "#/components/schemas/SourceReference" + $ref: "#/components/schemas/SourceTypeReference" artifact_refs: type: array items: @@ -2497,37 +2469,6 @@ components: content_digest: type: string pattern: '^sha256:[0-9a-f]{64}$' - ArtifactSearchHit: - type: object - additionalProperties: false - required: [artifact, score, snippets] - properties: - artifact: - $ref: "#/components/schemas/ArtifactRevision" - score: - type: number - minimum: 0 - maximum: 1 - snippets: - type: array - items: - type: string - ArtifactSearchResultPage: - type: object - additionalProperties: false - required: [query, mode, hits, next_cursor] - properties: - query: - type: string - mode: - $ref: "#/components/schemas/TextSearchUsedMode" - hits: - type: array - items: - $ref: "#/components/schemas/ArtifactSearchHit" - next_cursor: - type: string - nullable: true ArtifactReference: type: object additionalProperties: false @@ -5296,148 +5237,55 @@ components: CreateArtifactRequest: type: object additionalProperties: false - required: [scope_id, family, schema_version, content] + required: [family, content] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' family: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - artifact_id: - type: string - minLength: 1 - maxLength: 128 - nullable: true - schema_version: - type: integer - minimum: 1 - metadata: - type: object - additionalProperties: true - default: {} content: type: object additionalProperties: true source_refs: type: array - maxItems: 32 items: - $ref: "#/components/schemas/SourceReference" + $ref: "#/components/schemas/SourceTypeReference" default: [] artifact_refs: type: array - maxItems: 32 items: $ref: "#/components/schemas/ArtifactReference" default: [] CreateSourceRequest: type: object additionalProperties: false - required: [scope_id, source_type, source_id, content] + required: [content] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' source_type: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - source_id: - type: string - minLength: 1 - maxLength: 256 + default: content content: - type: string - minLength: 1 - maxLength: 200000 - pattern: '.*\S.*' + description: JSON value validated by the selected Source adapter. metadata: type: object additionalProperties: true default: {} - DeleteArtifactRequest: - type: object - additionalProperties: false - required: [scope_id, family] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetArtifactRequest: - type: object - additionalProperties: false - required: [scope_id, family] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetArtifactRevisionRequest: - type: object - additionalProperties: false - required: [scope_id, family] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - GetSourceRequest: - type: object - additionalProperties: false - required: [scope_id, source_type] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_type: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' ListArtifactsRequest: type: object additionalProperties: false - required: [scope_id, family] properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - family: + query: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + maxLength: 8192 + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchMode" + nullable: true limit: type: integer minimum: 1 @@ -5448,10 +5296,18 @@ components: minLength: 1 maxLength: 4096 nullable: true - ListScopesRequest: + ListSourcesRequest: type: object additionalProperties: false properties: + query: + type: string + maxLength: 8192 + nullable: true + mode: + allOf: + - $ref: "#/components/schemas/TextSearchMode" + nullable: true limit: type: integer minimum: 1 @@ -5465,153 +5321,64 @@ components: ReplaceArtifactRequest: type: object additionalProperties: false - required: [schema_version, content] + required: [content] properties: - schema_version: - type: integer - minimum: 1 - metadata: - type: object - additionalProperties: true - default: {} content: type: object additionalProperties: true source_refs: type: array - maxItems: 32 items: - $ref: "#/components/schemas/SourceReference" + $ref: "#/components/schemas/SourceTypeReference" default: [] artifact_refs: type: array - maxItems: 32 items: $ref: "#/components/schemas/ArtifactReference" default: [] - ScopePage: - type: object - additionalProperties: false - required: [items, next_cursor] - properties: - items: - type: array - items: - $ref: "#/components/schemas/ScopeSummary" - next_cursor: - type: string - nullable: true - ScopeSummary: - type: object - additionalProperties: false - required: - [scope_id, title, summary, parent_scope_id, version, source_types, artifact_families, source_count, artifact_count] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - title: - type: string - nullable: true - summary: - type: string - nullable: true - parent_scope_id: - type: string - nullable: true - version: - type: integer - minimum: 1 - nullable: true - source_types: - type: array - items: - type: string - artifact_families: - type: array - items: - type: string - source_count: - type: integer - minimum: 0 - artifact_count: - type: integer - minimum: 0 - SearchArtifactsRequest: + SourceCollectionItem: type: object - additionalProperties: false - required: [scope_id, family, q] + required: [scope_id, source_type, source_id, metadata, created_at, position, content_digest, score, snippets] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - family: + source_type: type: string minLength: 1 maxLength: 128 pattern: '^[\x21-\x7E]+$' - q: - type: string - minLength: 1 - maxLength: 8192 - pattern: '.*\S.*' - mode: - $ref: "#/components/schemas/TextSearchMode" - default: auto - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - cursor: - type: string - minLength: 1 - maxLength: 4096 - nullable: true - SearchSourcesRequest: - type: object - additionalProperties: false - required: [scope_id, source_type] - properties: - scope_id: + source_id: type: string minLength: 1 maxLength: 256 - pattern: '.*\S.*' - source_type: - type: string - minLength: 1 - maxLength: 128 pattern: '^[\x21-\x7E]+$' - type: - $ref: "#/components/schemas/SourceQueryType" - default: list - q: + metadata: + type: object + additionalProperties: true + created_at: type: string - minLength: 1 - maxLength: 8192 - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchMode" + format: date-time nullable: true - limit: + position: type: integer minimum: 1 - maximum: 100 - default: 50 - cursor: + content_digest: type: string - minLength: 1 - maxLength: 4096 + pattern: '^sha256:[0-9a-f]{64}$' + score: + type: number + minimum: 0 + maximum: 1 nullable: true + snippets: + type: array + items: + type: string SourcePage: type: object - additionalProperties: false required: [query, mode, items, next_cursor] properties: query: @@ -5624,28 +5391,31 @@ components: items: type: array items: - $ref: "#/components/schemas/SourceRecord" + $ref: "#/components/schemas/SourceCollectionItem" next_cursor: type: string nullable: true - SourceQueryType: - type: string - enum: [list, search] SourceRecord: type: object - additionalProperties: false - required: - [scope_id, source_ref, content, metadata, created_at, position, content_digest, score, snippets] + required: [scope_id, source_type, source_id, content, metadata, created_at, position, content_digest] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - source_ref: - $ref: "#/components/schemas/SourceReference" - content: + source_type: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + source_id: type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' + content: + description: Persisted canonical JSON content. metadata: type: object additionalProperties: true @@ -5659,15 +5429,21 @@ components: content_digest: type: string pattern: '^sha256:[0-9a-f]{64}$' - score: - type: number - minimum: 0 - maximum: 1 - nullable: true - snippets: - type: array - items: - type: string + SourceTypeReference: + type: object + additionalProperties: false + required: [source_type, source_id] + properties: + source_type: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[\x21-\x7E]+$' + source_id: + type: string + minLength: 1 + maxLength: 256 + pattern: '^[\x21-\x7E]+$' SourceReference: type: object additionalProperties: false diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 364eb1617..ed31b8aee 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -25,7 +25,17 @@ from datamodel_code_generator import GenerateConfig, InputFileType, generate from datamodel_code_generator.enums import StrictTypes from datamodel_code_generator.format import CodeFormatter, Formatter, PythonVersion -from fastapi.openapi.models import MediaType, OpenAPI, PathItem, Reference, RequestBody, Response, Schema +from fastapi.openapi.models import ( + MediaType, + OpenAPI, + Parameter, + ParameterInType, + PathItem, + Reference, + RequestBody, + Response, + Schema, +) from fastapi.openapi.models import Operation as OpenAPIOperation from pydantic import JsonValue, TypeAdapter, ValidationError @@ -150,8 +160,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(), @@ -160,7 +175,7 @@ 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=response_model[1], + response_model=None if response_model is None else response_model[1], success_status=success_status, summary=operation.summary, tags=tuple(operation.tags or ()), @@ -198,7 +213,7 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id: str request_type: type[RequestT] | None request_location: Literal["body", "query"] | None - response_type: type[ResponseT] + response_type: type[ResponseT] | None success_status: int summary: str tags: tuple[str, ...] @@ -302,7 +317,12 @@ def _request_model( module, name = _model_for_json_content(request_body.content, schemas, "request body") return module, name, "body" - if not operation.parameters: + query_parameters = [ + parameter + for parameter in operation.parameters or () + if isinstance(parameter, Parameter) and parameter.in_ is ParameterInType.query + ] + if not query_parameters: return None operation_id = operation.operationId if operation_id is None: @@ -361,20 +381,21 @@ def _render_operation( operation_id: str, request_model: str | None, request_location: Literal["body", "query"] | None, - response_model: str, + response_model: str | None, success_status: int, summary: str, tags: tuple[str, ...], 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}, - 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 64eec9041..692ad9950 100644 --- a/scripts/generate_js_operations.py +++ b/scripts/generate_js_operations.py @@ -84,12 +84,18 @@ 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(), "path": path, "location": _request_location(body_schema, parameters), "scope": _operation_has_scope(doc, body_schema, parameters), + "pathParams": _parameter_names(parameters, "path"), + "queryParams": _parameter_names(parameters, "query"), + "headerParams": _parameter_names(parameters, "header"), + "successStatuses": success_statuses, + "emptyStatuses": empty_statuses, }) return rows @@ -115,10 +121,17 @@ def _render_row(row: dict[str, Any]) -> str: scope = "true" if row["scope"] else "false" return ( f" {row['operationId']}: {{ method: '{row['method']}', path: '{row['path']}', " - f"location: {location}, scope: {scope} }}," + f"location: {location}, scope: {scope}, pathParams: {_render_array(row['pathParams'])}, " + 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 @@ -190,7 +203,33 @@ def _request_location(body_schema: Any, parameters: list[dict[str, Any]]) -> str def _operation_has_scope(doc: dict[str, Any], body_schema: Any, parameters: list[dict[str, Any]]) -> bool: if body_schema and _schema_has_scope(doc, body_schema): return True - return any(parameter.get("in") == "query" and parameter.get("name") == "scope_id" for parameter in parameters) + return any( + parameter.get("in") in {"path", "query"} and parameter.get("name") == "scope_id" for parameter in parameters + ) + + +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 if __name__ == "__main__": diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 0e65140df..87909a73d 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -17,6 +17,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping +from datetime import UTC, datetime from typing import Any from pydantic import BaseModel @@ -62,6 +63,12 @@ def __init__(self, artifact_types: Iterable[type[Artifact[Any]]], /) -> None: raise TypeError(f"{artifact_type.__name__}.content must be a BaseModel") # noqa: TRY003 self._content_types[family] = content_type + @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, @@ -94,6 +101,7 @@ async def create( family=ref.family, artifact_id=ref.artifact_id, revision=ref.revision, + deleted_at=None, ) ) except IntegrityError: @@ -129,6 +137,7 @@ async def revise( ARTIFACT_HEADS_TABLE.c.family == artifact.family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact.artifact_id, ARTIFACT_HEADS_TABLE.c.revision == artifact.revision, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .values(revision=ARTIFACT_HEADS_TABLE.c.revision) ) @@ -156,6 +165,7 @@ async def revise( ARTIFACT_HEADS_TABLE.c.family == artifact.family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact.artifact_id, ARTIFACT_HEADS_TABLE.c.revision == artifact.revision, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .values(revision=ref.revision) ) @@ -259,6 +269,7 @@ async def _insert_revision( artifact_id=ref.artifact_id, revision=ref.revision, content=payload, + created_at=datetime.now(UTC), ) ) if lineage.sources: @@ -392,6 +403,8 @@ async def _head_conflict( """Return the conflict raised by an already committed lifecycle.""" head = await self._find_head(connection, scope_id, ref.family, ref.artifact_id) + if head is None: + head = await self._find_deleted_head(connection, scope_id, ref.family, ref.artifact_id) if head is None: return None current = await self.get( @@ -407,12 +420,29 @@ async def _find_head( scope_id: str, family: str, artifact_id: str, + ) -> int | None: + 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, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), + ) + value = await connection.scalar(statement) + return None if value is None else int(value) + + async def _find_deleted_head( + self, + connection: AsyncConnection, + scope_id: str, + 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, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_not(None), ) ) return None if value is None else int(value) diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 8fd27f276..99ceafa43 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -17,17 +17,19 @@ from __future__ import annotations import base64 +import binascii +import hmac import json -from collections.abc import Callable, Mapping -from contextlib import suppress -from datetime import UTC, datetime +import secrets +from collections.abc import Callable, Iterable, 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 delete, func, insert, select, update +from sqlalchemy import func, insert, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection @@ -40,28 +42,26 @@ ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, - ARTIFACT_REVISION_RECORDS_TABLE, - ARTIFACT_TOMBSTONES_TABLE, ARTIFACTS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, - SOURCE_RECORDS_TABLE, SOURCES_TABLE, ) from powercontext.builtin.records import ( + ArtifactCollectionItem, ArtifactDeletion, ArtifactRecord, ArtifactRecordPage, ArtifactRevisionPreconditionError, - ArtifactSearchHit, - ArtifactSearchPage, ArtifactWrite, BaseOperationNotSupportedError, BaseValueConflictError, BaseValueNotFoundError, + CursorExpiredError, InvalidBaseAccessRequestError, + InvalidCursorError, ScopeSummary, ScopeSummaryPage, - SourceQueryType, + SourceCollectionItem, SourceRecord, SourceRecordPage, TextSearchMode, @@ -70,9 +70,12 @@ from powercontext.sources import SourceRef Clock = Callable[[], datetime] +IdFactory = Callable[[str], str] _JSON_OBJECT = TypeAdapter(dict[str, JsonValue]) +_JSON_VALUE = TypeAdapter(JsonValue) _PROTECTED_ARTIFACT_FAMILIES = frozenset({"experience", "handoff", "memory", "skill"}) +_DEFAULT_CURSOR_TTL_SECONDS = 3_600 class RelationalRecordService: @@ -85,70 +88,93 @@ def __init__( /, *, clock: Clock | None = None, + id_factory: IdFactory | None = None, + cursor_secret: bytes | None = None, + cursor_ttl_seconds: int = _DEFAULT_CURSOR_TTL_SECONDS, + protected_artifact_families: Iterable[str] | None = None, ) -> 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._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) + self._protected_artifact_families = frozenset( + _PROTECTED_ARTIFACT_FAMILIES if protected_artifact_families is None else protected_artifact_families + ) async def create_source( + self, + scope_id: str, + source_type: str, + content: JsonValue, + metadata: Mapping[str, JsonValue], + /, + ) -> SourceRecord: + return await self._store_source( + scope_id, + source_type, + self._id_factory("source"), + content, + metadata, + ) + + async def capture_source( self, scope_id: str, source_type: str, source_id: str, - content: str, + content: JsonValue, metadata: Mapping[str, JsonValue], /, + ) -> SourceRecord: + """Preserve the caller-stable identity used by the existing capture API.""" + + return await self._store_source(scope_id, source_type, source_id, content, metadata) + + async def _store_source( + self, + scope_id: str, + source_type: str, + source_id: str, + content: JsonValue, + metadata: Mapping[str, JsonValue], ) -> SourceRecord: self._require_content_source(source_type, "create") - capture = ContentCapture(source_id=source_id, content=content, metadata=dict(metadata)) + 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 source = await CONTENT_SOURCE_ADAPTER.resolve(capture) - digest = _content_digest({"content": source.content, "metadata": source.metadata}) - created_at = _aware_datetime(self._clock()) try: async with self._database.transaction() as connection: - stored = await self._sources.add(connection, scope_id, source) - record = await _source_projection(connection, scope_id, stored.ref) - if record is None: - with suppress(IntegrityError): - await connection.execute( - insert(SOURCE_RECORDS_TABLE).values( - scope_id=scope_id, - source_type=stored.ref.source_type, - source_id=stored.ref.source_id, - created_at=created_at, - content_digest=digest, - ) - ) - record = await _source_projection(connection, scope_id, stored.ref) - projected_at = created_at if record is None else _aware_datetime(record.created_at) - projected_digest = digest if record is None else str(record.content_digest) - return _source_record( + stored = await self._sources.add( + connection, scope_id, - stored, - created_at=projected_at, - content_digest=projected_digest, + source, + created_at=_aware_datetime(self._clock()), ) except StoredPayloadConflictError as error: raise BaseValueConflictError("source", (scope_id, source_type, source_id)) 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, "get") ref = SourceRef(source_type=source_type, source_id=source_id) async with self._database.transaction() as connection: - stored = await self._get_source(connection, scope_id, ref) - projection = await _source_projection(connection, scope_id, ref) - return _source_record( - scope_id, - stored, - created_at=None if projection is None else _aware_datetime(projection.created_at), - content_digest=(_source_digest(stored) if projection is None else str(projection.content_digest)), - ) + return _source_record(scope_id, await self._get_source(connection, scope_id, ref)) async def query_sources( self, scope_id: str, source_type: str, - query_type: SourceQueryType, /, *, query: str | None, @@ -156,17 +182,20 @@ async def query_sources( limit: int, cursor: str | None, ) -> SourceRecordPage: - self._require_content_source(source_type, query_type) - normalized_query = _validate_source_query(query_type, query, mode) + normalized_query = _normalize_query(query, mode) + self._require_content_source(source_type, "list" if normalized_query is None else "search") + _require_limit(limit) + actual_mode = None if normalized_query is None else "keyword" expected_cursor = { - "kind": "source", + "version": 1, + "endpoint": "list_sources", "scope_id": scope_id, "source_type": source_type, - "type": query_type, - "q": normalized_query, - "mode": None if mode is None else str(mode), + "query": normalized_query, + "mode": actual_mode, + "order": "journal_position:asc", } - after = _cursor_after_int(cursor, expected_cursor) + after = self._cursor_after_int(cursor, expected_cursor) async with self._database.transaction() as connection: statement = ( select(SOURCES_TABLE.c.source_id, SOURCES_TABLE.c.journal_position) @@ -177,39 +206,33 @@ async def query_sources( ) .order_by(SOURCES_TABLE.c.journal_position) ) - if query_type == "list": + if normalized_query is None: statement = statement.limit(limit + 1) rows = (await connection.execute(statement)).all() - matches: list[tuple[int, SourceRecord]] = [] + matches: list[tuple[int, SourceCollectionItem]] = [] for row in rows: ref = SourceRef(source_type=source_type, source_id=str(row.source_id)) - stored = await self._get_source(connection, scope_id, ref) - projection = await _source_projection(connection, scope_id, ref) - record = _source_record( - scope_id, - stored, - created_at=None if projection is None else _aware_datetime(projection.created_at), - content_digest=(_source_digest(stored) if projection is None else str(projection.content_digest)), - ) + record = _source_record(scope_id, await self._get_source(connection, scope_id, ref)) + score: float | None = None + snippets: tuple[str, ...] = () if normalized_query is not None: - if not _matches(record.content, record.metadata, normalized_query): + text = _searchable_text(record.content) + if not _matches_text(text, normalized_query): continue - record = record.model_copy( - update={"score": 1.0, "snippets": (_snippet(record.content, normalized_query),)} - ) - matches.append((int(row.journal_position), record)) + score = 1.0 + snippets = (_snippet(text, normalized_query),) + matches.append((int(row.journal_position), _source_collection_item(record, score, snippets))) if len(matches) > limit: break - has_more = len(matches) > limit selected = matches[:limit] next_cursor = None - if has_more and selected: - next_cursor = _encode_cursor(expected_cursor, selected[-1][0]) + if len(matches) > limit and selected: + next_cursor = self._encode_cursor(expected_cursor, selected[-1][0]) return SourceRecordPage( query=normalized_query, - mode=None if normalized_query is None else "keyword", - items=tuple(record for _, record in selected), + mode=actual_mode, + items=tuple(item for _, item in selected), next_cursor=next_cursor, ) @@ -217,12 +240,11 @@ async def create_artifact( self, scope_id: str, family: str, - artifact_id: str | None, write: ArtifactWrite, /, ) -> ArtifactRecord: self._require_direct_family(family, "create") - identity = f"art_{uuid4().hex}" if artifact_id is None else artifact_id + identity = self._id_factory("artifact") ref = ArtifactRef(family=family, artifact_id=identity, revision=1) now = _aware_datetime(self._clock()) try: @@ -236,7 +258,8 @@ async def create_artifact( family=family, artifact_id=identity, revision=1, - searchable_text=_searchable_text(write.content, write.metadata), + searchable_text=_searchable_text(write.content), + deleted_at=None, ) ) except IntegrityError as error: @@ -246,16 +269,10 @@ async def create_artifact( async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> ArtifactRecord: ArtifactRef(family=family, artifact_id=artifact_id, revision=1) async with self._database.transaction() as connection: - revision = 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, - ) - ) + revision = await _head_revision(connection, scope_id, family, artifact_id) if revision is None: raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - return await _load_artifact(connection, scope_id, family, artifact_id, int(revision)) + return await _load_artifact(connection, scope_id, family, artifact_id, revision) async def get_artifact_revision( self, @@ -269,114 +286,95 @@ async def get_artifact_revision( async with self._database.transaction() as connection: return await _load_artifact(connection, scope_id, family, artifact_id, revision) - async def list_artifacts( + async def query_artifacts( self, scope_id: str, family: str, /, *, + query: str | None, + mode: TextSearchMode | None, limit: int, cursor: str | None, ) -> ArtifactRecordPage: ArtifactRef(family=family, artifact_id="validation", revision=1) - expected_cursor = {"kind": "artifact-list", "scope_id": scope_id, "family": family} - after = _cursor_after_text(cursor, expected_cursor) - async with self._database.transaction() as connection: - rows = ( - await connection.execute( - 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) - ) - ).all() - selected = rows[:limit] - items = tuple([ - await _load_artifact( - connection, - scope_id, - family, - str(row.artifact_id), - int(row.revision), - ) - for row in selected - ]) - next_cursor = None - if len(rows) > limit and selected: - next_cursor = _encode_cursor(expected_cursor, str(selected[-1].artifact_id)) - return ArtifactRecordPage(items=items, next_cursor=next_cursor) - - async def search_artifacts( - self, - scope_id: str, - family: str, - query: str, - /, - *, - mode: TextSearchMode, - limit: int, - cursor: str | None, - ) -> ArtifactSearchPage: - ArtifactRef(family=family, artifact_id="validation", revision=1) - normalized_query = _require_query(query) - if mode not in {"auto", "keyword"}: - raise InvalidBaseAccessRequestError("mode", "must be auto or keyword") + normalized_query = _normalize_query(query, mode) + _require_limit(limit) + actual_mode = None if normalized_query is None else "keyword" expected_cursor = { - "kind": "artifact-search", + "version": 1, + "endpoint": "list_artifacts", "scope_id": scope_id, "family": family, - "q": normalized_query, - "mode": mode, + "query": normalized_query, + "mode": actual_mode, + "order": "artifact_id:asc", } - after = _cursor_after_text(cursor, expected_cursor) + after = self._cursor_after_text(cursor, expected_cursor) + head_matches_revision = ( + (ARTIFACTS_TABLE.c.scope_id == ARTIFACT_HEADS_TABLE.c.scope_id) + & (ARTIFACTS_TABLE.c.family == ARTIFACT_HEADS_TABLE.c.family) + & (ARTIFACTS_TABLE.c.artifact_id == ARTIFACT_HEADS_TABLE.c.artifact_id) + & (ARTIFACTS_TABLE.c.revision == ARTIFACT_HEADS_TABLE.c.revision) + ) async with self._database.transaction() as connection: - rows = ( - await connection.execute( - 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) + statement = ( + select( + ARTIFACT_HEADS_TABLE.c.artifact_id, + ARTIFACT_HEADS_TABLE.c.revision, + ARTIFACTS_TABLE.c.content, + ARTIFACTS_TABLE.c.created_at, ) - ).all() - matches: list[tuple[str, ArtifactSearchHit]] = [] - for row in rows: - artifact = await _load_artifact( - connection, - scope_id, - family, - str(row.artifact_id), - int(row.revision), + .join(ARTIFACTS_TABLE, head_matches_revision) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id > after, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) - text = _searchable_text(artifact.content, artifact.metadata) - if not _matches_text(text, normalized_query): - continue + .order_by(ARTIFACT_HEADS_TABLE.c.artifact_id) + ) + if normalized_query is None: + statement = statement.limit(limit + 1) + rows = (await connection.execute(statement)).all() + matches: list[tuple[str, ArtifactCollectionItem]] = [] + for row in rows: + content = _decode_object(row.content, "content") + text = _searchable_text(content) + score: float | None = None + snippets: tuple[str, ...] = () + if normalized_query is not None: + if not _matches_text(text, normalized_query): + continue + score = 1.0 + snippets = (_snippet(text, normalized_query),) + artifact_id = str(row.artifact_id) matches.append(( - artifact.artifact_ref.artifact_id, - ArtifactSearchHit( - artifact=artifact, - score=1.0, - snippets=(_snippet(text, normalized_query),), + artifact_id, + ArtifactCollectionItem( + scope_id=scope_id, + artifact_ref=ArtifactRef( + family=family, + artifact_id=artifact_id, + revision=int(row.revision), + ), + created_at=None if row.created_at is None else _aware_datetime(row.created_at), + content_digest=_content_digest(content), + score=score, + snippets=snippets, ), )) if len(matches) > limit: break - has_more = len(matches) > limit - selected_hits = matches[:limit] + selected = matches[:limit] next_cursor = None - if has_more and selected_hits: - next_cursor = _encode_cursor(expected_cursor, selected_hits[-1][0]) - return ArtifactSearchPage( + if len(matches) > limit and selected: + next_cursor = self._encode_cursor(expected_cursor, selected[-1][0]) + return ArtifactRecordPage( query=normalized_query, - mode="keyword", - hits=tuple(hit for _, hit in selected_hits), + mode=actual_mode, + items=tuple(item for _, item in selected), next_cursor=next_cursor, ) @@ -406,12 +404,15 @@ async def replace_artifact( ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, ARTIFACT_HEADS_TABLE.c.revision == expected_revision, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .values(revision=ARTIFACT_HEADS_TABLE.c.revision) ) if locked.rowcount != 1: latest = await _head_revision(connection, scope_id, family, artifact_id) - raise ArtifactRevisionPreconditionError(expected_revision, latest or expected_revision) + if latest is None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + raise ArtifactRevisionPreconditionError(expected_revision, latest) await self._validate_lineage(connection, scope_id, write) ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision + 1) await _insert_artifact_revision(connection, scope_id, ref, write, now) @@ -422,10 +423,11 @@ async def replace_artifact( ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, ARTIFACT_HEADS_TABLE.c.revision == expected_revision, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .values( revision=ref.revision, - searchable_text=_searchable_text(write.content, write.metadata), + searchable_text=_searchable_text(write.content), ) ) if advanced.rowcount != 1: @@ -446,61 +448,42 @@ async def delete_artifact( self._require_direct_family(family, "delete") ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision) async with self._database.transaction() as connection: - tombstone = ( - await connection.execute( - select( - ARTIFACT_TOMBSTONES_TABLE.c.revision, - ARTIFACT_TOMBSTONES_TABLE.c.deleted_at, - ).where( - ARTIFACT_TOMBSTONES_TABLE.c.scope_id == scope_id, - ARTIFACT_TOMBSTONES_TABLE.c.family == family, - ARTIFACT_TOMBSTONES_TABLE.c.artifact_id == artifact_id, - ) - ) - ).one_or_none() - if tombstone is not None: - revision = int(tombstone.revision) + state = await _head_state(connection, scope_id, family, artifact_id) + if state is None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + revision, existing_deletion = state + if existing_deletion is not None: if revision != expected_revision: - raise ArtifactRevisionPreconditionError(expected_revision, revision) - return ArtifactDeletion( - artifact_ref=ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), - deleted_at=_aware_datetime(tombstone.deleted_at), - ) + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + return _artifact_deletion(family, artifact_id, revision, existing_deletion) + if revision != expected_revision: + raise ArtifactRevisionPreconditionError(expected_revision, revision) - current = await _head_revision(connection, scope_id, family, artifact_id) - if current is None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - if current != expected_revision: - raise ArtifactRevisionPreconditionError(expected_revision, current) + deleted_at = _aware_datetime(self._clock()) removed = await connection.execute( - delete(ARTIFACT_HEADS_TABLE).where( + update(ARTIFACT_HEADS_TABLE) + .where( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, ARTIFACT_HEADS_TABLE.c.revision == expected_revision, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) + .values(deleted_at=deleted_at) ) if removed.rowcount != 1: - latest = await _head_revision(connection, scope_id, family, artifact_id) - raise ArtifactRevisionPreconditionError(expected_revision, latest or expected_revision) - deleted_at = _aware_datetime(self._clock()) - await connection.execute( - insert(ARTIFACT_TOMBSTONES_TABLE).values( - scope_id=scope_id, - family=family, - artifact_id=artifact_id, - revision=expected_revision, - deleted_at=deleted_at, - ) - ) - return ArtifactDeletion( - artifact_ref=ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision), - deleted_at=deleted_at, - ) + latest = await _head_state(connection, scope_id, family, artifact_id) + if latest is not None and latest[0] == expected_revision and latest[1] is not None: + return _artifact_deletion(family, artifact_id, latest[0], latest[1]) + if latest is None or latest[1] is not None: + raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) + raise ArtifactRevisionPreconditionError(expected_revision, latest[0]) + return _artifact_deletion(family, artifact_id, expected_revision, deleted_at) async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPage: - expected_cursor = {"kind": "scope-list"} - after = _cursor_after_text(cursor, expected_cursor) + _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() @@ -509,15 +492,26 @@ async def list_scopes(self, *, limit: int, cursor: str | None) -> ScopeSummaryPa 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 = _encode_cursor(expected_cursor, selected[-1]) + 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_int(self, cursor: str | None, expected: Mapping[str, JsonValue]) -> int: + return _cursor_after_int(cursor, expected, secret=self._cursor_secret, now=_aware_datetime(self._clock())) + + 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, operation: str) -> None: if source_type != CONTENT_SOURCE_NAME: raise BaseOperationNotSupportedError("source_type", source_type, operation) def _require_direct_family(self, family: str, operation: str) -> None: - if family in _PROTECTED_ARTIFACT_FAMILIES: + ArtifactRef(family=family, artifact_id="validation", revision=1) + if family in self._protected_artifact_families: raise BaseOperationNotSupportedError("artifact_family", family, operation) async def _get_source( @@ -592,27 +586,14 @@ async def _insert_artifact_revision( write: ArtifactWrite, created_at: datetime, ) -> None: - content = _canonical_object(write.content) - metadata = _canonical_object(write.metadata) await connection.execute( insert(ARTIFACTS_TABLE).values( scope_id=scope_id, family=ref.family, artifact_id=ref.artifact_id, revision=ref.revision, - content=content, - ) - ) - await connection.execute( - insert(ARTIFACT_REVISION_RECORDS_TABLE).values( - scope_id=scope_id, - family=ref.family, - artifact_id=ref.artifact_id, - revision=ref.revision, - schema_version=write.schema_version, - metadata=metadata, + content=_canonical_object(write.content), created_at=created_at, - content_digest=_content_digest(write.content), ) ) if write.source_refs: @@ -659,7 +640,7 @@ async def _load_artifact( ) -> ArtifactRecord: row = ( await connection.execute( - select(ARTIFACTS_TABLE.c.content).where( + select(ARTIFACTS_TABLE.c.content, ARTIFACTS_TABLE.c.created_at).where( ARTIFACTS_TABLE.c.scope_id == scope_id, ARTIFACTS_TABLE.c.family == family, ARTIFACTS_TABLE.c.artifact_id == artifact_id, @@ -670,21 +651,6 @@ async def _load_artifact( if row is None: raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id, revision)) content = _decode_object(row.content, "content") - projection = ( - await connection.execute( - select( - ARTIFACT_REVISION_RECORDS_TABLE.c.schema_version, - ARTIFACT_REVISION_RECORDS_TABLE.c.metadata, - ARTIFACT_REVISION_RECORDS_TABLE.c.created_at, - ARTIFACT_REVISION_RECORDS_TABLE.c.content_digest, - ).where( - ARTIFACT_REVISION_RECORDS_TABLE.c.scope_id == scope_id, - ARTIFACT_REVISION_RECORDS_TABLE.c.family == family, - ARTIFACT_REVISION_RECORDS_TABLE.c.artifact_id == artifact_id, - ARTIFACT_REVISION_RECORDS_TABLE.c.revision == revision, - ) - ) - ).one_or_none() sources = ( await connection.execute( select( @@ -717,8 +683,6 @@ async def _load_artifact( ) ).all() write = ArtifactWrite( - schema_version=1 if projection is None else int(projection.schema_version), - metadata={} if projection is None else _decode_object(projection.metadata, "metadata"), content=content, source_refs=tuple( SourceRef(source_type=str(source.source_type), source_id=str(source.source_id)) for source in sources @@ -736,17 +700,16 @@ async def _load_artifact( return ArtifactRecord( scope_id=scope_id, artifact_ref=ref, - schema_version=write.schema_version, - metadata=write.metadata, content=write.content, source_refs=write.source_refs, artifact_refs=write.artifact_refs, - created_at=None if projection is None else _aware_datetime(projection.created_at), - content_digest=(_content_digest(content) if projection is None else str(projection.content_digest)), + created_at=None if row.created_at is None else _aware_datetime(row.created_at), + content_digest=_content_digest(content), ) async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSummary: + active = ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None) source_types = ( await connection.execute( select(SOURCES_TABLE.c.source_type) @@ -758,7 +721,7 @@ async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSum artifact_families = ( await connection.execute( select(ARTIFACT_HEADS_TABLE.c.family) - .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id) + .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, active) .distinct() .order_by(ARTIFACT_HEADS_TABLE.c.family) ) @@ -767,7 +730,9 @@ async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSum 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) + select(func.count()) + .select_from(ARTIFACT_HEADS_TABLE) + .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, active) ) return ScopeSummary( scope_id=scope_id, @@ -778,19 +743,24 @@ async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSum ) -async def _source_projection(connection: AsyncConnection, scope_id: str, ref: SourceRef): - return ( +async def _head_state( + connection: AsyncConnection, + scope_id: str, + family: str, + artifact_id: str, +) -> tuple[int, datetime | None] | None: + row = ( await connection.execute( - select( - SOURCE_RECORDS_TABLE.c.created_at, - SOURCE_RECORDS_TABLE.c.content_digest, - ).where( - SOURCE_RECORDS_TABLE.c.scope_id == scope_id, - SOURCE_RECORDS_TABLE.c.source_type == ref.source_type, - SOURCE_RECORDS_TABLE.c.source_id == ref.source_id, + select(ARTIFACT_HEADS_TABLE.c.revision, ARTIFACT_HEADS_TABLE.c.deleted_at).where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == family, + ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, ) ) ).one_or_none() + if row is None: + return None + return int(row.revision), None if row.deleted_at is None else _aware_datetime(row.deleted_at) async def _head_revision( @@ -804,6 +774,7 @@ async def _head_revision( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) ) return None if value is None else int(value) @@ -812,27 +783,37 @@ async def _head_revision( def _source_record( scope_id: str, stored: StoredSource, - *, - created_at: datetime | None, - content_digest: str, ) -> SourceRecord: if not isinstance(stored.value, ContentSource): raise BaseOperationNotSupportedError("source_type", stored.ref.source_type, "read") return SourceRecord( scope_id=scope_id, - source_ref=stored.ref, + source_type=stored.ref.source_type, + source_id=stored.ref.source_id, content=stored.value.content, metadata=stored.value.metadata, - created_at=created_at, + created_at=stored.created_at, position=stored.journal_position, - content_digest=content_digest, + content_digest=_content_digest(stored.value.content), ) -def _source_digest(stored: StoredSource) -> str: - if not isinstance(stored.value, ContentSource): - raise BaseOperationNotSupportedError("source_type", stored.ref.source_type, "read") - return _content_digest({"content": stored.value.content, "metadata": stored.value.metadata}) +def _source_collection_item( + record: SourceRecord, + score: float | None, + snippets: tuple[str, ...], +) -> SourceCollectionItem: + return SourceCollectionItem( + scope_id=record.scope_id, + source_type=record.source_type, + source_id=record.source_id, + metadata=record.metadata, + created_at=record.created_at, + position=record.position, + content_digest=record.content_digest, + score=score, + snippets=snippets, + ) def _artifact_record( @@ -844,8 +825,6 @@ def _artifact_record( return ArtifactRecord( scope_id=scope_id, artifact_ref=ref, - schema_version=write.schema_version, - metadata=write.metadata, content=write.content, source_refs=write.source_refs, artifact_refs=write.artifact_refs, @@ -854,6 +833,18 @@ def _artifact_record( ) +def _artifact_deletion( + family: str, + artifact_id: str, + revision: int, + deleted_at: datetime, +) -> ArtifactDeletion: + return ArtifactDeletion( + artifact_ref=ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), + deleted_at=_aware_datetime(deleted_at), + ) + + def _canonical_object(value: Mapping[str, JsonValue]) -> bytes: validated = _JSON_OBJECT.validate_python(dict(value), strict=True) return rfc8785.dumps(cast(Any, validated)) @@ -867,16 +858,13 @@ def _decode_object(value: object, column: str) -> dict[str, JsonValue]: raise InvalidBaseAccessRequestError(column, "contains invalid stored JSON") from error -def _content_digest(value: Mapping[str, JsonValue]) -> str: - return f"sha256:{sha256(_canonical_object(value)).hexdigest()}" - - -def _searchable_text(content: Mapping[str, JsonValue], metadata: Mapping[str, JsonValue]) -> str: - return json.dumps({"content": content, "metadata": metadata}, ensure_ascii=False, sort_keys=True) +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 _matches(content: str, metadata: Mapping[str, JsonValue], query: str) -> bool: - return _matches_text(f"{content}\n{json.dumps(metadata, ensure_ascii=False, sort_keys=True)}", query) +def _searchable_text(value: JsonValue) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True) def _matches_text(text: str, query: str) -> bool: @@ -894,75 +882,116 @@ def _snippet(text: str, query: str, *, maximum: int = 240) -> str: return text[start : start + maximum] -def _validate_source_query( - query_type: SourceQueryType, - query: str | None, - mode: TextSearchMode | None, -) -> str | None: - if query_type == "list": - if query is not None or mode is not None: - raise InvalidBaseAccessRequestError("type", "list cannot include q or mode") - return None - if query_type != "search": - raise InvalidBaseAccessRequestError("type", "must be list or search") +def _normalize_query(query: str | None, mode: TextSearchMode | None) -> str | None: if mode not in {None, "auto", "keyword"}: raise InvalidBaseAccessRequestError("mode", "must be auto or keyword") - return _require_query(query) + normalized = None if query is None else query.strip() + if not normalized: + if mode is not None: + raise InvalidBaseAccessRequestError("mode", "is only valid with a non-empty query") + return None + return normalized -def _require_query(query: str | None) -> str: - if query is None or not query.strip(): - raise InvalidBaseAccessRequestError("q", "must contain non-whitespace text") - return query.strip() +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) -> str: - payload = {**expected, "after": after} +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)) - return base64.urlsafe_b64encode(encoded).decode("ascii").rstrip("=") + 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]) -> dict[str, JsonValue] | None: +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: - padding = "=" * (-len(cursor) % 4) - decoded = base64.urlsafe_b64decode(f"{cursor}{padding}") + 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 (ValueError, ValidationError) as error: - raise InvalidBaseAccessRequestError("cursor", "is invalid") from error + 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 InvalidBaseAccessRequestError("cursor", "does not match the query") - if set(payload) != {*expected, "after"}: - raise InvalidBaseAccessRequestError("cursor", "is invalid") + raise InvalidCursorError + if set(payload) != {*expected, "after", "expires_at"}: + raise InvalidCursorError + if int(now.timestamp()) >= expires_at: + raise CursorExpiredError return payload -def _cursor_after_int(cursor: str | None, expected: Mapping[str, JsonValue]) -> int: - payload = _cursor_payload(cursor, expected) +def _cursor_after_int( + cursor: str | None, + expected: Mapping[str, JsonValue], + *, + secret: bytes, + now: datetime, +) -> int: + payload = _cursor_payload(cursor, expected, secret=secret, now=now) if payload is None: return 0 after = payload["after"] if not isinstance(after, int) or isinstance(after, bool) or after < 0: - raise InvalidBaseAccessRequestError("cursor", "contains an invalid position") + raise InvalidCursorError return after -def _cursor_after_text(cursor: str | None, expected: Mapping[str, JsonValue]) -> str: - payload = _cursor_payload(cursor, expected) +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 InvalidBaseAccessRequestError("cursor", "contains an invalid identity") + 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/schema.py b/src/powercontext/builtin/persistence/schema.py index d1e4170bc..da9411b2a 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -18,9 +18,15 @@ from collections.abc import Sequence -from sqlalchemy import MetaData, Table +from sqlalchemy import MetaData, Table, text from sqlalchemy.ext.asyncio import AsyncConnection +_RECORD_COLUMNS = { + "pc_sources": {"created_at": "DATETIME NULL"}, + "pc_artifacts": {"created_at": "DATETIME NULL"}, + "pc_artifact_heads": {"deleted_at": "DATETIME NULL"}, +} + async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) -> None: """Create missing tables and indexes using SQLAlchemy's native metadata support.""" @@ -37,3 +43,34 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) checkfirst=True, ) ) + + await ensure_record_columns(connection, {table.name for table in tables}) + + +async def ensure_record_columns(connection: AsyncConnection, selected_tables: set[str], /) -> None: + """Add nullable lifecycle timestamps to schemas created before base access existed.""" + + dialect = connection.dialect.name + for table_name, required in _RECORD_COLUMNS.items(): + if table_name not in selected_tables: + continue + if dialect == "sqlite": + rows = (await connection.exec_driver_sql(f"PRAGMA table_info('{table_name}')")).mappings() + existing = {str(row["name"]) for row in rows} + elif dialect == "mysql": + rows = ( + await connection.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = :table_name" + ), + {"table_name": table_name}, + ) + ).scalars() + existing = {str(value) for value in rows} + else: + raise ValueError(f"unsupported record schema migration dialect: {dialect}") # noqa: TRY003 + + for column_name, column_type in required.items(): + if column_name not in existing: + await connection.exec_driver_sql(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}") diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index 7a9425702..500669fd9 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -18,6 +18,7 @@ from collections.abc import Iterable, Mapping from contextlib import suppress +from datetime import UTC, datetime from typing import Any, cast from pydantic import BaseModel @@ -47,6 +48,7 @@ class StoredSource(BaseModel): ref: SourceRef value: Source journal_position: int + created_at: datetime | None = None class SourceRepository: @@ -69,6 +71,8 @@ async def add( scope_id: str, source: Source, /, + *, + created_at: datetime | None = None, ) -> StoredSource: """Add one stable Source or return an identical existing capture.""" @@ -84,6 +88,7 @@ async def add( return self._decode_row(existing) position = await _next_journal_position(connection, scope_id) + persisted_at = _normalize_datetime(datetime.now(UTC) if created_at is None else created_at) try: await connection.execute( insert(SOURCES_TABLE).values( @@ -92,6 +97,7 @@ async def add( source_id=ref.source_id, payload=payload, journal_position=position, + created_at=persisted_at, ) ) except IntegrityError: @@ -104,7 +110,12 @@ async def add( if stored_bytes(existing["payload"], column="payload") != payload: raise StoredPayloadConflictError("source", (scope_id, ref)) from None return self._decode_row(existing) - return StoredSource(ref=ref, value=source, journal_position=position) + return StoredSource( + ref=ref, + value=source, + journal_position=position, + created_at=persisted_at, + ) async def get( self, @@ -213,6 +224,7 @@ def _decode_row(self, row: Mapping[Any, Any]) -> StoredSource: ref=indexed, value=cast(Source, source), journal_position=int(row["journal_position"]), + created_at=None if row["created_at"] is None else _normalize_datetime(row["created_at"]), ) @@ -280,3 +292,7 @@ def _require_identity(field: str, value: object, maximum: int) -> None: raise InvalidRepositoryArgumentError(field, "must be a non-empty trimmed string") if len(value) > maximum: raise InvalidRepositoryArgumentError(field, f"must not exceed {maximum} characters") + + +def _normalize_datetime(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) diff --git a/src/powercontext/builtin/persistence/statistics.py b/src/powercontext/builtin/persistence/statistics.py index 1e6d5aece..7a39c0a8a 100644 --- a/src/powercontext/builtin/persistence/statistics.py +++ b/src/powercontext/builtin/persistence/statistics.py @@ -83,7 +83,10 @@ 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, + ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), + ) .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 b9aa32798..be967c323 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -85,6 +85,7 @@ def _entry_text_type(): Column("source_id", identity_string(MAX_SOURCE_ID_LENGTH), primary_key=True), Column("payload", _canonical_payload_type(), nullable=False), Column("journal_position", BigInteger, nullable=False), + Column("created_at", DateTime(timezone=True)), UniqueConstraint("scope_id", "journal_position", name="uq_pc_sources_scope_journal_position"), ) @@ -96,21 +97,6 @@ def _entry_text_type(): CheckConstraint("position >= 0", name="ck_pc_source_journal_heads_position_nonnegative"), ) -SOURCE_RECORDS_TABLE = Table( - "pc_source_records", - SHARED_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("source_type", identity_string(MAX_SOURCE_TYPE_LENGTH), primary_key=True), - Column("source_id", identity_string(MAX_SOURCE_ID_LENGTH), primary_key=True), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("content_digest", identity_string(71), nullable=False), - ForeignKeyConstraint( - ("scope_id", "source_type", "source_id"), - ("pc_sources.scope_id", "pc_sources.source_type", "pc_sources.source_id"), - ondelete="CASCADE", - ), -) - ARTIFACTS_TABLE = Table( "pc_artifacts", SHARED_METADATA, @@ -119,6 +105,7 @@ def _entry_text_type(): Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), Column("revision", Integer, primary_key=True), Column("content", _canonical_payload_type(), nullable=False), + Column("created_at", DateTime(timezone=True)), ) ARTIFACT_HEADS_TABLE = Table( @@ -129,6 +116,7 @@ def _entry_text_type(): Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), Column("revision", Integer, nullable=False), Column("searchable_text", _entry_text_type()), + Column("deleted_at", DateTime(timezone=True)), ForeignKeyConstraint( ("scope_id", "family", "artifact_id", "revision"), ( @@ -142,52 +130,6 @@ def _entry_text_type(): CheckConstraint("revision > 0", name="ck_pc_artifact_heads_revision_positive"), ) -ARTIFACT_REVISION_RECORDS_TABLE = Table( - "pc_artifact_revision_records", - SHARED_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), - Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), - Column("revision", Integer, primary_key=True), - Column("schema_version", Integer, nullable=False), - Column("metadata", _canonical_payload_type(), nullable=False), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("content_digest", identity_string(71), nullable=False), - ForeignKeyConstraint( - ("scope_id", "family", "artifact_id", "revision"), - ( - "pc_artifacts.scope_id", - "pc_artifacts.family", - "pc_artifacts.artifact_id", - "pc_artifacts.revision", - ), - ondelete="CASCADE", - ), - CheckConstraint("schema_version > 0", name="ck_pc_artifact_revision_records_schema_version_positive"), -) - -ARTIFACT_TOMBSTONES_TABLE = Table( - "pc_artifact_tombstones", - SHARED_METADATA, - Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), - Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), - Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), - Column("revision", Integer, nullable=False), - Column("deleted_at", DateTime(timezone=True), nullable=False), - ForeignKeyConstraint( - ("scope_id", "family", "artifact_id", "revision"), - ( - "pc_artifacts.scope_id", - "pc_artifacts.family", - "pc_artifacts.artifact_id", - "pc_artifacts.revision", - ), - ondelete="RESTRICT", - ), - CheckConstraint("revision > 0", name="ck_pc_artifact_tombstones_revision_positive"), -) - - ARTIFACT_LINEAGE_SOURCES_TABLE = Table( "pc_artifact_lineage_sources", SHARED_METADATA, @@ -412,11 +354,8 @@ def _entry_text_type(): SHARED_TABLES = ( SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, - SOURCE_RECORDS_TABLE, ARTIFACTS_TABLE, ARTIFACT_HEADS_TABLE, - ARTIFACT_REVISION_RECORDS_TABLE, - ARTIFACT_TOMBSTONES_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_CANDIDATE_VERSIONS_TABLE, diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index e978f8a97..560000031 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -25,7 +25,6 @@ from powercontext.artifacts import ArtifactRef from powercontext.sources import SourceRef -SourceQueryType = Literal["list", "search"] TextSearchMode = Literal["auto", "keyword"] @@ -34,11 +33,24 @@ class _RecordModel(BaseModel): class SourceRecord(_RecordModel): - """One durable captured-text Source and its journal position.""" + """One durable Source and its journal position.""" scope_id: str - source_ref: SourceRef - content: str + source_type: str + source_id: str + content: JsonValue + metadata: dict[str, JsonValue] + created_at: datetime | None + position: int + content_digest: str + + +class SourceCollectionItem(_RecordModel): + """One Source collection projection without its potentially large content.""" + + scope_id: str + source_type: str + source_id: str metadata: dict[str, JsonValue] created_at: datetime | None position: int @@ -52,15 +64,13 @@ class SourceRecordPage(_RecordModel): query: str | None mode: Literal["keyword"] | None - items: tuple[SourceRecord, ...] + items: tuple[SourceCollectionItem, ...] next_cursor: str | None class ArtifactWrite(_RecordModel): """Complete content and direct lineage for one Artifact revision.""" - schema_version: int - metadata: dict[str, JsonValue] content: dict[str, JsonValue] source_refs: tuple[SourceRef, ...] = () artifact_refs: tuple[ArtifactRef, ...] = () @@ -71,8 +81,6 @@ class ArtifactRecord(_RecordModel): scope_id: str artifact_ref: ArtifactRef - schema_version: int - metadata: dict[str, JsonValue] content: dict[str, JsonValue] source_refs: tuple[SourceRef, ...] artifact_refs: tuple[ArtifactRef, ...] @@ -80,27 +88,23 @@ class ArtifactRecord(_RecordModel): content_digest: str -class ArtifactRecordPage(_RecordModel): - """One stable page of current Artifact heads.""" +class ArtifactCollectionItem(_RecordModel): + """One active Artifact head without content or lineage.""" - items: tuple[ArtifactRecord, ...] - next_cursor: str | None - - -class ArtifactSearchHit(_RecordModel): - """One matching current Artifact head.""" - - artifact: ArtifactRecord - score: float - snippets: tuple[str, ...] + scope_id: str + artifact_ref: ArtifactRef + created_at: datetime | None + content_digest: str + score: float | None = None + snippets: tuple[str, ...] = () -class ArtifactSearchPage(_RecordModel): - """One stable page of current Artifact-head search results.""" +class ArtifactRecordPage(_RecordModel): + """One stable page for Artifact listing or keyword search.""" - query: str - mode: Literal["keyword"] - hits: tuple[ArtifactSearchHit, ...] + query: str | None + mode: Literal["keyword"] | None + items: tuple[ArtifactCollectionItem, ...] next_cursor: str | None @@ -173,6 +177,17 @@ def __init__(self, field: str, reason: str) -> None: 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.""" @@ -186,11 +201,20 @@ class RecordService(Protocol): """Persistence-backed base Source, Artifact, and Scope operations.""" async def create_source( + self, + scope_id: str, + source_type: str, + content: JsonValue, + metadata: Mapping[str, JsonValue], + /, + ) -> SourceRecord: ... + + async def capture_source( self, scope_id: str, source_type: str, source_id: str, - content: str, + content: JsonValue, metadata: Mapping[str, JsonValue], /, ) -> SourceRecord: ... @@ -201,7 +225,6 @@ async def query_sources( self, scope_id: str, source_type: str, - query_type: SourceQueryType, /, *, query: str | None, @@ -214,7 +237,6 @@ async def create_artifact( self, scope_id: str, family: str, - artifact_id: str | None, write: ArtifactWrite, /, ) -> ArtifactRecord: ... @@ -230,28 +252,18 @@ async def get_artifact_revision( /, ) -> ArtifactRecord: ... - async def list_artifacts( + async def query_artifacts( self, scope_id: str, family: str, /, *, + query: str | None, + mode: TextSearchMode | None, limit: int, cursor: str | None, ) -> ArtifactRecordPage: ... - async def search_artifacts( - self, - scope_id: str, - family: str, - query: str, - /, - *, - mode: TextSearchMode, - limit: int, - cursor: str | None, - ) -> ArtifactSearchPage: ... - async def replace_artifact( self, scope_id: str, diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index 09294dc8e..b4cd78da3 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -75,12 +75,10 @@ ArtifactDeletion, ArtifactRecord, ArtifactRecordPage, - ArtifactSearchPage, ArtifactWrite, BaseValueConflictError, RecordService, ScopeSummaryPage, - SourceQueryType, SourceRecord, SourceRecordPage, TextSearchMode, @@ -247,15 +245,20 @@ def __init__(self, runtime: BuiltinRuntime, scope_id: str) -> None: async def capture(self, value: CaptureSource, /) -> SourceReceipt: if self._runtime._record_service is not None: try: - record = await self._runtime.records.for_scope(self.scope_id).create_source( - CONTENT_SOURCE_NAME, - value.source_id, - value.content, - value.metadata, - ) + 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=record.source_ref, sequence=record.position) + 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( @@ -287,8 +290,7 @@ def __init__(self, runtime: BuiltinRuntime, scope_id: str) -> None: async def create_source( self, source_type: str, - source_id: str, - content: str, + content: JsonValue, metadata: Mapping[str, JsonValue], /, ) -> SourceRecord: @@ -296,7 +298,6 @@ async def create_source( return await self._runtime._records().create_source( self.scope_id, source_type, - source_id, content, metadata, ) @@ -308,7 +309,6 @@ async def get_source(self, source_type: str, source_id: str, /) -> SourceRecord: async def query_sources( self, source_type: str, - query_type: SourceQueryType, /, *, query: str | None, @@ -320,7 +320,6 @@ async def query_sources( return await self._runtime._records().query_sources( self.scope_id, source_type, - query_type, query=query, mode=mode, limit=limit, @@ -330,12 +329,11 @@ async def query_sources( async def create_artifact( self, family: str, - artifact_id: str | None, write: ArtifactWrite, /, ) -> ArtifactRecord: 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, artifact_id, write) + 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): @@ -356,37 +354,21 @@ async def get_artifact_revision( revision, ) - async def list_artifacts( + async def query_artifacts( self, family: str, /, *, + query: str | None, + mode: TextSearchMode | None, limit: int, cursor: str | None, ) -> ArtifactRecordPage: async with self._runtime._scope_operation(self.scope_id): - return await self._runtime._records().list_artifacts( - self.scope_id, - family, - limit=limit, - cursor=cursor, - ) - - async def search_artifacts( - self, - family: str, - query: str, - /, - *, - mode: TextSearchMode, - limit: int, - cursor: str | None, - ) -> ArtifactSearchPage: - async with self._runtime._scope_operation(self.scope_id): - return await self._runtime._records().search_artifacts( + return await self._runtime._records().query_artifacts( self.scope_id, family, - query, + query=query, mode=mode, limit=limit, cursor=cursor, diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 6ff5b55c9..7627926d9 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -319,7 +319,12 @@ def __init__( external_skills=ExternalSkillRepository(), statistics=StatisticsRepository(), ) - self.records = RelationalRecordService(database, self.repositories.sources) + self.records = RelationalRecordService( + database, + self.repositories.sources, + id_factory=id_factory, + protected_artifact_families=self.repositories.artifacts.families, + ) self._candidate_pipeline = candidate_pipeline self.memory_extraction = candidate_pipeline is not None self._experience_pipeline = experience_pipeline diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index 547d82b93..1358592ea 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 Any, Self, TypeVar +from typing import Any, Self, TypeVar, cast from urllib.parse import quote import httpx @@ -33,10 +33,8 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - ArtifactDeletionStatus, ArtifactPage, ArtifactRevision, - ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -48,7 +46,6 @@ CreateHandoffReportProjectRequest, CreateSourceRequest, CreateWorkContractRequest, - DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, ErrorResponse, ExperienceArtifact, @@ -60,15 +57,12 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, - GetArtifactRequest, - GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, - GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, @@ -93,7 +87,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListScopesRequest, + ListSourcesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -121,11 +115,8 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, - ScopePage, - SearchArtifactsRequest, SearchMemoryRequest, SearchMemoryResponse, - SearchSourcesRequest, SkillArtifact, SourcePage, SourceRecord, @@ -179,7 +170,7 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, - LIST_SCOPES, + LIST_SOURCES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -196,9 +187,7 @@ REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, - SEARCH_ARTIFACTS, SEARCH_MEMORY, - SEARCH_SOURCES, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, Operation, @@ -454,59 +443,82 @@ async def capture_content_source(self, request: CaptureContentSourceRequest) -> return await self._request(CAPTURE_CONTENT_SOURCE, request) - async def create_source(self, request: CreateSourceRequest) -> SourceRecord: + 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) + return await self._request(CREATE_SOURCE, request, path_parameters={"scope_id": scope_id}) - async def get_source(self, source_id: str, request: GetSourceRequest) -> SourceRecord: + 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, request, path_parameters={"source_id": source_id}) + return await self._request( + GET_SOURCE, + path_parameters={"scope_id": scope_id, "source_type": source_type, "source_id": source_id}, + ) - async def search_sources(self, request: SearchSourcesRequest) -> SourcePage: - """List Sources or search them according to request.type.""" + async def list_sources(self, scope_id: str, source_type: str, request: ListSourcesRequest) -> SourcePage: + """List Sources or search them when the request includes a query.""" - return await self._request(SEARCH_SOURCES, request) + return await self._request( + LIST_SOURCES, + request, + path_parameters={"scope_id": scope_id, "source_type": source_type}, + ) - async def create_artifact(self, request: CreateArtifactRequest) -> ArtifactRevision: + async def create_artifact(self, scope_id: str, request: CreateArtifactRequest) -> ArtifactRevision: """Commit revision one for a direct Artifact family.""" - return await self._request(CREATE_ARTIFACT, request) + return await self._request(CREATE_ARTIFACT, request, path_parameters={"scope_id": scope_id}) - async def get_artifact(self, artifact_id: str, request: GetArtifactRequest) -> ArtifactRevision: + 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, request, path_parameters={"artifact_id": artifact_id}) + 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, - request: GetArtifactRevisionRequest, ) -> ArtifactRevision: """Read one exact immutable Artifact revision.""" return await self._request( GET_ARTIFACT_REVISION, - request, - path_parameters={"artifact_id": artifact_id, "revision": revision}, + path_parameters={ + "scope_id": scope_id, + "family": family, + "artifact_id": artifact_id, + "revision": revision, + }, ) - async def list_artifacts(self, request: ListArtifactsRequest) -> ArtifactPage: - """List current visible Artifact heads in one family.""" + async def list_artifacts(self, scope_id: str, family: str, request: ListArtifactsRequest) -> ArtifactPage: + """List Artifact heads or search them when the request includes a query.""" - return await self._request(LIST_ARTIFACTS, request) - - async def search_artifacts(self, request: SearchArtifactsRequest) -> ArtifactSearchResultPage: - """Search current visible Artifact heads in one family.""" - - return await self._request(SEARCH_ARTIFACTS, request) + 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, - selector: GetArtifactRequest, request: ReplaceArtifactRequest, *, expected_revision: int, @@ -516,32 +528,26 @@ async def replace_artifact( return await self._request( REPLACE_ARTIFACT, request, - path_parameters={"artifact_id": artifact_id}, - query_parameters=_query_parameters(selector), + path_parameters={"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, extra_headers={"If-Match": _artifact_etag(expected_revision)}, ) async def delete_artifact( self, + scope_id: str, + family: str, artifact_id: str, - request: DeleteArtifactRequest, *, expected_revision: int, - ) -> ArtifactDeletionStatus: + ) -> None: """Delete an Artifact head while preserving immutable revisions.""" - return await self._request( + await self._request( DELETE_ARTIFACT, - request, - path_parameters={"artifact_id": artifact_id}, + path_parameters={"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, extra_headers={"If-Match": _artifact_etag(expected_revision)}, ) - async def list_scopes(self, request: ListScopesRequest) -> ScopePage: - """List Scopes observable by the current Server identity.""" - - return await self._request(LIST_SCOPES, request) - async def create_work_contract(self, request: CreateWorkContractRequest) -> WorkSourceReceipt: """Create one grounded delegation baseline as durable Source evidence.""" @@ -745,13 +751,12 @@ async def _request( 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, @@ -761,6 +766,9 @@ 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: @@ -770,10 +778,6 @@ async def _request( ) from exc -def _query_parameters(value: GetArtifactRequest) -> dict[str, Any]: - return value.model_dump(mode="json", by_alias=True, exclude_none=True) - - def _prepare_request( operation: Operation[Any, Any], request: object | None, diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 1d82afbfc..903e68602 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -20,14 +20,11 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - ArtifactDeletionState, - ArtifactDeletionStatus, + ArtifactCollectionItem, ArtifactInventoryStatistics, ArtifactPage, ArtifactReference, ArtifactRevision, - ArtifactSearchHit, - ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, CandidateFamily, CandidateFamilyCount, @@ -45,7 +42,6 @@ CreateSourceRequest, CreateWorkContractRequest, CurrentWorkHandoff, - DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, EntryChange, EntryChangeOperation, @@ -68,15 +64,12 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, - GetArtifactRequest, - GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, - GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, @@ -125,7 +118,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListScopesRequest, + ListSourcesRequest, MemoryCitation, MemoryEntry, MemoryEntryInventoryStatistics, @@ -179,22 +172,19 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, - ScopePage, - ScopeSummary, - SearchArtifactsRequest, SearchMemoryHit, SearchMemoryRequest, SearchMemoryResponse, - SearchSourcesRequest, SkillArtifact, SkillGenerationOrigin, SkillProposal, SkillValidationItem, + SourceCollectionItem, SourceInventoryStatistics, SourcePage, - SourceQueryType, SourceRecord, SourceReference, + SourceTypeReference, StatsPeriod, StoredHandoffReportActivity, TaskCheck, @@ -223,14 +213,11 @@ "ApproveArtifactCandidateRequest", "ArtifactCandidate", "ArtifactCandidatePage", - "ArtifactDeletionState", - "ArtifactDeletionStatus", + "ArtifactCollectionItem", "ArtifactInventoryStatistics", "ArtifactPage", "ArtifactReference", "ArtifactRevision", - "ArtifactSearchHit", - "ArtifactSearchResultPage", "AttachHandoffReportWorkspaceRequest", "CandidateFamily", "CandidateFamilyCount", @@ -248,7 +235,6 @@ "CreateSourceRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", - "DeleteArtifactRequest", "DetachHandoffReportWorkspaceRequest", "EntryChange", "EntryChangeOperation", @@ -271,15 +257,12 @@ "GeneratedCandidateResponse", "GeneratedCandidateStatus", "GetArtifactCandidateRequest", - "GetArtifactRequest", - "GetArtifactRevisionRequest", "GetExperienceRequest", "GetHandoffReportProjectRequest", "GetHandoffReportRequest", "GetHandoffReportWorkspaceRequest", "GetMemoryEntryRequest", "GetSkillRequest", - "GetSourceRequest", "GetStatsRequest", "HandoffAcknowledgement", "HandoffActivation", @@ -328,7 +311,7 @@ "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", - "ListScopesRequest", + "ListSourcesRequest", "MemoryCitation", "MemoryEntry", "MemoryEntryInventoryStatistics", @@ -381,23 +364,20 @@ "ReviseMemoryEntryRequest", "ScanExternalSkillsRequest", "ScanExternalSkillsResponse", - "ScopePage", - "ScopeSummary", "ScopedStats", - "SearchArtifactsRequest", "SearchMemoryHit", "SearchMemoryRequest", "SearchMemoryResponse", - "SearchSourcesRequest", "SkillArtifact", "SkillGenerationOrigin", "SkillProposal", "SkillValidationItem", + "SourceCollectionItem", "SourceInventoryStatistics", "SourcePage", - "SourceQueryType", "SourceRecord", "SourceReference", + "SourceTypeReference", "StatsPeriod", "StoredHandoffReportActivity", "TaskCheck", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 89f8ad4e3..9b2a5c468 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -21,10 +21,6 @@ ) -class ArtifactDeletionState(StrEnum): - DELETED = "deleted" - - class ArtifactReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -786,81 +782,40 @@ class CreateSourceRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - source_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - content: Annotated[StrictStr, Field(max_length=200000, min_length=1, pattern=".*\\S.*")] + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] = "content" + content: Annotated[Any, Field(description="JSON value validated by the selected Source adapter.")] metadata: dict[str, Any] = {} -class DeleteArtifactRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - - -class GetArtifactRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - - -class GetArtifactRevisionRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - - -class GetSourceRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) +class SourceCollectionItem(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] + metadata: dict[str, Any] + created_at: Annotated[AwareDatetime | None, Field(...)] + position: Annotated[StrictInt, Field(ge=1)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + score: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] + snippets: list[StrictStr] -class ListArtifactsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) +class SourceRecord(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None - - -class ListScopesRequest(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 + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] + content: Annotated[Any, Field(description="Persisted canonical JSON content.")] + metadata: dict[str, Any] + created_at: Annotated[AwareDatetime | None, Field(...)] + position: Annotated[StrictInt, Field(ge=1)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] -class ScopeSummary(BaseModel): +class SourceTypeReference(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - title: Annotated[StrictStr | None, Field(...)] - summary: Annotated[StrictStr | None, Field(...)] - parent_scope_id: Annotated[StrictStr | None, Field(...)] - version: Annotated[StrictInt | None, Field(ge=1)] - source_types: list[StrictStr] - artifact_families: list[StrictStr] - source_count: Annotated[StrictInt, Field(ge=0)] - artifact_count: Annotated[StrictInt, Field(ge=0)] - - -class SourceQueryType(StrEnum): - LIST = "list" - SEARCH = "search" + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] class SourceReference(BaseModel): @@ -996,49 +951,32 @@ class PreparedHandoffSchema(StrEnum): POWERCONTEXT_PREPARED_HANDOFF_V1 = "powercontext.prepared-handoff.v1" -class ArtifactDeletionStatus(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) +class ArtifactCollectionItem(BaseModel): + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] artifact_ref: ArtifactReference - status: ArtifactDeletionState - deleted_at: AwareDatetime + created_at: Annotated[AwareDatetime | None, Field(...)] + content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + score: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] + snippets: list[StrictStr] + + +class ArtifactPage(BaseModel): + query: Annotated[StrictStr | None, Field(...)] + mode: Annotated[TextSearchUsedMode | None, Field(...)] + items: list[ArtifactCollectionItem] + next_cursor: Annotated[StrictStr | None, Field(...)] class ArtifactRevision(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] artifact_ref: ArtifactReference - schema_version: Annotated[StrictInt, Field(ge=1)] - metadata: dict[str, Any] content: dict[str, Any] - source_refs: list[SourceReference] + source_refs: list[SourceTypeReference] artifact_refs: list[ArtifactReference] created_at: Annotated[AwareDatetime | None, Field(...)] content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] -class ArtifactSearchHit(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - artifact: ArtifactRevision - score: Annotated[StrictFloat, Field(ge=0.0, le=1.0)] - snippets: list[StrictStr] - - -class ArtifactSearchResultPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - query: StrictStr - mode: TextSearchUsedMode - hits: list[ArtifactSearchHit] - next_cursor: Annotated[StrictStr | None, Field(...)] - - class Capabilities(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1696,96 +1634,45 @@ class CreateArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - artifact_id: Annotated[StrictStr | None, Field(max_length=128, min_length=1)] = None - schema_version: Annotated[StrictInt, Field(ge=1)] - metadata: dict[str, Any] = {} - content: dict[str, Any] - source_refs: Annotated[list[SourceReference], Field(max_length=32, validate_default=True)] = [] - artifact_refs: Annotated[list[ArtifactReference], Field(max_length=32, validate_default=True)] = [] - - @model_validator(mode="after") - def _reject_excess_candidate_evidence(self): - if len(self.source_refs) + len(self.artifact_refs) > 32: - raise ValueError( # noqa: TRY003 - "source_refs and artifact_refs together must not exceed 32 references" - ) - return self - - -class ReplaceArtifactRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - schema_version: Annotated[StrictInt, Field(ge=1)] - metadata: dict[str, Any] = {} content: dict[str, Any] - source_refs: Annotated[list[SourceReference], Field(max_length=32, validate_default=True)] = [] - artifact_refs: Annotated[list[ArtifactReference], Field(max_length=32, validate_default=True)] = [] - - @model_validator(mode="after") - def _reject_excess_candidate_evidence(self): - if len(self.source_refs) + len(self.artifact_refs) > 32: - raise ValueError( # noqa: TRY003 - "source_refs and artifact_refs together must not exceed 32 references" - ) - return self + source_refs: Annotated[list[SourceTypeReference], Field(validate_default=True)] = [] + artifact_refs: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] -class ScopePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: list[ScopeSummary] - next_cursor: Annotated[StrictStr | None, Field(...)] - - -class SearchArtifactsRequest(BaseModel): +class ListArtifactsRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - q: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] - mode: TextSearchMode = TextSearchMode.AUTO + query: Annotated[StrictStr | None, Field(max_length=8192)] = None + mode: TextSearchMode | None = None limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None -class SearchSourcesRequest(BaseModel): +class ListSourcesRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - type: SourceQueryType = SourceQueryType.LIST - q: Annotated[StrictStr | None, Field(max_length=8192, min_length=1)] = None + query: Annotated[StrictStr | None, Field(max_length=8192)] = None mode: TextSearchMode | None = None limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None -class SourceRecord(BaseModel): +class ReplaceArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_ref: SourceReference - content: StrictStr - metadata: dict[str, Any] - created_at: Annotated[AwareDatetime | None, Field(...)] - position: Annotated[StrictInt, Field(ge=1)] - content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] - score: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] - snippets: list[StrictStr] + content: dict[str, Any] + source_refs: Annotated[list[SourceTypeReference], Field(validate_default=True)] = [] + artifact_refs: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] -class ArtifactPage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - items: list[ArtifactRevision] +class SourcePage(BaseModel): + query: Annotated[StrictStr | None, Field(...)] + mode: Annotated[TextSearchUsedMode | None, Field(...)] + items: list[SourceCollectionItem] next_cursor: Annotated[StrictStr | None, Field(...)] @@ -1926,16 +1813,6 @@ class GeneratedCandidateResponse(BaseModel): candidate: Annotated[ArtifactCandidate | None, Field(...)] -class SourcePage(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - query: Annotated[StrictStr | None, Field(...)] - mode: Annotated[TextSearchUsedMode | None, Field(...)] - items: list[SourceRecord] - next_cursor: Annotated[StrictStr | None, Field(...)] - - class ActivateHandoffRequest(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 154f0e05c..3705db565 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,10 +12,8 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - ArtifactDeletionStatus, ArtifactPage, ArtifactRevision, - ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -27,7 +25,6 @@ CreateHandoffReportProjectRequest, CreateSourceRequest, CreateWorkContractRequest, - DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, ExperienceArtifact, ExternalSkillResolution, @@ -38,15 +35,12 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, - GetArtifactRequest, - GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, - GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffActivation, @@ -71,7 +65,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListScopesRequest, + ListSourcesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -99,11 +93,8 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, - ScopePage, - SearchArtifactsRequest, SearchMemoryRequest, SearchMemoryResponse, - SearchSourcesRequest, SkillArtifact, SourcePage, SourceRecord, @@ -130,7 +121,7 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): operation_id: str request_type: type[RequestT] | None request_location: Literal["body", "query"] | None - response_type: type[ResponseT] + response_type: type[ResponseT] | None success_status: int summary: str tags: tuple[str, ...] @@ -1367,32 +1358,9 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -SEARCH_SOURCES = Operation[SearchSourcesRequest, SourcePage]( - method="GET", - path="/v1/sources", - operation_id="search_sources", - request_type=SearchSourcesRequest, - request_location="query", - response_type=SourcePage, - success_status=200, - summary="List or search Sources", - tags=("sources",), - responses={ - 200: { - "description": "One stable page of Sources or Source search results.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 503: {"$ref": "#/components/responses/Unavailable"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - CREATE_SOURCE = Operation[CreateSourceRequest, SourceRecord]( method="POST", - path="/v1/sources", + path="/v1/scopes/{scope_id}/sources", operation_id="create_source", request_type=CreateSourceRequest, request_location="body", @@ -1417,46 +1385,49 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -GET_SOURCE = Operation[GetSourceRequest, SourceRecord]( +LIST_SOURCES = Operation[ListSourcesRequest, SourcePage]( method="GET", - path="/v1/sources/{source_id}", - operation_id="get_source", - request_type=GetSourceRequest, + path="/v1/scopes/{scope_id}/sources/{source_type}", + operation_id="list_sources", + request_type=ListSourcesRequest, request_location="query", - response_type=SourceRecord, + response_type=SourcePage, success_status=200, - summary="Get one exact Source", + summary="List or search Sources", tags=("sources",), responses={ 200: { - "description": "The exact Source.", + "description": "One stable page of Sources or Source search results.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, + 400: {"$ref": "#/components/responses/BadRequest"}, 401: {"$ref": "#/components/responses/Unauthorized"}, - 404: {"$ref": "#/components/responses/NotFound"}, 405: {"$ref": "#/components/responses/OperationNotSupported"}, + 410: {"$ref": "#/components/responses/CursorExpired"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, ) -LIST_ARTIFACTS = Operation[ListArtifactsRequest, ArtifactPage]( +GET_SOURCE = Operation[None, SourceRecord]( method="GET", - path="/v1/artifacts", - operation_id="list_artifacts", - request_type=ListArtifactsRequest, - request_location="query", - response_type=ArtifactPage, + path="/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", + operation_id="get_source", + request_type=None, + request_location=None, + response_type=SourceRecord, success_status=200, - summary="List current Artifact heads", - tags=("artifacts",), + summary="Get one exact Source", + tags=("sources",), responses={ 200: { - "description": "One stable page of current Artifact heads.", + "description": "The exact Source.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 401: {"$ref": "#/components/responses/Unauthorized"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 405: {"$ref": "#/components/responses/OperationNotSupported"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, @@ -1465,7 +1436,7 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): CREATE_ARTIFACT = Operation[CreateArtifactRequest, ArtifactRevision]( method="POST", - path="/v1/artifacts", + path="/v1/scopes/{scope_id}/artifacts", operation_id="create_artifact", request_type=CreateArtifactRequest, request_location="body", @@ -1491,12 +1462,36 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -GET_ARTIFACT = Operation[GetArtifactRequest, ArtifactRevision]( +LIST_ARTIFACTS = Operation[ListArtifactsRequest, ArtifactPage]( method="GET", - path="/v1/artifacts/{artifact_id}", - operation_id="get_artifact", - request_type=GetArtifactRequest, + path="/v1/scopes/{scope_id}/artifacts/{family}", + operation_id="list_artifacts", + request_type=ListArtifactsRequest, request_location="query", + response_type=ArtifactPage, + success_status=200, + summary="List or search current Artifact heads", + tags=("artifacts",), + responses={ + 200: { + "description": "One stable page of current Artifact heads or search results.", + "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, response_type=ArtifactRevision, success_status=200, summary="Get the current Artifact head", @@ -1509,6 +1504,13 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): "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"}, @@ -1519,7 +1521,7 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): REPLACE_ARTIFACT = Operation[ReplaceArtifactRequest, ArtifactRevision]( method="PUT", - path="/v1/artifacts/{artifact_id}", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", operation_id="replace_artifact", request_type=ReplaceArtifactRequest, request_location="body", @@ -1546,18 +1548,18 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -DELETE_ARTIFACT = Operation[DeleteArtifactRequest, ArtifactDeletionStatus]( +DELETE_ARTIFACT = Operation[None, None]( method="DELETE", - path="/v1/artifacts/{artifact_id}", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", operation_id="delete_artifact", - request_type=DeleteArtifactRequest, - request_location="query", - response_type=ArtifactDeletionStatus, - success_status=200, + request_type=None, + request_location=None, + response_type=None, + success_status=204, summary="Delete the current Artifact head", tags=("artifacts",), responses={ - 200: { + 204: { "description": "The Artifact head is deleted and immutable history is retained.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, @@ -1572,12 +1574,12 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -GET_ARTIFACT_REVISION = Operation[GetArtifactRevisionRequest, ArtifactRevision]( +GET_ARTIFACT_REVISION = Operation[None, ArtifactRevision]( method="GET", - path="/v1/artifacts/{artifact_id}/revisions/{revision}", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", operation_id="get_artifact_revision", - request_type=GetArtifactRevisionRequest, - request_location="query", + request_type=None, + request_location=None, response_type=ArtifactRevision, success_status=200, summary="Get one exact immutable Artifact revision", @@ -1594,47 +1596,3 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): 500: {"$ref": "#/components/responses/InternalError"}, }, ) - -SEARCH_ARTIFACTS = Operation[SearchArtifactsRequest, ArtifactSearchResultPage]( - method="GET", - path="/v1/artifact-search-results", - operation_id="search_artifacts", - request_type=SearchArtifactsRequest, - request_location="query", - response_type=ArtifactSearchResultPage, - success_status=200, - summary="Search current Artifact heads", - tags=("artifacts",), - responses={ - 200: { - "description": "One stable page of matching current Artifact heads.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 422: {"$ref": "#/components/responses/InvalidRequest"}, - 503: {"$ref": "#/components/responses/Unavailable"}, - 500: {"$ref": "#/components/responses/InternalError"}, - }, -) - -LIST_SCOPES = Operation[ListScopesRequest, ScopePage]( - method="GET", - path="/v1/scopes", - operation_id="list_scopes", - request_type=ListScopesRequest, - request_location="query", - response_type=ScopePage, - success_status=200, - summary="List observable Scopes", - tags=("scopes",), - responses={ - 200: { - "description": "One stable page of Scopes observable by the caller.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 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 09fc26bdf..79cb24265 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1505,12 +1505,23 @@ }, } }, - "/v1/sources": { + "/v1/scopes/{scope_id}/sources": { "post": { "tags": ["sources"], "summary": "Create a durable Source", - "description": "Create one idempotent Source without synchronously deriving Artifacts.", + "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"}}}, @@ -1531,36 +1542,32 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - }, + } + }, + "/v1/scopes/{scope_id}/sources/{source_type}": { "get": { "tags": ["sources"], "summary": "List or search Sources", - "description": "List when type is omitted or list; search when type is search and q is nonblank.", - "operationId": "search_sources", + "description": "List when query is absent or blank; search when query is nonblank.", + "operationId": "list_sources", "parameters": [ { "name": "scope_id", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { "name": "source_type", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, { - "name": "type", + "name": "query", "in": "query", "required": False, - "schema": {"$ref": "#/components/schemas/SourceQueryType", "default": "list"}, - }, - { - "name": "q", - "in": "query", - "required": False, - "schema": {"type": "string", "minLength": 1, "maxLength": 8192}, + "schema": {"type": "string", "maxLength": 8192}, }, { "name": "mode", @@ -1587,38 +1594,40 @@ "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourcePage"}}}, }, + "400": {"$ref": "#/components/responses/BadRequest"}, "401": {"$ref": "#/components/responses/Unauthorized"}, "405": {"$ref": "#/components/responses/OperationNotSupported"}, + "410": {"$ref": "#/components/responses/CursorExpired"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - }, + } }, - "/v1/sources/{source_id}": { + "/v1/scopes/{scope_id}/sources/{source_type}/{source_id}": { "get": { "tags": ["sources"], "summary": "Get one exact Source", "operationId": "get_source", "parameters": [ - { - "name": "source_id", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256}, - }, { "name": "scope_id", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { "name": "source_type", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, + { + "name": "source_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[\\x21-\\x7E]+$"}, + }, ], "responses": { "200": { @@ -1635,12 +1644,23 @@ }, } }, - "/v1/artifacts": { + "/v1/scopes/{scope_id}/artifacts": { "post": { "tags": ["artifacts"], "summary": "Create an Artifact", - "description": "Commit revision one for a family that permits direct creation.", + "description": "Commit revision one for a " + "family that permits direct " + "creation. The Server " + "generates artifact_id.", "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"}}}, @@ -1662,24 +1682,39 @@ "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, }, - }, + } + }, + "/v1/scopes/{scope_id}/artifacts/{family}": { "get": { "tags": ["artifacts"], - "summary": "List current Artifact heads", + "summary": "List or search current Artifact heads", + "description": "List when query is absent or blank; search when query is nonblank.", "operationId": "list_artifacts", "parameters": [ { "name": "scope_id", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { "name": "family", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, + { + "name": "query", + "in": "query", + "required": False, + "schema": {"type": "string", "maxLength": 8192}, + }, + { + "name": "mode", + "in": "query", + "required": False, + "schema": {"$ref": "#/components/schemas/TextSearchMode"}, + }, { "name": "limit", "in": "query", @@ -1695,41 +1730,49 @@ ], "responses": { "200": { - "description": "One stable page of current Artifact heads.", + "description": "One stable page of current Artifact heads or search results.", "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/artifacts/{artifact_id}": { + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}": { "get": { "tags": ["artifacts"], "summary": "Get the current Artifact head", "operationId": "get_artifact", "parameters": [ { - "name": "artifact_id", + "name": "scope_id", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { - "name": "scope_id", - "in": "query", + "name": "family", + "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, { - "name": "family", - "in": "query", + "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", "pattern": '^"revision:[1-9][0-9]*"$'}, + }, ], "responses": { "200": { @@ -1740,6 +1783,13 @@ }, "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"}, @@ -1754,20 +1804,20 @@ "operationId": "replace_artifact", "parameters": [ { - "name": "artifact_id", + "name": "scope_id", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { - "name": "scope_id", - "in": "query", + "name": "family", + "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, { - "name": "family", - "in": "query", + "name": "artifact_id", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, @@ -1810,20 +1860,20 @@ "operationId": "delete_artifact", "parameters": [ { - "name": "artifact_id", + "name": "scope_id", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { - "name": "scope_id", - "in": "query", + "name": "family", + "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, + "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, { - "name": "family", - "in": "query", + "name": "artifact_id", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, @@ -1835,12 +1885,9 @@ }, ], "responses": { - "200": { + "204": { "description": "The Artifact head is deleted and immutable history is retained.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactDeletionStatus"}} - }, }, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, @@ -1853,130 +1900,40 @@ }, }, }, - "/v1/artifacts/{artifact_id}/revisions/{revision}": { + "/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": "artifact_id", - "in": "path", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128}, - }, - {"name": "revision", "in": "path", "required": True, "schema": {"type": "integer", "minimum": 1}}, { "name": "scope_id", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, }, { "name": "family", - "in": "query", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, - ], - "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"}, - }, - } - }, - "/v1/artifact-search-results": { - "get": { - "tags": ["artifacts"], - "summary": "Search current Artifact heads", - "operationId": "search_artifacts", - "parameters": [ - { - "name": "scope_id", - "in": "query", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": ".*\\S.*"}, - }, { - "name": "family", - "in": "query", + "name": "artifact_id", + "in": "path", "required": True, "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, }, - { - "name": "q", - "in": "query", - "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 8192, "pattern": ".*\\S.*"}, - }, - { - "name": "mode", - "in": "query", - "required": False, - "schema": {"$ref": "#/components/schemas/TextSearchMode", "default": "auto"}, - }, - { - "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 matching current Artifact heads.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/ArtifactSearchResultPage"}} - }, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "422": {"$ref": "#/components/responses/InvalidRequest"}, - "503": {"$ref": "#/components/responses/Unavailable"}, - "500": {"$ref": "#/components/responses/InternalError"}, - }, - } - }, - "/v1/scopes": { - "get": { - "tags": ["scopes"], - "summary": "List observable Scopes", - "operationId": "list_scopes", - "parameters": [ - { - "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}, - }, + {"name": "revision", "in": "path", "required": True, "schema": {"type": "integer", "minimum": 1}}, ], "responses": { "200": { - "description": "One stable page of Scopes observable by the caller.", + "description": "The exact immutable Artifact revision.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ScopePage"}}}, + "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"}, @@ -2003,45 +1960,42 @@ "type": "object", "required": ["scope_id", "boundary_source", "objective"], }, - "ArtifactDeletionState": {"type": "string", "enum": ["deleted"]}, - "ArtifactDeletionStatus": { + "ArtifactCollectionItem": { "properties": { + "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, - "status": {"$ref": "#/components/schemas/ArtifactDeletionState"}, - "deleted_at": {"type": "string", "format": "date-time"}, + "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "score": {"type": "number", "maximum": 1.0, "minimum": 0.0, "nullable": True}, + "snippets": {"items": {"type": "string"}, "type": "array"}, }, - "additionalProperties": False, "type": "object", - "required": ["artifact_ref", "status", "deleted_at"], + "required": ["scope_id", "artifact_ref", "created_at", "content_digest", "score", "snippets"], }, "ArtifactPage": { "properties": { - "items": {"items": {"$ref": "#/components/schemas/ArtifactRevision"}, "type": "array"}, + "query": {"type": "string", "nullable": True}, + "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchUsedMode"}], "nullable": True}, + "items": {"items": {"$ref": "#/components/schemas/ArtifactCollectionItem"}, "type": "array"}, "next_cursor": {"type": "string", "nullable": True}, }, - "additionalProperties": False, "type": "object", - "required": ["items", "next_cursor"], + "required": ["query", "mode", "items", "next_cursor"], }, "ArtifactRevision": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, - "schema_version": {"type": "integer", "minimum": 1.0}, - "metadata": {"additionalProperties": True, "type": "object"}, "content": {"additionalProperties": True, "type": "object"}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceReference"}, "type": "array"}, + "source_refs": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, "created_at": {"type": "string", "format": "date-time", "nullable": True}, "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, }, - "additionalProperties": False, "type": "object", "required": [ "scope_id", "artifact_ref", - "schema_version", - "metadata", "content", "source_refs", "artifact_refs", @@ -2049,27 +2003,6 @@ "content_digest", ], }, - "ArtifactSearchHit": { - "properties": { - "artifact": {"$ref": "#/components/schemas/ArtifactRevision"}, - "score": {"type": "number", "maximum": 1.0, "minimum": 0.0}, - "snippets": {"items": {"type": "string"}, "type": "array"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["artifact", "score", "snippets"], - }, - "ArtifactSearchResultPage": { - "properties": { - "query": {"type": "string"}, - "mode": {"$ref": "#/components/schemas/TextSearchUsedMode"}, - "hits": {"items": {"$ref": "#/components/schemas/ArtifactSearchHit"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["query", "mode", "hits", "next_cursor"], - }, "ArtifactReference": { "properties": { "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, @@ -4237,90 +4170,53 @@ }, "CreateArtifactRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1, "nullable": True}, - "schema_version": {"type": "integer", "minimum": 1.0}, - "metadata": {"additionalProperties": True, "type": "object", "default": {}}, "content": {"additionalProperties": True, "type": "object"}, "source_refs": { - "items": {"$ref": "#/components/schemas/SourceReference"}, + "items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array", - "maxItems": 32, "default": [], }, "artifact_refs": { "items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array", - "maxItems": 32, "default": [], }, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "family", "schema_version", "content"], + "required": ["family", "content"], }, "CreateSourceRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1}, - "content": {"type": "string", "maxLength": 200000, "minLength": 1, "pattern": ".*\\S.*"}, + "source_type": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21-\\x7E]+$", + "default": "content", + }, + "content": {"description": "JSON value validated by the selected Source adapter."}, "metadata": {"additionalProperties": True, "type": "object", "default": {}}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "source_type", "source_id", "content"], - }, - "DeleteArtifactRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "family"], - }, - "GetArtifactRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "family"], - }, - "GetArtifactRevisionRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "family"], - }, - "GetSourceRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "source_type"], + "required": ["content"], }, "ListArtifactsRequest": { "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "query": {"type": "string", "maxLength": 8192, "nullable": True}, + "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchMode"}], "nullable": True}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, }, "additionalProperties": False, "type": "object", - "required": ["scope_id", "family"], }, - "ListScopesRequest": { + "ListSourcesRequest": { "properties": { + "query": {"type": "string", "maxLength": 8192, "nullable": True}, + "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchMode"}], "nullable": True}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, }, @@ -4329,126 +4225,89 @@ }, "ReplaceArtifactRequest": { "properties": { - "schema_version": {"type": "integer", "minimum": 1.0}, - "metadata": {"additionalProperties": True, "type": "object", "default": {}}, "content": {"additionalProperties": True, "type": "object"}, "source_refs": { - "items": {"$ref": "#/components/schemas/SourceReference"}, + "items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array", - "maxItems": 32, "default": [], }, "artifact_refs": { "items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array", - "maxItems": 32, "default": [], }, }, "additionalProperties": False, "type": "object", - "required": ["schema_version", "content"], + "required": ["content"], }, - "ScopePage": { - "properties": { - "items": {"items": {"$ref": "#/components/schemas/ScopeSummary"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["items", "next_cursor"], - }, - "ScopeSummary": { + "SourceCollectionItem": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "title": {"type": "string", "nullable": True}, - "summary": {"type": "string", "nullable": True}, - "parent_scope_id": {"type": "string", "nullable": True}, - "version": {"type": "integer", "minimum": 1.0, "nullable": True}, - "source_types": {"items": {"type": "string"}, "type": "array"}, - "artifact_families": {"items": {"type": "string"}, "type": "array"}, - "source_count": {"type": "integer", "minimum": 0.0}, - "artifact_count": {"type": "integer", "minimum": 0.0}, + "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "metadata": {"additionalProperties": True, "type": "object"}, + "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "position": {"type": "integer", "minimum": 1.0}, + "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "score": {"type": "number", "maximum": 1.0, "minimum": 0.0, "nullable": True}, + "snippets": {"items": {"type": "string"}, "type": "array"}, }, - "additionalProperties": False, "type": "object", "required": [ "scope_id", - "title", - "summary", - "parent_scope_id", - "version", - "source_types", - "artifact_families", - "source_count", - "artifact_count", + "source_type", + "source_id", + "metadata", + "created_at", + "position", + "content_digest", + "score", + "snippets", ], }, - "SearchArtifactsRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "q": {"type": "string", "maxLength": 8192, "minLength": 1, "pattern": ".*\\S.*"}, - "mode": {"$ref": "#/components/schemas/TextSearchMode", "default": "auto"}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "family", "q"], - }, - "SearchSourcesRequest": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "type": {"$ref": "#/components/schemas/SourceQueryType", "default": "list"}, - "q": {"type": "string", "maxLength": 8192, "minLength": 1, "nullable": True}, - "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchMode"}], "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - "required": ["scope_id", "source_type"], - }, "SourcePage": { "properties": { "query": {"type": "string", "nullable": True}, "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchUsedMode"}], "nullable": True}, - "items": {"items": {"$ref": "#/components/schemas/SourceRecord"}, "type": "array"}, + "items": {"items": {"$ref": "#/components/schemas/SourceCollectionItem"}, "type": "array"}, "next_cursor": {"type": "string", "nullable": True}, }, - "additionalProperties": False, "type": "object", "required": ["query", "mode", "items", "next_cursor"], }, - "SourceQueryType": {"type": "string", "enum": ["list", "search"]}, "SourceRecord": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_ref": {"$ref": "#/components/schemas/SourceReference"}, - "content": {"type": "string"}, + "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "content": {"description": "Persisted canonical JSON content."}, "metadata": {"additionalProperties": True, "type": "object"}, "created_at": {"type": "string", "format": "date-time", "nullable": True}, "position": {"type": "integer", "minimum": 1.0}, "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "score": {"type": "number", "maximum": 1.0, "minimum": 0.0, "nullable": True}, - "snippets": {"items": {"type": "string"}, "type": "array"}, }, - "additionalProperties": False, "type": "object", "required": [ "scope_id", - "source_ref", + "source_type", + "source_id", "content", "metadata", "created_at", "position", "content_digest", - "score", - "snippets", ], }, + "SourceTypeReference": { + "properties": { + "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "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."}, @@ -4484,6 +4343,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": { @@ -4499,7 +4368,14 @@ }, "OperationNotSupported": { "description": "The Source type or Artifact family does not permit this operation.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, + "headers": { + "Allow": { + "description": "HTTP methods supported by the selected Source type or Artifact family.", + "required": True, + "schema": {"type": "string"}, + }, + "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, + }, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, "PreconditionFailed": { diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 9f1c02405..489918e05 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -27,17 +27,21 @@ from functools import wraps from time import perf_counter from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, cast -from urllib.parse import quote +from urllib.parse import quote, unquote from uuid import uuid4 from fastapi import Depends, FastAPI, Header, Path, Query, Request, Response, status 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 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 ArtifactRef @@ -99,6 +103,9 @@ InvalidActivityRepositoryArgumentError, ) from powercontext.builtin.inference.errors import InferenceTimeoutError, InferenceUnavailableError +from powercontext.builtin.records import ( + ArtifactCollectionItem as RuntimeArtifactCollectionItem, +) from powercontext.builtin.records import ( ArtifactDeletion as RuntimeArtifactDeletion, ) @@ -114,20 +121,14 @@ BaseOperationNotSupportedError, BaseValueConflictError, BaseValueNotFoundError, + CursorExpiredError, InvalidBaseAccessRequestError, -) -from powercontext.builtin.records import ( - ArtifactSearchPage as RuntimeArtifactSearchPage, + InvalidCursorError, ) from powercontext.builtin.records import ( ArtifactWrite as RuntimeArtifactWrite, ) -from powercontext.builtin.records import ( - ScopeSummaryPage as RuntimeScopeSummaryPage, -) -from powercontext.builtin.records import ( - SourceQueryType as RuntimeSourceQueryType, -) +from powercontext.builtin.records import SourceCollectionItem as RuntimeSourceCollectionItem from powercontext.builtin.records import ( SourceRecord as RuntimeSourceRecord, ) @@ -260,12 +261,9 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, - ArtifactDeletionState, - ArtifactDeletionStatus, + ArtifactCollectionItem, ArtifactPage, ArtifactRevision, - ArtifactSearchHit, - ArtifactSearchResultPage, AttachHandoffReportWorkspaceRequest, Capabilities, CaptureContentSourceRequest, @@ -277,7 +275,6 @@ CreateHandoffReportProjectRequest, CreateSourceRequest, CreateWorkContractRequest, - DeleteArtifactRequest, DetachHandoffReportWorkspaceRequest, ErrorDetail, ErrorResponse, @@ -290,15 +287,12 @@ GenerateExperienceRequest, GenerateSkillRequest, GetArtifactCandidateRequest, - GetArtifactRequest, - GetArtifactRevisionRequest, GetExperienceRequest, GetHandoffReportProjectRequest, GetHandoffReportRequest, GetHandoffReportWorkspaceRequest, GetMemoryEntryRequest, GetSkillRequest, - GetSourceRequest, GetStatsRequest, HandoffAcknowledgement, HandoffCurrentWorkRequest, @@ -323,7 +317,7 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListScopesRequest, + ListSourcesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -351,15 +345,11 @@ ScanExternalSkillsRequest, ScanExternalSkillsResponse, ScopedStats, - ScopePage, - ScopeSummary, - SearchArtifactsRequest, SearchMemoryRequest, SearchMemoryResponse, - SearchSourcesRequest, SkillArtifact, + SourceCollectionItem, SourcePage, - SourceQueryType, SourceRecord, StoredHandoffReportActivity, TextSearchMode, @@ -428,7 +418,7 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, - LIST_SCOPES, + LIST_SOURCES, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, @@ -446,9 +436,7 @@ REVISE_ARTIFACT_CANDIDATE, REVISE_MEMORY_ENTRY, SCAN_EXTERNAL_SKILLS, - SEARCH_ARTIFACTS, SEARCH_MEMORY, - SEARCH_SOURCES, UPDATE_HANDOFF_REPORT_PROJECT, UPDATE_HANDOFF_REPORT_WORKSTREAM, Operation, @@ -491,9 +479,8 @@ class _ScopedRecordApplication(Protocol): async def create_source( self, source_type: str, - source_id: str, - content: str, - metadata: dict[str, Any], + content: JsonValue, + metadata: dict[str, JsonValue], /, ) -> RuntimeSourceRecord: ... @@ -502,7 +489,6 @@ async def get_source(self, source_type: str, source_id: str, /) -> RuntimeSource async def query_sources( self, source_type: str, - query_type: RuntimeSourceQueryType, /, *, query: str | None, @@ -514,7 +500,6 @@ async def query_sources( async def create_artifact( self, family: str, - artifact_id: str | None, write: RuntimeArtifactWrite, /, ) -> RuntimeArtifactRecord: ... @@ -529,26 +514,17 @@ async def get_artifact_revision( /, ) -> RuntimeArtifactRecord: ... - async def list_artifacts( + async def query_artifacts( self, family: str, /, *, + query: str | None, + mode: RuntimeTextSearchMode | None, limit: int, cursor: str | None, ) -> RuntimeArtifactRecordPage: ... - async def search_artifacts( - self, - family: str, - query: str, - /, - *, - mode: RuntimeTextSearchMode, - limit: int, - cursor: str | None, - ) -> RuntimeArtifactSearchPage: ... - async def replace_artifact( self, family: str, @@ -570,8 +546,6 @@ async def delete_artifact( class _RecordApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedRecordApplication: ... - async def list_scopes(self, *, limit: int, cursor: str | None) -> RuntimeScopeSummaryPage: ... - class _ScopedContextApplication(Protocol): async def prepare(self, request: RuntimePrepareContextRequest, /) -> RuntimePreparedContext: ... @@ -777,11 +751,19 @@ async def attach_request_id(request: Request, call_next: RequestResponseEndpoint @app.exception_handler(RequestValidationError) async def invalid_request(request: Request, error: RequestValidationError) -> JSONResponse: + errors = _validation_error_details(error) + scoped_resource_syntax_error = request.url.path.startswith("/v1/scopes/") and not any( + isinstance(item, dict) + and isinstance(item.get("loc"), (list, tuple)) + and item["loc"] + and item["loc"][0] == "body" + 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) @@ -790,7 +772,8 @@ async def invalid_request(request: Request, error: RequestValidationError) -> JS @app.exception_handler(PowerContextError) async def application_error(request: Request, error: Exception) -> JSONResponse: response_status, code, message, details = _map_error(error) - return _error_response(response_status, code=code, message=message, details=details) + headers = {"Allow": "GET"} if isinstance(error, BaseOperationNotSupportedError) else None + return _error_response(response_status, code=code, message=message, details=details, headers=headers) @app.exception_handler(Exception) async def unexpected_error(request: Request, error: Exception) -> JSONResponse: @@ -825,16 +808,14 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, DETACH_HANDOFF_REPORT_WORKSPACE, detach_handoff_report_workspace) _add_route(app, GET_HANDOFF_REPORT, get_handoff_report) _add_route(app, CREATE_SOURCE, create_source) - _add_route(app, SEARCH_SOURCES, search_sources) _add_route(app, GET_SOURCE, get_source) + _add_route(app, LIST_SOURCES, list_sources) _add_route(app, CREATE_ARTIFACT, create_artifact) - _add_route(app, LIST_ARTIFACTS, list_artifacts) + _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, DELETE_ARTIFACT, delete_artifact) - _add_route(app, GET_ARTIFACT_REVISION, get_artifact_revision) - _add_route(app, SEARCH_ARTIFACTS, search_artifacts) - _add_route(app, LIST_SCOPES, list_scopes) _add_route(app, CAPTURE_CONTENT_SOURCE, capture_content_source) _add_route(app, FLUSH_MEMORY, flush_memory) _add_route(app, REMEMBER_MEMORY, remember_memory) @@ -1182,13 +1163,13 @@ async def get_handoff_report( 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(request.scope_id).create_source( + result = await application.records.for_scope(scope_id).create_source( request.source_type, - request.source_id, request.content, request.metadata, ) @@ -1196,34 +1177,24 @@ async def create_source( return _source_record_response(result) -def _search_sources_query( - scope_id: Annotated[str, Query(min_length=1, max_length=256, pattern=r".*\S.*")], - source_type: Annotated[str, Query(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], - query_type: Annotated[SourceQueryType, Query(alias="type")] = SourceQueryType.LIST, - q: Annotated[str | None, Query(min_length=1, max_length=8192)] = None, +def _list_sources_query( + query: Annotated[str | None, Query(max_length=8192)] = None, mode: Annotated[TextSearchMode | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, -) -> SearchSourcesRequest: - return SearchSourcesRequest( - scope_id=scope_id, - source_type=source_type, - type=query_type, - q=q, - mode=mode, - limit=limit, - cursor=cursor, - ) +) -> ListSourcesRequest: + return ListSourcesRequest(query=query, mode=mode, limit=limit, cursor=cursor) -async def search_sources( - request: Annotated[SearchSourcesRequest, Depends(_search_sources_query)], +async def list_sources( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + source_type: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + request: Annotated[ListSourcesRequest, Depends(_list_sources_query)], application: Annotated[ServerApplication, Depends(_require_application)], ) -> SourcePage: - result = await application.records.for_scope(request.scope_id).query_sources( - request.source_type, - request.type.value, - query=request.q, + result = await application.records.for_scope(scope_id).query_sources( + source_type, + query=request.query, mode=None if request.mode is None else request.mode.value, limit=request.limit, cursor=request.cursor, @@ -1231,28 +1202,29 @@ async def search_sources( return SourcePage( query=result.query, mode=None if result.mode is None else TextSearchUsedMode(result.mode), - items=[_source_record_response(item) for item in result.items], + items=[_source_collection_item_response(item) for item in result.items], next_cursor=result.next_cursor, ) async def get_source( - source_id: Annotated[str, Path(min_length=1, max_length=256)], - request: Annotated[GetSourceRequest, Query()], + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + source_type: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + 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(request.scope_id).get_source(request.source_type, source_id) + 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)], ) -> ArtifactRevision: - result = await application.records.for_scope(request.scope_id).create_artifact( + result = await application.records.for_scope(scope_id).create_artifact( request.family, - request.artifact_id, _artifact_write(request), ) response.headers["Location"] = _artifact_location(result) @@ -1261,51 +1233,66 @@ async def create_artifact( def _list_artifacts_query( - scope_id: Annotated[str, Query(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[str, Query(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + query: Annotated[str | None, Query(max_length=8192)] = None, + mode: Annotated[TextSearchMode | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, ) -> ListArtifactsRequest: - return ListArtifactsRequest(scope_id=scope_id, family=family, limit=limit, cursor=cursor) + return ListArtifactsRequest(query=query, mode=mode, limit=limit, cursor=cursor) async def list_artifacts( + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], request: Annotated[ListArtifactsRequest, Depends(_list_artifacts_query)], application: Annotated[ServerApplication, Depends(_require_application)], ) -> ArtifactPage: - result = await application.records.for_scope(request.scope_id).list_artifacts( - request.family, + result = await application.records.for_scope(scope_id).query_artifacts( + family, + query=request.query, + mode=None if request.mode is None else request.mode.value, limit=request.limit, cursor=request.cursor, ) return ArtifactPage( - items=[_artifact_revision_response(item) for item in result.items], + query=result.query, + mode=None if result.mode is None else TextSearchUsedMode(result.mode), + items=[_artifact_collection_item_response(item) for item in result.items], next_cursor=result.next_cursor, ) async def get_artifact( - artifact_id: Annotated[str, Path(min_length=1, max_length=128)], - request: Annotated[GetArtifactRequest, Query()], + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], response: Response, application: Annotated[ServerApplication, Depends(_require_application)], -) -> ArtifactRevision: - result = await application.records.for_scope(request.scope_id).get_artifact(request.family, artifact_id) - response.headers["ETag"] = _artifact_etag(result.artifact_ref.revision) + if_none_match: Annotated[ + str | None, + Header(alias="If-None-Match", pattern=r'^"revision:[1-9][0-9]*"$'), + ] = None, +) -> ArtifactRevision | Response: + result = await application.records.for_scope(scope_id).get_artifact(family, artifact_id) + etag = _artifact_etag(result.artifact_ref.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( - artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], request: ReplaceArtifactRequest, - selector: Annotated[GetArtifactRequest, Query()], response: Response, application: Annotated[ServerApplication, Depends(_require_application)], if_match: Annotated[str | None, Header(alias="If-Match")] = None, ) -> ArtifactRevision: expected_revision = _require_artifact_etag(if_match) - result = await application.records.for_scope(selector.scope_id).replace_artifact( - selector.family, + result = await application.records.for_scope(scope_id).replace_artifact( + family, artifact_id, expected_revision, _artifact_write(request), @@ -1315,122 +1302,58 @@ async def replace_artifact( async def delete_artifact( - artifact_id: Annotated[str, Path(min_length=1, max_length=128)], - request: Annotated[DeleteArtifactRequest, Query()], + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], application: Annotated[ServerApplication, Depends(_require_application)], if_match: Annotated[str | None, Header(alias="If-Match")] = None, -) -> ArtifactDeletionStatus: +) -> Response: expected_revision = _require_artifact_etag(if_match) - result = await application.records.for_scope(request.scope_id).delete_artifact( - request.family, + await application.records.for_scope(scope_id).delete_artifact( + family, artifact_id, expected_revision, ) - return ArtifactDeletionStatus( - artifact_ref=mapping.artifact_reference(result.artifact_ref), - status=ArtifactDeletionState.DELETED, - deleted_at=result.deleted_at, - ) + return Response(status_code=status.HTTP_204_NO_CONTENT) async def get_artifact_revision( - artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], + family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], revision: Annotated[int, Path(ge=1)], - request: Annotated[GetArtifactRevisionRequest, Query()], application: Annotated[ServerApplication, Depends(_require_application)], ) -> ArtifactRevision: - result = await application.records.for_scope(request.scope_id).get_artifact_revision( - request.family, + result = await application.records.for_scope(scope_id).get_artifact_revision( + family, artifact_id, revision, ) return _artifact_revision_response(result) -def _search_artifacts_query( - scope_id: Annotated[str, Query(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[str, Query(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], - q: Annotated[str, Query(min_length=1, max_length=8192, pattern=r".*\S.*")], - mode: Annotated[TextSearchMode, Query()] = TextSearchMode.AUTO, - limit: Annotated[int, Query(ge=1, le=100)] = 50, - cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, -) -> SearchArtifactsRequest: - return SearchArtifactsRequest( - scope_id=scope_id, - family=family, - q=q, - mode=mode, - limit=limit, - cursor=cursor, - ) - - -async def search_artifacts( - request: Annotated[SearchArtifactsRequest, Depends(_search_artifacts_query)], - application: Annotated[ServerApplication, Depends(_require_application)], -) -> ArtifactSearchResultPage: - result = await application.records.for_scope(request.scope_id).search_artifacts( - request.family, - request.q, - mode=request.mode.value, - limit=request.limit, - cursor=request.cursor, - ) - return ArtifactSearchResultPage( - query=result.query, - mode=TextSearchUsedMode(result.mode), - hits=[ - ArtifactSearchHit( - artifact=_artifact_revision_response(hit.artifact), - score=hit.score, - snippets=list(hit.snippets), - ) - for hit in result.hits - ], - next_cursor=result.next_cursor, - ) - - -async def list_scopes( - request: Annotated[ListScopesRequest, Depends(_list_scopes_query)], - application: Annotated[ServerApplication, Depends(_require_application)], -) -> ScopePage: - result = await application.records.list_scopes(limit=request.limit, cursor=request.cursor) - return ScopePage( - items=[ - ScopeSummary( - scope_id=item.scope_id, - title=item.title, - summary=item.summary, - parent_scope_id=item.parent_scope_id, - version=item.version, - source_types=list(item.source_types), - artifact_families=list(item.artifact_families), - source_count=item.source_count, - artifact_count=item.artifact_count, - ) - for item in result.items - ], - next_cursor=result.next_cursor, - ) - - -def _list_scopes_query( - limit: Annotated[int, Query(ge=1, le=100)] = 50, - cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, -) -> ListScopesRequest: - return ListScopesRequest(limit=limit, cursor=cursor) - - def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord: return SourceRecord( scope_id=value.scope_id, - source_ref=mapping.source_reference(value.source_ref), + source_type=value.source_type, + source_id=value.source_id, content=value.content, metadata=value.metadata, created_at=value.created_at, position=value.position, content_digest=value.content_digest, + ) + + +def _source_collection_item_response(value: RuntimeSourceCollectionItem) -> SourceCollectionItem: + return SourceCollectionItem( + scope_id=value.scope_id, + source_type=value.source_type, + source_id=value.source_id, + metadata=value.metadata, + created_at=value.created_at, + position=value.position, + content_digest=value.content_digest, score=value.score, snippets=list(value.snippets), ) @@ -1440,38 +1363,45 @@ def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevisio return ArtifactRevision( scope_id=value.scope_id, artifact_ref=mapping.artifact_reference(value.artifact_ref), - schema_version=value.schema_version, - metadata=value.metadata, content=value.content, - source_refs=[mapping.source_reference(ref) for ref in value.source_refs], + source_refs=[mapping.source_type_reference(ref) for ref in value.source_refs], artifact_refs=[mapping.artifact_reference(ref) for ref in value.artifact_refs], created_at=value.created_at, content_digest=value.content_digest, ) +def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) -> ArtifactCollectionItem: + return ArtifactCollectionItem( + scope_id=value.scope_id, + artifact_ref=mapping.artifact_reference(value.artifact_ref), + created_at=value.created_at, + content_digest=value.content_digest, + score=value.score, + snippets=list(value.snippets), + ) + + def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: return RuntimeArtifactWrite( - schema_version=value.schema_version, - metadata=value.metadata, content=value.content, - source_refs=tuple(mapping.runtime_source_reference(ref) for ref in value.source_refs), + source_refs=tuple(mapping.runtime_source_type_reference(ref) for ref in value.source_refs), artifact_refs=tuple(mapping.runtime_artifact_reference(ref) for ref in value.artifact_refs), ) def _source_location(value: RuntimeSourceRecord) -> str: - source_id = quote(value.source_ref.source_id, safe="") + source_id = quote(value.source_id, safe="") scope_id = quote(value.scope_id, safe="") - source_type = quote(value.source_ref.source_type, safe="") - return f"/v1/sources/{source_id}?scope_id={scope_id}&source_type={source_type}" + source_type = quote(value.source_type, safe="") + return f"/v1/scopes/{scope_id}/sources/{source_type}/{source_id}" def _artifact_location(value: RuntimeArtifactRecord) -> str: artifact_id = quote(value.artifact_ref.artifact_id, safe="") scope_id = quote(value.scope_id, safe="") family = quote(value.artifact_ref.family, safe="") - return f"/v1/artifacts/{artifact_id}?scope_id={scope_id}&family={family}" + return f"/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}" def _artifact_etag(revision: int) -> str: @@ -1857,7 +1787,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], @@ -1867,9 +1797,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], @@ -1986,9 +1943,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) -> list[Any]: @@ -2081,9 +2039,28 @@ def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, A "The stable identity already names different durable state.", {"kind": error.kind}, ) + 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 ( - status.HTTP_422_UNPROCESSABLE_CONTENT, + response_status, "invalid_request", "The request is invalid.", {"field": error.field, "reason": error.reason}, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index c26efc143..2864fb20f 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -225,6 +225,7 @@ SkillProposal, SkillValidationItem, SourceReference, + SourceTypeReference, TaskCheck, WorkClaim, WorkSourceKind, @@ -842,6 +843,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=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/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index c65aa9473..c884c36a8 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -15,20 +15,38 @@ from __future__ import annotations import asyncio +import hashlib +import sqlite3 +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import cast +from unittest.mock import AsyncMock, MagicMock import pytest +import rfc8785 +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.builtin.persistence.records import RelationalRecordService +from powercontext.builtin.persistence.schema import ensure_record_columns from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import SHARED_TABLES +from powercontext.builtin.persistence.statistics import StatisticsRepository +from powercontext.builtin.persistence.tables import ( + ARTIFACT_HEADS_TABLE, + ARTIFACTS_TABLE, + SHARED_TABLES, + SOURCES_TABLE, +) from powercontext.builtin.records import ( ArtifactRevisionPreconditionError, ArtifactWrite, BaseOperationNotSupportedError, BaseValueConflictError, BaseValueNotFoundError, + CursorExpiredError, InvalidBaseAccessRequestError, + InvalidCursorError, ) from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER from powercontext.sources import SourceRef @@ -37,18 +55,20 @@ def test_source_records_support_create_get_list_search_and_bound_cursors() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: + generated_ids = iter(("source-1", "source-2")) records = RelationalRecordService( profile.database, SourceRepository((CONTENT_SOURCE_ADAPTER,)), + id_factory=lambda _kind: next(generated_ids), + cursor_secret=b"source-record-cursor-test", ) first = await records.create_source( "scope-a", "content", - "source-1", "Keep the OpenAPI contract authoritative.", {"channel": "test"}, ) - repeated = await records.create_source( + repeated = await records.capture_source( "scope-a", "content", "source-1", @@ -58,7 +78,6 @@ async def scenario() -> None: second = await records.create_source( "scope-a", "content", - "source-2", "Preserve immutable artifact revisions.", {}, ) @@ -66,100 +85,267 @@ async def scenario() -> None: assert repeated == first assert first.position == 1 assert second.position == 2 + assert first.content_digest == f"sha256:{hashlib.sha256(rfc8785.dumps(first.content)).hexdigest()}" assert (await records.get_source("scope-a", "content", "source-1")) == first first_page = await records.query_sources( "scope-a", "content", - "list", query=None, mode=None, limit=1, cursor=None, ) - assert [item.source_ref.source_id for item in first_page.items] == ["source-1"] + assert [item.source_id for item in first_page.items] == ["source-1"] + assert "content" not in type(first_page.items[0]).model_fields assert first_page.next_cursor is not None second_page = await records.query_sources( "scope-a", "content", - "list", query=None, mode=None, limit=1, cursor=first_page.next_cursor, ) - assert [item.source_ref.source_id for item in second_page.items] == ["source-2"] + assert [item.source_id for item in second_page.items] == ["source-2"] + + assert first_page.next_cursor is not None + payload, signature = first_page.next_cursor.split(".") + tampered_signature = ("A" if signature[0] != "A" else "B") + signature[1:] + with pytest.raises(InvalidCursorError): + await records.query_sources( + "scope-a", + "content", + query=None, + mode=None, + limit=1, + cursor=f"{payload}.{tampered_signature}", + ) + + other_caller = RelationalRecordService( + profile.database, + SourceRepository((CONTENT_SOURCE_ADAPTER,)), + cursor_secret=b"other-caller-cursor-test", + ) + with pytest.raises(InvalidCursorError): + await other_caller.query_sources( + "scope-a", + "content", + query=None, + mode=None, + limit=1, + cursor=first_page.next_cursor, + ) found = await records.query_sources( "scope-a", "content", - "search", query="OpenAPI authoritative", mode="auto", limit=10, cursor=None, ) - assert [item.source_ref.source_id for item in found.items] == ["source-1"] + assert [item.source_id for item in found.items] == ["source-1"] assert found.mode == "keyword" assert found.items[0].score == 1.0 with pytest.raises(BaseValueConflictError): - await records.create_source("scope-a", "content", "source-1", "different", {}) + await records.capture_source("scope-a", "content", "source-1", "different", {}) with pytest.raises(InvalidBaseAccessRequestError): await records.query_sources( "scope-a", "content", - "search", query="OpenAPI", mode="auto", limit=1, cursor=first_page.next_cursor, ) + with pytest.raises(InvalidBaseAccessRequestError): + await records.query_sources( + "scope-a", + "content", + query=None, + mode="auto", + limit=10, + cursor=None, + ) + with pytest.raises(InvalidBaseAccessRequestError): + await records.query_sources( + "scope-a", + "content", + query=None, + mode=None, + limit=101, + cursor=None, + ) with pytest.raises(BaseOperationNotSupportedError): await records.get_source("scope-a", "git", "source-1") asyncio.run(scenario()) +def test_record_cursor_expires_after_its_bounded_lifetime() -> None: + async def scenario() -> None: + current_time = [datetime(2026, 9, 3, tzinfo=UTC)] + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: + generated_ids = iter(("source-1", "source-2")) + records = RelationalRecordService( + profile.database, + SourceRepository((CONTENT_SOURCE_ADAPTER,)), + clock=lambda: current_time[0], + id_factory=lambda _kind: next(generated_ids), + cursor_secret=b"expiring-cursor-test", + cursor_ttl_seconds=60, + ) + await records.create_source("scope-a", "content", "first", {}) + await records.create_source("scope-a", "content", "second", {}) + page = await records.query_sources( + "scope-a", + "content", + query=None, + mode=None, + limit=1, + cursor=None, + ) + assert page.next_cursor is not None + + current_time[0] += timedelta(seconds=60) + with pytest.raises(CursorExpiredError): + await records.query_sources( + "scope-a", + "content", + query=None, + mode=None, + limit=1, + cursor=page.next_cursor, + ) + + asyncio.run(scenario()) + + +def test_record_tables_replace_sidecars_and_upgrade_legacy_sqlite_columns(tmp_path) -> None: + table_names = {table.name for table in SHARED_TABLES} + assert { + "pc_source_records", + "pc_artifact_revision_records", + "pc_artifact_tombstones", + }.isdisjoint(table_names) + + database = tmp_path / "legacy-records.db" + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE pc_sources (" + "scope_id VARCHAR(256) NOT NULL, source_type VARCHAR(128) NOT NULL, " + "source_id VARCHAR(256) NOT NULL, payload BLOB NOT NULL, journal_position BIGINT NOT NULL, " + "PRIMARY KEY (scope_id, source_type, source_id))" + ) + connection.execute( + "CREATE TABLE pc_artifacts (" + "scope_id VARCHAR(256) NOT NULL, family VARCHAR(128) NOT NULL, artifact_id VARCHAR(128) NOT NULL, " + "revision INTEGER NOT NULL, content BLOB NOT NULL, " + "PRIMARY KEY (scope_id, family, artifact_id, revision))" + ) + connection.execute( + "CREATE TABLE pc_artifact_heads (" + "scope_id VARCHAR(256) NOT NULL, family VARCHAR(128) NOT NULL, artifact_id VARCHAR(128) NOT NULL, " + "revision INTEGER NOT NULL, searchable_text TEXT NULL, PRIMARY KEY (scope_id, family, artifact_id))" + ) + + async def scenario() -> None: + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{database}") + selected = (SOURCES_TABLE, ARTIFACTS_TABLE, ARTIFACT_HEADS_TABLE) + for _ in range(2): + async with ( + SQLiteProfile.open(config, tables=selected) as profile, + profile.database.transaction() as connection, + ): + source_columns = { + str(row["name"]) + for row in (await connection.exec_driver_sql("PRAGMA table_info('pc_sources')")).mappings() + } + artifact_columns = { + str(row["name"]) + for row in (await connection.exec_driver_sql("PRAGMA table_info('pc_artifacts')")).mappings() + } + head_columns = { + str(row["name"]) + for row in (await connection.exec_driver_sql("PRAGMA table_info('pc_artifact_heads')")).mappings() + } + assert "created_at" in source_columns + assert "created_at" in artifact_columns + assert "deleted_at" in head_columns + + asyncio.run(scenario()) + + +def test_record_column_migration_uses_mysql_compatible_nullable_timestamps() -> None: + result = MagicMock() + result.scalars.return_value = () + connection = SimpleNamespace( + dialect=SimpleNamespace(name="mysql"), + execute=AsyncMock(return_value=result), + exec_driver_sql=AsyncMock(), + ) + + asyncio.run( + ensure_record_columns( + cast(AsyncConnection, connection), + {"pc_sources", "pc_artifacts", "pc_artifact_heads"}, + ) + ) + + assert [call.args[0] for call in connection.exec_driver_sql.await_args_list] == [ + "ALTER TABLE pc_sources ADD COLUMN created_at DATETIME NULL", + "ALTER TABLE pc_artifacts ADD COLUMN created_at DATETIME NULL", + "ALTER TABLE pc_artifact_heads ADD COLUMN deleted_at DATETIME NULL", + ] + + def test_artifact_records_preserve_revisions_and_delete_only_the_head() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: records = RelationalRecordService( profile.database, SourceRepository((CONTENT_SOURCE_ADAPTER,)), + id_factory=lambda kind: "source-1" if kind == "source" else "guide-1", ) source = await records.create_source( "scope-a", "content", - "source-1", "The reviewed API design.", {}, ) first = await records.create_artifact( "scope-a", "document", - "guide-1", ArtifactWrite( - schema_version=1, - metadata={"title": "API Guide"}, content={"body": "Use a complete replacement."}, - source_refs=(source.source_ref,), + source_refs=(SourceRef(source_type=source.source_type, source_id=source.source_id),), ), ) assert first.artifact_ref.revision == 1 assert first.source_refs == (SourceRef(source_type="content", source_id="source-1"),) assert (await records.get_artifact("scope-a", "document", "guide-1")) == first - assert (await records.list_artifacts("scope-a", "document", limit=10, cursor=None)).items == (first,) - found = await records.search_artifacts( + listed = await records.query_artifacts( "scope-a", "document", - "complete replacement", + query=None, + mode=None, + limit=10, + cursor=None, + ) + assert [item.artifact_ref for item in listed.items] == [first.artifact_ref] + assert "content" not in type(listed.items[0]).model_fields + found = await records.query_artifacts( + "scope-a", + "document", + query="complete replacement", mode="auto", limit=10, cursor=None, ) - assert [hit.artifact.artifact_ref.artifact_id for hit in found.hits] == ["guide-1"] + assert [item.artifact_ref.artifact_id for item in found.items] == ["guide-1"] second = await records.replace_artifact( "scope-a", @@ -167,10 +353,8 @@ async def scenario() -> None: "guide-1", 1, ArtifactWrite( - schema_version=2, - metadata={"title": "API Guide"}, content={"body": "Use If-Match for replacement."}, - source_refs=(source.source_ref,), + source_refs=(SourceRef(source_type=source.source_type, source_id=source.source_id),), artifact_refs=(first.artifact_ref,), ), ) @@ -182,7 +366,7 @@ async def scenario() -> None: "document", "guide-1", 1, - ArtifactWrite(schema_version=2, metadata={}, content={"body": "stale"}), + ArtifactWrite(content={"body": "stale"}), ) scopes = await records.list_scopes(limit=10, cursor=None) @@ -198,21 +382,29 @@ async def scenario() -> None: with pytest.raises(BaseValueNotFoundError): await records.get_artifact("scope-a", "document", "guide-1") assert (await records.get_artifact_revision("scope-a", "document", "guide-1", 2)) == second - assert (await records.list_artifacts("scope-a", "document", limit=10, cursor=None)).items == () + assert ( + await records.query_artifacts("scope-a", "document", query=None, mode=None, limit=10, cursor=None) + ).items == () + async with profile.database.transaction() as connection: + head = ( + await connection.execute(select(ARTIFACT_HEADS_TABLE.c.revision, ARTIFACT_HEADS_TABLE.c.deleted_at)) + ).one() + assert head.revision == 2 + assert head.deleted_at is not None + assert (await StatisticsRepository().inventory(connection, "scope-a")).artifacts == () with pytest.raises(BaseValueConflictError): await records.create_artifact( "scope-a", "document", - "guide-1", - ArtifactWrite(schema_version=1, metadata={}, content={"body": "reused identity"}), - ) - with pytest.raises(BaseOperationNotSupportedError): - await records.create_artifact( - "scope-a", - "memory", - "memory-1", - ArtifactWrite(schema_version=1, metadata={}, content={"body": "protected"}), + ArtifactWrite(content={"body": "reused identity"}), ) + for protected_family in ("experience", "handoff", "memory", "skill"): + with pytest.raises(BaseOperationNotSupportedError): + await records.create_artifact( + "scope-a", + protected_family, + ArtifactWrite(content={"body": "protected"}), + ) asyncio.run(scenario()) diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py index 5c1e856d3..89675f4a6 100644 --- a/tests/e2e/test_base_access_api.py +++ b/tests/e2e/test_base_access_api.py @@ -16,6 +16,7 @@ import asyncio from pathlib import Path +from urllib.parse import quote import httpx import pytest @@ -25,22 +26,17 @@ from powercontext.http import ( CreateArtifactRequest, CreateSourceRequest, - DeleteArtifactRequest, - GetArtifactRequest, - GetArtifactRevisionRequest, - GetSourceRequest, ListArtifactsRequest, - ListScopesRequest, + ListSourcesRequest, ReplaceArtifactRequest, - SearchArtifactsRequest, - SearchSourcesRequest, - SourceQueryType, + SourceTypeReference, + TextSearchMode, ) from powercontext.server.factory import create_server_app from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings -def test_source_artifact_and_scope_api_round_trip(tmp_path: Path) -> None: +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'}"), @@ -50,6 +46,8 @@ def test_source_artifact_and_scope_api_round_trip(tmp_path: Path) -> None: ) async def scenario() -> None: + scope_id = "git:github.com/oceanbase/powercontext" + family = "company.example/decision" async with ( app.router.lifespan_context(app), httpx.AsyncClient( @@ -58,97 +56,141 @@ async def scenario() -> None: ) as transport, ): client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) + unsupported = await transport.post( + f"/v1/scopes/{quote(scope_id, safe='')}/artifacts", + json={"family": "memory", "content": {}}, + ) + assert unsupported.status_code == 405 + assert unsupported.headers["Allow"] == "GET" source = await client.create_source( + scope_id, CreateSourceRequest( - scope_id="scope-a", - source_type="content", - source_id="source-1", content="Keep Source writes separate from Memory generation.", metadata={"channel": "e2e"}, - ) + ), ) - loaded_source = await client.get_source( - "source-1", - GetSourceRequest(scope_id="scope-a", source_type="content"), + loaded_source = await client.get_source(scope_id, "content", source.source_id) + listed_sources = await client.list_sources( + scope_id, + "content", + ListSourcesRequest(), ) - listed_sources = await client.search_sources( - SearchSourcesRequest(scope_id="scope-a", source_type="content") + found_sources = await client.list_sources( + scope_id, + "content", + ListSourcesRequest(query="Memory generation"), ) - found_sources = await client.search_sources( - SearchSourcesRequest( - scope_id="scope-a", - source_type="content", - type=SourceQueryType.SEARCH, - q="Memory generation", + with pytest.raises(ServerResponseError) as invalid_query: + await client.list_sources( + scope_id, + "content", + ListSourcesRequest(mode=TextSearchMode.AUTO), ) - ) + assert invalid_query.value.status_code == 400 + assert invalid_query.value.code == "invalid_request" + with pytest.raises(ServerResponseError) as invalid_cursor: + await client.list_sources( + scope_id, + "content", + ListSourcesRequest(cursor="not-a-valid-cursor"), + ) + assert invalid_cursor.value.status_code == 400 + assert invalid_cursor.value.code == "invalid_cursor" first = await client.create_artifact( + scope_id, CreateArtifactRequest( - scope_id="scope-a", - family="document", - artifact_id="guide-1", - schema_version=1, - metadata={"title": "Base API"}, + family=family, content={"body": "Use full replacement updates."}, - source_refs=[source.source_ref], - ) + source_refs=[SourceTypeReference(source_type=source.source_type, source_id=source.source_id)], + ), + ) + artifact_id = first.artifact_ref.artifact_id + loaded_first = await client.get_artifact(scope_id, family, artifact_id) + not_modified = await client.get_artifact( + scope_id, + family, + artifact_id, + if_none_match='"revision:1"', ) - selector = GetArtifactRequest(scope_id="scope-a", family="document") - listed_artifacts = await client.list_artifacts(ListArtifactsRequest(scope_id="scope-a", family="document")) - found_artifacts = await client.search_artifacts( - SearchArtifactsRequest(scope_id="scope-a", family="document", q="replacement updates") + listed_artifacts = await client.list_artifacts( + scope_id, + family, + ListArtifactsRequest(), + ) + found_artifacts = await client.list_artifacts( + scope_id, + family, + ListArtifactsRequest(query="replacement updates"), ) second = await client.replace_artifact( - "guide-1", - selector, + scope_id, + family, + artifact_id, ReplaceArtifactRequest( - schema_version=2, - metadata={"title": "Base API"}, content={"body": "Use If-Match with full replacement updates."}, - source_refs=[source.source_ref], + source_refs=[SourceTypeReference(source_type=source.source_type, source_id=source.source_id)], artifact_refs=[first.artifact_ref], ), expected_revision=1, ) exact_first = await client.get_artifact_revision( - "guide-1", + scope_id, + family, + artifact_id, 1, - GetArtifactRevisionRequest(scope_id="scope-a", family="document"), ) - scopes = await client.list_scopes(ListScopesRequest()) with pytest.raises(ServerResponseError) as stale: await client.replace_artifact( - "guide-1", - selector, - ReplaceArtifactRequest(schema_version=2, content={"body": "stale"}), + scope_id, + family, + artifact_id, + ReplaceArtifactRequest(content={"body": "stale"}), expected_revision=1, ) assert stale.value.status_code == 412 assert stale.value.code == "revision_conflict" deleted = await client.delete_artifact( - "guide-1", - DeleteArtifactRequest(scope_id="scope-a", family="document"), + scope_id, + family, + artifact_id, expected_revision=2, ) with pytest.raises(ServerResponseError) as missing: - await client.get_artifact("guide-1", selector) + await client.get_artifact(scope_id, family, artifact_id) assert missing.value.status_code == 404 + exact_second = await client.get_artifact_revision( + scope_id, + family, + artifact_id, + 2, + ) assert source == loaded_source - assert [item.source_ref.source_id for item in listed_sources.items] == ["source-1"] - assert [item.source_ref.source_id for item in found_sources.items] == ["source-1"] + assert source.scope_id == scope_id + assert source.source_type == "content" + assert source.source_id + assert source.created_at is not None + assert [item.source_id for item in listed_sources.items] == [source.source_id] + assert [item.source_id for item in found_sources.items] == [source.source_id] + assert "content" not in listed_sources.items[0].model_dump() assert listed_sources.query is None assert found_sources.query == "Memory generation" assert found_sources.mode == "keyword" - assert [item.artifact_ref.artifact_id for item in listed_artifacts.items] == ["guide-1"] - assert [hit.artifact.artifact_ref.artifact_id for hit in found_artifacts.hits] == ["guide-1"] + assert loaded_first == first + assert not_modified is None + assert first.created_at is not None + assert [item.artifact_ref.artifact_id for item in listed_artifacts.items] == [artifact_id] + assert [item.artifact_ref.artifact_id for item in found_artifacts.items] == [artifact_id] + assert "content" not in listed_artifacts.items[0].model_dump() + assert found_artifacts.query == "replacement updates" + assert found_artifacts.mode == "keyword" assert second.artifact_ref.revision == 2 + assert second.created_at is not None assert exact_first == first - assert [(item.scope_id, item.source_count, item.artifact_count) for item in scopes.items] == [("scope-a", 1, 1)] - assert deleted.status == "deleted" - assert deleted.artifact_ref == second.artifact_ref + assert exact_second == second + assert deleted is None asyncio.run(scenario()) diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 27fbb25e2..3abde1183 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -24,11 +24,14 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactReference, + ArtifactRevision, CaptureContentSourceRequest, CaptureContentSourceResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, + CreateArtifactRequest, + CreateSourceRequest, CreateWorkContractRequest, ExternalSkillResolution, FinalizeHandoffRequest, @@ -43,9 +46,11 @@ HandoffDraft, HandoffResolution, ImportExternalSkillRequest, + ListArtifactsRequest, ListExternalSkillsRequest, ListExternalSkillsResponse, ListMemoryEntriesRequest, + ListSourcesRequest, PrepareContextRequest, PreparedContext, PreparedHandoff, @@ -62,6 +67,8 @@ SearchMemoryRequest, SkillProposal, SkillValidationItem, + SourceRecord, + SourceTypeReference, StatsPeriod, WorkSourceReceipt, ) @@ -72,23 +79,31 @@ CAPTURE_CONTENT_SOURCE, COMMIT_HANDOFF, CONTINUE_HANDOFF, + CREATE_ARTIFACT, + CREATE_SOURCE, CREATE_WORK_CONTRACT, + DELETE_ARTIFACT, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, GENERATE_SKILL, + GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, + GET_ARTIFACT_REVISION, GET_EXPERIENCE, GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, LIST_ARTIFACT_CANDIDATES, + LIST_ARTIFACTS, LIST_EXTERNAL_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, + LIST_SOURCES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -96,6 +111,7 @@ RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, @@ -481,40 +497,141 @@ def test_generated_transport_rejects_values_outside_openapi( model.model_validate(value) -def test_base_access_contract_uses_fixed_source_artifact_and_scope_paths() -> None: +def test_base_access_contract_uses_only_the_nine_scoped_operations() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) paths = contract["paths"] expected_operations = { - ("/v1/sources", "post"): "create_source", - ("/v1/sources", "get"): "search_sources", - ("/v1/sources/{source_id}", "get"): "get_source", - ("/v1/artifacts", "post"): "create_artifact", - ("/v1/artifacts", "get"): "list_artifacts", - ("/v1/artifacts/{artifact_id}", "get"): "get_artifact", - ("/v1/artifacts/{artifact_id}", "put"): "replace_artifact", - ("/v1/artifacts/{artifact_id}", "delete"): "delete_artifact", - ("/v1/artifacts/{artifact_id}/revisions/{revision}", "get"): "get_artifact_revision", - ("/v1/artifact-search-results", "get"): "search_artifacts", - ("/v1/scopes", "get"): "list_scopes", + ("/v1/scopes/{scope_id}/sources", "post"): "create_source", + ("/v1/scopes/{scope_id}/sources/{source_type}", "get"): "list_sources", + ("/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}", "delete"): "delete_artifact", + ( + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", + "get", + ): "get_artifact_revision", } - assert { - (path, method): paths[path][method]["operationId"] for path, method in expected_operations - } == expected_operations - assert not any(path.startswith("/v1/resources") or "derivation" in path for path in paths) - - source_query = contract["components"]["schemas"]["SourceQueryType"] - assert source_query == {"type": "string", "enum": ["list", "search"]} - source_parameters = {item["name"]: item for item in paths["/v1/sources"]["get"]["parameters"]} - assert source_parameters["type"]["schema"] == { - "$ref": "#/components/schemas/SourceQueryType", - "default": "list", + 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 {"search_sources", "search_artifacts", "list_scopes"} + for operation_id in actual_operations.values() + ) + assert not any("search-results" in path or path == "/v1/scopes" 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", "metadata"} + assert source["properties"]["source_type"]["default"] == "content" + + artifact = schemas["CreateArtifactRequest"] + assert artifact["required"] == ["family", "content"] + assert set(artifact["properties"]) == {"family", "content", "source_refs", "artifact_refs"} + assert not {"scope_id", "source_id", "artifact_id"} & set(source["properties"] | artifact["properties"]) + + assert CreateSourceRequest(content="evidence").source_type == "content" + assert CreateArtifactRequest(family="decision", content={"value": 1}).source_refs == [] + for model, payload in ( + (CreateSourceRequest, {"scope_id": "scope", "content": "evidence"}), + (CreateSourceRequest, {"source_id": "source", "content": "evidence"}), + (CreateArtifactRequest, {"artifact_id": "artifact", "family": "decision", "content": {}}), + ): + with pytest.raises(ValidationError): + model.model_validate(payload) + + +def test_source_artifact_collections_combine_list_and_search_queries() -> None: + contract = yaml.safe_load(CONTRACT_PATH.read_text()) + paths = contract["paths"] + + for path in ( + "/v1/scopes/{scope_id}/sources/{source_type}", + "/v1/scopes/{scope_id}/artifacts/{family}", + ): + parameters = paths[path]["get"]["parameters"] + assert [parameter["name"] for parameter in parameters if parameter["in"] == "query"] == [ + "query", + "mode", + "limit", + "cursor", + ] + assert "type" not in {parameter["name"] for parameter in parameters} + + assert ListSourcesRequest().model_dump() == {"query": None, "mode": None, "limit": 50, "cursor": None} + assert ListArtifactsRequest(query="refund").query == "refund" + + +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 ("CreateArtifactRequest", "ReplaceArtifactRequest", "ArtifactRevision"): + assert schemas[schema_name]["properties"]["source_refs"]["items"] == { + "$ref": "#/components/schemas/SourceTypeReference" + } + assert SourceTypeReference(source_type="content", source_id="source").source_type == "content" + + +def test_base_access_operations_describe_no_content_and_conditional_get() -> None: + assert CREATE_SOURCE.request_type is CreateSourceRequest + assert CREATE_ARTIFACT.request_type is CreateArtifactRequest + assert LIST_SOURCES.request_type is ListSourcesRequest + 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 DELETE_ARTIFACT.request_type is None + assert DELETE_ARTIFACT.response_type is None + assert DELETE_ARTIFACT.success_status == 204 + 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}"] + allow_header = contract["components"]["responses"]["OperationNotSupported"]["headers"]["Allow"] + assert allow_header["required"] is True + assert allow_header["schema"] == {"type": "string"} + 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"] for method in ("put", "delete"): - parameters = {item["name"]: item for item in paths["/v1/artifacts/{artifact_id}"][method]["parameters"]} + parameters = {item["name"]: item for item in item_path[method]["parameters"]} assert parameters["If-Match"]["required"] is True - assert set(paths["/v1/artifacts/{artifact_id}"][method]["responses"]) >= {"200", "412", "428"} + assert set(item_path[method]["responses"]) >= {"412", "428"} + assert "content" not in item_path["delete"]["responses"]["204"] + + +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: 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 40e036b7d..2ff2574f6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,6 +24,8 @@ from powercontext.http import ( CaptureContentSourceRequest, GetHandoffReportRequest, + ListSourcesRequest, + TextSearchMode, ) @@ -175,6 +177,111 @@ def respond(request: httpx.Request) -> httpx.Response: asyncio.run(scenario()) +def test_client_serializes_scoped_source_paths_and_combined_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={ + "query": "durable source", + "mode": "keyword", + "items": [ + { + "scope_id": "scope one", + "source_type": "content", + "source_id": "source-1", + "metadata": {}, + "created_at": None, + "position": 1, + "content_digest": f"sha256:{'0' * 64}", + "score": 1.0, + "snippets": ["durable source"], + } + ], + "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_sources( + "scope one", + "content", + ListSourcesRequest( + query="durable source", + mode=TextSearchMode.KEYWORD, + limit=7, + cursor="cursor-1", + ), + ) + + assert [item.source_id for item in page.items] == ["source-1"] + assert len(requests) == 1 + assert requests[0].url.path == "/v1/scopes/scope one/sources/content" + assert dict(requests[0].url.params) == { + "query": "durable source", + "mode": "keyword", + "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_decodes_delete_no_content_and_sends_precondition() -> None: + async def scenario() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(204) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = PowerContextClient("https://memory.example", http_client=http_client) + result = await client.delete_artifact( + "scope-a", + "document", + "artifact-1", + expected_revision=3, + ) + + assert result is None + assert len(requests) == 1 + assert requests[0].method == "DELETE" + assert requests[0].url.path == "/v1/scopes/scope-a/artifacts/document/artifact-1" + assert requests[0].headers["If-Match"] == '"revision:3"' + + asyncio.run(scenario()) + + @pytest.mark.parametrize( "server_url", [ diff --git a/tests/test_js_operations.py b/tests/test_js_operations.py index 3ff76d830..15a87ceac 100644 --- a/tests/test_js_operations.py +++ b/tests/test_js_operations.py @@ -57,10 +57,26 @@ def test_js_operations_record_method_path_location_and_scope() -> None: "path": "/health/live", "location": None, "scope": False, + "pathParams": [], + "queryParams": [], + "headerParams": [], + "successStatuses": [200], + "emptyStatuses": [], } assert by_id["get_stats"]["location"] == "query" assert by_id["remember_memory"]["location"] == "body" assert by_id["remember_memory"]["scope"] is True + assert by_id["create_source"]["location"] == "body" + assert by_id["create_source"]["scope"] is True + assert by_id["get_artifact"]["location"] is None + assert by_id["get_artifact"]["scope"] is True + assert by_id["get_artifact"]["pathParams"] == ["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 by_id["delete_artifact"]["successStatuses"] == [204] + assert by_id["delete_artifact"]["emptyStatuses"] == [204] def test_committed_js_operations_match_openapi() -> None: From c859468b8667f09e3770a71d7872fa326abab0c1 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 3 Sep 2026 17:36:26 +0800 Subject: [PATCH 3/9] feat(api): implement latest source and artifact RFC --- .../dsh/plugins/powercontext/lib/index.js | 38 +- .../powercontext/openapi/powercontext.yaml | 419 ++------- .../powercontext/src/operations.generated.ts | 4 +- .../plugins/powercontext/tests/client.spec.ts | 20 +- .../tests/operations-coverage.spec.ts | 7 +- .../powercontext/src/operations.generated.ts | 4 +- .../plugins/powercontext/tests/client.spec.ts | 20 +- .../powercontext/src/operations.generated.ts | 4 +- .../plugins/powercontext/tests/client.spec.ts | 20 +- openapi/powercontext.yaml | 439 ++-------- .../builtin/persistence/artifacts.py | 110 ++- .../builtin/persistence/handoff.py | 2 + .../builtin/persistence/records.py | 805 +++++------------- .../builtin/persistence/schema.py | 39 +- .../builtin/persistence/sources.py | 18 +- .../builtin/persistence/statistics.py | 1 - .../builtin/persistence/tables.py | 4 - src/powercontext/builtin/records.py | 106 +-- src/powercontext/builtin/review/generation.py | 2 + src/powercontext/builtin/review/service.py | 4 +- .../builtin/runtime/application.py | 51 +- .../builtin/runtime/relational.py | 60 +- .../builtin/source_eligibility.py | 68 ++ src/powercontext/builtin/sources/__init__.py | 4 + src/powercontext/builtin/sources/content.py | 25 +- src/powercontext/client/client.py | 46 +- src/powercontext/http/__init__.py | 14 +- src/powercontext/http/_generated/models.py | 123 ++- .../http/_generated/operations.py | 66 +- src/powercontext/http/_generated/schema.py | 319 ++----- src/powercontext/server/app.py | 231 ++--- src/powercontext/server/mapping.py | 3 +- tests/builtin/persistence/test_records.py | 458 +++------- .../runtime/test_experience_incubation.py | 29 + tests/e2e/test_base_access_api.py | 195 ++--- tests/e2e/test_builtin_runtime.py | 33 + tests/test_api_contract.py | 74 +- tests/test_client.py | 73 +- tests/test_js_operations.py | 4 +- 39 files changed, 1151 insertions(+), 2791 deletions(-) create mode 100644 src/powercontext/builtin/source_eligibility.py diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 99202c24d..9a385b62c 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -677,22 +677,6 @@ const OPERATIONS = { successStatuses: [201], emptyStatuses: [] }, - list_sources: { - method: "GET", - path: "/v1/scopes/{scope_id}/sources/{source_type}", - location: "query", - scope: true, - pathParams: ["scope_id", "source_type"], - queryParams: [ - "query", - "mode", - "limit", - "cursor" - ], - headerParams: [], - successStatuses: [200], - emptyStatuses: [] - }, get_source: { method: "GET", path: "/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", @@ -725,12 +709,7 @@ const OPERATIONS = { location: "query", scope: true, pathParams: ["scope_id", "family"], - queryParams: [ - "query", - "mode", - "limit", - "cursor" - ], + queryParams: ["limit", "cursor"], headerParams: [], successStatuses: [200], emptyStatuses: [] @@ -765,21 +744,6 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, - delete_artifact: { - method: "DELETE", - path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", - location: null, - scope: true, - pathParams: [ - "scope_id", - "family", - "artifact_id" - ], - queryParams: [], - headerParams: ["If-Match"], - successStatuses: [204], - emptyStatuses: [204] - }, get_artifact_revision: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", diff --git a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml index fc62c82b1..496b0d30c 100644 --- a/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml +++ b/integrations/dsh/plugins/powercontext/openapi/powercontext.yaml @@ -1723,8 +1723,6 @@ paths: $ref: "#/components/schemas/SourceRecord" "401": $ref: "#/components/responses/Unauthorized" - "405": - $ref: "#/components/responses/OperationNotSupported" "409": $ref: "#/components/responses/Conflict" "422": @@ -1733,79 +1731,6 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/scopes/{scope_id}/sources/{source_type}: - get: - tags: [sources] - summary: List or search Sources - description: List when query is absent or blank; search when query is nonblank. - operationId: list_sources - 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 - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - - name: query - in: query - required: false - schema: - type: string - maxLength: 8192 - - name: mode - in: query - required: false - schema: - $ref: "#/components/schemas/TextSearchMode" - - 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 Sources or Source search results. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/SourcePage" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "405": - $ref: "#/components/responses/OperationNotSupported" - "410": - $ref: "#/components/responses/CursorExpired" - "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] @@ -1825,9 +1750,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] - name: source_id in: path required: true @@ -1850,8 +1773,6 @@ paths: $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" - "405": - $ref: "#/components/responses/OperationNotSupported" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1862,7 +1783,7 @@ paths: post: tags: [artifacts] summary: Create an Artifact - description: Commit revision one for a family that permits direct creation. The Server generates artifact_id. + description: Validate family content and atomically create revision one with its system provenance Source. operationId: create_artifact parameters: - name: scope_id @@ -1892,11 +1813,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ArtifactRevision" + $ref: "#/components/schemas/ArtifactCreated" "401": $ref: "#/components/responses/Unauthorized" - "405": - $ref: "#/components/responses/OperationNotSupported" "409": $ref: "#/components/responses/Conflict" "422": @@ -1908,8 +1827,8 @@ paths: /v1/scopes/{scope_id}/artifacts/{family}: get: tags: [artifacts] - summary: List or search current Artifact heads - description: List when query is absent or blank; search when query is nonblank. + summary: List current Artifact heads + description: List current heads for exactly one built-in Artifact family. operationId: list_artifacts parameters: - name: scope_id @@ -1925,20 +1844,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - - name: query - in: query - required: false - schema: - type: string - maxLength: 8192 - - name: mode - in: query - required: false - schema: - $ref: "#/components/schemas/TextSearchMode" + enum: [memory, experience, skill, handoff] - name: limit in: query required: false @@ -1956,7 +1862,7 @@ paths: maxLength: 4096 responses: "200": - description: One stable page of current Artifact heads or search results. + description: One stable page of current Artifact heads. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -1995,9 +1901,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [memory, experience, skill, handoff] - name: artifact_id in: path required: true @@ -2011,7 +1915,7 @@ paths: required: false schema: type: string - pattern: '^"revision:[1-9][0-9]*"$' + minLength: 1 responses: "200": description: The current visible Artifact head. @@ -2060,9 +1964,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [memory, experience, skill, handoff] - name: artifact_id in: path required: true @@ -2076,7 +1978,7 @@ paths: required: true schema: type: string - pattern: '^"revision:[1-9][0-9]*"$' + minLength: 1 requestBody: required: true content: @@ -2099,66 +2001,6 @@ paths: $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" - "405": - $ref: "#/components/responses/OperationNotSupported" - "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" - delete: - tags: [artifacts] - summary: Delete the current Artifact head - description: Record a tombstone without deleting immutable historical revisions. - operationId: delete_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 - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - - 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 - pattern: '^"revision:[1-9][0-9]*"$' - responses: - "204": - description: The Artifact head is deleted and immutable history is retained. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "405": - $ref: "#/components/responses/OperationNotSupported" "412": $ref: "#/components/responses/PreconditionFailed" "422": @@ -2188,9 +2030,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [memory, experience, skill, handoff] - name: artifact_id in: path required: true @@ -2233,10 +2073,10 @@ components: description: Static bearer token used when local Server authentication is enabled. headers: ArtifactETag: - description: Strong validator for the current immutable Artifact revision. + description: Opaque strong validator for the current Artifact head. Clients must replay it verbatim. schema: type: string - pattern: '^"revision:[1-9][0-9]*"$' + minLength: 1 BearerChallenge: description: Authentication scheme required by the Server. schema: @@ -2397,42 +2237,65 @@ components: default: 8000 ArtifactCollectionItem: type: object - required: [scope_id, artifact_ref, created_at, content_digest, score, snippets] + required: [scope_id, family, artifact_id, revision, sources, artifacts, content_digest] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - created_at: + family: + $ref: "#/components/schemas/BaseArtifactFamily" + artifact_id: type: string - format: date-time - nullable: true + 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}$' - score: - type: number - minimum: 0 - maximum: 1 - nullable: true - snippets: + 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: - type: string + $ref: "#/components/schemas/SourceTypeReference" + artifacts: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" ArtifactPage: type: object - required: [query, mode, items, next_cursor] + required: [items, next_cursor] properties: - query: - type: string - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchUsedMode" - nullable: true items: type: array items: @@ -2442,30 +2305,34 @@ components: nullable: true ArtifactRevision: type: object - required: [scope_id, artifact_ref, content, source_refs, artifact_refs, created_at, content_digest] + required: [scope_id, family, artifact_id, revision, content, sources, artifacts, content_digest] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" + 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 - source_refs: + sources: type: array items: $ref: "#/components/schemas/SourceTypeReference" - artifact_refs: + artifacts: type: array items: $ref: "#/components/schemas/ArtifactReference" - created_at: - type: string - format: date-time - nullable: true content_digest: type: string pattern: '^sha256:[0-9a-f]{64}$' @@ -5240,23 +5107,10 @@ components: required: [family, content] properties: family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + $ref: "#/components/schemas/BaseArtifactFamily" content: type: object additionalProperties: true - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceTypeReference" - default: [] - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - default: [] CreateSourceRequest: type: object additionalProperties: false @@ -5264,50 +5118,14 @@ components: properties: source_type: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] default: content content: - description: JSON value validated by the selected Source adapter. - metadata: - type: object - additionalProperties: true - default: {} + description: JSON value persisted by the built-in content Source adapter. ListArtifactsRequest: type: object additionalProperties: false properties: - query: - type: string - maxLength: 8192 - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchMode" - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - cursor: - type: string - minLength: 1 - maxLength: 4096 - nullable: true - ListSourcesRequest: - type: object - additionalProperties: false - properties: - query: - type: string - maxLength: 8192 - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchMode" - nullable: true limit: type: integer minimum: 1 @@ -5326,78 +5144,9 @@ components: content: type: object additionalProperties: true - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceTypeReference" - default: [] - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - default: [] - SourceCollectionItem: - type: object - required: [scope_id, source_type, source_id, metadata, created_at, position, content_digest, score, snippets] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_type: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - source_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '^[\x21-\x7E]+$' - metadata: - type: object - additionalProperties: true - created_at: - type: string - format: date-time - nullable: true - position: - type: integer - minimum: 1 - content_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - score: - type: number - minimum: 0 - maximum: 1 - nullable: true - snippets: - type: array - items: - type: string - SourcePage: - type: object - required: [query, mode, items, next_cursor] - properties: - query: - type: string - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchUsedMode" - nullable: true - items: - type: array - items: - $ref: "#/components/schemas/SourceCollectionItem" - next_cursor: - type: string - nullable: true SourceRecord: type: object - required: [scope_id, source_type, source_id, content, metadata, created_at, position, content_digest] + required: [scope_id, source_type, source_id, content, position, content_digest] properties: scope_id: type: string @@ -5406,9 +5155,7 @@ components: pattern: '.*\S.*' source_type: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] source_id: type: string minLength: 1 @@ -5416,13 +5163,6 @@ components: pattern: '^[\x21-\x7E]+$' content: description: Persisted canonical JSON content. - metadata: - type: object - additionalProperties: true - created_at: - type: string - format: date-time - nullable: true position: type: integer minimum: 1 @@ -5436,9 +5176,7 @@ components: properties: source_type: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] source_id: type: string minLength: 1 @@ -5457,6 +5195,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/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index 157c61567..3b211b09c 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -71,13 +71,11 @@ export const OPERATIONS = { attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, - list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}', location: "query", scope: true, pathParams: ['scope_id','source_type'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scope: true, pathParams: ['scope_id','source_type','source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, - list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['scope_id','family'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['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, scope: true, pathParams: ['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", scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, - delete_artifact: { method: 'DELETE', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [204], emptyStatuses: [204] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id','revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const diff --git a/integrations/dsh/plugins/powercontext/tests/client.spec.ts b/integrations/dsh/plugins/powercontext/tests/client.spec.ts index 2ba7d24b9..13cd10f09 100644 --- a/integrations/dsh/plugins/powercontext/tests/client.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/client.spec.ts @@ -84,7 +84,7 @@ describe('PowerContextClient', () => { const headers = new Headers(init?.headers) if (call === 1) { expect(url).toBe( - 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/decision%20records?query=recent+work&limit=5', + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory?limit=5', ) expect(init?.method).toBe('GET') expect(init?.body).toBeUndefined() @@ -92,7 +92,7 @@ describe('PowerContextClient', () => { } if (call === 2) { expect(url).toBe( - 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/decision%20records/artifact%2F1', + '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"') @@ -105,10 +105,7 @@ describe('PowerContextClient', () => { expect(init?.body).toBeUndefined() return new Response(null, { status: 304 }) } - expect(init?.method).toBe('DELETE') - expect(headers.get('If-Match')).toBe('"revision:2"') - expect(init?.body).toBeUndefined() - return new Response(null, { status: 204 }) + throw new Error('unexpected request') }) const client = new PowerContextClient({ baseUrl: 'http://127.0.0.1:8000/', @@ -117,10 +114,10 @@ describe('PowerContextClient', () => { }) const path = { scope_id: 'scope/team', - family: 'decision records', + family: 'memory', } - await client.request('list_artifacts', { ...path, query: 'recent work', limit: 5, ignored: 'value' }) + await client.request('list_artifacts', { ...path, limit: 5, ignored: 'value' }) await client.request('replace_artifact', { ...path, artifact_id: 'artifact/1', @@ -132,12 +129,7 @@ describe('PowerContextClient', () => { artifact_id: 'artifact/1', if_none_match: '"revision:2"', })).resolves.toMatchObject({ kind: 'json', value: null, status: 304 }) - await expect(client.request('delete_artifact', { - ...path, - artifact_id: 'artifact/1', - if_match: '"revision:2"', - })).resolves.toMatchObject({ kind: 'json', value: null, status: 204 }) - expect(fetchImpl).toHaveBeenCalledTimes(4) + expect(fetchImpl).toHaveBeenCalledTimes(3) }) it('returns markdown text and raw bytes for get_handoff_report', async () => { diff --git a/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts b/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts index 433ba2140..c5aac71a8 100644 --- a/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/operations-coverage.spec.ts @@ -74,11 +74,8 @@ describe('operations coverage', () => { successStatuses: [200, 304], emptyStatuses: [304], }) - expect(OPERATIONS.delete_artifact).toMatchObject({ - headerParams: ['If-Match'], - successStatuses: [204], - emptyStatuses: [204], - }) + expect(OPERATIONS).not.toHaveProperty('delete_artifact') + expect(OPERATIONS).not.toHaveProperty('list_sources') }) it('matches every generated operation field', () => { diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index 157c61567..3b211b09c 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -71,13 +71,11 @@ export const OPERATIONS = { attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, - list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}', location: "query", scope: true, pathParams: ['scope_id','source_type'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scope: true, pathParams: ['scope_id','source_type','source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, - list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['scope_id','family'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['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, scope: true, pathParams: ['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", scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, - delete_artifact: { method: 'DELETE', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [204], emptyStatuses: [204] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id','revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const diff --git a/integrations/opencode/plugins/powercontext/tests/client.spec.ts b/integrations/opencode/plugins/powercontext/tests/client.spec.ts index 19e9430a3..f0d347ded 100644 --- a/integrations/opencode/plugins/powercontext/tests/client.spec.ts +++ b/integrations/opencode/plugins/powercontext/tests/client.spec.ts @@ -87,7 +87,7 @@ describe('PowerContextClient generated operation requests', () => { const headers = new Headers(init?.headers) if (call === 1) { expect(url).toBe( - 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/decision%20records?query=recent+work&limit=5', + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory?limit=5', ) expect(init?.method).toBe('GET') expect(init?.body).toBeUndefined() @@ -95,7 +95,7 @@ describe('PowerContextClient generated operation requests', () => { } if (call === 2) { expect(url).toBe( - 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/decision%20records/artifact%2F1', + '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"') @@ -108,19 +108,16 @@ describe('PowerContextClient generated operation requests', () => { expect(init?.body).toBeUndefined() return new Response(null, { status: 304 }) } - expect(init?.method).toBe('DELETE') - expect(headers.get('If-Match')).toBe('"revision:2"') - expect(init?.body).toBeUndefined() - return new Response(null, { status: 204 }) + 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: 'decision records' } + const path = { scope_id: 'scope/team', family: 'memory' } - await client.request('list_artifacts', { ...path, query: 'recent work', limit: 5, ignored: 'value' }) + await client.request('list_artifacts', { ...path, limit: 5, ignored: 'value' }) await client.request('replace_artifact', { ...path, artifact_id: 'artifact/1', @@ -132,12 +129,7 @@ describe('PowerContextClient generated operation requests', () => { artifact_id: 'artifact/1', if_none_match: '"revision:2"', })).resolves.toMatchObject({ kind: 'json', value: null, status: 304 }) - await expect(client.request('delete_artifact', { - ...path, - artifact_id: 'artifact/1', - if_match: '"revision:2"', - })).resolves.toMatchObject({ kind: 'json', value: null, status: 204 }) - expect(fetchImpl).toHaveBeenCalledTimes(4) + expect(fetchImpl).toHaveBeenCalledTimes(3) }) it('rejects an undeclared redirect response', async () => { diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index 157c61567..3b211b09c 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -71,13 +71,11 @@ export const OPERATIONS = { attach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/attach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, detach_handoff_report_workspace: { method: 'POST', path: '/v1/handoff-reports/workspace-bindings/detach', location: "body", scope: false, pathParams: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_source: { method: 'POST', path: '/v1/scopes/{scope_id}/sources', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, - list_sources: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}', location: "query", scope: true, pathParams: ['scope_id','source_type'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_source: { method: 'GET', path: '/v1/scopes/{scope_id}/sources/{source_type}/{source_id}', location: null, scope: true, pathParams: ['scope_id','source_type','source_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, create_artifact: { method: 'POST', path: '/v1/scopes/{scope_id}/artifacts', location: "body", scope: true, pathParams: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [201], emptyStatuses: [] }, - list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['scope_id','family'], queryParams: ['query','mode','limit','cursor'], headerParams: [], successStatuses: [200], emptyStatuses: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scope: true, pathParams: ['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, scope: true, pathParams: ['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", scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, - delete_artifact: { method: 'DELETE', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [204], emptyStatuses: [204] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scope: true, pathParams: ['scope_id','family','artifact_id','revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, } as const diff --git a/integrations/pi/plugins/powercontext/tests/client.spec.ts b/integrations/pi/plugins/powercontext/tests/client.spec.ts index ecb51fdb1..9c5606ef8 100644 --- a/integrations/pi/plugins/powercontext/tests/client.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/client.spec.ts @@ -71,7 +71,7 @@ describe('PowerContext Pi HTTP client', () => { const headers = new Headers(init?.headers) if (call === 1) { expect(url).toBe( - 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/decision%20records?query=recent+work&limit=5', + 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/memory?limit=5', ) expect(init?.method).toBe('GET') expect(init?.body).toBeUndefined() @@ -79,7 +79,7 @@ describe('PowerContext Pi HTTP client', () => { } if (call === 2) { expect(url).toBe( - 'http://127.0.0.1:8000/v1/scopes/scope%2Fteam/artifacts/decision%20records/artifact%2F1', + '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"') @@ -92,19 +92,16 @@ describe('PowerContext Pi HTTP client', () => { expect(init?.body).toBeUndefined() return new Response(null, { status: 304 }) } - expect(init?.method).toBe('DELETE') - expect(headers.get('If-Match')).toBe('"revision:2"') - expect(init?.body).toBeUndefined() - return new Response(null, { status: 204 }) + 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: 'decision records' } + const path = { scope_id: 'scope/team', family: 'memory' } - await client.request('list_artifacts', { ...path, query: 'recent work', limit: 5, ignored: 'value' }) + await client.request('list_artifacts', { ...path, limit: 5, ignored: 'value' }) await client.request('replace_artifact', { ...path, artifact_id: 'artifact/1', @@ -116,11 +113,6 @@ describe('PowerContext Pi HTTP client', () => { artifact_id: 'artifact/1', if_none_match: '"revision:2"', })).resolves.toMatchObject({ kind: 'json', value: null, status: 304 }) - await expect(client.request('delete_artifact', { - ...path, - artifact_id: 'artifact/1', - if_match: '"revision:2"', - })).resolves.toMatchObject({ kind: 'json', value: null, status: 204 }) - expect(fetch).toHaveBeenCalledTimes(4) + expect(fetch).toHaveBeenCalledTimes(3) }) }) diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index fc62c82b1..693292540 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -1723,8 +1723,6 @@ paths: $ref: "#/components/schemas/SourceRecord" "401": $ref: "#/components/responses/Unauthorized" - "405": - $ref: "#/components/responses/OperationNotSupported" "409": $ref: "#/components/responses/Conflict" "422": @@ -1733,79 +1731,6 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" - /v1/scopes/{scope_id}/sources/{source_type}: - get: - tags: [sources] - summary: List or search Sources - description: List when query is absent or blank; search when query is nonblank. - operationId: list_sources - 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 - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - - name: query - in: query - required: false - schema: - type: string - maxLength: 8192 - - name: mode - in: query - required: false - schema: - $ref: "#/components/schemas/TextSearchMode" - - 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 Sources or Source search results. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/SourcePage" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "405": - $ref: "#/components/responses/OperationNotSupported" - "410": - $ref: "#/components/responses/CursorExpired" - "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] @@ -1825,9 +1750,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] - name: source_id in: path required: true @@ -1850,8 +1773,6 @@ paths: $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" - "405": - $ref: "#/components/responses/OperationNotSupported" "422": $ref: "#/components/responses/InvalidRequest" "503": @@ -1862,7 +1783,7 @@ paths: post: tags: [artifacts] summary: Create an Artifact - description: Commit revision one for a family that permits direct creation. The Server generates artifact_id. + description: Validate family content and atomically create revision one with its system provenance Source. operationId: create_artifact parameters: - name: scope_id @@ -1892,11 +1813,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ArtifactRevision" + $ref: "#/components/schemas/ArtifactCreated" "401": $ref: "#/components/responses/Unauthorized" - "405": - $ref: "#/components/responses/OperationNotSupported" "409": $ref: "#/components/responses/Conflict" "422": @@ -1908,8 +1827,8 @@ paths: /v1/scopes/{scope_id}/artifacts/{family}: get: tags: [artifacts] - summary: List or search current Artifact heads - description: List when query is absent or blank; search when query is nonblank. + summary: List current Artifact heads + description: List current heads for exactly one built-in Artifact family. operationId: list_artifacts parameters: - name: scope_id @@ -1925,20 +1844,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - - name: query - in: query - required: false - schema: - type: string - maxLength: 8192 - - name: mode - in: query - required: false - schema: - $ref: "#/components/schemas/TextSearchMode" + enum: [memory, experience, skill, handoff] - name: limit in: query required: false @@ -1956,7 +1862,7 @@ paths: maxLength: 4096 responses: "200": - description: One stable page of current Artifact heads or search results. + description: One stable page of current Artifact heads. headers: X-PowerContext-Request-ID: $ref: "#/components/headers/RequestId" @@ -1995,9 +1901,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [memory, experience, skill, handoff] - name: artifact_id in: path required: true @@ -2011,7 +1915,7 @@ paths: required: false schema: type: string - pattern: '^"revision:[1-9][0-9]*"$' + minLength: 1 responses: "200": description: The current visible Artifact head. @@ -2060,9 +1964,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [memory, experience, skill, handoff] - name: artifact_id in: path required: true @@ -2076,7 +1978,7 @@ paths: required: true schema: type: string - pattern: '^"revision:[1-9][0-9]*"$' + minLength: 1 requestBody: required: true content: @@ -2099,66 +2001,6 @@ paths: $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" - "405": - $ref: "#/components/responses/OperationNotSupported" - "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" - delete: - tags: [artifacts] - summary: Delete the current Artifact head - description: Record a tombstone without deleting immutable historical revisions. - operationId: delete_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 - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - - 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 - pattern: '^"revision:[1-9][0-9]*"$' - responses: - "204": - description: The Artifact head is deleted and immutable history is retained. - headers: - X-PowerContext-Request-ID: - $ref: "#/components/headers/RequestId" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "405": - $ref: "#/components/responses/OperationNotSupported" "412": $ref: "#/components/responses/PreconditionFailed" "422": @@ -2188,9 +2030,7 @@ paths: required: true schema: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [memory, experience, skill, handoff] - name: artifact_id in: path required: true @@ -2233,10 +2073,10 @@ components: description: Static bearer token used when local Server authentication is enabled. headers: ArtifactETag: - description: Strong validator for the current immutable Artifact revision. + description: Opaque strong validator for the current Artifact head. Clients must replay it verbatim. schema: type: string - pattern: '^"revision:[1-9][0-9]*"$' + minLength: 1 BearerChallenge: description: Authentication scheme required by the Server. schema: @@ -2289,20 +2129,6 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - OperationNotSupported: - description: The Source type or Artifact family does not permit this operation. - headers: - Allow: - description: HTTP methods supported by the selected Source type or Artifact family. - required: true - schema: - type: string - 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: @@ -2397,42 +2223,65 @@ components: default: 8000 ArtifactCollectionItem: type: object - required: [scope_id, artifact_ref, created_at, content_digest, score, snippets] + required: [scope_id, family, artifact_id, revision, sources, artifacts, content_digest] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" - created_at: + family: + $ref: "#/components/schemas/BaseArtifactFamily" + artifact_id: type: string - format: date-time - nullable: true + 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}$' - score: - type: number - minimum: 0 - maximum: 1 - nullable: true - snippets: + 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: - type: string + $ref: "#/components/schemas/SourceTypeReference" + artifacts: + type: array + items: + $ref: "#/components/schemas/ArtifactReference" ArtifactPage: type: object - required: [query, mode, items, next_cursor] + required: [items, next_cursor] properties: - query: - type: string - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchUsedMode" - nullable: true items: type: array items: @@ -2442,30 +2291,34 @@ components: nullable: true ArtifactRevision: type: object - required: [scope_id, artifact_ref, content, source_refs, artifact_refs, created_at, content_digest] + required: [scope_id, family, artifact_id, revision, content, sources, artifacts, content_digest] properties: scope_id: type: string minLength: 1 maxLength: 256 pattern: '.*\S.*' - artifact_ref: - $ref: "#/components/schemas/ArtifactReference" + 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 - source_refs: + sources: type: array items: $ref: "#/components/schemas/SourceTypeReference" - artifact_refs: + artifacts: type: array items: $ref: "#/components/schemas/ArtifactReference" - created_at: - type: string - format: date-time - nullable: true content_digest: type: string pattern: '^sha256:[0-9a-f]{64}$' @@ -5240,23 +5093,10 @@ components: required: [family, content] properties: family: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + $ref: "#/components/schemas/BaseArtifactFamily" content: type: object additionalProperties: true - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceTypeReference" - default: [] - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - default: [] CreateSourceRequest: type: object additionalProperties: false @@ -5264,50 +5104,14 @@ components: properties: source_type: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] default: content content: - description: JSON value validated by the selected Source adapter. - metadata: - type: object - additionalProperties: true - default: {} + description: JSON value persisted by the built-in content Source adapter. ListArtifactsRequest: type: object additionalProperties: false properties: - query: - type: string - maxLength: 8192 - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchMode" - nullable: true - limit: - type: integer - minimum: 1 - maximum: 100 - default: 50 - cursor: - type: string - minLength: 1 - maxLength: 4096 - nullable: true - ListSourcesRequest: - type: object - additionalProperties: false - properties: - query: - type: string - maxLength: 8192 - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchMode" - nullable: true limit: type: integer minimum: 1 @@ -5326,78 +5130,9 @@ components: content: type: object additionalProperties: true - source_refs: - type: array - items: - $ref: "#/components/schemas/SourceTypeReference" - default: [] - artifact_refs: - type: array - items: - $ref: "#/components/schemas/ArtifactReference" - default: [] - SourceCollectionItem: - type: object - required: [scope_id, source_type, source_id, metadata, created_at, position, content_digest, score, snippets] - properties: - scope_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '.*\S.*' - source_type: - type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' - source_id: - type: string - minLength: 1 - maxLength: 256 - pattern: '^[\x21-\x7E]+$' - metadata: - type: object - additionalProperties: true - created_at: - type: string - format: date-time - nullable: true - position: - type: integer - minimum: 1 - content_digest: - type: string - pattern: '^sha256:[0-9a-f]{64}$' - score: - type: number - minimum: 0 - maximum: 1 - nullable: true - snippets: - type: array - items: - type: string - SourcePage: - type: object - required: [query, mode, items, next_cursor] - properties: - query: - type: string - nullable: true - mode: - allOf: - - $ref: "#/components/schemas/TextSearchUsedMode" - nullable: true - items: - type: array - items: - $ref: "#/components/schemas/SourceCollectionItem" - next_cursor: - type: string - nullable: true SourceRecord: type: object - required: [scope_id, source_type, source_id, content, metadata, created_at, position, content_digest] + required: [scope_id, source_type, source_id, content, position, content_digest] properties: scope_id: type: string @@ -5406,9 +5141,7 @@ components: pattern: '.*\S.*' source_type: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] source_id: type: string minLength: 1 @@ -5416,13 +5149,6 @@ components: pattern: '^[\x21-\x7E]+$' content: description: Persisted canonical JSON content. - metadata: - type: object - additionalProperties: true - created_at: - type: string - format: date-time - nullable: true position: type: integer minimum: 1 @@ -5436,9 +5162,7 @@ components: properties: source_type: type: string - minLength: 1 - maxLength: 128 - pattern: '^[\x21-\x7E]+$' + enum: [content] source_id: type: string minLength: 1 @@ -5457,15 +5181,12 @@ components: CaptureStatus: type: string enum: [accepted] + BaseArtifactFamily: + type: string + enum: [memory, experience, skill, handoff] StatsPeriod: type: string enum: [today, 7d, 30d] - TextSearchMode: - type: string - enum: [auto, keyword] - TextSearchUsedMode: - type: string - enum: [keyword] CandidateFamily: type: string enum: [experience, skill] diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 87909a73d..d511e29d0 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -17,10 +17,9 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from datetime import UTC, datetime from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError from sqlalchemy import insert, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection @@ -43,6 +42,7 @@ ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACTS_TABLE, ) +from powercontext.builtin.source_eligibility import ArtifactLineageTarget, require_source_eligible from powercontext.errors import ( ArtifactFamilyMismatchError, RevisionConflictError, @@ -51,10 +51,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(): @@ -62,6 +79,7 @@ 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]: @@ -74,7 +92,7 @@ async def create( 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.""" @@ -83,6 +101,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 @@ -101,7 +120,6 @@ async def create( family=ref.family, artifact_id=ref.artifact_id, revision=ref.revision, - deleted_at=None, ) ) except IntegrityError: @@ -119,7 +137,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.""" @@ -129,6 +147,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) @@ -137,7 +161,6 @@ async def revise( ARTIFACT_HEADS_TABLE.c.family == artifact.family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact.artifact_id, ARTIFACT_HEADS_TABLE.c.revision == artifact.revision, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .values(revision=ARTIFACT_HEADS_TABLE.c.revision) ) @@ -165,7 +188,6 @@ async def revise( ARTIFACT_HEADS_TABLE.c.family == artifact.family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact.artifact_id, ARTIFACT_HEADS_TABLE.c.revision == artifact.revision, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .values(revision=ref.revision) ) @@ -269,7 +291,6 @@ async def _insert_revision( artifact_id=ref.artifact_id, revision=ref.revision, content=payload, - created_at=datetime.now(UTC), ) ) if lineage.sources: @@ -398,13 +419,11 @@ 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.""" head = await self._find_head(connection, scope_id, ref.family, ref.artifact_id) - if head is None: - head = await self._find_deleted_head(connection, scope_id, ref.family, ref.artifact_id) if head is None: return None current = await self.get( @@ -425,28 +444,10 @@ async def _find_head( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) value = await connection.scalar(statement) return None if value is None else int(value) - async def _find_deleted_head( - self, - connection: AsyncConnection, - scope_id: str, - 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, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_not(None), - ) - ) - return None if value is None else int(value) - def _artifact_type(self, family: str) -> type[Artifact[Any]]: try: return self._by_family[family] @@ -458,6 +459,55 @@ 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: + # The REST body is already JSON typed; allow Pydantic to normalize + # JSON arrays into the tuple-backed immutable domain models. + validated = content_type.model_validate(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/handoff.py b/src/powercontext/builtin/persistence/handoff.py index dcf2a6e0f..2f344cd7a 100644 --- a/src/powercontext/builtin/persistence/handoff.py +++ b/src/powercontext/builtin/persistence/handoff.py @@ -41,6 +41,7 @@ from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import RepositoryNotFoundError from powercontext.builtin.persistence.sources import SourceRepository +from powercontext.builtin.source_eligibility import require_source_eligible from powercontext.errors import ArtifactNotFoundError @@ -133,6 +134,7 @@ async def resolve(self, citation: HandoffCitation, /) -> HandoffGenerationEviden if isinstance(citation, HandoffSourceCitation): async with self._database.transaction() as connection: source = await self._sources.get(connection, self._scope_id, citation.source_ref) + require_source_eligible(citation.source_ref, source.value) return HandoffSourceEvidence(citation=citation, source=source.value) if isinstance(citation, HandoffArtifactCitation): async with self._database.transaction() as connection: diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 99ceafa43..e6489d546 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -19,9 +19,8 @@ import base64 import binascii import hmac -import json import secrets -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Mapping from datetime import UTC, datetime, timedelta from hashlib import sha256 from typing import Any, cast @@ -29,31 +28,31 @@ import rfc8785 from pydantic import JsonValue, TypeAdapter, ValidationError -from sqlalchemy import func, insert, select, update -from sqlalchemy.exc import IntegrityError +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncConnection -from powercontext.artifacts import ArtifactRef -from powercontext.builtin.persistence.codec import stored_bytes +from powercontext.artifacts import Artifact, ArtifactRef +from powercontext.builtin.persistence.artifacts import ArtifactRepository, RepositoryArtifactDraft from powercontext.builtin.persistence.database import AsyncDatabase -from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError +from powercontext.builtin.persistence.errors import ( + InvalidRepositoryArgumentError, + RepositoryNotFoundError, + StoredPayloadConflictError, +) from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, - ARTIFACT_LINEAGE_ARTIFACTS_TABLE, - ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACTS_TABLE, SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ) from powercontext.builtin.records import ( ArtifactCollectionItem, - ArtifactDeletion, + ArtifactCreated, ArtifactRecord, ArtifactRecordPage, ArtifactRevisionPreconditionError, ArtifactWrite, - BaseOperationNotSupportedError, BaseValueConflictError, BaseValueNotFoundError, CursorExpiredError, @@ -61,20 +60,25 @@ InvalidCursorError, ScopeSummary, ScopeSummaryPage, - SourceCollectionItem, SourceRecord, - SourceRecordPage, - TextSearchMode, ) -from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER, CONTENT_SOURCE_NAME, ContentCapture, ContentSource -from powercontext.sources import SourceRef +from powercontext.builtin.source_eligibility import is_generation_eligible +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) -_PROTECTED_ARTIFACT_FAMILIES = frozenset({"experience", "handoff", "memory", "skill"}) _DEFAULT_CURSOR_TTL_SECONDS = 3_600 @@ -85,13 +89,13 @@ def __init__( self, database: AsyncDatabase, sources: SourceRepository, + artifacts: ArtifactRepository, /, *, clock: Clock | None = None, id_factory: IdFactory | None = None, cursor_secret: bytes | None = None, cursor_ttl_seconds: int = _DEFAULT_CURSOR_TTL_SECONDS, - protected_artifact_families: Iterable[str] | None = None, ) -> None: if isinstance(cursor_ttl_seconds, bool) or cursor_ttl_seconds < 1: raise ValueError("cursor_ttl_seconds must be a positive integer") # noqa: TRY003 @@ -99,29 +103,27 @@ def __init__( raise ValueError("cursor_secret must not be empty") # noqa: TRY003 self._database = database self._sources = sources + self._artifacts = artifacts 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) - self._protected_artifact_families = frozenset( - _PROTECTED_ARTIFACT_FAMILIES if protected_artifact_families is None else protected_artifact_families - ) async def create_source( self, scope_id: str, source_type: str, content: JsonValue, - metadata: Mapping[str, JsonValue], /, ) -> SourceRecord: - return await self._store_source( - scope_id, - source_type, - self._id_factory("source"), - content, - metadata, + self._require_content_source(source_type) + source = ContentSource( + name=self._id_factory("source"), + materialization=SourceMaterialization.CAPTURED, + content=_canonical_source_text(content), + wire_content=None if isinstance(content, str) else _JSON_VALUE.validate_python(content, strict=True), ) + return await self._store_source(scope_id, source_type, source) async def capture_source( self, @@ -134,17 +136,7 @@ async def capture_source( ) -> SourceRecord: """Preserve the caller-stable identity used by the existing capture API.""" - return await self._store_source(scope_id, source_type, source_id, content, metadata) - - async def _store_source( - self, - scope_id: str, - source_type: str, - source_id: str, - content: JsonValue, - metadata: Mapping[str, JsonValue], - ) -> SourceRecord: - self._require_content_source(source_type, "create") + self._require_content_source(source_type) try: capture = ContentCapture.model_validate( {"source_id": source_id, "content": content, "metadata": dict(metadata)}, @@ -152,127 +144,75 @@ async def _store_source( ) except ValidationError as error: raise InvalidBaseAccessRequestError("content", "does not match the Source adapter") from error - source = await CONTENT_SOURCE_ADAPTER.resolve(capture) + 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, - created_at=_aware_datetime(self._clock()), - ) + stored = await self._sources.add(connection, scope_id, source) except StoredPayloadConflictError as error: - raise BaseValueConflictError("source", (scope_id, source_type, source_id)) from 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, "get") + 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 query_sources( - self, - scope_id: str, - source_type: str, - /, - *, - query: str | None, - mode: TextSearchMode | None, - limit: int, - cursor: str | None, - ) -> SourceRecordPage: - normalized_query = _normalize_query(query, mode) - self._require_content_source(source_type, "list" if normalized_query is None else "search") - _require_limit(limit) - actual_mode = None if normalized_query is None else "keyword" - expected_cursor = { - "version": 1, - "endpoint": "list_sources", - "scope_id": scope_id, - "source_type": source_type, - "query": normalized_query, - "mode": actual_mode, - "order": "journal_position:asc", - } - after = self._cursor_after_int(cursor, expected_cursor) - async with self._database.transaction() as connection: - statement = ( - select(SOURCES_TABLE.c.source_id, SOURCES_TABLE.c.journal_position) - .where( - SOURCES_TABLE.c.scope_id == scope_id, - SOURCES_TABLE.c.source_type == source_type, - SOURCES_TABLE.c.journal_position > after, - ) - .order_by(SOURCES_TABLE.c.journal_position) - ) - if normalized_query is None: - statement = statement.limit(limit + 1) - rows = (await connection.execute(statement)).all() - matches: list[tuple[int, SourceCollectionItem]] = [] - for row in rows: - ref = SourceRef(source_type=source_type, source_id=str(row.source_id)) - record = _source_record(scope_id, await self._get_source(connection, scope_id, ref)) - score: float | None = None - snippets: tuple[str, ...] = () - if normalized_query is not None: - text = _searchable_text(record.content) - if not _matches_text(text, normalized_query): - continue - score = 1.0 - snippets = (_snippet(text, normalized_query),) - matches.append((int(row.journal_position), _source_collection_item(record, score, snippets))) - if len(matches) > limit: - break - - selected = matches[:limit] - next_cursor = None - if len(matches) > limit and selected: - next_cursor = self._encode_cursor(expected_cursor, selected[-1][0]) - return SourceRecordPage( - query=normalized_query, - mode=actual_mode, - items=tuple(item for _, item in selected), - next_cursor=next_cursor, - ) - async def create_artifact( self, scope_id: str, family: str, write: ArtifactWrite, /, - ) -> ArtifactRecord: - self._require_direct_family(family, "create") - identity = self._id_factory("artifact") - ref = ArtifactRef(family=family, artifact_id=identity, revision=1) - now = _aware_datetime(self._clock()) + ) -> ArtifactCreated: + draft = self._validated_draft(family, write.content) + artifact_id = self._id_factory(family) + source_id = self._id_factory("source") + canonical_content = cast(dict[str, JsonValue], draft.content.model_dump(mode="json", by_alias=True)) + source = ContentSource( + name=source_id, + materialization=SourceMaterialization.CAPTURED, + content=_canonical_source_text(canonical_content), + wire_content=canonical_content, + internal=ContentSourceInternal( + role="lineage_only", + operation="artifact_create", + target=ContentSourceTarget( + scope_id=scope_id, + family=cast(Any, family), + artifact_id=artifact_id, + ), + ), + ) try: async with self._database.transaction() as connection: - await self._require_new_artifact(connection, scope_id, ref) - await self._validate_lineage(connection, scope_id, write) - await _insert_artifact_revision(connection, scope_id, ref, write, now) - await connection.execute( - insert(ARTIFACT_HEADS_TABLE).values( - scope_id=scope_id, - family=family, - artifact_id=identity, - revision=1, - searchable_text=_searchable_text(write.content), - deleted_at=None, - ) + stored = await self._sources.add(connection, scope_id, source) + draft = draft.model_copy(update={"sources": (stored.ref,)}) + artifact = await self._artifacts.create( + connection, + scope_id, + artifact_id, + draft, ) - except IntegrityError as error: - raise BaseValueConflictError("artifact", (scope_id, family, identity)) from error - return _artifact_record(scope_id, ref, write, now) + 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: - ArtifactRef(family=family, artifact_id=artifact_id, revision=1) + self._require_family(family) async with self._database.transaction() as connection: - revision = await _head_revision(connection, scope_id, family, artifact_id) - if revision is None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - return await _load_artifact(connection, scope_id, family, artifact_id, revision) + 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, @@ -282,9 +222,17 @@ async def get_artifact_revision( revision: int, /, ) -> ArtifactRecord: - ArtifactRef(family=family, artifact_id=artifact_id, revision=revision) + self._require_family(family) async with self._database.transaction() as connection: - return await _load_artifact(connection, scope_id, family, artifact_id, revision) + 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, @@ -292,89 +240,49 @@ async def query_artifacts( family: str, /, *, - query: str | None, - mode: TextSearchMode | None, limit: int, cursor: str | None, ) -> ArtifactRecordPage: - ArtifactRef(family=family, artifact_id="validation", revision=1) - normalized_query = _normalize_query(query, mode) + self._require_family(family) _require_limit(limit) - actual_mode = None if normalized_query is None else "keyword" expected_cursor = { "version": 1, "endpoint": "list_artifacts", "scope_id": scope_id, "family": family, - "query": normalized_query, - "mode": actual_mode, "order": "artifact_id:asc", } after = self._cursor_after_text(cursor, expected_cursor) - head_matches_revision = ( - (ARTIFACTS_TABLE.c.scope_id == ARTIFACT_HEADS_TABLE.c.scope_id) - & (ARTIFACTS_TABLE.c.family == ARTIFACT_HEADS_TABLE.c.family) - & (ARTIFACTS_TABLE.c.artifact_id == ARTIFACT_HEADS_TABLE.c.artifact_id) - & (ARTIFACTS_TABLE.c.revision == ARTIFACT_HEADS_TABLE.c.revision) - ) async with self._database.transaction() as connection: statement = ( select( ARTIFACT_HEADS_TABLE.c.artifact_id, ARTIFACT_HEADS_TABLE.c.revision, - ARTIFACTS_TABLE.c.content, - ARTIFACTS_TABLE.c.created_at, ) - .join(ARTIFACTS_TABLE, head_matches_revision) .where( ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, ARTIFACT_HEADS_TABLE.c.family == family, ARTIFACT_HEADS_TABLE.c.artifact_id > after, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .order_by(ARTIFACT_HEADS_TABLE.c.artifact_id) + .limit(limit + 1) ) - if normalized_query is None: - statement = statement.limit(limit + 1) rows = (await connection.execute(statement)).all() - matches: list[tuple[str, ArtifactCollectionItem]] = [] - for row in rows: - content = _decode_object(row.content, "content") - text = _searchable_text(content) - score: float | None = None - snippets: tuple[str, ...] = () - if normalized_query is not None: - if not _matches_text(text, normalized_query): - continue - score = 1.0 - snippets = (_snippet(text, normalized_query),) - artifact_id = str(row.artifact_id) - matches.append(( - artifact_id, - ArtifactCollectionItem( - scope_id=scope_id, - artifact_ref=ArtifactRef( - family=family, - artifact_id=artifact_id, - revision=int(row.revision), - ), - created_at=None if row.created_at is None else _aware_datetime(row.created_at), - content_digest=_content_digest(content), - score=score, - snippets=snippets, - ), - )) - if len(matches) > limit: - break - - selected = matches[:limit] + selected_rows = rows[:limit] + items = [] + for row in selected_rows: + artifact = await self._artifacts.get( + connection, + scope_id, + ArtifactRef(family=family, artifact_id=str(row.artifact_id), revision=int(row.revision)), + ) + items.append(_artifact_collection_item(scope_id, artifact)) + next_cursor = None - if len(matches) > limit and selected: - next_cursor = self._encode_cursor(expected_cursor, selected[-1][0]) + if len(rows) > limit and selected_rows: + next_cursor = self._encode_cursor(expected_cursor, str(selected_rows[-1].artifact_id)) return ArtifactRecordPage( - query=normalized_query, - mode=actual_mode, - items=tuple(item for _, item in selected), + items=tuple(items), next_cursor=next_cursor, ) @@ -383,102 +291,36 @@ async def replace_artifact( scope_id: str, family: str, artifact_id: str, - expected_revision: int, + expected_etag: str, write: ArtifactWrite, /, ) -> ArtifactRecord: - self._require_direct_family(family, "replace") - ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision) - now = _aware_datetime(self._clock()) - try: - async with self._database.transaction() as connection: - current = await _head_revision(connection, scope_id, family, artifact_id) - if current is None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - if current != expected_revision: - raise ArtifactRevisionPreconditionError(expected_revision, current) - locked = await connection.execute( - update(ARTIFACT_HEADS_TABLE) - .where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ARTIFACT_HEADS_TABLE.c.revision == expected_revision, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), - ) - .values(revision=ARTIFACT_HEADS_TABLE.c.revision) - ) - if locked.rowcount != 1: - latest = await _head_revision(connection, scope_id, family, artifact_id) - if latest is None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - raise ArtifactRevisionPreconditionError(expected_revision, latest) - await self._validate_lineage(connection, scope_id, write) - ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision + 1) - await _insert_artifact_revision(connection, scope_id, ref, write, now) - advanced = await connection.execute( - update(ARTIFACT_HEADS_TABLE) - .where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ARTIFACT_HEADS_TABLE.c.revision == expected_revision, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), - ) - .values( - revision=ref.revision, - searchable_text=_searchable_text(write.content), - ) - ) - if advanced.rowcount != 1: - latest = await _head_revision(connection, scope_id, family, artifact_id) - raise ArtifactRevisionPreconditionError(expected_revision, latest or expected_revision) - except IntegrityError as error: - raise BaseValueConflictError("artifact", (scope_id, family, artifact_id)) from error - return _artifact_record(scope_id, ref, write, now) - - async def delete_artifact( - self, - scope_id: str, - family: str, - artifact_id: str, - expected_revision: int, - /, - ) -> ArtifactDeletion: - self._require_direct_family(family, "delete") - ArtifactRef(family=family, artifact_id=artifact_id, revision=expected_revision) + self._require_family(family) async with self._database.transaction() as connection: - state = await _head_state(connection, scope_id, family, artifact_id) - if state is None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - revision, existing_deletion = state - if existing_deletion is not None: - if revision != expected_revision: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - return _artifact_deletion(family, artifact_id, revision, existing_deletion) - if revision != expected_revision: - raise ArtifactRevisionPreconditionError(expected_revision, revision) - - deleted_at = _aware_datetime(self._clock()) - removed = await connection.execute( - update(ARTIFACT_HEADS_TABLE) - .where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ARTIFACT_HEADS_TABLE.c.revision == expected_revision, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), - ) - .values(deleted_at=deleted_at) + 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) + sources = [] + for source_ref in current.lineage.sources: + stored = await self._sources.get(connection, scope_id, source_ref) + if is_generation_eligible(stored.value): + sources.append(source_ref) + draft = self._validated_draft( + family, + write.content, + sources=tuple(sources), + artifacts=current.lineage.artifacts, ) - if removed.rowcount != 1: - latest = await _head_state(connection, scope_id, family, artifact_id) - if latest is not None and latest[0] == expected_revision and latest[1] is not None: - return _artifact_deletion(family, artifact_id, latest[0], latest[1]) - if latest is None or latest[1] is not None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id)) - raise ArtifactRevisionPreconditionError(expected_revision, latest[0]) - return _artifact_deletion(family, artifact_id, expected_revision, deleted_at) + try: + revised = await self._artifacts.revise(connection, scope_id, current, draft) + 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) @@ -499,20 +341,31 @@ def _encode_cursor(self, expected: Mapping[str, JsonValue], after: int | 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_int(self, cursor: str | None, expected: Mapping[str, JsonValue]) -> int: - return _cursor_after_int(cursor, expected, secret=self._cursor_secret, now=_aware_datetime(self._clock())) - 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, operation: str) -> None: + def _require_content_source(self, source_type: str) -> None: if source_type != CONTENT_SOURCE_NAME: - raise BaseOperationNotSupportedError("source_type", source_type, operation) + 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") - def _require_direct_family(self, family: str, operation: str) -> None: - ArtifactRef(family=family, artifact_id="validation", revision=1) - if family in self._protected_artifact_families: - raise BaseOperationNotSupportedError("artifact_family", family, operation) + def _validated_draft( + self, + family: str, + content: dict[str, JsonValue], + /, + *, + sources: tuple[SourceRef, ...] = (), + artifacts: tuple[ArtifactRef, ...] = (), + ) -> RepositoryArtifactDraft: + self._require_family(family) + try: + return self._artifacts.draft(family, content, sources=sources, artifacts=artifacts) + except InvalidRepositoryArgumentError as error: + raise InvalidBaseAccessRequestError("content", "does not match the Artifact family") from error async def _get_source( self, @@ -525,191 +378,8 @@ async def _get_source( except RepositoryNotFoundError as error: raise BaseValueNotFoundError("source", (scope_id, ref)) from error - async def _require_new_artifact( - self, - connection: AsyncConnection, - scope_id: str, - ref: ArtifactRef, - ) -> None: - existing = await connection.scalar( - select(ARTIFACTS_TABLE.c.revision) - .where( - ARTIFACTS_TABLE.c.scope_id == scope_id, - ARTIFACTS_TABLE.c.family == ref.family, - ARTIFACTS_TABLE.c.artifact_id == ref.artifact_id, - ) - .limit(1) - ) - if existing is not None: - raise BaseValueConflictError("artifact", (scope_id, ref.family, ref.artifact_id)) - - async def _validate_lineage( - self, - connection: AsyncConnection, - scope_id: str, - write: ArtifactWrite, - ) -> None: - for source in write.source_refs: - exists = await connection.scalar( - select(func.count()) - .select_from(SOURCES_TABLE) - .where( - SOURCES_TABLE.c.scope_id == scope_id, - SOURCES_TABLE.c.source_type == source.source_type, - SOURCES_TABLE.c.source_id == source.source_id, - ) - ) - if not exists: - raise InvalidBaseAccessRequestError("source_refs", "must identify same-Scope Sources") - for artifact in write.artifact_refs: - exists = await connection.scalar( - select(func.count()) - .select_from(ARTIFACTS_TABLE) - .where( - ARTIFACTS_TABLE.c.scope_id == scope_id, - ARTIFACTS_TABLE.c.family == artifact.family, - ARTIFACTS_TABLE.c.artifact_id == artifact.artifact_id, - ARTIFACTS_TABLE.c.revision == artifact.revision, - ) - ) - if not exists: - raise InvalidBaseAccessRequestError( - "artifact_refs", - "must identify exact same-Scope Artifact revisions", - ) - - -async def _insert_artifact_revision( - connection: AsyncConnection, - scope_id: str, - ref: ArtifactRef, - write: ArtifactWrite, - created_at: datetime, -) -> None: - await connection.execute( - insert(ARTIFACTS_TABLE).values( - scope_id=scope_id, - family=ref.family, - artifact_id=ref.artifact_id, - revision=ref.revision, - content=_canonical_object(write.content), - created_at=created_at, - ) - ) - if write.source_refs: - await connection.execute( - insert(ARTIFACT_LINEAGE_SOURCES_TABLE), - [ - { - "scope_id": scope_id, - "family": ref.family, - "artifact_id": ref.artifact_id, - "revision": ref.revision, - "ordinal": ordinal, - "source_type": source.source_type, - "source_id": source.source_id, - } - for ordinal, source in enumerate(write.source_refs) - ], - ) - if write.artifact_refs: - await connection.execute( - insert(ARTIFACT_LINEAGE_ARTIFACTS_TABLE), - [ - { - "scope_id": scope_id, - "family": ref.family, - "artifact_id": ref.artifact_id, - "revision": ref.revision, - "ordinal": ordinal, - "upstream_family": artifact.family, - "upstream_artifact_id": artifact.artifact_id, - "upstream_revision": artifact.revision, - } - for ordinal, artifact in enumerate(write.artifact_refs) - ], - ) - - -async def _load_artifact( - connection: AsyncConnection, - scope_id: str, - family: str, - artifact_id: str, - revision: int, -) -> ArtifactRecord: - row = ( - await connection.execute( - select(ARTIFACTS_TABLE.c.content, ARTIFACTS_TABLE.c.created_at).where( - ARTIFACTS_TABLE.c.scope_id == scope_id, - ARTIFACTS_TABLE.c.family == family, - ARTIFACTS_TABLE.c.artifact_id == artifact_id, - ARTIFACTS_TABLE.c.revision == revision, - ) - ) - ).one_or_none() - if row is None: - raise BaseValueNotFoundError("artifact", (scope_id, family, artifact_id, revision)) - content = _decode_object(row.content, "content") - sources = ( - await connection.execute( - select( - ARTIFACT_LINEAGE_SOURCES_TABLE.c.source_type, - ARTIFACT_LINEAGE_SOURCES_TABLE.c.source_id, - ) - .where( - ARTIFACT_LINEAGE_SOURCES_TABLE.c.scope_id == scope_id, - ARTIFACT_LINEAGE_SOURCES_TABLE.c.family == family, - ARTIFACT_LINEAGE_SOURCES_TABLE.c.artifact_id == artifact_id, - ARTIFACT_LINEAGE_SOURCES_TABLE.c.revision == revision, - ) - .order_by(ARTIFACT_LINEAGE_SOURCES_TABLE.c.ordinal) - ) - ).all() - artifacts = ( - await connection.execute( - select( - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.upstream_family, - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.upstream_artifact_id, - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.upstream_revision, - ) - .where( - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.scope_id == scope_id, - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.family == family, - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.artifact_id == artifact_id, - ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.revision == revision, - ) - .order_by(ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c.ordinal) - ) - ).all() - write = ArtifactWrite( - content=content, - source_refs=tuple( - SourceRef(source_type=str(source.source_type), source_id=str(source.source_id)) for source in sources - ), - artifact_refs=tuple( - ArtifactRef( - family=str(artifact.upstream_family), - artifact_id=str(artifact.upstream_artifact_id), - revision=int(artifact.upstream_revision), - ) - for artifact in artifacts - ), - ) - ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=revision) - return ArtifactRecord( - scope_id=scope_id, - artifact_ref=ref, - content=write.content, - source_refs=write.source_refs, - artifact_refs=write.artifact_refs, - created_at=None if row.created_at is None else _aware_datetime(row.created_at), - content_digest=_content_digest(content), - ) - async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSummary: - active = ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None) source_types = ( await connection.execute( select(SOURCES_TABLE.c.source_type) @@ -721,7 +391,7 @@ async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSum artifact_families = ( await connection.execute( select(ARTIFACT_HEADS_TABLE.c.family) - .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, active) + .where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id) .distinct() .order_by(ARTIFACT_HEADS_TABLE.c.family) ) @@ -730,9 +400,7 @@ async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSum 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, active) + select(func.count()).select_from(ARTIFACT_HEADS_TABLE).where(ARTIFACT_HEADS_TABLE.c.scope_id == scope_id) ) return ScopeSummary( scope_id=scope_id, @@ -743,119 +411,67 @@ async def _scope_summary(connection: AsyncConnection, scope_id: str) -> ScopeSum ) -async def _head_state( - connection: AsyncConnection, - scope_id: str, - family: str, - artifact_id: str, -) -> tuple[int, datetime | None] | None: - row = ( - await connection.execute( - select(ARTIFACT_HEADS_TABLE.c.revision, ARTIFACT_HEADS_TABLE.c.deleted_at).where( - ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, - ARTIFACT_HEADS_TABLE.c.family == family, - ARTIFACT_HEADS_TABLE.c.artifact_id == artifact_id, - ) - ) - ).one_or_none() - if row is None: - return None - return int(row.revision), None if row.deleted_at is None else _aware_datetime(row.deleted_at) - - -async def _head_revision( - connection: AsyncConnection, - scope_id: str, - 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, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), - ) - ) - return None if value is None else int(value) - - def _source_record( scope_id: str, stored: StoredSource, ) -> SourceRecord: if not isinstance(stored.value, ContentSource): - raise BaseOperationNotSupportedError("source_type", stored.ref.source_type, "read") + raise BaseValueNotFoundError("source", (scope_id, stored.ref)) return SourceRecord( scope_id=scope_id, - source_type=stored.ref.source_type, + source_type=cast(Any, stored.ref.source_type), source_id=stored.ref.source_id, - content=stored.value.content, - metadata=stored.value.metadata, - created_at=stored.created_at, + content=stored.value.content if stored.value.wire_content is None else stored.value.wire_content, position=stored.journal_position, - content_digest=_content_digest(stored.value.content), - ) - - -def _source_collection_item( - record: SourceRecord, - score: float | None, - snippets: tuple[str, ...], -) -> SourceCollectionItem: - return SourceCollectionItem( - scope_id=record.scope_id, - source_type=record.source_type, - source_id=record.source_id, - metadata=record.metadata, - created_at=record.created_at, - position=record.position, - content_digest=record.content_digest, - score=score, - snippets=snippets, + content_digest=_content_digest( + stored.value.content if stored.value.wire_content is None else stored.value.wire_content + ), ) def _artifact_record( scope_id: str, - ref: ArtifactRef, - write: ArtifactWrite, - created_at: datetime, + artifact: Artifact[Any], ) -> ArtifactRecord: + content = cast(dict[str, JsonValue], artifact.content.model_dump(mode="json", by_alias=True)) return ArtifactRecord( scope_id=scope_id, - artifact_ref=ref, - content=write.content, - source_refs=write.source_refs, - artifact_refs=write.artifact_refs, - created_at=created_at, - content_digest=_content_digest(write.content), + 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_deletion( - family: str, - artifact_id: str, - revision: int, - deleted_at: datetime, -) -> ArtifactDeletion: - return ArtifactDeletion( - artifact_ref=ArtifactRef(family=family, artifact_id=artifact_id, revision=revision), - deleted_at=_aware_datetime(deleted_at), +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 _canonical_object(value: Mapping[str, JsonValue]) -> bytes: - validated = _JSON_OBJECT.validate_python(dict(value), strict=True) - return rfc8785.dumps(cast(Any, validated)) +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 _decode_object(value: object, column: str) -> dict[str, JsonValue]: - try: - decoded = json.loads(stored_bytes(value, column=column)) - return _JSON_OBJECT.validate_python(decoded, strict=True) - except (UnicodeDecodeError, json.JSONDecodeError, ValidationError) as error: - raise InvalidBaseAccessRequestError(column, "contains invalid stored JSON") from error +def _artifact_etag(revision: int) -> str: + return f'"revision:{revision}"' def _content_digest(value: JsonValue) -> str: @@ -863,34 +479,11 @@ def _content_digest(value: JsonValue) -> str: return f"sha256:{sha256(rfc8785.dumps(cast(Any, validated))).hexdigest()}" -def _searchable_text(value: JsonValue) -> str: - return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True) - - -def _matches_text(text: str, query: str) -> bool: - searchable = text.casefold() - return all(token in searchable for token in query.casefold().split()) - - -def _snippet(text: str, query: str, *, maximum: int = 240) -> str: - folded = text.casefold() - first = query.casefold().split()[0] - position = folded.find(first) - if position < 0: - return text[:maximum] - start = max(position - maximum // 3, 0) - return text[start : start + maximum] - - -def _normalize_query(query: str | None, mode: TextSearchMode | None) -> str | None: - if mode not in {None, "auto", "keyword"}: - raise InvalidBaseAccessRequestError("mode", "must be auto or keyword") - normalized = None if query is None else query.strip() - if not normalized: - if mode is not None: - raise InvalidBaseAccessRequestError("mode", "is only valid with a non-empty query") - return None - return normalized +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: @@ -941,22 +534,6 @@ def _cursor_payload( return payload -def _cursor_after_int( - cursor: str | None, - expected: Mapping[str, JsonValue], - *, - secret: bytes, - now: datetime, -) -> int: - payload = _cursor_payload(cursor, expected, secret=secret, now=now) - if payload is None: - return 0 - after = payload["after"] - if not isinstance(after, int) or isinstance(after, bool) or after < 0: - raise InvalidCursorError - return after - - def _cursor_after_text( cursor: str | None, expected: Mapping[str, JsonValue], diff --git a/src/powercontext/builtin/persistence/schema.py b/src/powercontext/builtin/persistence/schema.py index da9411b2a..d1e4170bc 100644 --- a/src/powercontext/builtin/persistence/schema.py +++ b/src/powercontext/builtin/persistence/schema.py @@ -18,15 +18,9 @@ from collections.abc import Sequence -from sqlalchemy import MetaData, Table, text +from sqlalchemy import MetaData, Table from sqlalchemy.ext.asyncio import AsyncConnection -_RECORD_COLUMNS = { - "pc_sources": {"created_at": "DATETIME NULL"}, - "pc_artifacts": {"created_at": "DATETIME NULL"}, - "pc_artifact_heads": {"deleted_at": "DATETIME NULL"}, -} - async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) -> None: """Create missing tables and indexes using SQLAlchemy's native metadata support.""" @@ -43,34 +37,3 @@ async def create_tables(connection: AsyncConnection, tables: Sequence[Table], /) checkfirst=True, ) ) - - await ensure_record_columns(connection, {table.name for table in tables}) - - -async def ensure_record_columns(connection: AsyncConnection, selected_tables: set[str], /) -> None: - """Add nullable lifecycle timestamps to schemas created before base access existed.""" - - dialect = connection.dialect.name - for table_name, required in _RECORD_COLUMNS.items(): - if table_name not in selected_tables: - continue - if dialect == "sqlite": - rows = (await connection.exec_driver_sql(f"PRAGMA table_info('{table_name}')")).mappings() - existing = {str(row["name"]) for row in rows} - elif dialect == "mysql": - rows = ( - await connection.execute( - text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_schema = DATABASE() AND table_name = :table_name" - ), - {"table_name": table_name}, - ) - ).scalars() - existing = {str(value) for value in rows} - else: - raise ValueError(f"unsupported record schema migration dialect: {dialect}") # noqa: TRY003 - - for column_name, column_type in required.items(): - if column_name not in existing: - await connection.exec_driver_sql(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}") diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index 500669fd9..7a9425702 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -18,7 +18,6 @@ from collections.abc import Iterable, Mapping from contextlib import suppress -from datetime import UTC, datetime from typing import Any, cast from pydantic import BaseModel @@ -48,7 +47,6 @@ class StoredSource(BaseModel): ref: SourceRef value: Source journal_position: int - created_at: datetime | None = None class SourceRepository: @@ -71,8 +69,6 @@ async def add( scope_id: str, source: Source, /, - *, - created_at: datetime | None = None, ) -> StoredSource: """Add one stable Source or return an identical existing capture.""" @@ -88,7 +84,6 @@ async def add( return self._decode_row(existing) position = await _next_journal_position(connection, scope_id) - persisted_at = _normalize_datetime(datetime.now(UTC) if created_at is None else created_at) try: await connection.execute( insert(SOURCES_TABLE).values( @@ -97,7 +92,6 @@ async def add( source_id=ref.source_id, payload=payload, journal_position=position, - created_at=persisted_at, ) ) except IntegrityError: @@ -110,12 +104,7 @@ async def add( if stored_bytes(existing["payload"], column="payload") != payload: raise StoredPayloadConflictError("source", (scope_id, ref)) from None return self._decode_row(existing) - return StoredSource( - ref=ref, - value=source, - journal_position=position, - created_at=persisted_at, - ) + return StoredSource(ref=ref, value=source, journal_position=position) async def get( self, @@ -224,7 +213,6 @@ def _decode_row(self, row: Mapping[Any, Any]) -> StoredSource: ref=indexed, value=cast(Source, source), journal_position=int(row["journal_position"]), - created_at=None if row["created_at"] is None else _normalize_datetime(row["created_at"]), ) @@ -292,7 +280,3 @@ def _require_identity(field: str, value: object, maximum: int) -> None: raise InvalidRepositoryArgumentError(field, "must be a non-empty trimmed string") if len(value) > maximum: raise InvalidRepositoryArgumentError(field, f"must not exceed {maximum} characters") - - -def _normalize_datetime(value: datetime) -> datetime: - return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) diff --git a/src/powercontext/builtin/persistence/statistics.py b/src/powercontext/builtin/persistence/statistics.py index 7a39c0a8a..a1e97198d 100644 --- a/src/powercontext/builtin/persistence/statistics.py +++ b/src/powercontext/builtin/persistence/statistics.py @@ -85,7 +85,6 @@ async def inventory(self, connection: AsyncConnection, scope_id: str, /) -> Stor select(ARTIFACT_HEADS_TABLE.c.family, func.count()) .where( ARTIFACT_HEADS_TABLE.c.scope_id == scope, - ARTIFACT_HEADS_TABLE.c.deleted_at.is_(None), ) .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 be967c323..f61f2e23a 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -20,7 +20,6 @@ CheckConstraint, Column, Date, - DateTime, ForeignKeyConstraint, Integer, LargeBinary, @@ -85,7 +84,6 @@ def _entry_text_type(): Column("source_id", identity_string(MAX_SOURCE_ID_LENGTH), primary_key=True), Column("payload", _canonical_payload_type(), nullable=False), Column("journal_position", BigInteger, nullable=False), - Column("created_at", DateTime(timezone=True)), UniqueConstraint("scope_id", "journal_position", name="uq_pc_sources_scope_journal_position"), ) @@ -105,7 +103,6 @@ def _entry_text_type(): Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), Column("revision", Integer, primary_key=True), Column("content", _canonical_payload_type(), nullable=False), - Column("created_at", DateTime(timezone=True)), ) ARTIFACT_HEADS_TABLE = Table( @@ -116,7 +113,6 @@ def _entry_text_type(): Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), Column("revision", Integer, nullable=False), Column("searchable_text", _entry_text_type()), - Column("deleted_at", DateTime(timezone=True)), ForeignKeyConstraint( ("scope_id", "family", "artifact_id", "revision"), ( diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index 560000031..cb28e7669 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -17,7 +17,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import datetime from typing import Literal, Protocol from pydantic import BaseModel, ConfigDict, JsonValue @@ -25,7 +24,7 @@ from powercontext.artifacts import ArtifactRef from powercontext.sources import SourceRef -TextSearchMode = Literal["auto", "keyword"] +BaseArtifactFamily = Literal["memory", "experience", "skill", "handoff"] class _RecordModel(BaseModel): @@ -36,55 +35,40 @@ class SourceRecord(_RecordModel): """One durable Source and its journal position.""" scope_id: str - source_type: str + source_type: Literal["content"] source_id: str content: JsonValue - metadata: dict[str, JsonValue] - created_at: datetime | None position: int content_digest: str -class SourceCollectionItem(_RecordModel): - """One Source collection projection without its potentially large content.""" - - scope_id: str - source_type: str - source_id: str - metadata: dict[str, JsonValue] - created_at: datetime | None - position: int - content_digest: str - score: float | None = None - snippets: tuple[str, ...] = () - - -class SourceRecordPage(_RecordModel): - """One stable page for either Source listing or keyword search.""" +class ArtifactWrite(_RecordModel): + """Complete family-specific content for one Artifact write.""" - query: str | None - mode: Literal["keyword"] | None - items: tuple[SourceCollectionItem, ...] - next_cursor: str | None + content: dict[str, JsonValue] -class ArtifactWrite(_RecordModel): - """Complete content and direct lineage for one Artifact revision.""" +class ArtifactCreated(_RecordModel): + """Identity and server-owned lineage returned by Artifact Create.""" - content: dict[str, JsonValue] - source_refs: tuple[SourceRef, ...] = () - artifact_refs: tuple[ArtifactRef, ...] = () + 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 - artifact_ref: ArtifactRef + family: BaseArtifactFamily + artifact_id: str + revision: int content: dict[str, JsonValue] - source_refs: tuple[SourceRef, ...] - artifact_refs: tuple[ArtifactRef, ...] - created_at: datetime | None + sources: tuple[SourceRef, ...] + artifacts: tuple[ArtifactRef, ...] content_digest: str @@ -92,29 +76,21 @@ class ArtifactCollectionItem(_RecordModel): """One active Artifact head without content or lineage.""" scope_id: str - artifact_ref: ArtifactRef - created_at: datetime | None + family: BaseArtifactFamily + artifact_id: str + revision: int + sources: tuple[SourceRef, ...] + artifacts: tuple[ArtifactRef, ...] content_digest: str - score: float | None = None - snippets: tuple[str, ...] = () class ArtifactRecordPage(_RecordModel): - """One stable page for Artifact listing or keyword search.""" + """One stable page of current Artifact heads.""" - query: str | None - mode: Literal["keyword"] | None items: tuple[ArtifactCollectionItem, ...] next_cursor: str | None -class ArtifactDeletion(_RecordModel): - """The durable deletion state for one Artifact lifecycle.""" - - artifact_ref: ArtifactRef - deleted_at: datetime - - class ScopeSummary(_RecordModel): """Scope identity plus Source and Artifact activity summaries.""" @@ -191,9 +167,9 @@ class CursorExpiredError(BaseAccessError): class ArtifactRevisionPreconditionError(BaseAccessError): """Report an Artifact ETag that no longer identifies the current head.""" - def __init__(self, provided_revision: int, current_revision: int) -> None: - self.provided_revision = provided_revision - self.current_revision = current_revision + 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") @@ -205,7 +181,6 @@ async def create_source( scope_id: str, source_type: str, content: JsonValue, - metadata: Mapping[str, JsonValue], /, ) -> SourceRecord: ... @@ -221,25 +196,13 @@ async def capture_source( async def get_source(self, scope_id: str, source_type: str, source_id: str, /) -> SourceRecord: ... - async def query_sources( - self, - scope_id: str, - source_type: str, - /, - *, - query: str | None, - mode: TextSearchMode | None, - limit: int, - cursor: str | None, - ) -> SourceRecordPage: ... - async def create_artifact( self, scope_id: str, family: str, write: ArtifactWrite, /, - ) -> ArtifactRecord: ... + ) -> ArtifactCreated: ... async def get_artifact(self, scope_id: str, family: str, artifact_id: str, /) -> ArtifactRecord: ... @@ -258,8 +221,6 @@ async def query_artifacts( family: str, /, *, - query: str | None, - mode: TextSearchMode | None, limit: int, cursor: str | None, ) -> ArtifactRecordPage: ... @@ -269,18 +230,9 @@ async def replace_artifact( scope_id: str, family: str, artifact_id: str, - expected_revision: int, + expected_etag: str, write: ArtifactWrite, /, ) -> ArtifactRecord: ... - async def delete_artifact( - self, - scope_id: str, - family: str, - artifact_id: str, - expected_revision: int, - /, - ) -> ArtifactDeletion: ... - 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..7076c4ec9 100644 --- a/src/powercontext/builtin/review/generation.py +++ b/src/powercontext/builtin/review/generation.py @@ -37,6 +37,7 @@ from powercontext.builtin.review.errors import InvalidCandidateError from powercontext.builtin.review.models import ArtifactCandidate from powercontext.builtin.review.service import ReviewService +from powercontext.builtin.source_eligibility import require_source_eligible from powercontext.errors import PowerContextError from powercontext.sources import Source, SourceRef @@ -152,6 +153,7 @@ async def _evidence( async with self._database.transaction() as connection: for ref in sources: row = await self._sources.get(connection, self._scope_id, ref) + require_source_eligible(ref, row.value) evidence.append(_source_evidence(ref, row.value)) for ref in artifacts: artifact = await self._artifacts.get(connection, self._scope_id, ref) diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index ed40d26c5..653b1d99d 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -38,6 +38,7 @@ ArtifactCandidatePage, CandidateStatus, ) +from powercontext.builtin.source_eligibility import require_source_eligible from powercontext.errors import ArtifactNotFoundError, RevisionConflictError from powercontext.sources import SourceRef @@ -314,7 +315,8 @@ async def _validate_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) + stored = await self._sources.get(connection, self._scope_id, source) + require_source_eligible(source, stored.value) 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 b4cd78da3..561f3ac6e 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -72,7 +72,7 @@ from powercontext.builtin.inference.models import InferenceUsage from powercontext.builtin.inference.usage import bind_usage_reporter from powercontext.builtin.records import ( - ArtifactDeletion, + ArtifactCreated, ArtifactRecord, ArtifactRecordPage, ArtifactWrite, @@ -80,8 +80,6 @@ RecordService, ScopeSummaryPage, SourceRecord, - SourceRecordPage, - TextSearchMode, ) from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService @@ -291,7 +289,6 @@ async def create_source( self, source_type: str, content: JsonValue, - metadata: Mapping[str, JsonValue], /, ) -> SourceRecord: async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): @@ -299,39 +296,18 @@ async def create_source( self.scope_id, source_type, content, - metadata, ) 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 query_sources( - self, - source_type: str, - /, - *, - query: str | None, - mode: TextSearchMode | None, - limit: int, - cursor: str | None, - ) -> SourceRecordPage: - async with self._runtime._scope_operation(self.scope_id): - return await self._runtime._records().query_sources( - self.scope_id, - source_type, - query=query, - mode=mode, - limit=limit, - cursor=cursor, - ) - async def create_artifact( self, family: str, write: ArtifactWrite, /, - ) -> ArtifactRecord: + ) -> 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) @@ -359,8 +335,6 @@ async def query_artifacts( family: str, /, *, - query: str | None, - mode: TextSearchMode | None, limit: int, cursor: str | None, ) -> ArtifactRecordPage: @@ -368,8 +342,6 @@ async def query_artifacts( return await self._runtime._records().query_artifacts( self.scope_id, family, - query=query, - mode=mode, limit=limit, cursor=cursor, ) @@ -378,7 +350,7 @@ async def replace_artifact( self, family: str, artifact_id: str, - expected_revision: int, + expected_etag: str, write: ArtifactWrite, /, ) -> ArtifactRecord: @@ -387,25 +359,10 @@ async def replace_artifact( self.scope_id, family, artifact_id, - expected_revision, + expected_etag, write, ) - async def delete_artifact( - self, - family: str, - artifact_id: str, - expected_revision: int, - /, - ) -> ArtifactDeletion: - async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): - return await self._runtime._records().delete_artifact( - self.scope_id, - family, - artifact_id, - expected_revision, - ) - class RecordApplication: """Select scoped base access and list observable Scopes.""" diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 7627926d9..edc2056ed 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -90,6 +90,7 @@ from powercontext.builtin.runtime.protocols import BuiltinTriggers from powercontext.builtin.runtime.recall import RelationalRecallTokenEstimator from powercontext.builtin.runtime.statistics import RelationalScopedStatistics +from powercontext.builtin.source_eligibility import is_generation_eligible, require_source_eligible from powercontext.builtin.sources import ( CONTENT_SOURCE_ADAPTER, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, @@ -308,9 +309,14 @@ def __init__( self.database = database self.index = NoMemoryIndex() if index is None else index self.experience_index = NoExperienceIndex() if experience_index is None else experience_index + source_repository = SourceRepository(_SOURCE_ADAPTERS) + artifact_repository = ArtifactRepository( + (Handoff, Memory, Experience, Skill), + sources=source_repository, + ) self.repositories = _Repositories( - sources=SourceRepository(_SOURCE_ADAPTERS), - artifacts=ArtifactRepository((Handoff, Memory, Experience, Skill)), + sources=source_repository, + artifacts=artifact_repository, candidates=CandidateRepository({ Experience.family: ExperienceContent, Skill.family: SkillContent, @@ -322,8 +328,8 @@ def __init__( self.records = RelationalRecordService( database, self.repositories.sources, + self.repositories.artifacts, id_factory=id_factory, - protected_artifact_families=self.repositories.artifacts.families, ) self._candidate_pipeline = candidate_pipeline self.memory_extraction = candidate_pipeline is not None @@ -562,7 +568,9 @@ async def get(self, source: Source, /) -> Source: ref = self._as_ref(source) try: async with self._database.connection(self._bound_connection) as connection: - return (await self._repository.get(connection, self._scope_id, ref)).value + value = (await self._repository.get(connection, self._scope_id, ref)).value + require_source_eligible(ref, value) + return value except RepositoryNotFoundError: raise SourceNotFoundError(source) from None @@ -645,6 +653,7 @@ async def activate_handoff(self, request: ActivateHandoff, /) -> HandoffActivati raise HandoffEvidenceUnavailableError( HandoffSourceCitation(source_ref=request.boundary_source) ) from None + require_source_eligible(request.boundary_source, source.value) state_row = await self._services.repositories.cursors.load( connection, self._services.scope_id, @@ -720,6 +729,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) @@ -749,7 +774,9 @@ async def _sources( self._services.scope_id, after=action.after, ) - return tuple(row.value for row in rows if row.journal_position <= action.through) + return tuple( + row.value for row in rows if row.journal_position <= action.through and is_generation_eligible(row.value) + ) async def _prepare_memory(self, sources: tuple[Source, ...]) -> MemoryWritePlan: _, source_catalog = self._services.sources() @@ -804,9 +831,26 @@ 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 - plans = await pipeline.incubate(tuple(row.value for row in rows)) - _validate_experience_plans(plans, rows) action = transition.actions[0] + eligible_rows = tuple(row for row in rows if is_generation_eligible(row.value)) + if not eligible_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 eligible_rows)) + _validate_experience_plans(plans, eligible_rows) async with self._services.database.transaction() as connection: review = self._services.review(connection) for plan in plans: @@ -828,7 +872,7 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: previous_cursor=action.after, high_watermark=high_watermark, current_cursor=action.through, - source_count=len(rows), + source_count=len(eligible_rows), candidate_count=len(plans), ) 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 d596a4588..65987b884 100644 --- a/src/powercontext/builtin/sources/__init__.py +++ b/src/powercontext/builtin/sources/__init__.py @@ -20,6 +20,8 @@ ContentCapture, ContentSource, ContentSourceAdapter, + ContentSourceInternal, + ContentSourceTarget, ) from powercontext.builtin.sources.external_skill import ( EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, @@ -44,6 +46,8 @@ "ContentCapture", "ContentSource", "ContentSourceAdapter", + "ContentSourceInternal", + "ContentSourceTarget", "ExternalSkillImportMode", "ExternalSkillSnapshotCapture", "ExternalSkillSnapshotSource", diff --git a/src/powercontext/builtin/sources/content.py b/src/powercontext/builtin/sources/content.py index d516f0471..aed95d94f 100644 --- a/src/powercontext/builtin/sources/content.py +++ b/src/powercontext/builtin/sources/content.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Literal from pydantic import BaseModel, Field, JsonValue, field_validator @@ -41,11 +41,30 @@ 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: Literal["memory", "experience", "skill", "handoff"] + artifact_id: str + revision: Literal[1] = 1 + + +class ContentSourceInternal(BaseModel): + """Server-owned Source purpose data never exposed by the base REST API.""" + + role: Literal["lineage_only"] + operation: Literal["artifact_create"] + 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 + internal: ContentSourceInternal | None = None class ContentSourceAdapter: @@ -64,6 +83,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 1358592ea..02c11db64 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -33,6 +33,7 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactCreated, ArtifactPage, ArtifactRevision, AttachHandoffReportWorkspaceRequest, @@ -87,7 +88,6 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListSourcesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -118,7 +118,6 @@ SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, - SourcePage, SourceRecord, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, @@ -139,7 +138,6 @@ CREATE_HANDOFF_REPORT_PROJECT, CREATE_SOURCE, CREATE_WORK_CONTRACT, - DELETE_ARTIFACT, DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, @@ -170,7 +168,6 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, - LIST_SOURCES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -456,17 +453,8 @@ async def get_source(self, scope_id: str, source_type: str, source_id: str) -> S path_parameters={"scope_id": scope_id, "source_type": source_type, "source_id": source_id}, ) - async def list_sources(self, scope_id: str, source_type: str, request: ListSourcesRequest) -> SourcePage: - """List Sources or search them when the request includes a query.""" - - return await self._request( - LIST_SOURCES, - request, - path_parameters={"scope_id": scope_id, "source_type": source_type}, - ) - - async def create_artifact(self, scope_id: str, request: CreateArtifactRequest) -> ArtifactRevision: - """Commit revision one for a direct Artifact family.""" + 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}) @@ -506,7 +494,7 @@ async def get_artifact_revision( ) async def list_artifacts(self, scope_id: str, family: str, request: ListArtifactsRequest) -> ArtifactPage: - """List Artifact heads or search them when the request includes a query.""" + """List current Artifact heads for one family.""" return await self._request( LIST_ARTIFACTS, @@ -521,7 +509,7 @@ async def replace_artifact( artifact_id: str, request: ReplaceArtifactRequest, *, - expected_revision: int, + expected_etag: str, ) -> ArtifactRevision: """Commit a complete next revision using optimistic concurrency.""" @@ -529,23 +517,7 @@ async def replace_artifact( REPLACE_ARTIFACT, request, path_parameters={"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, - extra_headers={"If-Match": _artifact_etag(expected_revision)}, - ) - - async def delete_artifact( - self, - scope_id: str, - family: str, - artifact_id: str, - *, - expected_revision: int, - ) -> None: - """Delete an Artifact head while preserving immutable revisions.""" - - await self._request( - DELETE_ARTIFACT, - path_parameters={"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, - extra_headers={"If-Match": _artifact_etag(expected_revision)}, + extra_headers={"If-Match": expected_etag}, ) async def create_work_contract(self, request: CreateWorkContractRequest) -> WorkSourceReceipt: @@ -820,12 +792,6 @@ def _operation_path( return path -def _artifact_etag(revision: int) -> str: - if isinstance(revision, bool) or revision < 1: - raise ValueError("expected_revision must be positive") # noqa: TRY003 - return f'"revision:{revision}"' - - def _decode_error(content: bytes) -> ErrorResponse | None: try: return ErrorResponse.model_validate_json(content) diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 903e68602..fd6c131c3 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -21,11 +21,13 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactCollectionItem, + ArtifactCreated, ArtifactInventoryStatistics, ArtifactPage, ArtifactReference, ArtifactRevision, AttachHandoffReportWorkspaceRequest, + BaseArtifactFamily, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -118,7 +120,6 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListSourcesRequest, MemoryCitation, MemoryEntry, MemoryEntryInventoryStatistics, @@ -179,11 +180,10 @@ SkillGenerationOrigin, SkillProposal, SkillValidationItem, - SourceCollectionItem, SourceInventoryStatistics, - SourcePage, SourceRecord, SourceReference, + SourceType, SourceTypeReference, StatsPeriod, StoredHandoffReportActivity, @@ -191,8 +191,6 @@ TaskCheckStatus, TaskOutcome, TaskOutcomeStatus, - TextSearchMode, - TextSearchUsedMode, TokenEstimatorProfile, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, @@ -214,11 +212,13 @@ "ArtifactCandidate", "ArtifactCandidatePage", "ArtifactCollectionItem", + "ArtifactCreated", "ArtifactInventoryStatistics", "ArtifactPage", "ArtifactReference", "ArtifactRevision", "AttachHandoffReportWorkspaceRequest", + "BaseArtifactFamily", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -311,7 +311,6 @@ "ListMemoryChangesResponse", "ListMemoryEntriesRequest", "ListMemoryEntriesResponse", - "ListSourcesRequest", "MemoryCitation", "MemoryEntry", "MemoryEntryInventoryStatistics", @@ -372,11 +371,10 @@ "SkillGenerationOrigin", "SkillProposal", "SkillValidationItem", - "SourceCollectionItem", "SourceInventoryStatistics", - "SourcePage", "SourceRecord", "SourceReference", + "SourceType", "SourceTypeReference", "StatsPeriod", "StoredHandoffReportActivity", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 9b2a5c468..b807d8bb2 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -778,34 +778,38 @@ class ImportExternalSkillRequest(BaseModel): reason: Annotated[StrictStr | None, Field(max_length=2000, min_length=1)] = None +class SourceType(StrEnum): + CONTENT = "content" + + class CreateSourceRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] = "content" - content: Annotated[Any, Field(description="JSON value validated by the selected Source adapter.")] - metadata: dict[str, Any] = {} + source_type: SourceType = SourceType.CONTENT + content: Annotated[Any, Field(description="JSON value persisted by the built-in content Source adapter.")] -class SourceCollectionItem(BaseModel): - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] - source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] - metadata: dict[str, Any] - created_at: Annotated[AwareDatetime | None, Field(...)] - position: Annotated[StrictInt, Field(ge=1)] - content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] - score: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] - snippets: list[StrictStr] +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 ReplaceArtifactRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + content: dict[str, Any] class SourceRecord(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + 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.")] - metadata: dict[str, Any] - created_at: Annotated[AwareDatetime | None, Field(...)] position: Annotated[StrictInt, Field(ge=1)] content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] @@ -814,7 +818,7 @@ class SourceTypeReference(BaseModel): model_config = ConfigDict( extra="forbid", ) - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + source_type: SourceType source_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern="^[\\x21-\\x7E]+$")] @@ -830,21 +834,19 @@ class CaptureStatus(StrEnum): ACCEPTED = "accepted" +class BaseArtifactFamily(StrEnum): + MEMORY = "memory" + EXPERIENCE = "experience" + SKILL = "skill" + HANDOFF = "handoff" + + class StatsPeriod(StrEnum): TODAY = "today" FIELD_7D = "7d" FIELD_30D = "30d" -class TextSearchMode(StrEnum): - AUTO = "auto" - KEYWORD = "keyword" - - -class TextSearchUsedMode(StrEnum): - KEYWORD = "keyword" - - class CandidateFamily(StrEnum): EXPERIENCE = "experience" SKILL = "skill" @@ -953,27 +955,36 @@ class PreparedHandoffSchema(StrEnum): class ArtifactCollectionItem(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - artifact_ref: ArtifactReference - created_at: Annotated[AwareDatetime | None, Field(...)] + 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}$")] - score: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] - snippets: list[StrictStr] + + +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): - query: Annotated[StrictStr | None, Field(...)] - mode: Annotated[TextSearchUsedMode | None, Field(...)] 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.*")] - artifact_ref: ArtifactReference + 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] - source_refs: list[SourceTypeReference] - artifact_refs: list[ArtifactReference] - created_at: Annotated[AwareDatetime | None, Field(...)] + sources: list[SourceTypeReference] + artifacts: list[ArtifactReference] content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] @@ -1634,46 +1645,8 @@ class CreateArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - family: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] + family: BaseArtifactFamily content: dict[str, Any] - source_refs: Annotated[list[SourceTypeReference], Field(validate_default=True)] = [] - artifact_refs: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] - - -class ListArtifactsRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - query: Annotated[StrictStr | None, Field(max_length=8192)] = None - mode: TextSearchMode | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None - - -class ListSourcesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - query: Annotated[StrictStr | None, Field(max_length=8192)] = None - mode: TextSearchMode | None = None - limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 - cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None - - -class ReplaceArtifactRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - content: dict[str, Any] - source_refs: Annotated[list[SourceTypeReference], Field(validate_default=True)] = [] - artifact_refs: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] - - -class SourcePage(BaseModel): - query: Annotated[StrictStr | None, Field(...)] - mode: Annotated[TextSearchUsedMode | None, Field(...)] - items: list[SourceCollectionItem] - next_cursor: Annotated[StrictStr | None, Field(...)] class ArtifactCandidate(BaseModel): diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 3705db565..3b54f5a57 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -12,6 +12,7 @@ ApproveArtifactCandidateRequest, ArtifactCandidate, ArtifactCandidatePage, + ArtifactCreated, ArtifactPage, ArtifactRevision, AttachHandoffReportWorkspaceRequest, @@ -65,7 +66,6 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListSourcesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -96,7 +96,6 @@ SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, - SourcePage, SourceRecord, StoredHandoffReportActivity, UpdateHandoffReportProjectRequest, @@ -1377,7 +1376,6 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, }, 401: {"$ref": "#/components/responses/Unauthorized"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, 409: {"$ref": "#/components/responses/Conflict"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, @@ -1385,31 +1383,6 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, ) -LIST_SOURCES = Operation[ListSourcesRequest, SourcePage]( - method="GET", - path="/v1/scopes/{scope_id}/sources/{source_type}", - operation_id="list_sources", - request_type=ListSourcesRequest, - request_location="query", - response_type=SourcePage, - success_status=200, - summary="List or search Sources", - tags=("sources",), - responses={ - 200: { - "description": "One stable page of Sources or Source search results.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 400: {"$ref": "#/components/responses/BadRequest"}, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, - 410: {"$ref": "#/components/responses/CursorExpired"}, - 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}", @@ -1427,20 +1400,19 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 401: {"$ref": "#/components/responses/Unauthorized"}, 404: {"$ref": "#/components/responses/NotFound"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, 500: {"$ref": "#/components/responses/InternalError"}, }, ) -CREATE_ARTIFACT = Operation[CreateArtifactRequest, ArtifactRevision]( +CREATE_ARTIFACT = Operation[CreateArtifactRequest, ArtifactCreated]( method="POST", path="/v1/scopes/{scope_id}/artifacts", operation_id="create_artifact", request_type=CreateArtifactRequest, request_location="body", - response_type=ArtifactRevision, + response_type=ArtifactCreated, success_status=201, summary="Create an Artifact", tags=("artifacts",), @@ -1454,7 +1426,6 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, }, 401: {"$ref": "#/components/responses/Unauthorized"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, 409: {"$ref": "#/components/responses/Conflict"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 503: {"$ref": "#/components/responses/Unavailable"}, @@ -1470,11 +1441,11 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): request_location="query", response_type=ArtifactPage, success_status=200, - summary="List or search current Artifact heads", + summary="List current Artifact heads", tags=("artifacts",), responses={ 200: { - "description": "One stable page of current Artifact heads or search results.", + "description": "One stable page of current Artifact heads.", "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, }, 400: {"$ref": "#/components/responses/BadRequest"}, @@ -1539,33 +1510,6 @@ class Operation(BaseModel, Generic[RequestT, ResponseT]): }, 401: {"$ref": "#/components/responses/Unauthorized"}, 404: {"$ref": "#/components/responses/NotFound"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, - 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"}, - }, -) - -DELETE_ARTIFACT = Operation[None, None]( - method="DELETE", - path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}", - operation_id="delete_artifact", - request_type=None, - request_location=None, - response_type=None, - success_status=204, - summary="Delete the current Artifact head", - tags=("artifacts",), - responses={ - 204: { - "description": "The Artifact head is deleted and immutable history is retained.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - 401: {"$ref": "#/components/responses/Unauthorized"}, - 404: {"$ref": "#/components/responses/NotFound"}, - 405: {"$ref": "#/components/responses/OperationNotSupported"}, 412: {"$ref": "#/components/responses/PreconditionFailed"}, 422: {"$ref": "#/components/responses/InvalidRequest"}, 428: {"$ref": "#/components/responses/PreconditionRequired"}, diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 79cb24265..dd0cfe029 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -1536,7 +1536,6 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourceRecord"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, - "405": {"$ref": "#/components/responses/OperationNotSupported"}, "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -1544,66 +1543,6 @@ }, } }, - "/v1/scopes/{scope_id}/sources/{source_type}": { - "get": { - "tags": ["sources"], - "summary": "List or search Sources", - "description": "List when query is absent or blank; search when query is nonblank.", - "operationId": "list_sources", - "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", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, - }, - { - "name": "query", - "in": "query", - "required": False, - "schema": {"type": "string", "maxLength": 8192}, - }, - { - "name": "mode", - "in": "query", - "required": False, - "schema": {"$ref": "#/components/schemas/TextSearchMode"}, - }, - { - "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 Sources or Source search results.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SourcePage"}}}, - }, - "400": {"$ref": "#/components/responses/BadRequest"}, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "405": {"$ref": "#/components/responses/OperationNotSupported"}, - "410": {"$ref": "#/components/responses/CursorExpired"}, - "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"], @@ -1620,7 +1559,7 @@ "name": "source_type", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + "schema": {"type": "string", "enum": ["content"]}, }, { "name": "source_id", @@ -1637,7 +1576,6 @@ }, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, - "405": {"$ref": "#/components/responses/OperationNotSupported"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, "500": {"$ref": "#/components/responses/InternalError"}, @@ -1648,10 +1586,10 @@ "post": { "tags": ["artifacts"], "summary": "Create an Artifact", - "description": "Commit revision one for a " - "family that permits direct " - "creation. The Server " - "generates artifact_id.", + "description": "Validate family content " + "and atomically create " + "revision one with its " + "system provenance Source.", "operationId": "create_artifact", "parameters": [ { @@ -1673,10 +1611,9 @@ "ETag": {"$ref": "#/components/headers/ArtifactETag"}, "X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}, }, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactRevision"}}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactCreated"}}}, }, "401": {"$ref": "#/components/responses/Unauthorized"}, - "405": {"$ref": "#/components/responses/OperationNotSupported"}, "409": {"$ref": "#/components/responses/Conflict"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "503": {"$ref": "#/components/responses/Unavailable"}, @@ -1687,8 +1624,8 @@ "/v1/scopes/{scope_id}/artifacts/{family}": { "get": { "tags": ["artifacts"], - "summary": "List or search current Artifact heads", - "description": "List when query is absent or blank; search when query is nonblank.", + "summary": "List current Artifact heads", + "description": "List current heads for exactly one built-in Artifact family.", "operationId": "list_artifacts", "parameters": [ { @@ -1701,19 +1638,7 @@ "name": "family", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, - }, - { - "name": "query", - "in": "query", - "required": False, - "schema": {"type": "string", "maxLength": 8192}, - }, - { - "name": "mode", - "in": "query", - "required": False, - "schema": {"$ref": "#/components/schemas/TextSearchMode"}, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, }, { "name": "limit", @@ -1730,7 +1655,7 @@ ], "responses": { "200": { - "description": "One stable page of current Artifact heads or search results.", + "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"}}}, }, @@ -1759,7 +1684,7 @@ "name": "family", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, }, { "name": "artifact_id", @@ -1771,7 +1696,7 @@ "name": "If-None-Match", "in": "header", "required": False, - "schema": {"type": "string", "pattern": '^"revision:[1-9][0-9]*"$'}, + "schema": {"type": "string", "minLength": 1}, }, ], "responses": { @@ -1813,7 +1738,7 @@ "name": "family", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, }, { "name": "artifact_id", @@ -1825,7 +1750,7 @@ "name": "If-Match", "in": "header", "required": True, - "schema": {"type": "string", "pattern": '^"revision:[1-9][0-9]*"$'}, + "schema": {"type": "string", "minLength": 1}, }, ], "requestBody": { @@ -1845,53 +1770,6 @@ }, "401": {"$ref": "#/components/responses/Unauthorized"}, "404": {"$ref": "#/components/responses/NotFound"}, - "405": {"$ref": "#/components/responses/OperationNotSupported"}, - "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"}, - }, - }, - "delete": { - "tags": ["artifacts"], - "summary": "Delete the current Artifact head", - "description": "Record a tombstone without deleting immutable historical revisions.", - "operationId": "delete_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", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, - }, - { - "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", "pattern": '^"revision:[1-9][0-9]*"$'}, - }, - ], - "responses": { - "204": { - "description": "The Artifact head is deleted and immutable history is retained.", - "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, - }, - "401": {"$ref": "#/components/responses/Unauthorized"}, - "404": {"$ref": "#/components/responses/NotFound"}, - "405": {"$ref": "#/components/responses/OperationNotSupported"}, "412": {"$ref": "#/components/responses/PreconditionFailed"}, "422": {"$ref": "#/components/responses/InvalidRequest"}, "428": {"$ref": "#/components/responses/PreconditionRequired"}, @@ -1916,7 +1794,7 @@ "name": "family", "in": "path", "required": True, - "schema": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x21-\\x7E]+$"}, + "schema": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, }, { "name": "artifact_id", @@ -1963,43 +1841,56 @@ "ArtifactCollectionItem": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, - "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "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}$"}, - "score": {"type": "number", "maximum": 1.0, "minimum": 0.0, "nullable": True}, - "snippets": {"items": {"type": "string"}, "type": "array"}, }, "type": "object", - "required": ["scope_id", "artifact_ref", "created_at", "content_digest", "score", "snippets"], + "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": { - "query": {"type": "string", "nullable": True}, - "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchUsedMode"}], "nullable": True}, "items": {"items": {"$ref": "#/components/schemas/ArtifactCollectionItem"}, "type": "array"}, "next_cursor": {"type": "string", "nullable": True}, }, "type": "object", - "required": ["query", "mode", "items", "next_cursor"], + "required": ["items", "next_cursor"], }, "ArtifactRevision": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "artifact_ref": {"$ref": "#/components/schemas/ArtifactReference"}, + "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"}, - "source_refs": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, - "artifact_refs": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, - "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "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", - "artifact_ref", + "family", + "artifact_id", + "revision", "content", - "source_refs", - "artifact_refs", - "created_at", + "sources", + "artifacts", "content_digest", ], }, @@ -4170,18 +4061,8 @@ }, "CreateArtifactRequest": { "properties": { - "family": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "family": {"$ref": "#/components/schemas/BaseArtifactFamily"}, "content": {"additionalProperties": True, "type": "object"}, - "source_refs": { - "items": {"$ref": "#/components/schemas/SourceTypeReference"}, - "type": "array", - "default": [], - }, - "artifact_refs": { - "items": {"$ref": "#/components/schemas/ArtifactReference"}, - "type": "array", - "default": [], - }, }, "additionalProperties": False, "type": "object", @@ -4189,15 +4070,8 @@ }, "CreateSourceRequest": { "properties": { - "source_type": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "pattern": "^[\\x21-\\x7E]+$", - "default": "content", - }, - "content": {"description": "JSON value validated by the selected Source adapter."}, - "metadata": {"additionalProperties": True, "type": "object", "default": {}}, + "source_type": {"type": "string", "enum": ["content"], "default": "content"}, + "content": {"description": "JSON value persisted by the built-in content Source adapter."}, }, "additionalProperties": False, "type": "object", @@ -4205,18 +4079,6 @@ }, "ListArtifactsRequest": { "properties": { - "query": {"type": "string", "maxLength": 8192, "nullable": True}, - "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchMode"}], "nullable": True}, - "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, - "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, - }, - "additionalProperties": False, - "type": "object", - }, - "ListSourcesRequest": { - "properties": { - "query": {"type": "string", "maxLength": 8192, "nullable": True}, - "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchMode"}], "nullable": True}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, }, @@ -4224,84 +4086,26 @@ "type": "object", }, "ReplaceArtifactRequest": { - "properties": { - "content": {"additionalProperties": True, "type": "object"}, - "source_refs": { - "items": {"$ref": "#/components/schemas/SourceTypeReference"}, - "type": "array", - "default": [], - }, - "artifact_refs": { - "items": {"$ref": "#/components/schemas/ArtifactReference"}, - "type": "array", - "default": [], - }, - }, + "properties": {"content": {"additionalProperties": True, "type": "object"}}, "additionalProperties": False, "type": "object", "required": ["content"], }, - "SourceCollectionItem": { - "properties": { - "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, - "metadata": {"additionalProperties": True, "type": "object"}, - "created_at": {"type": "string", "format": "date-time", "nullable": True}, - "position": {"type": "integer", "minimum": 1.0}, - "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "score": {"type": "number", "maximum": 1.0, "minimum": 0.0, "nullable": True}, - "snippets": {"items": {"type": "string"}, "type": "array"}, - }, - "type": "object", - "required": [ - "scope_id", - "source_type", - "source_id", - "metadata", - "created_at", - "position", - "content_digest", - "score", - "snippets", - ], - }, - "SourcePage": { - "properties": { - "query": {"type": "string", "nullable": True}, - "mode": {"allOf": [{"$ref": "#/components/schemas/TextSearchUsedMode"}], "nullable": True}, - "items": {"items": {"$ref": "#/components/schemas/SourceCollectionItem"}, "type": "array"}, - "next_cursor": {"type": "string", "nullable": True}, - }, - "type": "object", - "required": ["query", "mode", "items", "next_cursor"], - }, "SourceRecord": { "properties": { "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, - "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "source_type": {"type": "string", "enum": ["content"]}, "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, "content": {"description": "Persisted canonical JSON content."}, - "metadata": {"additionalProperties": True, "type": "object"}, - "created_at": {"type": "string", "format": "date-time", "nullable": True}, "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", - "metadata", - "created_at", - "position", - "content_digest", - ], + "required": ["scope_id", "source_type", "source_id", "content", "position", "content_digest"], }, "SourceTypeReference": { "properties": { - "source_type": {"type": "string", "maxLength": 128, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, + "source_type": {"type": "string", "enum": ["content"]}, "source_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": "^[\\x21-\\x7E]+$"}, }, "additionalProperties": False, @@ -4318,9 +4122,8 @@ "required": ["name", "source_id"], }, "CaptureStatus": {"type": "string", "enum": ["accepted"]}, + "BaseArtifactFamily": {"type": "string", "enum": ["memory", "experience", "skill", "handoff"]}, "StatsPeriod": {"type": "string", "enum": ["today", "7d", "30d"]}, - "TextSearchMode": {"type": "string", "enum": ["auto", "keyword"]}, - "TextSearchUsedMode": {"type": "string", "enum": ["keyword"]}, "CandidateFamily": {"type": "string", "enum": ["experience", "skill"]}, "ExternalSkillInstallationScope": {"type": "string", "enum": ["user", "project", "plugin"]}, "ExternalSkillResolutionStatus": {"type": "string", "enum": ["available", "unavailable"]}, @@ -4366,18 +4169,6 @@ "headers": {"X-PowerContext-Request-ID": {"$ref": "#/components/headers/RequestId"}}, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}, }, - "OperationNotSupported": { - "description": "The Source type or Artifact family does not permit this operation.", - "headers": { - "Allow": { - "description": "HTTP methods supported by the selected Source type or Artifact family.", - "required": True, - "schema": {"type": "string"}, - }, - "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"}}, @@ -4416,8 +4207,10 @@ }, "headers": { "ArtifactETag": { - "description": "Strong validator for the current immutable Artifact revision.", - "schema": {"type": "string", "pattern": '^"revision:[1-9][0-9]*"$'}, + "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.", diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 489918e05..ec8f32e7f 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -19,14 +19,13 @@ import asyncio import json import logging -import re from collections.abc import Awaitable, Callable, Sequence from contextlib import suppress from copy import deepcopy 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 uuid import uuid4 @@ -107,7 +106,7 @@ ArtifactCollectionItem as RuntimeArtifactCollectionItem, ) from powercontext.builtin.records import ( - ArtifactDeletion as RuntimeArtifactDeletion, + ArtifactCreated as RuntimeArtifactCreated, ) from powercontext.builtin.records import ( ArtifactRecord as RuntimeArtifactRecord, @@ -118,7 +117,6 @@ from powercontext.builtin.records import ( ArtifactRevisionPreconditionError, BaseAccessError, - BaseOperationNotSupportedError, BaseValueConflictError, BaseValueNotFoundError, CursorExpiredError, @@ -128,16 +126,9 @@ from powercontext.builtin.records import ( ArtifactWrite as RuntimeArtifactWrite, ) -from powercontext.builtin.records import SourceCollectionItem as RuntimeSourceCollectionItem from powercontext.builtin.records import ( SourceRecord as RuntimeSourceRecord, ) -from powercontext.builtin.records import ( - SourceRecordPage as RuntimeSourceRecordPage, -) -from powercontext.builtin.records import ( - TextSearchMode as RuntimeTextSearchMode, -) from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -234,6 +225,7 @@ from powercontext.builtin.runtime import ( StatisticsPeriod as RuntimeStatisticsPeriod, ) +from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -262,9 +254,11 @@ ArtifactCandidate, ArtifactCandidatePage, ArtifactCollectionItem, + ArtifactCreated, ArtifactPage, ArtifactRevision, AttachHandoffReportWorkspaceRequest, + BaseArtifactFamily, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -317,7 +311,6 @@ ListMemoryChangesResponse, ListMemoryEntriesRequest, ListMemoryEntriesResponse, - ListSourcesRequest, MemoryEntry, MemoryMutationResponse, PrepareContextRequest, @@ -348,12 +341,9 @@ SearchMemoryRequest, SearchMemoryResponse, SkillArtifact, - SourceCollectionItem, - SourcePage, SourceRecord, + SourceType, StoredHandoffReportActivity, - TextSearchMode, - TextSearchUsedMode, UpdateHandoffReportProjectRequest, UpdateHandoffReportWorkstreamRequest, WorkSourceReceipt, @@ -387,7 +377,6 @@ CREATE_HANDOFF_REPORT_PROJECT, CREATE_SOURCE, CREATE_WORK_CONTRACT, - DELETE_ARTIFACT, DETACH_HANDOFF_REPORT_WORKSPACE, FINALIZE_HANDOFF, FLUSH_MEMORY, @@ -418,7 +407,6 @@ LIST_HANDOFF_REPORT_WORKSTREAMS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, - LIST_SOURCES, OPENAPI_VERSION, PREPARE_CONTEXT, PREPARE_HANDOFF, @@ -480,29 +468,17 @@ async def create_source( self, source_type: str, content: JsonValue, - metadata: dict[str, JsonValue], /, ) -> RuntimeSourceRecord: ... async def get_source(self, source_type: str, source_id: str, /) -> RuntimeSourceRecord: ... - async def query_sources( - self, - source_type: str, - /, - *, - query: str | None, - mode: RuntimeTextSearchMode | None, - limit: int, - cursor: str | None, - ) -> RuntimeSourceRecordPage: ... - async def create_artifact( self, family: str, write: RuntimeArtifactWrite, /, - ) -> RuntimeArtifactRecord: ... + ) -> RuntimeArtifactCreated: ... async def get_artifact(self, family: str, artifact_id: str, /) -> RuntimeArtifactRecord: ... @@ -519,8 +495,6 @@ async def query_artifacts( family: str, /, *, - query: str | None, - mode: RuntimeTextSearchMode | None, limit: int, cursor: str | None, ) -> RuntimeArtifactRecordPage: ... @@ -529,19 +503,11 @@ async def replace_artifact( self, family: str, artifact_id: str, - expected_revision: int, + expected_etag: str, write: RuntimeArtifactWrite, /, ) -> RuntimeArtifactRecord: ... - async def delete_artifact( - self, - family: str, - artifact_id: str, - expected_revision: int, - /, - ) -> RuntimeArtifactDeletion: ... - class _RecordApplication(Protocol): def for_scope(self, scope_id: str, /) -> _ScopedRecordApplication: ... @@ -752,11 +718,11 @@ async def attach_request_id(request: Request, call_next: RequestResponseEndpoint @app.exception_handler(RequestValidationError) async def invalid_request(request: Request, error: RequestValidationError) -> JSONResponse: errors = _validation_error_details(error) - scoped_resource_syntax_error = request.url.path.startswith("/v1/scopes/") and not any( + 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] == "body" + and item["loc"][0] == "query" for item in errors ) return _error_response( @@ -769,11 +735,11 @@ async def invalid_request(request: Request, error: RequestValidationError) -> JS @app.exception_handler(_RuntimeNotReadyError) @app.exception_handler(_PreconditionRequiredError) @app.exception_handler(BaseAccessError) + @app.exception_handler(SourceNotEligibleError) @app.exception_handler(PowerContextError) async def application_error(request: Request, error: Exception) -> JSONResponse: response_status, code, message, details = _map_error(error) - headers = {"Allow": "GET"} if isinstance(error, BaseOperationNotSupportedError) else None - return _error_response(response_status, code=code, message=message, details=details, headers=headers) + return _error_response(response_status, code=code, message=message, details=details) @app.exception_handler(Exception) async def unexpected_error(request: Request, error: Exception) -> JSONResponse: @@ -809,13 +775,11 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _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, LIST_SOURCES, list_sources) _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, DELETE_ARTIFACT, delete_artifact) _add_route(app, CAPTURE_CONTENT_SOURCE, capture_content_source) _add_route(app, FLUSH_MEMORY, flush_memory) _add_route(app, REMEMBER_MEMORY, remember_memory) @@ -1169,47 +1133,16 @@ async def create_source( application: Annotated[ServerApplication, Depends(_require_application)], ) -> SourceRecord: result = await application.records.for_scope(scope_id).create_source( - request.source_type, + request.source_type.value, request.content, - request.metadata, ) response.headers["Location"] = _source_location(result) return _source_record_response(result) -def _list_sources_query( - query: Annotated[str | None, Query(max_length=8192)] = None, - mode: Annotated[TextSearchMode | None, Query()] = None, - limit: Annotated[int, Query(ge=1, le=100)] = 50, - cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, -) -> ListSourcesRequest: - return ListSourcesRequest(query=query, mode=mode, limit=limit, cursor=cursor) - - -async def list_sources( - scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - source_type: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], - request: Annotated[ListSourcesRequest, Depends(_list_sources_query)], - application: Annotated[ServerApplication, Depends(_require_application)], -) -> SourcePage: - result = await application.records.for_scope(scope_id).query_sources( - source_type, - query=request.query, - mode=None if request.mode is None else request.mode.value, - limit=request.limit, - cursor=request.cursor, - ) - return SourcePage( - query=result.query, - mode=None if result.mode is None else TextSearchUsedMode(result.mode), - items=[_source_collection_item_response(item) for item in result.items], - next_cursor=result.next_cursor, - ) - - async def get_source( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - source_type: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + 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: @@ -1222,41 +1155,35 @@ async def create_artifact( request: CreateArtifactRequest, response: Response, application: Annotated[ServerApplication, Depends(_require_application)], -) -> ArtifactRevision: +) -> ArtifactCreated: result = await application.records.for_scope(scope_id).create_artifact( - request.family, + request.family.value, _artifact_write(request), ) response.headers["Location"] = _artifact_location(result) - response.headers["ETag"] = _artifact_etag(result.artifact_ref.revision) - return _artifact_revision_response(result) + response.headers["ETag"] = _artifact_etag(result.revision) + return _artifact_created_response(result) def _list_artifacts_query( - query: Annotated[str | None, Query(max_length=8192)] = None, - mode: Annotated[TextSearchMode | None, Query()] = None, limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, ) -> ListArtifactsRequest: - return ListArtifactsRequest(query=query, mode=mode, limit=limit, cursor=cursor) + 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[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + 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, - query=request.query, - mode=None if request.mode is None else request.mode.value, + family.value, limit=request.limit, cursor=request.cursor, ) return ArtifactPage( - query=result.query, - mode=None if result.mode is None else TextSearchUsedMode(result.mode), items=[_artifact_collection_item_response(item) for item in result.items], next_cursor=result.next_cursor, ) @@ -1264,17 +1191,17 @@ async def list_artifacts( async def get_artifact( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + 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", pattern=r'^"revision:[1-9][0-9]*"$'), + Header(alias="If-None-Match", min_length=1), ] = None, ) -> ArtifactRevision | Response: - result = await application.records.for_scope(scope_id).get_artifact(family, artifact_id) - etag = _artifact_etag(result.artifact_ref.revision) + 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 @@ -1283,49 +1210,33 @@ async def get_artifact( async def replace_artifact( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + 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_revision = _require_artifact_etag(if_match) + expected_etag = _require_artifact_etag(if_match) result = await application.records.for_scope(scope_id).replace_artifact( - family, + family.value, artifact_id, - expected_revision, + expected_etag, _artifact_write(request), ) - response.headers["ETag"] = _artifact_etag(result.artifact_ref.revision) + response.headers["ETag"] = _artifact_etag(result.revision) return _artifact_revision_response(result) -async def delete_artifact( - scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], - artifact_id: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], - application: Annotated[ServerApplication, Depends(_require_application)], - if_match: Annotated[str | None, Header(alias="If-Match")] = None, -) -> Response: - expected_revision = _require_artifact_etag(if_match) - await application.records.for_scope(scope_id).delete_artifact( - family, - artifact_id, - expected_revision, - ) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - async def get_artifact_revision( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], - family: Annotated[str, Path(min_length=1, max_length=128, pattern=r"^[\x21-\x7E]+$")], + 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, + family.value, artifact_id, revision, ) @@ -1335,58 +1246,53 @@ async def get_artifact_revision( def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord: return SourceRecord( scope_id=value.scope_id, - source_type=value.source_type, + source_type=SourceType(value.source_type), source_id=value.source_id, content=value.content, - metadata=value.metadata, - created_at=value.created_at, position=value.position, content_digest=value.content_digest, ) -def _source_collection_item_response(value: RuntimeSourceCollectionItem) -> SourceCollectionItem: - return SourceCollectionItem( +def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevision: + return ArtifactRevision( scope_id=value.scope_id, - source_type=value.source_type, - source_id=value.source_id, - metadata=value.metadata, - created_at=value.created_at, - position=value.position, + 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, - score=value.score, - snippets=list(value.snippets), ) -def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevision: - return ArtifactRevision( +def _artifact_created_response(value: RuntimeArtifactCreated) -> ArtifactCreated: + return ArtifactCreated( scope_id=value.scope_id, - artifact_ref=mapping.artifact_reference(value.artifact_ref), - content=value.content, - source_refs=[mapping.source_type_reference(ref) for ref in value.source_refs], - artifact_refs=[mapping.artifact_reference(ref) for ref in value.artifact_refs], - created_at=value.created_at, - content_digest=value.content_digest, + 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, - artifact_ref=mapping.artifact_reference(value.artifact_ref), - created_at=value.created_at, + 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, - score=value.score, - snippets=list(value.snippets), ) def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: return RuntimeArtifactWrite( content=value.content, - source_refs=tuple(mapping.runtime_source_type_reference(ref) for ref in value.source_refs), - artifact_refs=tuple(mapping.runtime_artifact_reference(ref) for ref in value.artifact_refs), ) @@ -1397,10 +1303,10 @@ def _source_location(value: RuntimeSourceRecord) -> str: return f"/v1/scopes/{scope_id}/sources/{source_type}/{source_id}" -def _artifact_location(value: RuntimeArtifactRecord) -> str: - artifact_id = quote(value.artifact_ref.artifact_id, safe="") +def _artifact_location(value: RuntimeArtifactCreated) -> str: + artifact_id = quote(value.artifact_id, safe="") scope_id = quote(value.scope_id, safe="") - family = quote(value.artifact_ref.family, safe="") + family = quote(value.family, safe="") return f"/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}" @@ -1408,13 +1314,10 @@ def _artifact_etag(revision: int) -> str: return f'"revision:{revision}"' -def _require_artifact_etag(value: str | None) -> int: +def _require_artifact_etag(value: str | None) -> str: if value is None: raise _PreconditionRequiredError - match = re.fullmatch(r'"revision:([1-9][0-9]*)"', value) - if match is None: - raise InvalidBaseAccessRequestError("If-Match", "must be an Artifact revision ETag") - return int(match.group(1)) + return value def _require_report_size(estimated_bytes: int, report: Any) -> None: @@ -2001,6 +1904,13 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | 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 is reserved for its bound Artifact creation lineage.", + {"source": error.source.model_dump(mode="json")}, + ) if isinstance(error, _PreconditionRequiredError): return ( status.HTTP_428_PRECONDITION_REQUIRED, @@ -2014,17 +1924,10 @@ def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, A "revision_conflict", "Artifact ETag does not match the current head.", { - "provided_etag": _artifact_etag(error.provided_revision), - "current_etag": _artifact_etag(error.current_revision), + "provided_etag": error.provided_etag, + "current_etag": error.current_etag, }, ) - if isinstance(error, BaseOperationNotSupportedError): - return ( - status.HTTP_405_METHOD_NOT_ALLOWED, - "operation_not_supported", - "The Source type or Artifact family does not permit this operation.", - {"kind": error.kind, "name": error.name, "operation": error.operation}, - ) if isinstance(error, BaseValueNotFoundError): return ( status.HTTP_404_NOT_FOUND, diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 2864fb20f..5e8f3d81b 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -225,6 +225,7 @@ SkillProposal, SkillValidationItem, SourceReference, + SourceType, SourceTypeReference, TaskCheck, WorkClaim, @@ -844,7 +845,7 @@ def runtime_source_reference(value: SourceReference) -> SourceRef: def source_type_reference(value: SourceRef) -> SourceTypeReference: - return SourceTypeReference(source_type=value.source_type, source_id=value.source_id) + return SourceTypeReference(source_type=SourceType(value.source_type), source_id=value.source_id) def runtime_source_type_reference(value: SourceTypeReference) -> SourceRef: diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index c884c36a8..a664906ec 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -15,25 +15,24 @@ from __future__ import annotations import asyncio -import hashlib -import sqlite3 -from datetime import UTC, datetime, timedelta -from types import SimpleNamespace -from typing import cast -from unittest.mock import AsyncMock, MagicMock +from collections.abc import Iterator import pytest -import rfc8785 -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncConnection - +from pydantic import JsonValue +from sqlalchemy import func, select + +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.records import RelationalRecordService -from powercontext.builtin.persistence.schema import ensure_record_columns from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.statistics import StatisticsRepository from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACTS_TABLE, SHARED_TABLES, SOURCES_TABLE, @@ -41,370 +40,141 @@ from powercontext.builtin.records import ( ArtifactRevisionPreconditionError, ArtifactWrite, - BaseOperationNotSupportedError, BaseValueConflictError, - BaseValueNotFoundError, - CursorExpiredError, InvalidBaseAccessRequestError, - InvalidCursorError, ) -from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER -from powercontext.sources import SourceRef +from powercontext.builtin.source_eligibility import SourceNotEligibleError +from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER, ContentSource + + +def _memory_content() -> dict[str, JsonValue]: + return { + "manifest": {"entries": [], "format": "flat-v1"}, + "changes": [], + "schema": "powercontext.memory.v1", + } + + +def _services( + profile: SQLiteProfile, + ids: Iterator[str], +) -> tuple[RelationalRecordService, ArtifactRepository, SourceRepository]: + sources = SourceRepository((CONTENT_SOURCE_ADAPTER,)) + artifacts = ArtifactRepository((Handoff, Memory, Experience, Skill), sources=sources) + records = RelationalRecordService( + profile.database, + sources, + artifacts, + id_factory=lambda _kind: next(ids), + cursor_secret=b"record-test-secret", + ) + return records, artifacts, sources -def test_source_records_support_create_get_list_search_and_bound_cursors() -> None: +def test_source_create_persists_json_without_public_internal_fields() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - generated_ids = iter(("source-1", "source-2")) - records = RelationalRecordService( - profile.database, - SourceRepository((CONTENT_SOURCE_ADAPTER,)), - id_factory=lambda _kind: next(generated_ids), - cursor_secret=b"source-record-cursor-test", - ) - first = await records.create_source( - "scope-a", - "content", - "Keep the OpenAPI contract authoritative.", - {"channel": "test"}, - ) - repeated = await records.capture_source( - "scope-a", - "content", - "source-1", - "Keep the OpenAPI contract authoritative.", - {"channel": "test"}, - ) - second = await records.create_source( - "scope-a", - "content", - "Preserve immutable artifact revisions.", - {}, - ) - - assert repeated == first - assert first.position == 1 - assert second.position == 2 - assert first.content_digest == f"sha256:{hashlib.sha256(rfc8785.dumps(first.content)).hexdigest()}" - assert (await records.get_source("scope-a", "content", "source-1")) == first - - first_page = await records.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=1, - cursor=None, - ) - assert [item.source_id for item in first_page.items] == ["source-1"] - assert "content" not in type(first_page.items[0]).model_fields - assert first_page.next_cursor is not None - second_page = await records.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=1, - cursor=first_page.next_cursor, - ) - assert [item.source_id for item in second_page.items] == ["source-2"] - - assert first_page.next_cursor is not None - payload, signature = first_page.next_cursor.split(".") - tampered_signature = ("A" if signature[0] != "A" else "B") + signature[1:] - with pytest.raises(InvalidCursorError): - await records.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=1, - cursor=f"{payload}.{tampered_signature}", - ) - - other_caller = RelationalRecordService( - profile.database, - SourceRepository((CONTENT_SOURCE_ADAPTER,)), - cursor_secret=b"other-caller-cursor-test", - ) - with pytest.raises(InvalidCursorError): - await other_caller.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=1, - cursor=first_page.next_cursor, - ) - - found = await records.query_sources( - "scope-a", + records, _, _ = _services(profile, iter(("src-1",))) + created = await records.create_source("scope-a", "content", {"fact": True}) + loaded = await records.get_source("scope-a", "content", "src-1") + + assert loaded == created + assert created.content == {"fact": True} + assert set(created.model_dump()) == { + "scope_id", + "source_type", + "source_id", "content", - query="OpenAPI authoritative", - mode="auto", - limit=10, - cursor=None, - ) - assert [item.source_id for item in found.items] == ["source-1"] - assert found.mode == "keyword" - assert found.items[0].score == 1.0 - - with pytest.raises(BaseValueConflictError): - await records.capture_source("scope-a", "content", "source-1", "different", {}) + "position", + "content_digest", + } with pytest.raises(InvalidBaseAccessRequestError): - await records.query_sources( - "scope-a", - "content", - query="OpenAPI", - mode="auto", - limit=1, - cursor=first_page.next_cursor, - ) - with pytest.raises(InvalidBaseAccessRequestError): - await records.query_sources( - "scope-a", - "content", - query=None, - mode="auto", - limit=10, - cursor=None, - ) - with pytest.raises(InvalidBaseAccessRequestError): - await records.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=101, - cursor=None, - ) - with pytest.raises(BaseOperationNotSupportedError): - await records.get_source("scope-a", "git", "source-1") + await records.create_source("scope-a", "private", "not public") asyncio.run(scenario()) -def test_record_cursor_expires_after_its_bounded_lifetime() -> None: +def test_artifact_create_is_atomic_and_binds_its_system_source() -> None: async def scenario() -> None: - current_time = [datetime(2026, 9, 3, tzinfo=UTC)] async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - generated_ids = iter(("source-1", "source-2")) - records = RelationalRecordService( - profile.database, - SourceRepository((CONTENT_SOURCE_ADAPTER,)), - clock=lambda: current_time[0], - id_factory=lambda _kind: next(generated_ids), - cursor_secret=b"expiring-cursor-test", - cursor_ttl_seconds=60, - ) - await records.create_source("scope-a", "content", "first", {}) - await records.create_source("scope-a", "content", "second", {}) - page = await records.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=1, - cursor=None, - ) - assert page.next_cursor is not None - - current_time[0] += timedelta(seconds=60) - with pytest.raises(CursorExpiredError): - await records.query_sources( - "scope-a", - "content", - query=None, - mode=None, - limit=1, - cursor=page.next_cursor, - ) + records, artifacts, sources = _services(profile, iter(("mem-1", "src-1", "mem-1", "src-2"))) + created = await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) - asyncio.run(scenario()) + 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" - -def test_record_tables_replace_sidecars_and_upgrade_legacy_sqlite_columns(tmp_path) -> None: - table_names = {table.name for table in SHARED_TABLES} - assert { - "pc_source_records", - "pc_artifact_revision_records", - "pc_artifact_tombstones", - }.isdisjoint(table_names) - - database = tmp_path / "legacy-records.db" - with sqlite3.connect(database) as connection: - connection.execute( - "CREATE TABLE pc_sources (" - "scope_id VARCHAR(256) NOT NULL, source_type VARCHAR(128) NOT NULL, " - "source_id VARCHAR(256) NOT NULL, payload BLOB NOT NULL, journal_position BIGINT NOT NULL, " - "PRIMARY KEY (scope_id, source_type, source_id))" - ) - connection.execute( - "CREATE TABLE pc_artifacts (" - "scope_id VARCHAR(256) NOT NULL, family VARCHAR(128) NOT NULL, artifact_id VARCHAR(128) NOT NULL, " - "revision INTEGER NOT NULL, content BLOB NOT NULL, " - "PRIMARY KEY (scope_id, family, artifact_id, revision))" - ) - connection.execute( - "CREATE TABLE pc_artifact_heads (" - "scope_id VARCHAR(256) NOT NULL, family VARCHAR(128) NOT NULL, artifact_id VARCHAR(128) NOT NULL, " - "revision INTEGER NOT NULL, searchable_text TEXT NULL, PRIMARY KEY (scope_id, family, artifact_id))" - ) - - async def scenario() -> None: - config = SQLiteConfig(url=f"sqlite+aiosqlite:///{database}") - selected = (SOURCES_TABLE, ARTIFACTS_TABLE, ARTIFACT_HEADS_TABLE) - for _ in range(2): - async with ( - SQLiteProfile.open(config, tables=selected) as profile, - profile.database.transaction() as connection, - ): - source_columns = { - str(row["name"]) - for row in (await connection.exec_driver_sql("PRAGMA table_info('pc_sources')")).mappings() - } - artifact_columns = { - str(row["name"]) - for row in (await connection.exec_driver_sql("PRAGMA table_info('pc_artifacts')")).mappings() - } - head_columns = { - str(row["name"]) - for row in (await connection.exec_driver_sql("PRAGMA table_info('pc_artifact_heads')")).mappings() + 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, } - assert "created_at" in source_columns - assert "created_at" in artifact_columns - assert "deleted_at" in head_columns - - asyncio.run(scenario()) - + lineage = (await connection.execute(select(ARTIFACT_LINEAGE_SOURCES_TABLE))).mappings().one() + assert lineage["ordinal"] == 0 -def test_record_column_migration_uses_mysql_compatible_nullable_timestamps() -> None: - result = MagicMock() - result.scalars.return_value = () - connection = SimpleNamespace( - dialect=SimpleNamespace(name="mysql"), - execute=AsyncMock(return_value=result), - exec_driver_sql=AsyncMock(), - ) + with pytest.raises(BaseValueConflictError): + await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) - asyncio.run( - ensure_record_columns( - cast(AsyncConnection, connection), - {"pc_sources", "pc_artifacts", "pc_artifact_heads"}, - ) - ) + 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 + foreign = artifacts.draft("memory", _memory_content(), sources=created.sources) + with pytest.raises(SourceNotEligibleError): + await artifacts.create(connection, "scope-a", "mem-foreign", foreign) - assert [call.args[0] for call in connection.exec_driver_sql.await_args_list] == [ - "ALTER TABLE pc_sources ADD COLUMN created_at DATETIME NULL", - "ALTER TABLE pc_artifacts ADD COLUMN created_at DATETIME NULL", - "ALTER TABLE pc_artifact_heads ADD COLUMN deleted_at DATETIME NULL", - ] + asyncio.run(scenario()) -def test_artifact_records_preserve_revisions_and_delete_only_the_head() -> None: +def test_artifact_get_list_replace_use_family_models_and_opaque_etags() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records = RelationalRecordService( - profile.database, - SourceRepository((CONTENT_SOURCE_ADAPTER,)), - id_factory=lambda kind: "source-1" if kind == "source" else "guide-1", - ) - source = await records.create_source( - "scope-a", - "content", - "The reviewed API design.", - {}, - ) - first = await records.create_artifact( - "scope-a", - "document", - ArtifactWrite( - content={"body": "Use a complete replacement."}, - source_refs=(SourceRef(source_type=source.source_type, source_id=source.source_id),), - ), - ) - - assert first.artifact_ref.revision == 1 - assert first.source_refs == (SourceRef(source_type="content", source_id="source-1"),) - assert (await records.get_artifact("scope-a", "document", "guide-1")) == first - listed = await records.query_artifacts( + records, _, _ = _services(profile, iter(("mem-1", "src-1"))) + 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", - "document", - query=None, - mode=None, - limit=10, - cursor=None, + "memory", + created.artifact_id, + '"revision:1"', + ArtifactWrite(content=_memory_content()), ) - assert [item.artifact_ref for item in listed.items] == [first.artifact_ref] - assert "content" not in type(listed.items[0]).model_fields - found = await records.query_artifacts( - "scope-a", - "document", - query="complete replacement", - mode="auto", - limit=10, - cursor=None, - ) - assert [item.artifact_ref.artifact_id for item in found.items] == ["guide-1"] + assert replaced.revision == 2 + assert replaced.sources == () + assert (await records.get_artifact_revision("scope-a", "memory", created.artifact_id, 1)).revision == 1 - second = await records.replace_artifact( - "scope-a", - "document", - "guide-1", - 1, - ArtifactWrite( - content={"body": "Use If-Match for replacement."}, - source_refs=(SourceRef(source_type=source.source_type, source_id=source.source_id),), - artifact_refs=(first.artifact_ref,), - ), - ) - assert second.artifact_ref.revision == 2 - assert (await records.get_artifact_revision("scope-a", "document", "guide-1", 1)) == first with pytest.raises(ArtifactRevisionPreconditionError): await records.replace_artifact( "scope-a", - "document", - "guide-1", - 1, - ArtifactWrite(content={"body": "stale"}), + "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})) - scopes = await records.list_scopes(limit=10, cursor=None) - assert [(item.scope_id, item.source_count, item.artifact_count) for item in scopes.items] == [ - ("scope-a", 1, 1) - ] - assert scopes.items[0].source_types == ("content",) - assert scopes.items[0].artifact_families == ("document",) - - deleted = await records.delete_artifact("scope-a", "document", "guide-1", 2) - assert deleted.artifact_ref == second.artifact_ref - assert await records.delete_artifact("scope-a", "document", "guide-1", 2) == deleted - with pytest.raises(BaseValueNotFoundError): - await records.get_artifact("scope-a", "document", "guide-1") - assert (await records.get_artifact_revision("scope-a", "document", "guide-1", 2)) == second - assert ( - await records.query_artifacts("scope-a", "document", query=None, mode=None, limit=10, cursor=None) - ).items == () - async with profile.database.transaction() as connection: - head = ( - await connection.execute(select(ARTIFACT_HEADS_TABLE.c.revision, ARTIFACT_HEADS_TABLE.c.deleted_at)) - ).one() - assert head.revision == 2 - assert head.deleted_at is not None - assert (await StatisticsRepository().inventory(connection, "scope-a")).artifacts == () + asyncio.run(scenario()) - with pytest.raises(BaseValueConflictError): - await records.create_artifact( - "scope-a", - "document", - ArtifactWrite(content={"body": "reused identity"}), - ) - for protected_family in ("experience", "handoff", "memory", "skill"): - with pytest.raises(BaseOperationNotSupportedError): - await records.create_artifact( - "scope-a", - protected_family, - ArtifactWrite(content={"body": "protected"}), - ) - 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/runtime/test_experience_incubation.py b/tests/builtin/runtime/test_experience_incubation.py index e6bbb8309..2f4b56a81 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, @@ -178,6 +179,34 @@ 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: + created = await runtime.records.for_scope("lineage-only").create_artifact( + "memory", + ArtifactWrite( + content={ + "manifest": {"entries": [], "format": "flat-v1"}, + "changes": [], + "schema": "powercontext.memory.v1", + } + ), + ) + result = await runtime.experience.for_scope("lineage-only").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 index 89675f4a6..4b5163d67 100644 --- a/tests/e2e/test_base_access_api.py +++ b/tests/e2e/test_base_access_api.py @@ -24,18 +24,24 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.client import PowerContextClient, ServerResponseError from powercontext.http import ( + BaseArtifactFamily, CreateArtifactRequest, CreateSourceRequest, ListArtifactsRequest, - ListSourcesRequest, ReplaceArtifactRequest, - SourceTypeReference, - TextSearchMode, ) from powercontext.server.factory import create_server_app from powercontext.server.settings import BearerAuthConfig, McpConfig, ServerSettings +def _memory_content() -> dict[str, object]: + return { + "manifest": {"entries": [], "format": "flat-v1"}, + "changes": [], + "schema": "powercontext.memory.v1", + } + + def test_source_and_artifact_api_round_trip(tmp_path: Path) -> None: app = create_server_app( settings=ServerSettings( @@ -47,150 +53,95 @@ def test_source_and_artifact_api_round_trip(tmp_path: Path) -> None: async def scenario() -> None: scope_id = "git:github.com/oceanbase/powercontext" - family = "company.example/decision" + encoded_scope = quote(scope_id, safe="") async with ( app.router.lifespan_context(app), - httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - ) as transport, + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as transport, ): client = PowerContextClient("http://testserver", http_client=transport, trust_transport_security=True) - unsupported = await transport.post( - f"/v1/scopes/{quote(scope_id, safe='')}/artifacts", - json={"family": "memory", "content": {}}, - ) - assert unsupported.status_code == 405 - assert unsupported.headers["Allow"] == "GET" source = await client.create_source( scope_id, - CreateSourceRequest( - content="Keep Source writes separate from Memory generation.", - metadata={"channel": "e2e"}, - ), + CreateSourceRequest(content={"statement": "Keep the public Source immutable."}), ) - loaded_source = await client.get_source(scope_id, "content", source.source_id) - listed_sources = await client.list_sources( - scope_id, - "content", - ListSourcesRequest(), - ) - found_sources = await client.list_sources( - scope_id, + 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", - ListSourcesRequest(query="Memory generation"), - ) - with pytest.raises(ServerResponseError) as invalid_query: - await client.list_sources( - scope_id, - "content", - ListSourcesRequest(mode=TextSearchMode.AUTO), - ) - assert invalid_query.value.status_code == 400 - assert invalid_query.value.code == "invalid_request" - with pytest.raises(ServerResponseError) as invalid_cursor: - await client.list_sources( - scope_id, - "content", - ListSourcesRequest(cursor="not-a-valid-cursor"), - ) - assert invalid_cursor.value.status_code == 400 - assert invalid_cursor.value.code == "invalid_cursor" + "position", + "content_digest", + } - first = await client.create_artifact( - scope_id, - CreateArtifactRequest( - family=family, - content={"body": "Use full replacement updates."}, - source_refs=[SourceTypeReference(source_type=source.source_type, source_id=source.source_id)], - ), + invalid_source_type = await transport.get( + f"/v1/scopes/{encoded_scope}/sources/private/{quote(source.source_id, safe='')}" ) - artifact_id = first.artifact_ref.artifact_id - loaded_first = await client.get_artifact(scope_id, family, artifact_id) - not_modified = await client.get_artifact( - scope_id, - family, - artifact_id, - if_none_match='"revision:1"', + assert invalid_source_type.status_code == 422 + invalid_family = await transport.post( + f"/v1/scopes/{encoded_scope}/artifacts", + json={"family": "document", "content": {}}, ) - listed_artifacts = await client.list_artifacts( + assert invalid_family.status_code == 422 + + created = await client.create_artifact( scope_id, - family, - ListArtifactsRequest(), + CreateArtifactRequest(family=BaseArtifactFamily.MEMORY, content=_memory_content()), ) - found_artifacts = await client.list_artifacts( + 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, - family, - ListArtifactsRequest(query="replacement updates"), + created.sources[0].source_type.value, + created.sources[0].source_id, ) - second = await client.replace_artifact( + 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 + not_modified = await client.get_artifact( scope_id, - family, - artifact_id, - ReplaceArtifactRequest( - content={"body": "Use If-Match with full replacement updates."}, - source_refs=[SourceTypeReference(source_type=source.source_type, source_id=source.source_id)], - artifact_refs=[first.artifact_ref], - ), - expected_revision=1, + "memory", + created.artifact_id, + if_none_match=etag, ) - exact_first = await client.get_artifact_revision( + 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, - family, - artifact_id, - 1, + "memory", + created.artifact_id, + ReplaceArtifactRequest(content=_memory_content()), + expected_etag=etag, ) + assert replaced.revision == 2 + assert replaced.sources == [] + exact_first = await client.get_artifact_revision(scope_id, "memory", created.artifact_id, 1) + assert exact_first.revision == 1 with pytest.raises(ServerResponseError) as stale: await client.replace_artifact( scope_id, - family, - artifact_id, - ReplaceArtifactRequest(content={"body": "stale"}), - expected_revision=1, + "memory", + created.artifact_id, + ReplaceArtifactRequest(content=_memory_content()), + expected_etag=etag, ) assert stale.value.status_code == 412 assert stale.value.code == "revision_conflict" - deleted = await client.delete_artifact( - scope_id, - family, - artifact_id, - expected_revision=2, - ) - with pytest.raises(ServerResponseError) as missing: - await client.get_artifact(scope_id, family, artifact_id) - assert missing.value.status_code == 404 - exact_second = await client.get_artifact_revision( - scope_id, - family, - artifact_id, - 2, - ) - - assert source == loaded_source - assert source.scope_id == scope_id - assert source.source_type == "content" - assert source.source_id - assert source.created_at is not None - assert [item.source_id for item in listed_sources.items] == [source.source_id] - assert [item.source_id for item in found_sources.items] == [source.source_id] - assert "content" not in listed_sources.items[0].model_dump() - assert listed_sources.query is None - assert found_sources.query == "Memory generation" - assert found_sources.mode == "keyword" - assert loaded_first == first - assert not_modified is None - assert first.created_at is not None - assert [item.artifact_ref.artifact_id for item in listed_artifacts.items] == [artifact_id] - assert [item.artifact_ref.artifact_id for item in found_artifacts.items] == [artifact_id] - assert "content" not in listed_artifacts.items[0].model_dump() - assert found_artifacts.query == "replacement updates" - assert found_artifacts.mode == "keyword" - assert second.artifact_ref.revision == 2 - assert second.created_at is not None - assert exact_first == first - assert exact_second == second - assert deleted is None - asyncio.run(scenario()) diff --git a/tests/e2e/test_builtin_runtime.py b/tests/e2e/test_builtin_runtime.py index b18f47584..4fa6bba15 100644 --- a/tests/e2e/test_builtin_runtime.py +++ b/tests/e2e/test_builtin_runtime.py @@ -20,6 +20,7 @@ from powercontext.builtin.artifacts.memory import MemoryCandidateRequest, MemoryEntryInput, MemoryRerankDecision from powercontext.builtin.inference import InferenceUsage from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.records import ArtifactWrite from powercontext.builtin.runtime import ( BuiltinConfig, CaptureSource, @@ -32,7 +33,11 @@ class _ContentCandidatePipeline: + def __init__(self) -> None: + self.calls = 0 + async def extract(self, request: MemoryCandidateRequest, /) -> tuple[MemoryEntryInput, ...]: + self.calls += 1 return tuple( MemoryEntryInput( kind="fact", @@ -92,6 +97,34 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_memory_flush_skips_lineage_only_sources_and_advances_its_cursor() -> None: + async def scenario() -> None: + pipeline = _ContentCandidatePipeline() + async with open_builtin_runtime( + BuiltinConfig(database=SQLiteConfig()), + candidate_pipeline=pipeline, + ) as runtime: + created = await runtime.records.for_scope("lineage-only").create_artifact( + "memory", + ArtifactWrite( + content={ + "manifest": {"entries": [], "format": "flat-v1"}, + "changes": [], + "schema": "powercontext.memory.v1", + } + ), + ) + result = await runtime.memory.for_scope("lineage-only").flush() + + assert created.revision == 1 + assert result.current_cursor == 1 + assert result.source_count == 0 + assert result.memory_ref is None + assert pipeline.calls == 0 + + asyncio.run(scenario()) + + class _ConcurrentReranker: policy_id = "test.concurrent-rerank.v1" diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 3abde1183..80b9ae78b 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -23,8 +23,10 @@ ActivateHandoffRequest, ApproveArtifactCandidateRequest, ArtifactCandidate, + ArtifactCreated, ArtifactReference, ArtifactRevision, + BaseArtifactFamily, CaptureContentSourceRequest, CaptureContentSourceResponse, CommitHandoffRequest, @@ -50,7 +52,6 @@ ListExternalSkillsRequest, ListExternalSkillsResponse, ListMemoryEntriesRequest, - ListSourcesRequest, PrepareContextRequest, PreparedContext, PreparedHandoff, @@ -68,6 +69,7 @@ SkillProposal, SkillValidationItem, SourceRecord, + SourceType, SourceTypeReference, StatsPeriod, WorkSourceReceipt, @@ -82,7 +84,6 @@ CREATE_ARTIFACT, CREATE_SOURCE, CREATE_WORK_CONTRACT, - DELETE_ARTIFACT, FINALIZE_HANDOFF, FLUSH_MEMORY, GENERATE_EXPERIENCE, @@ -103,7 +104,6 @@ LIST_EXTERNAL_SKILLS, LIST_MEMORY_CHANGES, LIST_MEMORY_ENTRIES, - LIST_SOURCES, PREPARE_CONTEXT, PREPARE_HANDOFF, PROPOSE_EXPERIENCE, @@ -497,19 +497,17 @@ def test_generated_transport_rejects_values_outside_openapi( model.model_validate(value) -def test_base_access_contract_uses_only_the_nine_scoped_operations() -> None: +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}", "get"): "list_sources", ("/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}", "delete"): "delete_artifact", ( "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", "get", @@ -523,7 +521,7 @@ def test_base_access_contract_uses_only_the_nine_scoped_operations() -> None: } assert actual_operations == expected_operations assert not any( - operation_id in {"search_sources", "search_artifacts", "list_scopes"} + 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 or path == "/v1/scopes" for path in paths) @@ -535,44 +533,37 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - source = schemas["CreateSourceRequest"] assert source["required"] == ["content"] - assert set(source["properties"]) == {"source_type", "content", "metadata"} + 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 artifact["required"] == ["family", "content"] - assert set(artifact["properties"]) == {"family", "content", "source_refs", "artifact_refs"} + assert set(artifact["properties"]) == {"family", "content"} assert not {"scope_id", "source_id", "artifact_id"} & set(source["properties"] | artifact["properties"]) - assert CreateSourceRequest(content="evidence").source_type == "content" - assert CreateArtifactRequest(family="decision", content={"value": 1}).source_refs == [] + assert CreateSourceRequest(content="evidence").source_type is SourceType.CONTENT + assert ( + CreateArtifactRequest(family=BaseArtifactFamily.MEMORY, content={"manifest": {}}).family + is BaseArtifactFamily.MEMORY + ) for model, payload in ( (CreateSourceRequest, {"scope_id": "scope", "content": "evidence"}), (CreateSourceRequest, {"source_id": "source", "content": "evidence"}), - (CreateArtifactRequest, {"artifact_id": "artifact", "family": "decision", "content": {}}), + (CreateArtifactRequest, {"artifact_id": "artifact", "family": "memory", "content": {}}), ): with pytest.raises(ValidationError): model.model_validate(payload) -def test_source_artifact_collections_combine_list_and_search_queries() -> None: +def test_artifact_collection_only_accepts_pagination() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) paths = contract["paths"] - for path in ( - "/v1/scopes/{scope_id}/sources/{source_type}", - "/v1/scopes/{scope_id}/artifacts/{family}", - ): - parameters = paths[path]["get"]["parameters"] - assert [parameter["name"] for parameter in parameters if parameter["in"] == "query"] == [ - "query", - "mode", - "limit", - "cursor", - ] - assert "type" not in {parameter["name"] for parameter in parameters} - - assert ListSourcesRequest().model_dump() == {"query": None, "mode": None, "limit": 50, "cursor": None} - assert ListArtifactsRequest(query="refund").query == "refund" + 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: @@ -581,41 +572,36 @@ def test_base_access_uses_a_dedicated_source_type_reference() -> None: assert set(schemas["SourceReference"]["properties"]) == {"name", "source_id"} assert set(schemas["SourceTypeReference"]["properties"]) == {"source_type", "source_id"} - for schema_name in ("CreateArtifactRequest", "ReplaceArtifactRequest", "ArtifactRevision"): - assert schemas[schema_name]["properties"]["source_refs"]["items"] == { + for schema_name in ("ArtifactCreated", "ArtifactRevision", "ArtifactCollectionItem"): + assert schemas[schema_name]["properties"]["sources"]["items"] == { "$ref": "#/components/schemas/SourceTypeReference" } - assert SourceTypeReference(source_type="content", source_id="source").source_type == "content" + assert "sources" not in schemas["CreateArtifactRequest"]["properties"] + assert "sources" not in schemas["ReplaceArtifactRequest"]["properties"] + assert SourceTypeReference(source_type=SourceType.CONTENT, source_id="source").source_type is SourceType.CONTENT -def test_base_access_operations_describe_no_content_and_conditional_get() -> None: +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 LIST_SOURCES.request_type is ListSourcesRequest + 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 DELETE_ARTIFACT.request_type is None - assert DELETE_ARTIFACT.response_type is None - assert DELETE_ARTIFACT.success_status == 204 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}"] - allow_header = contract["components"]["responses"]["OperationNotSupported"]["headers"]["Allow"] - assert allow_header["required"] is True - assert allow_header["schema"] == {"type": "string"} 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"] - for method in ("put", "delete"): - parameters = {item["name"]: item for item in item_path[method]["parameters"]} - assert parameters["If-Match"]["required"] is True - assert set(item_path[method]["responses"]) >= {"412", "428"} - assert "content" not in item_path["delete"]["responses"]["204"] + 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: diff --git a/tests/test_client.py b/tests/test_client.py index 2ff2574f6..589fa0ce8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,8 +24,8 @@ from powercontext.http import ( CaptureContentSourceRequest, GetHandoffReportRequest, - ListSourcesRequest, - TextSearchMode, + ListArtifactsRequest, + ReplaceArtifactRequest, ) @@ -177,7 +177,7 @@ def respond(request: httpx.Request) -> httpx.Response: asyncio.run(scenario()) -def test_client_serializes_scoped_source_paths_and_combined_list_query() -> None: +def test_client_serializes_scoped_artifact_paths_and_list_query() -> None: async def scenario() -> None: requests: list[httpx.Request] = [] @@ -186,19 +186,15 @@ def respond(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "query": "durable source", - "mode": "keyword", "items": [ { "scope_id": "scope one", - "source_type": "content", - "source_id": "source-1", - "metadata": {}, - "created_at": None, - "position": 1, + "family": "memory", + "artifact_id": "memory-1", + "revision": 1, + "sources": [], + "artifacts": [], "content_digest": f"sha256:{'0' * 64}", - "score": 1.0, - "snippets": ["durable source"], } ], "next_cursor": None, @@ -207,26 +203,16 @@ def respond(request: httpx.Request) -> httpx.Response: async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: client = PowerContextClient("https://memory.example", http_client=http_client) - page = await client.list_sources( + page = await client.list_artifacts( "scope one", - "content", - ListSourcesRequest( - query="durable source", - mode=TextSearchMode.KEYWORD, - limit=7, - cursor="cursor-1", - ), + "memory", + ListArtifactsRequest(limit=7, cursor="cursor-1"), ) - assert [item.source_id for item in page.items] == ["source-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/sources/content" - assert dict(requests[0].url.params) == { - "query": "durable source", - "mode": "keyword", - "limit": "7", - "cursor": "cursor-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()) @@ -256,28 +242,41 @@ def respond(request: httpx.Request) -> httpx.Response: asyncio.run(scenario()) -def test_client_decodes_delete_no_content_and_sends_precondition() -> None: +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(204) + 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.delete_artifact( + result = await client.replace_artifact( "scope-a", - "document", + "memory", "artifact-1", - expected_revision=3, + ReplaceArtifactRequest(content={"manifest": {}}), + expected_etag='"opaque-v4"', ) - assert result is None + assert result.revision == 4 assert len(requests) == 1 - assert requests[0].method == "DELETE" - assert requests[0].url.path == "/v1/scopes/scope-a/artifacts/document/artifact-1" - assert requests[0].headers["If-Match"] == '"revision:3"' + 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()) diff --git a/tests/test_js_operations.py b/tests/test_js_operations.py index 15a87ceac..5a9815020 100644 --- a/tests/test_js_operations.py +++ b/tests/test_js_operations.py @@ -75,8 +75,8 @@ def test_js_operations_record_method_path_location_and_scope() -> None: assert by_id["get_artifact"]["successStatuses"] == [200, 304] assert by_id["get_artifact"]["emptyStatuses"] == [304] assert by_id["replace_artifact"]["headerParams"] == ["If-Match"] - assert by_id["delete_artifact"]["successStatuses"] == [204] - assert by_id["delete_artifact"]["emptyStatuses"] == [204] + assert "delete_artifact" not in by_id + assert "list_sources" not in by_id def test_committed_js_operations_match_openapi() -> None: From edd44c49903f57d5b6c6d9bd289ddab982f64a5a Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Thu, 3 Sep 2026 18:47:23 +0800 Subject: [PATCH 4/9] fix(api): validate artifact family content as JSON --- .../builtin/persistence/artifacts.py | 6 +- src/powercontext/builtin/persistence/codec.py | 9 ++- tests/builtin/persistence/test_records.py | 48 +++++++++++++++ tests/e2e/test_base_access_api.py | 58 +++++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index d511e29d0..4707003ea 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -30,7 +30,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, InvalidRepositoryArgumentError, @@ -474,9 +474,7 @@ def draft( if content_type is None: raise RepositoryNotFoundError("artifact-family", family) try: - # The REST body is already JSON typed; allow Pydantic to normalize - # JSON arrays into the tuple-backed immutable domain models. - validated = content_type.model_validate(content) + validated = validate_json_model(content_type, content) except ValidationError as error: raise InvalidRepositoryArgumentError("content", "does not match the Artifact family") from error return RepositoryArtifactDraft( 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/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index a664906ec..15977934a 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -55,6 +55,27 @@ def _memory_content() -> dict[str, JsonValue]: } +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, ids: Iterator[str], @@ -173,6 +194,33 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_artifact_create_and_replace_validate_handoff_as_json() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: + records, _, _ = _services(profile, iter(("handoff-1", "src-1"))) + created = await records.create_artifact( + "scope-a", + "handoff", + ArtifactWrite(content=_handoff_content()), + ) + + loaded = await records.get_artifact("scope-a", "handoff", created.artifact_id) + assert loaded.content == _handoff_content() + + replacement = _handoff_content("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.content == replacement + + 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 diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py index 4b5163d67..07b671ad7 100644 --- a/tests/e2e/test_base_access_api.py +++ b/tests/e2e/test_base_access_api.py @@ -42,6 +42,27 @@ def _memory_content() -> dict[str, object]: } +def _handoff_content(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": {"source_type": "content", "source_id": "source-evidence"}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + } + + def test_source_and_artifact_api_round_trip(tmp_path: Path) -> None: app = create_server_app( settings=ServerSettings( @@ -145,3 +166,40 @@ async def scenario() -> None: 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: + scope_id = "git:github.com/oceanbase/powercontext" + 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) + created = await client.create_artifact( + scope_id, + CreateArtifactRequest(family=BaseArtifactFamily.HANDOFF, content=_handoff_content()), + ) + loaded = await client.get_artifact(scope_id, "handoff", created.artifact_id) + assert loaded is not None + assert loaded.content == _handoff_content() + + replaced = await client.replace_artifact( + scope_id, + "handoff", + created.artifact_id, + ReplaceArtifactRequest(content=_handoff_content("Transfer the verified API test result.")), + expected_etag='"revision:1"', + ) + assert replaced.revision == 2 + assert replaced.content == _handoff_content("Transfer the verified API test result.") + + asyncio.run(scenario()) From c764d64f07e94b528135cbf33425fb02cbc5a220 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Fri, 4 Sep 2026 11:54:56 +0800 Subject: [PATCH 5/9] fix(api): align artifact revision source semantics --- docs/en/docs/reference/configuration.md | 7 + docs/zh/docs/reference/configuration.md | 6 + .../builtin/persistence/artifacts.py | 125 +++++++++++++++++- .../builtin/persistence/records.py | 74 +++++++---- .../builtin/runtime/composition.py | 5 + .../builtin/runtime/relational.py | 2 + src/powercontext/builtin/sources/content.py | 5 +- src/powercontext/server/cursor_secret.py | 72 ++++++++++ src/powercontext/server/factory.py | 6 + src/powercontext/server/settings.py | 8 ++ tests/builtin/persistence/test_records.py | 49 ++++++- tests/e2e/test_base_access_api.py | 13 +- tests/test_cursor_secret.py | 60 +++++++++ tests/test_server.py | 38 ++++++ 14 files changed, 433 insertions(+), 37 deletions(-) create mode 100644 src/powercontext/server/cursor_secret.py create mode 100644 tests/test_cursor_secret.py diff --git a/docs/en/docs/reference/configuration.md b/docs/en/docs/reference/configuration.md index 7ee144fec..24a8c819f 100644 --- a/docs/en/docs/reference/configuration.md +++ b/docs/en/docs/reference/configuration.md @@ -59,6 +59,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` | @@ -92,6 +93,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 5f31434ce..8a886e771 100644 --- a/docs/zh/docs/reference/configuration.md +++ b/docs/zh/docs/reference/configuration.md @@ -55,6 +55,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` 时使用 | @@ -88,6 +89,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/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 4707003ea..86db65dd7 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, ConfigDict, JsonValue, ValidationError -from sqlalchemy import insert, select, update +from sqlalchemy import insert, select, tuple_, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection @@ -223,6 +223,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, @@ -340,6 +384,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) @@ -354,7 +412,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, @@ -365,6 +422,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, diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index e6489d546..532d42fd4 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -62,7 +62,6 @@ ScopeSummaryPage, SourceRecord, ) -from powercontext.builtin.source_eligibility import is_generation_eligible from powercontext.builtin.sources import ( CONTENT_SOURCE_ADAPTER, CONTENT_SOURCE_NAME, @@ -121,7 +120,8 @@ async def create_source( name=self._id_factory("source"), materialization=SourceMaterialization.CAPTURED, content=_canonical_source_text(content), - wire_content=None if isinstance(content, str) else _JSON_VALUE.validate_python(content, strict=True), + wire_content=_JSON_VALUE.validate_python(content, strict=True), + wire_content_present=True, ) return await self._store_source(scope_id, source_type, source) @@ -181,6 +181,7 @@ async def create_artifact( 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", @@ -269,20 +270,21 @@ async def query_artifacts( ) rows = (await connection.execute(statement)).all() selected_rows = rows[:limit] - items = [] - for row in selected_rows: - artifact = await self._artifacts.get( - connection, - scope_id, - ArtifactRef(family=family, artifact_id=str(row.artifact_id), revision=int(row.revision)), - ) - items.append(_artifact_collection_item(scope_id, artifact)) + 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=tuple(items), + items=items, next_cursor=next_cursor, ) @@ -296,6 +298,7 @@ async def replace_artifact( /, ) -> ArtifactRecord: self._require_family(family) + draft = self._validated_draft(family, write.content) async with self._database.transaction() as connection: try: current = await self._artifacts.latest(connection, scope_id, family, artifact_id) @@ -304,19 +307,36 @@ async def replace_artifact( current_etag = _artifact_etag(current.revision) if expected_etag != current_etag: raise ArtifactRevisionPreconditionError(expected_etag, current_etag) - sources = [] - for source_ref in current.lineage.sources: - stored = await self._sources.get(connection, scope_id, source_ref) - if is_generation_eligible(stored.value): - sources.append(source_ref) - draft = self._validated_draft( - family, - write.content, - sources=tuple(sources), - artifacts=current.lineage.artifacts, + next_revision = current.revision + 1 + canonical_content = cast(dict[str, JsonValue], draft.content.model_dump(mode="json", by_alias=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) + draft = draft.model_copy( + update={ + "sources": (stored.ref,), + "artifacts": current.lineage.artifacts, + } + ) revised = await self._artifacts.revise(connection, scope_id, current, draft) + 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 @@ -421,14 +441,18 @@ def _source_record( scope_id=scope_id, source_type=cast(Any, stored.ref.source_type), source_id=stored.ref.source_id, - content=stored.value.content if stored.value.wire_content is None else stored.value.wire_content, + content=_source_content(stored.value), position=stored.journal_position, - content_digest=_content_digest( - stored.value.content if stored.value.wire_content is None else stored.value.wire_content - ), + 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], diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index a06ef4604..bcc7be2b6 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -178,6 +178,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.""" @@ -254,6 +255,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] = { @@ -350,6 +352,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.""" @@ -386,6 +389,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, ) return experience_index = OceanBaseExperienceFTSIndex() @@ -420,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, ) diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index ac86770ec..0798b2417 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -364,6 +364,7 @@ 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.source_registry = source_registry or BUILTIN_SOURCE_REGISTRY @@ -396,6 +397,7 @@ def __init__( self.repositories.sources, self.repositories.artifacts, id_factory=id_factory, + cursor_secret=cursor_secret, ) self._candidate_pipeline = candidate_pipeline self.memory_extraction = candidate_pipeline is not None diff --git a/src/powercontext/builtin/sources/content.py b/src/powercontext/builtin/sources/content.py index 87d176673..3d51080b3 100644 --- a/src/powercontext/builtin/sources/content.py +++ b/src/powercontext/builtin/sources/content.py @@ -48,14 +48,14 @@ class ContentSourceTarget(BaseModel): scope_id: str family: Literal["memory", "experience", "skill", "handoff"] artifact_id: str - revision: Literal[1] = 1 + revision: Annotated[int, Field(ge=1)] = 1 class ContentSourceInternal(BaseModel): """Server-owned Source purpose data never exposed by the base REST API.""" role: Literal["lineage_only"] - operation: Literal["artifact_create"] + operation: Literal["artifact_create", "artifact_replace"] target: ContentSourceTarget @@ -65,6 +65,7 @@ class ContentSource(Source): content: str metadata: dict[str, JsonValue] = Field(default_factory=dict) wire_content: JsonValue | None = None + wire_content_present: bool = False internal: ContentSourceInternal | None = None 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 7011277b0..235d22df4 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/settings.py b/src/powercontext/server/settings.py index 583a2592f..792f87386 100644 --- a/src/powercontext/server/settings.py +++ b/src/powercontext/server/settings.py @@ -213,12 +213,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_records.py b/tests/builtin/persistence/test_records.py index 15977934a..aeb1c18dd 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -19,7 +19,7 @@ import pytest from pydantic import JsonValue -from sqlalchemy import func, select +from sqlalchemy import event, func, select from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.experience import Experience @@ -95,12 +95,15 @@ def _services( def test_source_create_persists_json_without_public_internal_fields() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, _ = _services(profile, iter(("src-1",))) + records, _, _ = _services(profile, iter(("src-1", "src-2"))) 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", @@ -158,7 +161,7 @@ async def scenario() -> None: def test_artifact_get_list_replace_use_family_models_and_opaque_etags() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, _ = _services(profile, iter(("mem-1", "src-1"))) + records, _, sources = _services(profile, iter(("mem-1", "src-1", "src-2"))) 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) @@ -175,8 +178,15 @@ async def scenario() -> None: ArtifactWrite(content=_memory_content()), ) assert replaced.revision == 2 - assert replaced.sources == () - assert (await records.get_artifact_revision("scope-a", "memory", created.artifact_id, 1)).revision == 1 + 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]) + 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 with pytest.raises(ArtifactRevisionPreconditionError): await records.replace_artifact( @@ -197,7 +207,7 @@ async def scenario() -> None: def test_artifact_create_and_replace_validate_handoff_as_json() -> None: async def scenario() -> None: async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, _ = _services(profile, iter(("handoff-1", "src-1"))) + records, _, _ = _services(profile, iter(("handoff-1", "src-1", "src-2"))) created = await records.create_artifact( "scope-a", "handoff", @@ -221,6 +231,33 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_artifact_list_batches_revision_and_lineage_reads() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: + records, _, _ = _services( + profile, + iter(("mem-1", "src-1", "mem-2", "src-2", "mem-3", "src-3")), + ) + 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_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 diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py index 07b671ad7..eb7ee128b 100644 --- a/tests/e2e/test_base_access_api.py +++ b/tests/e2e/test_base_access_api.py @@ -94,6 +94,9 @@ async def scenario() -> None: "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='')}" @@ -150,9 +153,17 @@ async def scenario() -> None: expected_etag=etag, ) assert replaced.revision == 2 - assert replaced.sources == [] + 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 == _memory_content() 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( diff --git a/tests/test_cursor_secret.py b/tests/test_cursor_secret.py new file mode 100644 index 000000000..db6049bc1 --- /dev/null +++ b/tests/test_cursor_secret.py @@ -0,0 +1,60 @@ +# 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_server.py b/tests/test_server.py index 37aa80fa2..e4f0ac701 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -105,6 +105,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", @@ -141,6 +142,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}} @@ -180,6 +182,42 @@ 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: + content = { + "manifest": {"entries": [], "format": "flat-v1"}, + "changes": [], + "schema": "powercontext.memory.v1", + } + for _ in range(2): + response = client.post( + "/v1/scopes/scope-a/artifacts", + json={"family": "memory", "content": content}, + ) + assert response.status_code == 201 + first_page = client.get("/v1/scopes/scope-a/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( + "/v1/scopes/scope-a/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) From 0153496a26a0ce6e2d83218aa7b407f7b5859954 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Fri, 4 Sep 2026 17:23:25 +0800 Subject: [PATCH 6/9] fix(api): reuse family artifact write flows --- .../powercontext/src/operations.generated.ts | 147 ++--- .../powercontext/src/operations.generated.ts | 147 ++--- .../powercontext/src/operations.generated.ts | 147 ++--- openapi/powercontext.yaml | 148 ++++- .../builtin/artifacts/handoff/service.py | 13 +- .../builtin/persistence/family_management.py | 544 ++++++++++++++++++ .../builtin/persistence/handoff.py | 20 +- .../builtin/persistence/records.py | 57 +- src/powercontext/builtin/records.py | 10 + .../builtin/runtime/application.py | 2 +- .../builtin/runtime/relational.py | 36 +- src/powercontext/client/client.py | 6 +- src/powercontext/http/__init__.py | 36 +- src/powercontext/http/_generated/models.py | 167 +++++- .../http/_generated/operations.py | 208 ++++++- src/powercontext/http/_generated/schema.py | 202 ++++++- src/powercontext/server/app.py | 66 ++- tests/builtin/persistence/test_records.py | 254 +++++++- .../runtime/test_experience_incubation.py | 13 +- tests/e2e/test_base_access_api.py | 135 ++++- tests/test_api_contract.py | 62 +- tests/test_client.py | 11 +- tests/test_js_operations.py | 14 +- 23 files changed, 2062 insertions(+), 383 deletions(-) create mode 100644 src/powercontext/builtin/persistence/family_management.py 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/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/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/openapi/powercontext.yaml b/openapi/powercontext.yaml index 42ea7845d..962aff703 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -2240,7 +2240,10 @@ paths: post: tags: [artifacts] summary: Create an Artifact - description: Validate family content and atomically create revision one with its system provenance Source. + 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 @@ -6220,15 +6223,88 @@ components: 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: - $ref: "#/components/schemas/BaseArtifactFamily" + type: string + enum: [memory] content: - type: object - additionalProperties: true + $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 @@ -6255,13 +6331,73 @@ components: 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: - type: object - additionalProperties: true + $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] diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 4456c20bd..6217ee818 100644 --- a/src/powercontext/builtin/artifacts/handoff/service.py +++ b/src/powercontext/builtin/artifacts/handoff/service.py @@ -102,19 +102,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/family_management.py b/src/powercontext/builtin/persistence/family_management.py new file mode 100644 index 000000000..5da7ebd5a --- /dev/null +++ b/src/powercontext/builtin/persistence/family_management.py @@ -0,0 +1,544 @@ +# 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.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=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/handoff.py b/src/powercontext/builtin/persistence/handoff.py index 2f344cd7a..580b8dc82 100644 --- a/src/powercontext/builtin/persistence/handoff.py +++ b/src/powercontext/builtin/persistence/handoff.py @@ -18,6 +18,8 @@ from typing import cast +from sqlalchemy.ext.asyncio import AsyncConnection + from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.handoff import ( Handoff, @@ -54,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, @@ -70,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, @@ -81,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 @@ -89,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, @@ -101,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, @@ -122,22 +126,24 @@ def __init__( sources: SourceRepository, 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: try: if isinstance(citation, HandoffSourceCitation): - async with self._database.transaction() as connection: + async with self._database.connection(self._bound_connection) as connection: source = await self._sources.get(connection, self._scope_id, citation.source_ref) require_source_eligible(citation.source_ref, source.value) return HandoffSourceEvidence(citation=citation, source=source.value) if isinstance(citation, HandoffArtifactCitation): - 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, citation.artifact_ref) return HandoffArtifactEvidence(citation=citation, artifact=artifact) if isinstance(citation, HandoffMemoryCitation): diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 532d42fd4..dfa08347a 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -32,13 +32,13 @@ from sqlalchemy.ext.asyncio import AsyncConnection from powercontext.artifacts import Artifact, ArtifactRef -from powercontext.builtin.persistence.artifacts import ArtifactRepository, RepositoryArtifactDraft +from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.errors import ( - InvalidRepositoryArgumentError, 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, @@ -89,6 +89,7 @@ def __init__( database: AsyncDatabase, sources: SourceRepository, artifacts: ArtifactRepository, + family_writers: FamilyManagementWriterRegistry, /, *, clock: Clock | None = None, @@ -103,6 +104,7 @@ def __init__( 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 @@ -172,10 +174,14 @@ async def create_artifact( write: ArtifactWrite, /, ) -> ArtifactCreated: - draft = self._validated_draft(family, write.content) - artifact_id = self._id_factory(family) + 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], draft.content.model_dump(mode="json", by_alias=True)) + 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, @@ -189,19 +195,14 @@ async def create_artifact( 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) - draft = draft.model_copy(update={"sources": (stored.ref,)}) - artifact = await self._artifacts.create( - connection, - scope_id, - artifact_id, - draft, - ) + 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) @@ -297,8 +298,8 @@ async def replace_artifact( write: ArtifactWrite, /, ) -> ArtifactRecord: - self._require_family(family) - draft = self._validated_draft(family, write.content) + 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) @@ -308,7 +309,10 @@ async def replace_artifact( if expected_etag != current_etag: raise ArtifactRevisionPreconditionError(expected_etag, current_etag) next_revision = current.revision + 1 - canonical_content = cast(dict[str, JsonValue], draft.content.model_dump(mode="json", by_alias=True)) + 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, @@ -328,13 +332,7 @@ async def replace_artifact( ) try: stored = await self._sources.add(connection, scope_id, source) - draft = draft.model_copy( - update={ - "sources": (stored.ref,), - "artifacts": current.lineage.artifacts, - } - ) - revised = await self._artifacts.revise(connection, scope_id, current, draft) + 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: @@ -372,21 +370,6 @@ def _require_family(self, family: str) -> None: if family not in self._artifacts.families: raise InvalidBaseAccessRequestError("family", "must be memory, experience, skill, or handoff") - def _validated_draft( - self, - family: str, - content: dict[str, JsonValue], - /, - *, - sources: tuple[SourceRef, ...] = (), - artifacts: tuple[ArtifactRef, ...] = (), - ) -> RepositoryArtifactDraft: - self._require_family(family) - try: - return self._artifacts.draft(family, content, sources=sources, artifacts=artifacts) - except InvalidRepositoryArgumentError as error: - raise InvalidBaseAccessRequestError("content", "does not match the Artifact family") from error - async def _get_source( self, connection: AsyncConnection, diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index cb28e7669..7a5f8f66d 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -134,6 +134,16 @@ def __init__(self, kind: Literal["source", "artifact"], identity: object) -> Non 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.""" diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index d39d71eae..7c28d1349 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -97,6 +97,7 @@ ArtifactLifecycleState, ) from powercontext.builtin.persistence.skill_publications import SkillPublication +from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.records import ( ArtifactCreated, ArtifactRecord, @@ -107,7 +108,6 @@ ScopeSummaryPage, SourceRecord, ) -from powercontext.builtin.publication import ArtifactPublicationApplication from powercontext.builtin.review.generation import GeneratedCandidateResult, ReviewedGenerationService from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._scope_cache import ( diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 1f500d375..07f81e451 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -89,6 +89,13 @@ 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.handoff import ( RelationalHandoffBackend, RelationalHandoffEvidenceResolver, @@ -123,8 +130,8 @@ from powercontext.builtin.runtime.protocols import BuiltinTriggers from powercontext.builtin.runtime.recall import RelationalRecallTokenEstimator from powercontext.builtin.runtime.statistics import RelationalScopedStatistics -from powercontext.builtin.source_eligibility import is_generation_eligible, require_source_eligible from powercontext.builtin.scope import ScopeApplication +from powercontext.builtin.source_eligibility import is_generation_eligible, require_source_eligible from powercontext.builtin.sources import ( BUILTIN_SOURCE_REGISTRY, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, @@ -409,10 +416,36 @@ 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, ) @@ -438,7 +471,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[ diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index d13e6744c..d752ad0b3 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -35,8 +35,8 @@ ArtifactCandidatePage, ArtifactCreated, ArtifactPage, - ArtifactRevision, ArtifactPublication, + ArtifactRevision, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -49,8 +49,8 @@ ContinueHandoffRequest, CreateArtifactRequest, CreateRemoteSkillTargetRequest, - CreateSourceRequest, CreateScopeRequest, + CreateSourceRequest, CreateWorkContractRequest, DownloadRemoteSkillPackageRequest, EnrollRemoteSkillTargetRequest, @@ -160,8 +160,8 @@ CONTINUE_HANDOFF, CREATE_ARTIFACT, CREATE_REMOTE_SKILL_TARGET, - CREATE_SOURCE, CREATE_SCOPE, + CREATE_SOURCE, CREATE_WORK_CONTRACT, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index 3ddd1f7e5..89ae3d4e4 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -27,11 +27,11 @@ ArtifactCreated, ArtifactInventoryStatistics, ArtifactPage, - ArtifactRevision, - BaseArtifactFamily, ArtifactPublication, ArtifactReference, + ArtifactRevision, AuthorizationNote, + BaseArtifactFamily, CandidateFamily, CandidateFamilyCount, CandidateInventoryStatistics, @@ -51,9 +51,15 @@ ContextReference, ContinueHandoffRequest, CreateArtifactRequest, - CreateSourceRequest, + CreateExperienceArtifactRequest, + CreateHandoffArtifactRequest, + CreateMemoryArtifactContent, + CreateMemoryArtifactEntry, + CreateMemoryArtifactRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, + CreateSkillArtifactRequest, + CreateSourceRequest, CreateWorkContractRequest, CurrentWorkHandoff, DownloadRemoteSkillPackageRequest, @@ -196,6 +202,12 @@ RemoteSkillTargetStatus, RenameRemoteSkillTargetRequest, ReplaceArtifactRequest, + ReplaceExperienceArtifactRequest, + ReplaceHandoffArtifactRequest, + ReplaceMemoryArtifactContent, + ReplaceMemoryArtifactEntry, + ReplaceMemoryArtifactRequest, + ReplaceSkillArtifactRequest, ReportFormat, ResolvedUsagePeriod, ResolveExternalSkillRequest, @@ -280,11 +292,11 @@ "ArtifactCreated", "ArtifactInventoryStatistics", "ArtifactPage", - "ArtifactRevision", - "BaseArtifactFamily", "ArtifactPublication", "ArtifactReference", + "ArtifactRevision", "AuthorizationNote", + "BaseArtifactFamily", "CandidateFamily", "CandidateFamilyCount", "CandidateInventoryStatistics", @@ -304,9 +316,15 @@ "ContextReference", "ContinueHandoffRequest", "CreateArtifactRequest", - "CreateSourceRequest", + "CreateExperienceArtifactRequest", + "CreateHandoffArtifactRequest", + "CreateMemoryArtifactContent", + "CreateMemoryArtifactEntry", + "CreateMemoryArtifactRequest", "CreateRemoteSkillTargetRequest", "CreateScopeRequest", + "CreateSkillArtifactRequest", + "CreateSourceRequest", "CreateWorkContractRequest", "CurrentWorkHandoff", "DownloadRemoteSkillPackageRequest", @@ -449,6 +467,12 @@ "RemoteSkillTargetStatus", "RenameRemoteSkillTargetRequest", "ReplaceArtifactRequest", + "ReplaceExperienceArtifactRequest", + "ReplaceHandoffArtifactRequest", + "ReplaceMemoryArtifactContent", + "ReplaceMemoryArtifactEntry", + "ReplaceMemoryArtifactRequest", + "ReplaceSkillArtifactRequest", "ReportFormat", "ResolveExternalSkillRequest", "ResolveScopeBindingRequest", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 639c17d2a..c2722d5c4 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1200,6 +1200,54 @@ 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" @@ -1220,11 +1268,36 @@ class ListArtifactsRequest(BaseModel): cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None -class ReplaceArtifactRequest(BaseModel): +class ReplaceMemoryArtifactEntry(BaseModel): model_config = ConfigDict( extra="forbid", ) - content: dict[str, Any] + 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): @@ -1976,12 +2049,33 @@ class SearchMemoryResponse(BaseModel): hits: list[SearchMemoryHit] -class CreateArtifactRequest(BaseModel): +class CreateSkillArtifactRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - family: BaseArtifactFamily - content: dict[str, Any] + 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): @@ -2123,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", @@ -2272,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", @@ -2348,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 c286800e4..363361c0f 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -2056,10 +2056,19 @@ "post": { "tags": ["artifacts"], "summary": "Create an Artifact", - "description": "Validate family content " - "and atomically create " - "revision one with its " - "system provenance Source.", + "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": [ { @@ -5044,14 +5053,131 @@ "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": {"$ref": "#/components/schemas/BaseArtifactFamily"}, - "content": {"additionalProperties": True, "type": "object"}, + "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"}, @@ -5070,7 +5196,69 @@ "type": "object", }, "ReplaceArtifactRequest": { - "properties": {"content": {"additionalProperties": True, "type": "object"}}, + "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"], diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index d30942101..59340b017 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -30,7 +30,6 @@ from time import perf_counter from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeVar, cast from urllib.parse import quote, unquote -from uuid import uuid4 from fastapi import Depends, FastAPI, Header, Path, Query, Request, Response, status from fastapi import Path as PathParameter @@ -128,6 +127,24 @@ StoredPayloadConflictError, ) from powercontext.builtin.persistence.skill_publications import SkillPublication +from powercontext.builtin.publication import ( + ArtifactPublicationApplication, + ArtifactPublicationConflictError, + ArtifactPublicationUnsupportedError, +) +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, ) @@ -140,29 +157,12 @@ from powercontext.builtin.records import ( ArtifactRecordPage as RuntimeArtifactRecordPage, ) -from powercontext.builtin.records import ( - ArtifactRevisionPreconditionError, - BaseAccessError, - BaseValueConflictError, - BaseValueNotFoundError, - CursorExpiredError, - InvalidBaseAccessRequestError, - InvalidCursorError, -) from powercontext.builtin.records import ( ArtifactWrite as RuntimeArtifactWrite, ) from powercontext.builtin.records import ( SourceRecord as RuntimeSourceRecord, ) -from powercontext.builtin.publication import ( - ArtifactPublicationApplication, - ArtifactPublicationConflictError, - ArtifactPublicationUnsupportedError, -) -from powercontext.builtin.publication import ( - ArtifactPublicationRequest as DomainArtifactPublicationRequest, -) from powercontext.builtin.review import ( ArtifactTargetConflictError, CandidateConflictError, @@ -268,7 +268,6 @@ from powercontext.builtin.runtime import ( SubmitSourceObservation as RuntimeSubmitSourceObservation, ) -from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.builtin.scope import ( ScopeApplication, ScopeBindingNotFoundError, @@ -291,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, @@ -344,9 +344,9 @@ ConnectorCheckpointState, ContinueHandoffRequest, CreateArtifactRequest, - CreateSourceRequest, CreateRemoteSkillTargetRequest, CreateScopeRequest, + CreateSourceRequest, CreateWorkContractRequest, DownloadRemoteSkillPackageRequest, EnrollRemoteSkillTargetRequest, @@ -456,6 +456,7 @@ from powercontext.http import ( HandoffActivation as TransportHandoffActivation, ) +from powercontext.http import HandoffContent as TransportHandoffContent from powercontext.http import ( HandoffDraft as TransportHandoffDraft, ) @@ -478,9 +479,9 @@ COMMIT_HANDOFF, CONTINUE_HANDOFF, CREATE_ARTIFACT, - CREATE_SOURCE, CREATE_REMOTE_SKILL_TARGET, CREATE_SCOPE, + CREATE_SOURCE, CREATE_WORK_CONTRACT, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, @@ -1387,7 +1388,7 @@ async def create_artifact( application: Annotated[ServerApplication, Depends(_require_application)], ) -> ArtifactCreated: result = await application.records.for_scope(scope_id).create_artifact( - request.family.value, + request.root.family, _artifact_write(request), ) response.headers["Location"] = _artifact_location(result) @@ -1521,8 +1522,14 @@ def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) -> def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> RuntimeArtifactWrite: + content = value.root.content + if isinstance(content, TransportHandoffContent): + content = mapping.runtime_handoff_content(content) return RuntimeArtifactWrite( - content=value.content, + content=cast( + dict[str, JsonValue], + content.model_dump(mode="json", by_alias=True, exclude_none=True), + ), ) @@ -2552,7 +2559,7 @@ 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) @@ -2674,6 +2681,17 @@ def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, A "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, diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index aeb1c18dd..79093d6ba 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -15,11 +15,12 @@ from __future__ import annotations import asyncio -from collections.abc import Iterator +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 @@ -27,32 +28,52 @@ 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, - SHARED_TABLES, + BUILTIN_TABLES, + MEMORY_ENTRY_HEADS_TABLE, + MEMORY_ENTRY_VERSIONS_TABLE, + SKILL_PACKAGES_TABLE, SOURCES_TABLE, ) from powercontext.builtin.records import ( ArtifactRevisionPreconditionError, ArtifactWrite, - BaseValueConflictError, 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 { - "manifest": {"entries": [], "format": "flat-v1"}, - "changes": [], - "schema": "powercontext.memory.v1", - } + return {"entries": [{"kind": "preference", "text": "用户偏好使用中文回答"}]} def _handoff_content(objective: str = "Transfer the API test result.") -> dict[str, JsonValue]: @@ -78,15 +99,47 @@ def _handoff_content(objective: str = "Transfer the API test result.") -> dict[s def _services( profile: SQLiteProfile, - ids: Iterator[str], + *, + 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, - id_factory=lambda _kind: next(ids), + writers, + id_factory=new_id, cursor_secret=b"record-test-secret", ) return records, artifacts, sources @@ -94,8 +147,8 @@ def _services( def test_source_create_persists_json_without_public_internal_fields() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, _ = _services(profile, iter(("src-1", "src-2"))) + 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) @@ -120,8 +173,8 @@ async def scenario() -> None: def test_artifact_create_is_atomic_and_binds_its_system_source() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, artifacts, sources = _services(profile, iter(("mem-1", "src-1", "mem-1", "src-2"))) + 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) @@ -143,15 +196,17 @@ async def scenario() -> None: } lineage = (await connection.execute(select(ARTIFACT_LINEAGE_SOURCES_TABLE))).mappings().one() assert lineage["ordinal"] == 0 - - with pytest.raises(BaseValueConflictError): - await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) + 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 - foreign = artifacts.draft("memory", _memory_content(), sources=created.sources) + 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) @@ -160,8 +215,8 @@ async def scenario() -> None: def test_artifact_get_list_replace_use_family_models_and_opaque_etags() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, sources = _services(profile, iter(("mem-1", "src-1", "src-2"))) + 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) @@ -175,7 +230,7 @@ async def scenario() -> None: "memory", created.artifact_id, '"revision:1"', - ArtifactWrite(content=_memory_content()), + ArtifactWrite(content={"entries": [{"kind": "working_note", "text": "继续验证 API"}]}), ) assert replaced.revision == 2 assert replaced.sources[0].source_id == "src-2" @@ -183,10 +238,25 @@ async def scenario() -> None: 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( @@ -206,18 +276,36 @@ async def scenario() -> None: def test_artifact_create_and_replace_validate_handoff_as_json() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, _ = _services(profile, iter(("handoff-1", "src-1", "src-2"))) + 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()), + 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.content == _handoff_content() + assert loaded.artifact_id == "handoff" + assert loaded.sources[0] == created.sources[0] + assert loaded.sources[1].source_id == evidence.source_id - replacement = _handoff_content("Transfer the verified API test result.") + replacement = loaded.content | {"objective": "Transfer the verified API test result."} replaced = await records.replace_artifact( "scope-a", "handoff", @@ -226,6 +314,8 @@ async def scenario() -> None: 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()) @@ -233,11 +323,8 @@ async def scenario() -> None: def test_artifact_list_batches_revision_and_lineage_reads() -> None: async def scenario() -> None: - async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES) as profile: - records, _, _ = _services( - profile, - iter(("mem-1", "src-1", "mem-2", "src-2", "mem-3", "src-3")), - ) + 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())) @@ -258,6 +345,111 @@ def record_statement(*args: object) -> None: 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 diff --git a/tests/builtin/runtime/test_experience_incubation.py b/tests/builtin/runtime/test_experience_incubation.py index dfaf84e80..447009c4d 100644 --- a/tests/builtin/runtime/test_experience_incubation.py +++ b/tests/builtin/runtime/test_experience_incubation.py @@ -196,17 +196,12 @@ async def scenario() -> None: BuiltinConfig(database=SQLiteConfig()), experience_pipeline=pipeline, ) as runtime: - created = await runtime.records.for_scope("lineage-only").create_artifact( + scope = await _create_scope(runtime, "lineage-only") + created = await runtime.records.for_scope(scope).create_artifact( "memory", - ArtifactWrite( - content={ - "manifest": {"entries": [], "format": "flat-v1"}, - "changes": [], - "schema": "powercontext.memory.v1", - } - ), + ArtifactWrite(content={"entries": [{"kind": "working_note", "text": "Do not incubate direct writes"}]}), ) - result = await runtime.experience.for_scope("lineage-only").incubate() + result = await runtime.experience.for_scope(scope).incubate() assert created.revision == 1 assert result.current_cursor == 1 diff --git a/tests/e2e/test_base_access_api.py b/tests/e2e/test_base_access_api.py index eb7ee128b..10b2170a9 100644 --- a/tests/e2e/test_base_access_api.py +++ b/tests/e2e/test_base_access_api.py @@ -24,9 +24,14 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.client import PowerContextClient, ServerResponseError from powercontext.http import ( - BaseArtifactFamily, + ArtifactReference, + ContinueHandoffRequest, CreateArtifactRequest, CreateSourceRequest, + GetExperienceRequest, + GetSkillPackageRequest, + GetSkillRequest, + HandoffSelection, ListArtifactsRequest, ReplaceArtifactRequest, ) @@ -36,13 +41,11 @@ def _memory_content() -> dict[str, object]: return { - "manifest": {"entries": [], "format": "flat-v1"}, - "changes": [], - "schema": "powercontext.memory.v1", + "entries": [{"kind": "preference", "text": "用户偏好使用中文回答"}], } -def _handoff_content(objective: str = "Transfer the API test result.") -> dict[str, object]: +def _handoff_content(source_id: str, objective: str = "Transfer the API test result.") -> dict[str, object]: return { "schema": "powercontext.handoff.v1", "objective": objective, @@ -52,7 +55,7 @@ def _handoff_content(objective: str = "Transfer the API test result.") -> dict[s "citations": [ { "kind": "source", - "source_ref": {"source_type": "content", "source_id": "source-evidence"}, + "source_ref": {"name": "content", "source_id": source_id}, } ], } @@ -73,13 +76,13 @@ def test_source_and_artifact_api_round_trip(tmp_path: Path) -> None: ) async def scenario() -> None: - scope_id = "git:github.com/oceanbase/powercontext" - encoded_scope = quote(scope_id, safe="") 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."}), @@ -110,7 +113,7 @@ async def scenario() -> None: created = await client.create_artifact( scope_id, - CreateArtifactRequest(family=BaseArtifactFamily.MEMORY, content=_memory_content()), + CreateArtifactRequest.model_validate({"family": "memory", "content": _memory_content()}), ) assert created.revision == 1 assert len(created.sources) == 1 @@ -132,6 +135,8 @@ async def scenario() -> None: 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", @@ -149,7 +154,9 @@ async def scenario() -> None: scope_id, "memory", created.artifact_id, - ReplaceArtifactRequest(content=_memory_content()), + ReplaceArtifactRequest.model_validate({ + "content": {"entries": [{"kind": "working_note", "text": "继续验证基础 API"}]} + }), expected_etag=etag, ) assert replaced.revision == 2 @@ -160,7 +167,7 @@ async def scenario() -> None: replaced.sources[0].source_type.value, replaced.sources[0].source_id, ) - assert replacement_source.content == _memory_content() + 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 @@ -170,7 +177,9 @@ async def scenario() -> None: scope_id, "memory", created.artifact_id, - ReplaceArtifactRequest(content=_memory_content()), + ReplaceArtifactRequest.model_validate({ + "content": {"entries": [{"kind": "working_note", "text": "不能覆盖并发更新"}]} + }), expected_etag=etag, ) assert stale.value.status_code == 412 @@ -189,28 +198,120 @@ def test_handoff_artifact_round_trip_accepts_json_arrays(tmp_path: Path) -> None ) async def scenario() -> None: - scope_id = "git:github.com/oceanbase/powercontext" 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(family=BaseArtifactFamily.HANDOFF, content=_handoff_content()), + 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.content == _handoff_content() + 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(content=_handoff_content("Transfer the verified API test result.")), + 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.content == _handoff_content("Transfer the verified API test result.") + 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/test_api_contract.py b/tests/test_api_contract.py index 2beafb557..1cee522af 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -26,13 +26,15 @@ ArtifactCreated, ArtifactReference, ArtifactRevision, - BaseArtifactFamily, CaptureContentSourceRequest, CaptureContentSourceResponse, CommitHandoffRequest, CommittedHandoff, ContinueHandoffRequest, CreateArtifactRequest, + CreateMemoryArtifactContent, + CreateMemoryArtifactEntry, + CreateMemoryArtifactRequest, CreateSourceRequest, CreateWorkContractRequest, ExternalSkillResolution, @@ -82,8 +84,8 @@ COMMIT_HANDOFF, CONTINUE_HANDOFF, CREATE_ARTIFACT, - CREATE_SOURCE, CREATE_REMOTE_SKILL_TARGET, + CREATE_SOURCE, CREATE_WORK_CONTRACT, DOWNLOAD_REMOTE_SKILL_PACKAGE, DOWNLOAD_SKILL_PACKAGE, @@ -99,8 +101,8 @@ GET_MEMORY_ENTRY, GET_READINESS, GET_SKILL, - GET_SOURCE, GET_SKILL_PACKAGE_MANIFEST, + GET_SOURCE, GET_STATS, HANDOFF_CURRENT_WORK, IMPORT_EXTERNAL_SKILL, @@ -123,8 +125,8 @@ RECORD_TASK_OUTCOME, REJECT_ARTIFACT_CANDIDATE, REMEMBER_MEMORY, - REPLACE_ARTIFACT, RENAME_REMOTE_SKILL_TARGET, + REPLACE_ARTIFACT, RESOLVE_EXTERNAL_SKILL, RETIRE_MEMORY_ENTRY, REVISE_ARTIFACT_CANDIDATE, @@ -620,7 +622,7 @@ def test_base_access_contract_uses_only_the_seven_scoped_operations() -> None: 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 or path == "/v1/scopes" for path in paths) + assert not any("search-results" in path for path in paths) def test_base_access_create_requests_leave_identity_generation_to_the_server() -> None: @@ -634,15 +636,42 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - assert source["properties"]["source_type"]["default"] == "content" artifact = schemas["CreateArtifactRequest"] - assert artifact["required"] == ["family", "content"] - assert set(artifact["properties"]) == {"family", "content"} - assert not {"scope_id", "source_id", "artifact_id"} & set(source["properties"] | artifact["properties"]) + 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(family=BaseArtifactFamily.MEMORY, content={"manifest": {}}).family - is BaseArtifactFamily.MEMORY + 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"}), @@ -672,8 +701,17 @@ def test_base_access_uses_a_dedicated_source_type_reference() -> None: assert schemas[schema_name]["properties"]["sources"]["items"] == { "$ref": "#/components/schemas/SourceTypeReference" } - assert "sources" not in schemas["CreateArtifactRequest"]["properties"] - assert "sources" not in schemas["ReplaceArtifactRequest"]["properties"] + 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 diff --git a/tests/test_client.py b/tests/test_client.py index 35abf15da..b8bb94fc4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -27,6 +27,9 @@ GetHandoffReportRequest, ListArtifactsRequest, ReplaceArtifactRequest, + ReplaceMemoryArtifactContent, + ReplaceMemoryArtifactEntry, + ReplaceMemoryArtifactRequest, ReportFormat, ScopeId, ScopeSelection, @@ -321,7 +324,13 @@ def respond(request: httpx.Request) -> httpx.Response: "scope-a", "memory", "artifact-1", - ReplaceArtifactRequest(content={"manifest": {}}), + ReplaceArtifactRequest( + root=ReplaceMemoryArtifactRequest( + content=ReplaceMemoryArtifactContent( + entries=[ReplaceMemoryArtifactEntry(kind="preference", text="Use Chinese")] + ) + ) + ), expected_etag='"opaque-v4"', ) diff --git a/tests/test_js_operations.py b/tests/test_js_operations.py index 78e69218a..c9935c4bb 100644 --- a/tests/test_js_operations.py +++ b/tests/test_js_operations.py @@ -51,7 +51,19 @@ 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", From 20cb679cd72b94cac7038e5f1549a2156f612a39 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Fri, 4 Sep 2026 17:52:54 +0800 Subject: [PATCH 7/9] fix(ci): sync base API generated artifacts --- .../dsh/plugins/powercontext/lib/index.js | 597 +++++++++++++++--- tests/test_cursor_secret.py | 4 +- tests/test_server.py | 13 +- 3 files changed, 505 insertions(+), 109 deletions(-) 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/tests/test_cursor_secret.py b/tests/test_cursor_secret.py index db6049bc1..613813b37 100644 --- a/tests/test_cursor_secret.py +++ b/tests/test_cursor_secret.py @@ -48,9 +48,7 @@ def test_explicit_cursor_secret_overrides_database_storage(tmp_path) -> None: 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") - ) + 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) diff --git a/tests/test_server.py b/tests/test_server.py index ceb57af28..88bfc84b4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -189,18 +189,15 @@ def test_server_reuses_file_backed_cursor_secret_across_restarts(tmp_path) -> No ) first_app = create_server_app(settings=settings) with TestClient(first_app) as client: - content = { - "manifest": {"entries": [], "format": "flat-v1"}, - "changes": [], - "schema": "powercontext.memory.v1", - } + 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( - "/v1/scopes/scope-a/artifacts", + f"/v1/scopes/{scope_id}/artifacts", json={"family": "memory", "content": content}, ) assert response.status_code == 201 - first_page = client.get("/v1/scopes/scope-a/artifacts/memory", params={"limit": 1}) + 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 @@ -208,7 +205,7 @@ def test_server_reuses_file_backed_cursor_secret_across_restarts(tmp_path) -> No second_app = create_server_app(settings=settings) with TestClient(second_app) as client: second_page = client.get( - "/v1/scopes/scope-a/artifacts/memory", + f"/v1/scopes/{scope_id}/artifacts/memory", params={"limit": 1, "cursor": cursor}, ) From d7eb3d3428b705e67d2873b327de4a9aab0e038f Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Fri, 4 Sep 2026 18:02:47 +0800 Subject: [PATCH 8/9] test(cli): follow current config validation path --- tests/test_config_cli.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 7c0e57355d47a6c0fc412cc8be34ae9e74fadb60 Mon Sep 17 00:00:00 2001 From: Frey <370805886@qq.com> Date: Fri, 4 Sep 2026 20:24:44 +0800 Subject: [PATCH 9/9] feat(generation): standardize source access --- .../builtin/artifacts/handoff/service.py | 18 ++- .../builtin/persistence/family_management.py | 3 +- .../builtin/persistence/generation_sources.py | 63 ++++++++++ .../builtin/persistence/handoff.py | 87 +++++++++---- .../builtin/persistence/sources.py | 77 +++++++++++- src/powercontext/builtin/review/generation.py | 11 +- src/powercontext/builtin/review/service.py | 10 +- .../builtin/runtime/relational.py | 42 +++---- src/powercontext/builtin/sources/content.py | 10 +- src/powercontext/server/app.py | 4 +- .../persistence/test_generation_sources.py | 115 ++++++++++++++++++ tests/builtin/persistence/test_provider.py | 25 ++++ tests/builtin/persistence/test_sources.py | 51 ++++++++ tests/builtin/review/test_generation.py | 26 ++++ tests/builtin/review/test_service.py | 44 +++++++ tests/e2e/test_handoff_runtime.py | 29 +++++ 16 files changed, 550 insertions(+), 65 deletions(-) create mode 100644 src/powercontext/builtin/persistence/generation_sources.py create mode 100644 tests/builtin/persistence/test_generation_sources.py diff --git a/src/powercontext/builtin/artifacts/handoff/service.py b/src/powercontext/builtin/artifacts/handoff/service.py index 6217ee818..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, diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py index 5da7ebd5a..07b360ef6 100644 --- a/src/powercontext/builtin/persistence/family_management.py +++ b/src/powercontext/builtin/persistence/family_management.py @@ -45,6 +45,7 @@ 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 @@ -462,7 +463,7 @@ def _service(self, scope_id: str, connection: AsyncConnection) -> HandoffService evidence_resolver=RelationalHandoffEvidenceResolver( database=self._database, scope_id=scope_id, - sources=self._sources, + sources=GenerationSourceAccess(self._sources), artifacts=self._artifacts, memory=memory, connection=connection, 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 580b8dc82..7cb757ca3 100644 --- a/src/powercontext/builtin/persistence/handoff.py +++ b/src/powercontext/builtin/persistence/handoff.py @@ -16,6 +16,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import cast from sqlalchemy.ext.asyncio import AsyncConnection @@ -42,8 +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.source_eligibility import require_source_eligible +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.errors import ArtifactNotFoundError @@ -123,7 +123,7 @@ def __init__( *, database: AsyncDatabase, scope_id: str, - sources: SourceRepository, + sources: GenerationSourceAccess, artifacts: ArtifactRepository, memory: MemoryService, connection: AsyncConnection | None = None, @@ -136,30 +136,73 @@ def __init__( 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.connection(self._bound_connection) as connection: - source = await self._sources.get(connection, self._scope_id, citation.source_ref) - require_source_eligible(citation.source_ref, source.value) - return HandoffSourceEvidence(citation=citation, source=source.value) - if isinstance(citation, HandoffArtifactCitation): - async with self._database.connection(self._bound_connection) 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/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/review/generation.py b/src/powercontext/builtin/review/generation.py index 7076c4ec9..43cd3d0fa 100644 --- a/src/powercontext/builtin/review/generation.py +++ b/src/powercontext/builtin/review/generation.py @@ -33,11 +33,10 @@ 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 -from powercontext.builtin.source_eligibility import require_source_eligible from powercontext.errors import PowerContextError from powercontext.sources import Source, SourceRef @@ -79,7 +78,7 @@ def __init__( *, database: AsyncDatabase, scope_id: str, - sources: SourceRepository, + sources: GenerationSourceAccess, artifacts: ArtifactRepository, review: ReviewService, experience_generator: ExperienceGenerator | None, @@ -151,10 +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) - require_source_eligible(ref, row.value) - 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 d0af9e3db..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, @@ -39,7 +39,6 @@ ArtifactCandidatePage, CandidateStatus, ) -from powercontext.builtin.source_eligibility import require_source_eligible from powercontext.errors import ArtifactNotFoundError, RevisionConflictError from powercontext.sources import SourceRef @@ -62,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: @@ -286,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) @@ -353,9 +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: - stored = await self._sources.get(connection, self._scope_id, source) - require_source_eligible(source, stored.value) + 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/relational.py b/src/powercontext/builtin/runtime/relational.py index 07f81e451..04ac731a0 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -96,6 +96,7 @@ MemoryManagementWriter, SkillManagementWriter, ) +from powercontext.builtin.persistence.generation_sources import GenerationSourceAccess from powercontext.builtin.persistence.handoff import ( RelationalHandoffBackend, RelationalHandoffEvidenceResolver, @@ -131,7 +132,6 @@ from powercontext.builtin.runtime.recall import RelationalRecallTokenEstimator from powercontext.builtin.runtime.statistics import RelationalScopedStatistics from powercontext.builtin.scope import ScopeApplication -from powercontext.builtin.source_eligibility import is_generation_eligible, require_source_eligible from powercontext.builtin.sources import ( BUILTIN_SOURCE_REGISTRY, EXTERNAL_SKILL_SNAPSHOT_SOURCE_ADAPTER, @@ -227,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, @@ -276,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, ) @@ -285,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, @@ -304,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), ) @@ -320,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, ), @@ -1035,9 +1038,7 @@ async def get(self, source: Source, /) -> Source: ref = self._as_ref(source) try: async with self._database.connection(self._bound_connection) as connection: - value = (await self._repository.get(connection, self._scope_id, ref)).value - require_source_eligible(ref, value) - return value + return (await self._repository.get(connection, self._scope_id, ref)).value except RepositoryNotFoundError: raise SourceNotFoundError(source) from None @@ -1114,16 +1115,15 @@ 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( HandoffSourceCitation(source_ref=request.boundary_source) ) from None - require_source_eligible(request.boundary_source, source.value) state_row = await self._services.repositories.cursors.load( connection, self._services.scope_id, @@ -1239,14 +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 and is_generation_eligible(row.value) - ) + return tuple(row.value for row in rows) async def _prepare_memory(self, sources: tuple[Source, ...]) -> MemoryWritePlan: _, source_catalog = self._services.sources() @@ -1302,8 +1301,7 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: if pipeline is None: raise RuntimeError("Experience incubation pipeline is not configured") # noqa: TRY003 action = transition.actions[0] - eligible_rows = tuple(row for row in rows if is_generation_eligible(row.value)) - if not eligible_rows: + if not rows: async with self._services.database.transaction() as connection: await self._services.repositories.cursors.save( connection, @@ -1319,8 +1317,8 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: source_count=0, candidate_count=0, ) - plans = await pipeline.incubate(tuple(row.value for row in eligible_rows)) - _validate_experience_plans(plans, eligible_rows) + plans = await pipeline.incubate(tuple(row.value for row in rows)) + _validate_experience_plans(plans, rows) async with self._services.database.transaction() as connection: review = self._services.review(connection) for plan in plans: @@ -1342,7 +1340,7 @@ async def flush(self, *, limit: int) -> ExperienceIncubationResult: previous_cursor=action.after, high_watermark=high_watermark, current_cursor=action.through, - source_count=len(eligible_rows), + source_count=len(rows), candidate_count=len(plans), ) @@ -1351,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/sources/content.py b/src/powercontext/builtin/sources/content.py index 3d51080b3..0f49917f3 100644 --- a/src/powercontext/builtin/sources/content.py +++ b/src/powercontext/builtin/sources/content.py @@ -20,6 +20,7 @@ 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 @@ -46,10 +47,17 @@ class ContentSourceTarget(BaseModel): """Exact Artifact revision to which one system Source is bound.""" scope_id: str - family: Literal["memory", "experience", "skill", "handoff"] + 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.""" diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 59340b017..78aa971c3 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -2647,8 +2647,8 @@ def _map_base_access_error(error: Exception) -> tuple[int, str, str, dict[str, A return ( status.HTTP_422_UNPROCESSABLE_CONTENT, "source_not_eligible", - "The Source is reserved for its bound Artifact creation lineage.", - {"source": error.source.model_dump(mode="json")}, + "The Source cannot be used as Artifact generation evidence.", + {"source_ref": error.source.model_dump(mode="json")}, ) if isinstance(error, _PreconditionRequiredError): return ( 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_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/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(