diff --git a/docs/en/docs/how-to/full-capability-runtime.md b/docs/en/docs/how-to/full-capability-runtime.md index b4adb2ee9..15f5dce93 100644 --- a/docs/en/docs/how-to/full-capability-runtime.md +++ b/docs/en/docs/how-to/full-capability-runtime.md @@ -143,3 +143,5 @@ ID remain stable because they are persisted in the database. | Existing data is missing | Restore the previous database URL or `POWERCONTEXT_HOME` | See [Troubleshooting](troubleshoot.md) and [Configuration](../reference/configuration.md) for details. + +To organize saved Artifacts and individual Memory entries, see [Custom tags](manage-artifact-tags.md). diff --git a/docs/en/docs/how-to/manage-artifact-tags.md b/docs/en/docs/how-to/manage-artifact-tags.md new file mode 100644 index 000000000..1630cb09e --- /dev/null +++ b/docs/en/docs/how-to/manage-artifact-tags.md @@ -0,0 +1,132 @@ +--- +title: Organize Artifacts with custom tags +description: Label logical Artifacts and Memory entries, then find them with exact tag filters. +--- + +# Organize Artifacts with custom tags + +Custom tags organize Memory, Experience, Skill, and Handoff Artifacts within one Scope. A Memory Artifact and each +logical entry inside it have independent tag sets. Tags follow these identities across content revisions; they do not +change content, lineage, embeddings, or Context Versions. + +With access control enabled, tags follow their target's read and write permissions. A viewer of a shared target can read +its tags but cannot edit them or run a Scope-wide tag query. Queries require `scope.read`. Tags on the entire Memory +Artifact require `scope.read` to read and `scope.admin` to edit; individual entries use their own `artifact.read` / +`artifact.write` permissions. Insufficient permission returns **403**, and revoking a share also revokes tag access. + +## Use the Dashboard + +Start the Server and open its Overview page. In **Custom tags**: + +1. Select the exact Scope, target kind, family, and Artifact. For a Memory entry, also select its entry ID. +2. Enter one label per line and select **Save tags**. Saving an empty field clears that target's labels. +3. Enter labels under **Find by exact labels**. Choose **All** or **Any**, then select **Find targets**. +4. Select a result to edit its tags. **Include inactive** also returns inactive Memory entries and deprecated or retired + Artifacts. + +If another writer changes the labels, saving displays a conflict and preserves your input. Use **Reload tags** to read +the current state before deciding what to save. Reloading replaces the input field; copy any text you want to retain first. + +## Use the Python Client + +You need a running Server and an existing Scope and Artifact. Take their IDs from the Dashboard or the corresponding +Scope and Artifact APIs; a title is not an ID. Set these non-secret example variables in your terminal: + +```bash +export POWERCONTEXT_TAG_SCOPE='your-existing-scope-id' +export POWERCONTEXT_TAG_FAMILY='skill' +export POWERCONTEXT_TAG_ARTIFACT='your-existing-artifact-id' +``` + +Run this with `powercontext` installed. If the Server requires authentication, provide its bearer token through +`POWERCONTEXT_SERVER_AUTH_TOKEN`; do not embed it in the script. + +```python +import asyncio +import os + +from powercontext.client import PowerContextClient +from powercontext.http import QueryArtifactTagsRequest, ReplaceArtifactTagsRequest + + +async def main(): + scope = os.environ["POWERCONTEXT_TAG_SCOPE"] + family = os.environ["POWERCONTEXT_TAG_FAMILY"] + artifact = os.environ["POWERCONTEXT_TAG_ARTIFACT"] + async with PowerContextClient( + "http://127.0.0.1:8000", token=os.getenv("POWERCONTEXT_SERVER_AUTH_TOKEN") + ) as client: + current = await client.get_artifact_tags(scope, family, artifact) + if current is None: + raise RuntimeError("An unconditional read must return the current tag set") + saved = await client.replace_artifact_tags( + scope, family, artifact, + ReplaceArtifactTagsRequest.model_validate({"tags": ["customer-a", "release"]}), + expected_etag=current.etag, + ) + print(saved.tag_set.model_dump(mode="json")["tags"]) + matches = await client.query_artifact_tags( + scope, QueryArtifactTagsRequest.model_validate({"tags": ["CUSTOMER-A"]}) + ) + print([item.target.model_dump(mode="json") for item in matches.items]) + + +asyncio.run(main()) +``` + +The output contains the two saved labels and a matching target. For an entry, use `get_memory_entry_tags` and +`replace_memory_entry_tags` with `(scope_id, artifact_id, entry_id)`. Read the entry ID from the current Memory manifest +or a Memory citation, not from `entry_version_id`. A Scope can hold multiple Memory Artifacts; the existing scoped +Memory list and search operations address the runtime's designated Memory. + +## HTTP and retrieval filters + +| Method | Path | Purpose | +| --- | --- | --- | +| GET / PUT | `/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags` | Read or replace an Artifact's labels | +| GET / PUT | `/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags` | Read or replace a logical entry's labels | +| POST | `/v1/scopes/{scope_id}/artifact-tags/query` | Find tagged targets across families | + +PUT accepts `{"tags":["customer-a","release"]}` and requires the ETag returned by GET in `If-Match`. Missing `If-Match` +returns **428**; stale or wrong-target state returns **412**. An unchanged conditional GET returns **304**. +`tag_digest` describes the canonical tag set, but is not the HTTP mutation precondition. + +Artifact listing accepts repeated `tag` parameters and optional `tag_match=all|any`, for example +`/v1/scopes/{scope_id}/artifacts/skill?tag=release&tag=customer-a&tag_match=all`. +Supplying `tag_match` without `tag` is invalid. + +Memory entry listing and search accept this optional request field: + +```json +{"tag_filter":{"tags":["customer-a","release"],"match":"all"}} +``` + +Search filters entry tags, not the parent Memory Artifact's tags, and still returns only active entries. Full-text and +vector candidates are filtered in the database before candidate limits, fusion, and reranking. Tagged vector queries +use exact distance ordering over the eligible set on SQLite and OceanBase; this can cost more than an unfiltered +approximate search. A backend without tag-filter support rejects the request instead of silently post-filtering. + +Tag queries return exact current Artifact references or Memory citations, ordered by family, target type, Artifact ID, +and target ID. Pass `next_cursor` unchanged with the same filters, Scope, and caller. Cursors expire after one hour; +invalid or mismatched cursors return **400**, expired cursors **410**. Each page is internally consistent, but pagination +does not freeze a snapshot across requests. + +## Label rules and storage + +- A target holds at most 32 labels; a filter accepts 1–16 labels. +- Each label has 1–64 Unicode code points, no outer whitespace, and no control, surrogate, or unassigned characters. +- Matching uses NFC normalization followed by Unicode case folding. The submitted display spelling is retained. + Normalized duplicates such as `Straße` and `STRASSE` are rejected atomically. A normalized key may not exceed 128 + code points. +- Tags are Scope-local discovery metadata, not permissions or trusted instructions. They are not added to model prompts, + Skill package frontmatter, or publication/import payloads. Published copies start without the source target's tags. +- Inactive entries remain taggable while present in the current authoritative manifest. Rebuilding active search + projections does not remove their labels. + +All assignments live in `pc_artifact_tags`, with a foreign key to the owning Artifact head. The table retains the full +normalized key and indexes a 32-byte SHA-256 key fingerprint. This preserves the parent column lengths required by +OceanBase while keeping composite indexes below its 3072-byte limit. Matching checks both fingerprint and complete key. +Identical replacement preserves assignment timestamps. Existing Artifacts need no backfill; their initial tag sets are +empty. Include the table in backups and restore it after Artifact heads. + +See the Server's [HTTP API reference](/api) for complete request and response schemas. diff --git a/docs/en/docs/how-to/troubleshoot.md b/docs/en/docs/how-to/troubleshoot.md index 1fb9bb7b4..0112b6902 100644 --- a/docs/en/docs/how-to/troubleshoot.md +++ b/docs/en/docs/how-to/troubleshoot.md @@ -206,7 +206,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_artifact_candidate_heads,pc_memory_entry_heads' \ + --table 'pc_artifact_candidate_heads,pc_memory_entry_heads,pc_artifact_tags' \ -f ``` diff --git a/docs/zh/docs/how-to/full-capability-runtime.md b/docs/zh/docs/how-to/full-capability-runtime.md index 206e5da2b..aad9f2961 100644 --- a/docs/zh/docs/how-to/full-capability-runtime.md +++ b/docs/zh/docs/how-to/full-capability-runtime.md @@ -136,3 +136,5 @@ Codex 启动后发送普通 prompt。插件从绑定 Scope 召回内容,并把 | 已有数据消失 | 恢复原数据库 URL 或 `POWERCONTEXT_HOME` | 更多信息见[故障排查](troubleshoot.md)和[配置](../reference/configuration.md)。 + +需要分类和检索制品或单条记忆时,参见[自定义标签](manage-artifact-tags.md)。 diff --git a/docs/zh/docs/how-to/manage-artifact-tags.md b/docs/zh/docs/how-to/manage-artifact-tags.md new file mode 100644 index 000000000..bb202f2bf --- /dev/null +++ b/docs/zh/docs/how-to/manage-artifact-tags.md @@ -0,0 +1,124 @@ +--- +title: 用自定义标签管理制品 +description: 为逻辑制品和记忆条目设置标签,并通过精确标签进行检索。 +--- + +# 用自定义标签管理制品 + +Memory、Experience、Skill 和 Handoff 都可以在各自 Scope 内设置标签。一个 Memory 制品与其中的每条逻辑记忆分别拥有独立的标签集合。 +标签跟随逻辑 ID,不会修改内容 Revision、条目版本、血缘、向量或 Context Version。 + +开启访问控制时,标签遵循所属对象的读取与修改权限。只读分享者可以读取该对象的标签,不能修改标签或执行 Scope 级标签查询。 +跨对象查询需要 `scope.read`;Memory 制品整体标签需要 `scope.read` 才能读取、`scope.admin` 才能修改, +单条记忆的标签则使用该条目的 `artifact.read` / `artifact.write` 权限。权限不足返回 **403**,撤销分享后立即失去相应标签访问权限。 + +## 在 Dashboard 中使用 + +启动 Server,打开总览页的**自定义标签**面板: + +1. 选择准确的 Scope、标签对象类型、制品类型和制品。对记忆条目,还需要选择 entry ID。 +2. 每行输入一个标签,点击**保存标签**。清空输入框后保存,即可清除该对象的全部标签。 +3. 在精确查找输入框中填写标签,选择**全部匹配**或**任一匹配**,点击**查找对象**。 +4. 点击结果可以编辑对应对象。勾选**包含非活跃对象**,还能找到已停用的记忆条目和已弃用或退役的制品。 + +如果标签被其他操作修改,保存会提示冲突并保留输入。先点击**重新读取标签**,查看最新状态,再决定如何保存。 +重新读取会替换输入框内容;需要保留的文字请先复制。 + +## 使用 Python Client + +需要一个运行中的 Server,以及已有的 Scope 和制品。请从 Dashboard 或对应 API 获取 ID,不能用标题代替 ID。 +在终端设置以下非敏感参数: + +```bash +export POWERCONTEXT_TAG_SCOPE='已有的-scope-id' +export POWERCONTEXT_TAG_FAMILY='skill' +export POWERCONTEXT_TAG_ARTIFACT='已有的-artifact-id' +``` + +在已安装 `powercontext` 的环境中运行下面的代码。若 Server 开启认证,通过 `POWERCONTEXT_SERVER_AUTH_TOKEN` 提供 bearer token, +不要把凭据写进代码。 + +```python +import asyncio +import os + +from powercontext.client import PowerContextClient +from powercontext.http import QueryArtifactTagsRequest, ReplaceArtifactTagsRequest + + +async def main(): + scope = os.environ["POWERCONTEXT_TAG_SCOPE"] + family = os.environ["POWERCONTEXT_TAG_FAMILY"] + artifact = os.environ["POWERCONTEXT_TAG_ARTIFACT"] + async with PowerContextClient( + "http://127.0.0.1:8000", token=os.getenv("POWERCONTEXT_SERVER_AUTH_TOKEN") + ) as client: + current = await client.get_artifact_tags(scope, family, artifact) + if current is None: + raise RuntimeError("An unconditional read must return the current tag set") + saved = await client.replace_artifact_tags( + scope, family, artifact, + ReplaceArtifactTagsRequest.model_validate({"tags": ["customer-a", "release"]}), + expected_etag=current.etag, + ) + print(saved.tag_set.model_dump(mode="json")["tags"]) + matches = await client.query_artifact_tags( + scope, QueryArtifactTagsRequest.model_validate({"tags": ["CUSTOMER-A"]}) + ) + print([item.target.model_dump(mode="json") for item in matches.items]) + + +asyncio.run(main()) +``` + +输出应包含保存的两个标签和匹配的对象。对记忆条目,使用 `get_memory_entry_tags` 与 `replace_memory_entry_tags`, +传入 `(scope_id, artifact_id, entry_id)`。entry ID 来自当前 manifest 或 Memory citation,不是 `entry_version_id`。 +一个 Scope 可以有多个 Memory 制品;已有的 Scope 级记忆列表和检索接口操作的是运行时指定的 Memory。 + +## HTTP 接口与检索过滤 + +| Method | Path | 用途 | +| --- | --- | --- | +| GET / PUT | `/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags` | 读取或替换制品标签 | +| GET / PUT | `/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags` | 读取或替换逻辑记忆条目的标签 | +| POST | `/v1/scopes/{scope_id}/artifact-tags/query` | 跨制品类型精确查找标签对象 | + +PUT 请求体是 `{"tags":["customer-a","release"]}`,并且必须通过 `If-Match` 提交 GET 返回的 ETag。 +缺少 `If-Match` 返回 **428**;标签状态过期或 ETag 属于其他对象,返回 **412**。 +条件 GET 检测到标签未变化时返回 **304**。`tag_digest` 仅描述规范化的标签集合,不能代替 HTTP 写入使用的 ETag。 + +制品列表支持重复的 `tag` 参数,以及可选的 `tag_match=all|any`,例如: +`/v1/scopes/{scope_id}/artifacts/skill?tag=release&tag=customer-a&tag_match=all`。 +没有 `tag` 时不能单独传入 `tag_match`。 + +记忆条目列表和检索请求支持以下可选字段: + +```json +{"tag_filter":{"tags":["customer-a","release"],"match":"all"}} +``` + +记忆检索匹配的是条目自己的标签,不是所属 Memory 制品的标签,且仍然只返回活跃条目。 +全文与向量通道都在数据库候选集阶段过滤,之后才应用候选数量限制、融合与重排。 +SQLite 和 OceanBase 的带标签向量查询会对符合条件的集合进行精确距离排序,成本可能高于不带标签的近似搜索。 +不支持标签过滤的后端会明确拒绝请求,不会静默改成先截断再过滤。 + +标签查询返回当前的精确 Artifact 引用或 Memory citation,按制品类型、对象类型、制品 ID、对象 ID 排序。 +翻页时原样传回 `next_cursor`,并保持 Scope、过滤条件和调用方一致。游标有效期是一小时;无效或不匹配返回 **400**, +过期返回 **410**。单页内部保持一致,但跨页不固定数据库快照。 + +## 标签规则与存储 + +- 每个对象最多 32 个标签;一次过滤接受 1–16 个标签。 +- 每个标签包含 1–64 个 Unicode 码点,首尾不能有空白,不能包含控制字符、代理字符或未分配字符。 +- 匹配键由 NFC 规范化后再执行 Unicode case folding 得到,显示文字保留原始写法。 + `Straße` 和 `STRASSE` 等规范化后重复的标签会让整次请求失败;规范化键不能超过 128 个码点。 +- 标签是 Scope 内的检索元数据,不是权限或可信指令,不会进入模型提示词、Skill 包 frontmatter 或发布/导入内容。 + 发布后的副本不会继承源对象的标签。 +- 只要条目仍在当前权威 manifest 中,即使已经停用也可以维护标签。重建活跃搜索投影不会删除标签。 + +所有关联都存储在 `pc_artifact_tags` 一张表中,通过外键关联所属制品的 head。 +表内保留完整规范化键,并使用 32 字节 SHA-256 键摘要建立索引,以同时满足 OceanBase 的外键列长度要求和 3072 字节索引限制。 +匹配时同时校验摘要与完整键。相同集合的重复替换保留关联时间。已有制品无需回填,初始标签为空;备份时应包含此表, +恢复时安排在 Artifact heads 之后。 + +完整请求与响应结构见 Server 的 [HTTP API 参考](/api)。 diff --git a/docs/zh/docs/how-to/troubleshoot.md b/docs/zh/docs/how-to/troubleshoot.md index f6b0986c0..a1a6c7599 100644 --- a/docs/zh/docs/how-to/troubleshoot.md +++ b/docs/zh/docs/how-to/troubleshoot.md @@ -199,7 +199,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_artifact_candidate_heads,pc_memory_entry_heads' \ + --table 'pc_artifact_candidate_heads,pc_memory_entry_heads,pc_artifact_tags' \ -f ``` diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 666eea9d3..3366901d6 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -895,7 +895,12 @@ const OPERATIONS = { location: "query", scopeMode: "none", pathParameters: ["scope_id", "family"], - queryParams: ["limit", "cursor"], + queryParams: [ + "tag", + "tag_match", + "limit", + "cursor" + ], headerParams: [], successStatuses: [200], emptyStatuses: [] @@ -930,6 +935,77 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_tags: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-None-Match"], + successStatuses: [200, 304], + emptyStatuses: [304] + }, + replace_artifact_tags: { + method: "PUT", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", + location: "body", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [200], + emptyStatuses: [] + }, + get_memory_entry_tags: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "artifact_id", + "entry_id" + ], + queryParams: [], + headerParams: ["If-None-Match"], + successStatuses: [200, 304], + emptyStatuses: [304] + }, + replace_memory_entry_tags: { + method: "PUT", + path: "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + location: "body", + scopeMode: "none", + pathParameters: [ + "scope_id", + "artifact_id", + "entry_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [200], + emptyStatuses: [] + }, + query_artifact_tags: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifact-tags/query", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_artifact_revision: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", @@ -1121,7 +1197,7 @@ function queryString(payload) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(payload ?? {})) { if (value === void 0 || value === null) continue; - params.set(key, String(value)); + for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)); } const encoded = params.toString(); return encoded ? `?${encoded}` : ""; @@ -1229,7 +1305,8 @@ var PowerContextClient = class { kind: "json", value: null, status: response.status, - requestId + requestId, + etag: response.headers.get("ETag") ?? void 0 }; } if (id === "get_handoff_report" && payload?.download === true) return { @@ -1249,7 +1326,8 @@ var PowerContextClient = class { kind: "json", value: JSON.parse(Buffer.from(bytes).toString("utf8")), status: response.status, - requestId + requestId, + etag: response.headers.get("ETag") ?? void 0 }; } catch { throw new InvalidResponseError(spec.path, requestId); diff --git a/integrations/dsh/plugins/powercontext/src/client.ts b/integrations/dsh/plugins/powercontext/src/client.ts index a15d36984..17dafb91f 100644 --- a/integrations/dsh/plugins/powercontext/src/client.ts +++ b/integrations/dsh/plugins/powercontext/src/client.ts @@ -30,9 +30,9 @@ export type JsonObject = Record export type FetchFn = (input: string, init: RequestInit) => Promise export type ClientSuccess = - | { kind: 'json'; value: unknown; status: number; requestId: string | undefined } - | { kind: 'text'; value: string; status: number; requestId: string | undefined } - | { kind: 'bytes'; value: Uint8Array; status: number; requestId: string | undefined } + | { kind: 'json'; value: unknown; status: number; requestId: string | undefined; etag?: string } + | { kind: 'text'; value: string; status: number; requestId: string | undefined; etag?: string } + | { kind: 'bytes'; value: Uint8Array; status: number; requestId: string | undefined; etag?: string } export interface ClientOptions { baseUrl: string @@ -117,7 +117,7 @@ function queryString(payload: JsonObject | undefined): string { const params = new URLSearchParams() for (const [key, value] of Object.entries(payload ?? {})) { if (value === undefined || value === null) continue - params.set(key, String(value)) + for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)) } const encoded = params.toString() return encoded ? `?${encoded}` : '' @@ -257,7 +257,7 @@ export class PowerContextClient { } 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 } + return { kind: 'json', value: null, status: response.status, requestId, etag: response.headers.get('ETag') ?? undefined } } if (id === 'get_handoff_report' && payload?.download === true) { return { kind: 'bytes', value: bytes, status: response.status, requestId } @@ -266,7 +266,7 @@ export class PowerContextClient { return { kind: 'text', value: Buffer.from(bytes).toString('utf8'), status: response.status, requestId } } try { - return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId } + return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId, etag: response.headers.get('ETag') ?? undefined } } catch { throw new InvalidResponseError(spec.path, requestId) } diff --git a/integrations/dsh/plugins/powercontext/src/operations.generated.ts b/integrations/dsh/plugins/powercontext/src/operations.generated.ts index dae686780..200ced785 100644 --- a/integrations/dsh/plugins/powercontext/src/operations.generated.ts +++ b/integrations/dsh/plugins/powercontext/src/operations.generated.ts @@ -90,9 +90,14 @@ export const OPERATIONS = { 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: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['tag','tag_match','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_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_artifact_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_memory_entry_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + query_artifact_tags: { method: 'POST', path: '/v1/scopes/{scope_id}/artifact-tags/query', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, check_access: { method: 'POST', path: '/v1/access/check', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/dsh/plugins/powercontext/tests/client.spec.ts b/integrations/dsh/plugins/powercontext/tests/client.spec.ts index b9e838a7c..5c1471c9c 100644 --- a/integrations/dsh/plugins/powercontext/tests/client.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/client.spec.ts @@ -29,6 +29,30 @@ function jsonResponse(status: number, body: unknown, headers?: Record { + it('preserves repeated tag filters and the opaque ETag for conditional tag writes', async () => { + const requests: Array<{ url: string; init: RequestInit }> = [] + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + requestTimeoutMs: 1000, + fetch: async (url, init) => { + requests.push({ url, init }) + return new Response(JSON.stringify({ tags: ['Release'] }), { + status: 200, headers: { ETag: '"opaque-tag-token"' }, + }) + }, + }) + const target = { scope_id: 'project', family: 'memory', artifact_id: 'memory' } + await client.request('list_artifacts', { ...target, tag: ['Release', '客户A'], tag_match: 'all' }) + const listed = new URL(requests[0]!.url) + expect(listed.searchParams.getAll('tag')).toEqual(['Release', '客户A']) + expect(listed.searchParams.get('tag_match')).toBe('all') + const current = await client.request('get_artifact_tags', target) + expect(current.etag).toBe('"opaque-tag-token"') + await client.request('replace_artifact_tags', { ...target, tags: [], if_match: current.etag }) + expect(new Headers(requests[2]!.init.headers).get('If-Match')).toBe(current.etag) + expect(JSON.parse(String(requests[2]!.init.body))).toEqual({ tags: [] }) + }) + it('keeps the User-Agent version aligned with package.json', () => { const manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')) expect(PLUGIN_VERSION).toBe(manifest.version) diff --git a/integrations/opencode/plugins/powercontext/lib/index.js b/integrations/opencode/plugins/powercontext/lib/index.js index edd2f717c..689cf4353 100644 --- a/integrations/opencode/plugins/powercontext/lib/index.js +++ b/integrations/opencode/plugins/powercontext/lib/index.js @@ -880,7 +880,12 @@ const OPERATIONS = { location: "query", scopeMode: "none", pathParameters: ["scope_id", "family"], - queryParams: ["limit", "cursor"], + queryParams: [ + "tag", + "tag_match", + "limit", + "cursor" + ], headerParams: [], successStatuses: [200], emptyStatuses: [] @@ -915,6 +920,77 @@ const OPERATIONS = { successStatuses: [200], emptyStatuses: [] }, + get_artifact_tags: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-None-Match"], + successStatuses: [200, 304], + emptyStatuses: [304] + }, + replace_artifact_tags: { + method: "PUT", + path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", + location: "body", + scopeMode: "none", + pathParameters: [ + "scope_id", + "family", + "artifact_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [200], + emptyStatuses: [] + }, + get_memory_entry_tags: { + method: "GET", + path: "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + location: null, + scopeMode: "none", + pathParameters: [ + "scope_id", + "artifact_id", + "entry_id" + ], + queryParams: [], + headerParams: ["If-None-Match"], + successStatuses: [200, 304], + emptyStatuses: [304] + }, + replace_memory_entry_tags: { + method: "PUT", + path: "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + location: "body", + scopeMode: "none", + pathParameters: [ + "scope_id", + "artifact_id", + "entry_id" + ], + queryParams: [], + headerParams: ["If-Match"], + successStatuses: [200], + emptyStatuses: [] + }, + query_artifact_tags: { + method: "POST", + path: "/v1/scopes/{scope_id}/artifact-tags/query", + location: "body", + scopeMode: "none", + pathParameters: ["scope_id"], + queryParams: [], + headerParams: [], + successStatuses: [200], + emptyStatuses: [] + }, get_artifact_revision: { method: "GET", path: "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", @@ -1096,7 +1172,10 @@ async function readLimitedBody(response) { } function queryString(payload) { const params = new URLSearchParams(); - for (const [key, value] of Object.entries(payload ?? {})) if (value !== void 0 && value !== null) params.set(key, String(value)); + for (const [key, value] of Object.entries(payload ?? {})) { + if (value === void 0 || value === null) continue; + for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)); + } const encoded = params.toString(); return encoded ? `?${encoded}` : ""; } @@ -1176,7 +1255,8 @@ var PowerContextClient = class { kind: "json", value: null, status: response.status, - requestId + requestId, + etag: response.headers.get("ETag") ?? void 0 }; } try { @@ -1184,7 +1264,8 @@ var PowerContextClient = class { kind: "json", value: JSON.parse(Buffer.from(bytes).toString("utf8")), status: response.status, - requestId + requestId, + etag: response.headers.get("ETag") ?? void 0 }; } catch { throw new InvalidResponseError(spec.path, requestId); diff --git a/integrations/opencode/plugins/powercontext/src/client.ts b/integrations/opencode/plugins/powercontext/src/client.ts index 6ebbfebd7..e6a216bbf 100644 --- a/integrations/opencode/plugins/powercontext/src/client.ts +++ b/integrations/opencode/plugins/powercontext/src/client.ts @@ -27,7 +27,7 @@ import { OPERATIONS, type OperationId, type OperationSpec } from './operations.g export type JsonObject = Record export type FetchFn = (input: string, init: RequestInit) => Promise -export type ClientSuccess = { kind: 'json'; value: unknown; status: number; requestId: string | undefined } +export type ClientSuccess = { kind: 'json'; value: unknown; status: number; requestId: string | undefined; etag?: string } export interface ClientOptions { baseUrl: string @@ -110,7 +110,8 @@ async function readLimitedBody(response: Response): Promise { function queryString(payload: JsonObject | undefined): string { const params = new URLSearchParams() for (const [key, value] of Object.entries(payload ?? {})) { - if (value !== undefined && value !== null) params.set(key, String(value)) + if (value === undefined || value === null) continue + for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)) } const encoded = params.toString() return encoded ? `?${encoded}` : '' @@ -208,10 +209,10 @@ export class PowerContextClient { } 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 } + return { kind: 'json', value: null, status: response.status, requestId, etag: response.headers.get('ETag') ?? undefined } } try { - return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId } + return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId, etag: response.headers.get('ETag') ?? undefined } } catch { throw new InvalidResponseError(spec.path, requestId) } diff --git a/integrations/opencode/plugins/powercontext/src/operations.generated.ts b/integrations/opencode/plugins/powercontext/src/operations.generated.ts index dae686780..200ced785 100644 --- a/integrations/opencode/plugins/powercontext/src/operations.generated.ts +++ b/integrations/opencode/plugins/powercontext/src/operations.generated.ts @@ -90,9 +90,14 @@ export const OPERATIONS = { 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: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['tag','tag_match','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_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_artifact_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_memory_entry_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + query_artifact_tags: { method: 'POST', path: '/v1/scopes/{scope_id}/artifact-tags/query', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, check_access: { method: 'POST', path: '/v1/access/check', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/opencode/plugins/powercontext/tests/client.spec.ts b/integrations/opencode/plugins/powercontext/tests/client.spec.ts index 27ca9f9b2..d0bdd7f36 100644 --- a/integrations/opencode/plugins/powercontext/tests/client.spec.ts +++ b/integrations/opencode/plugins/powercontext/tests/client.spec.ts @@ -28,6 +28,30 @@ function clientFor(response: Response): PowerContextClient { } describe('PowerContextClient response limits', () => { + it('preserves repeated tag filters and the opaque ETag for conditional tag writes', async () => { + const requests: Array<{ url: string; init: RequestInit }> = [] + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + requestTimeoutMs: 1000, + fetch: async (url, init) => { + requests.push({ url, init }) + return new Response(JSON.stringify({ tags: ['Release'] }), { + status: 200, headers: { ETag: '"opaque-tag-token"' }, + }) + }, + }) + const target = { scope_id: 'project', family: 'memory', artifact_id: 'memory' } + await client.request('list_artifacts', { ...target, tag: ['Release', '客户A'], tag_match: 'all' }) + const listed = new URL(requests[0]!.url) + expect(listed.searchParams.getAll('tag')).toEqual(['Release', '客户A']) + expect(listed.searchParams.get('tag_match')).toBe('all') + const current = await client.request('get_artifact_tags', target) + expect(current.etag).toBe('"opaque-tag-token"') + await client.request('replace_artifact_tags', { ...target, tags: [], if_match: current.etag }) + expect(new Headers(requests[2]!.init.headers).get('If-Match')).toBe(current.etag) + expect(JSON.parse(String(requests[2]!.init.body))).toEqual({ tags: [] }) + }) + it('forwards authorization and preserves Access denial details', async () => { const client = new PowerContextClient({ baseUrl: 'http://127.0.0.1:8000', diff --git a/integrations/pi/plugins/powercontext/src/client.ts b/integrations/pi/plugins/powercontext/src/client.ts index 329e59d4b..4367c8d18 100644 --- a/integrations/pi/plugins/powercontext/src/client.ts +++ b/integrations/pi/plugins/powercontext/src/client.ts @@ -28,7 +28,7 @@ import { OPERATIONS, type OperationId, type OperationSpec } from './operations.g export type JsonObject = Record export type FetchFn = (input: string, init: RequestInit) => Promise -export type ClientSuccess = { kind: 'json'; value: unknown; status: number; requestId: string | undefined } +export type ClientSuccess = { kind: 'json'; value: unknown; status: number; requestId: string | undefined; etag?: string } export interface ClientOptions { baseUrl: string @@ -115,7 +115,8 @@ function decodeError(bytes: Uint8Array): { code?: string; message?: string } { function queryString(payload: JsonObject | undefined): string { const params = new URLSearchParams() for (const [key, value] of Object.entries(payload ?? {})) { - if (value !== undefined && value !== null) params.set(key, String(value)) + if (value === undefined || value === null) continue + for (const item of Array.isArray(value) ? value : [value]) params.append(key, String(item)) } const encoded = params.toString() return encoded ? `?${encoded}` : '' @@ -254,10 +255,10 @@ export class PowerContextClient { } 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 } + return { kind: 'json', value: null, status: response.status, requestId, etag: response.headers.get('ETag') ?? undefined } } try { - return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId } + return { kind: 'json', value: JSON.parse(Buffer.from(bytes).toString('utf8')), status: response.status, requestId, etag: response.headers.get('ETag') ?? undefined } } catch { throw new InvalidResponseError(spec.path, requestId) } diff --git a/integrations/pi/plugins/powercontext/src/operations.generated.ts b/integrations/pi/plugins/powercontext/src/operations.generated.ts index dae686780..200ced785 100644 --- a/integrations/pi/plugins/powercontext/src/operations.generated.ts +++ b/integrations/pi/plugins/powercontext/src/operations.generated.ts @@ -90,9 +90,14 @@ export const OPERATIONS = { 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: [] }, + list_artifacts: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}', location: "query", scopeMode: 'none', pathParameters: ['scope_id', 'family'], queryParams: ['tag','tag_match','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_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_artifact_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + get_memory_entry_tags: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-None-Match'], successStatuses: [200,304], emptyStatuses: [304] }, + replace_memory_entry_tags: { method: 'PUT', path: '/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags', location: "body", scopeMode: 'none', pathParameters: ['scope_id', 'artifact_id', 'entry_id'], queryParams: [], headerParams: ['If-Match'], successStatuses: [200], emptyStatuses: [] }, + query_artifact_tags: { method: 'POST', path: '/v1/scopes/{scope_id}/artifact-tags/query', location: "body", scopeMode: 'none', pathParameters: ['scope_id'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_artifact_revision: { method: 'GET', path: '/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}', location: null, scopeMode: 'none', pathParameters: ['scope_id', 'family', 'artifact_id', 'revision'], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, get_access_principal: { method: 'GET', path: '/v1/access/me', location: null, scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, check_access: { method: 'POST', path: '/v1/access/check', location: "body", scopeMode: 'none', pathParameters: [], queryParams: [], headerParams: [], successStatuses: [200], emptyStatuses: [] }, diff --git a/integrations/pi/plugins/powercontext/tests/client.spec.ts b/integrations/pi/plugins/powercontext/tests/client.spec.ts index 2d5de2530..9a07db57e 100644 --- a/integrations/pi/plugins/powercontext/tests/client.spec.ts +++ b/integrations/pi/plugins/powercontext/tests/client.spec.ts @@ -18,6 +18,30 @@ import { describe, expect, it, vi } from 'vitest' import { PowerContextClient } from '../src/client.ts' describe('PowerContext Pi HTTP client', () => { + it('preserves repeated tag filters and the opaque ETag for conditional tag writes', async () => { + const requests: Array<{ url: string; init: RequestInit }> = [] + const client = new PowerContextClient({ + baseUrl: 'http://127.0.0.1:8000', + requestTimeoutMs: 1000, + fetch: async (url, init) => { + requests.push({ url, init }) + return new Response(JSON.stringify({ tags: ['Release'] }), { + status: 200, headers: { ETag: '"opaque-tag-token"' }, + }) + }, + }) + const target = { scope_id: 'project', family: 'memory', artifact_id: 'memory' } + await client.request('list_artifacts', { ...target, tag: ['Release', '客户A'], tag_match: 'all' }) + const listed = new URL(requests[0]!.url) + expect(listed.searchParams.getAll('tag')).toEqual(['Release', '客户A']) + expect(listed.searchParams.get('tag_match')).toBe('all') + const current = await client.request('get_artifact_tags', target) + expect(current.etag).toBe('"opaque-tag-token"') + await client.request('replace_artifact_tags', { ...target, tags: [], if_match: current.etag }) + expect(new Headers(requests[2]!.init.headers).get('If-Match')).toBe(current.etag) + expect(JSON.parse(String(requests[2]!.init.body))).toEqual({ tags: [] }) + }) + it('binds scope resource paths and omits path values from the request body', async () => { const requests: Array<{ url: string; init: RequestInit }> = [] const client = new PowerContextClient({ diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index 51b6cd936..1876b89a7 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -2531,6 +2531,20 @@ paths: operationId: list_artifacts x-powercontext-access: {action: scope.read, resource: {type: scope, scope-id-from: scope_id}} parameters: + - name: tag + in: query + required: false + style: form + explode: true + schema: + type: array + minItems: 1 + maxItems: 16 + items: {type: string, minLength: 1, maxLength: 64} + - name: tag_match + in: query + required: false + schema: {$ref: '#/components/schemas/TagMatch'} - name: scope_id in: path required: true @@ -2719,6 +2733,251 @@ paths: $ref: "#/components/responses/Unavailable" "500": $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags: + get: + tags: [artifact-tags] + summary: Read Artifact tags + description: Scope-local labels follow logical identity without changing content revisions. Inactive manifest entries remain valid targets. + operationId: get_artifact_tags + x-powercontext-access: {resolver: path_artifact_read_access} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256} + - name: family + in: path + required: true + schema: {$ref: '#/components/schemas/BaseArtifactFamily'} + - name: artifact_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + - name: If-None-Match + in: header + required: false + schema: {type: string, minLength: 1} + responses: + "200": + description: Complete current target-local tag set. + headers: + ETag: {schema: {type: string}, description: Opaque target-bound tag state validator.} + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactTagSet'} + "304": + description: The target tag set has not changed. + headers: + ETag: {schema: {type: string}} + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [artifact-tags] + summary: Replace Artifact tags + description: Scope-local labels follow logical identity without changing content revisions. Inactive manifest entries remain valid targets. + operationId: replace_artifact_tags + x-powercontext-access: {resolver: path_artifact_tags_write_access} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256} + - name: family + in: path + required: true + schema: {$ref: '#/components/schemas/BaseArtifactFamily'} + - name: artifact_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + - name: If-Match + in: header + required: true + schema: {type: string, minLength: 1} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplaceArtifactTagsRequest'} + responses: + "200": + description: Complete current target-local tag set. + headers: + ETag: {schema: {type: string}, description: Opaque target-bound tag state validator.} + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactTagSet'} + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags: + get: + tags: [artifact-tags] + summary: Read Memory entry tags + description: Scope-local labels follow logical identity without changing content revisions. Inactive manifest entries remain valid targets. + operationId: get_memory_entry_tags + x-powercontext-access: {resolver: path_memory_entry_read_access} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256} + - name: artifact_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + - name: entry_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + - name: If-None-Match + in: header + required: false + schema: {type: string, minLength: 1} + responses: + "200": + description: Complete current target-local tag set. + headers: + ETag: {schema: {type: string}, description: Opaque target-bound tag state validator.} + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactTagSet'} + "304": + description: The target tag set has not changed. + headers: + ETag: {schema: {type: string}} + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + put: + tags: [artifact-tags] + summary: Replace Memory entry tags + description: Scope-local labels follow logical identity without changing content revisions. Inactive manifest entries remain valid targets. + operationId: replace_memory_entry_tags + x-powercontext-access: {resolver: path_memory_entry_write_access} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256} + - name: artifact_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + - name: entry_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 128} + - name: If-Match + in: header + required: true + schema: {type: string, minLength: 1} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplaceArtifactTagsRequest'} + responses: + "200": + description: Complete current target-local tag set. + headers: + ETag: {schema: {type: string}, description: Opaque target-bound tag state validator.} + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactTagSet'} + "412": + $ref: "#/components/responses/PreconditionFailed" + "428": + $ref: "#/components/responses/PreconditionRequired" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/InvalidRequest" + "503": + $ref: "#/components/responses/Unavailable" + "500": + $ref: "#/components/responses/InternalError" + /v1/scopes/{scope_id}/artifact-tags/query: + post: + tags: [artifact-tags] + summary: Query targets by exact custom tags + description: Match all or any normalized labels within one Scope before pagination. Tags never grant visibility or enter model prompts. + operationId: query_artifact_tags + x-powercontext-access: {resolver: path_scope_read_access} + parameters: + - name: scope_id + in: path + required: true + schema: {type: string, minLength: 1, maxLength: 256} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryArtifactTagsRequest'} + responses: + "200": + description: Current visible matches in family, target type, Artifact ID, and target ID order. + content: + application/json: + schema: {$ref: '#/components/schemas/ArtifactTagPage'} + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "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}/revisions/{revision}: get: tags: [artifacts] @@ -6200,6 +6459,8 @@ components: additionalProperties: false required: [scope_id] properties: + tag_filter: + $ref: '#/components/schemas/TagFilter' scope_id: type: string minLength: 1 @@ -6659,6 +6920,8 @@ components: additionalProperties: false required: [scope_id, query] properties: + tag_filter: + $ref: '#/components/schemas/TagFilter' scope_id: type: string minLength: 1 @@ -6842,10 +7105,146 @@ components: default: content content: description: JSON value persisted by the built-in content Source adapter. + TagMatch: + type: string + enum: [all, any] + TagTargetType: + type: string + enum: [artifact, memory_entry] + ArtifactTagTarget: + type: object + additionalProperties: false + required: [type, family, artifact_id] + properties: + type: {type: string, enum: [artifact]} + family: {$ref: '#/components/schemas/BaseArtifactFamily'} + artifact_id: {type: string, minLength: 1, maxLength: 128} + MemoryEntryTagTarget: + type: object + additionalProperties: false + required: [type, family, artifact_id, entry_id] + properties: + type: {type: string, enum: [memory_entry]} + family: {type: string, enum: [memory]} + artifact_id: {type: string, minLength: 1, maxLength: 128} + entry_id: {type: string, minLength: 1, maxLength: 128} + TagTarget: + oneOf: + - $ref: '#/components/schemas/ArtifactTagTarget' + - $ref: '#/components/schemas/MemoryEntryTagTarget' + discriminator: + propertyName: type + mapping: + artifact: '#/components/schemas/ArtifactTagTarget' + memory_entry: '#/components/schemas/MemoryEntryTagTarget' + TagFilter: + type: object + additionalProperties: false + required: [tags] + properties: + tags: + type: array + minItems: 1 + maxItems: 16 + items: {type: string, minLength: 1, maxLength: 64} + match: + $ref: '#/components/schemas/TagMatch' + default: all + ReplaceArtifactTagsRequest: + type: object + additionalProperties: false + required: [tags] + description: Replace all labels atomically. Empty clears the set. Labels preserve display text, use NFC then casefold for exact matching, and reject normalized duplicates, outer whitespace, and Unicode control, surrogate or unassigned characters. + properties: + tags: + type: array + minItems: 0 + maxItems: 32 + items: {type: string, minLength: 1, maxLength: 64} + QueryArtifactTagsRequest: + type: object + additionalProperties: false + required: [tags] + properties: + tags: + type: array + minItems: 1 + maxItems: 16 + items: {type: string, minLength: 1, maxLength: 64} + match: + $ref: '#/components/schemas/TagMatch' + default: all + families: + type: array + minItems: 1 + maxItems: 4 + uniqueItems: true + items: {$ref: '#/components/schemas/BaseArtifactFamily'} + target_types: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: {$ref: '#/components/schemas/TagTargetType'} + include_inactive: {type: boolean, default: false} + limit: {type: integer, minimum: 1, maximum: 100, default: 50} + cursor: {type: string, minLength: 1, maxLength: 4096, nullable: true} + ArtifactTagSet: + type: object + additionalProperties: false + required: [scope_id, target, tags, tag_digest] + properties: + scope_id: {type: string} + target: {$ref: '#/components/schemas/TagTarget'} + tags: + type: array + minItems: 0 + maxItems: 32 + items: {type: string, minLength: 1, maxLength: 64} + tag_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + description: Digest of canonical display labels; informational, not a mutation precondition. + TaggedTarget: + type: object + additionalProperties: false + required: [scope_id, target, tags, tag_digest, reference] + properties: + scope_id: {type: string} + target: {$ref: '#/components/schemas/TagTarget'} + tags: + type: array + minItems: 0 + maxItems: 32 + items: {type: string, minLength: 1, maxLength: 64} + tag_digest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + description: Digest of canonical display labels; informational, not a mutation precondition. + reference: + oneOf: + - $ref: '#/components/schemas/ArtifactReference' + - $ref: '#/components/schemas/MemoryCitation' + ArtifactTagPage: + type: object + additionalProperties: false + required: [items, next_cursor] + properties: + items: + type: array + items: {$ref: '#/components/schemas/TaggedTarget'} + next_cursor: {type: string, nullable: true} ListArtifactsRequest: type: object additionalProperties: false properties: + tag: + type: array + minItems: 1 + maxItems: 16 + items: {type: string, minLength: 1, maxLength: 64} + tag_match: + $ref: '#/components/schemas/TagMatch' limit: type: integer minimum: 1 diff --git a/src/powercontext/builtin/artifacts/memory/models.py b/src/powercontext/builtin/artifacts/memory/models.py index 8e1dda78e..f90bfd9f4 100644 --- a/src/powercontext/builtin/artifacts/memory/models.py +++ b/src/powercontext/builtin/artifacts/memory/models.py @@ -47,6 +47,7 @@ class MemoryCapabilities(BaseModel): fts: bool vector: bool = False hybrid: bool = False + tag_filter: bool = False embedding_profile: EmbeddingProfile | None = None diff --git a/src/powercontext/builtin/artifacts/memory/protocols.py b/src/powercontext/builtin/artifacts/memory/protocols.py index d0039dba5..c79303fcb 100644 --- a/src/powercontext/builtin/artifacts/memory/protocols.py +++ b/src/powercontext/builtin/artifacts/memory/protocols.py @@ -34,6 +34,7 @@ MemoryUsedSearchMode, ) from powercontext.builtin.inference import EmbeddingModel, EmbeddingVector +from powercontext.builtin.tags import TagFilter from powercontext.sources import Source @@ -81,6 +82,7 @@ class MemorySearchRequest(BaseModel): mode: MemoryUsedSearchMode query_vector: EmbeddingVector | None = None embedding_profile: EmbeddingProfile | None = None + tag_filter: TagFilter | None = None class MemorySearchChannels(BaseModel): @@ -109,6 +111,11 @@ async def commit(self, value: MemoryCommit, /) -> Memory: class MemoryBackend(Protocol): + async def tagged_entry_ids(self, memory: ArtifactRef, tag_filter: TagFilter) -> frozenset[str]: + """Return exact tag matches without imposing a candidate limit.""" + + ... + """Storage and retrieval capabilities required by the Memory Family.""" async def capabilities(self) -> MemoryCapabilities: diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index ea22f251f..cc76e8f7e 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -84,6 +84,7 @@ InferenceTimeoutError, InferenceUnavailableError, ) +from powercontext.builtin.tags import TagFilter from powercontext.errors import RevisionConflictError from powercontext.sources import Source, SourceRef @@ -136,6 +137,11 @@ def __init__(self, code: str) -> None: super().__init__(messages[code]) +def _require_tag_filter(capabilities: MemoryCapabilities, tag_filter: TagFilter | None) -> None: + if tag_filter is not None and not capabilities.tag_filter: + raise CapabilityNotSupportedError("tag-filter") + + class MemoryService: """Validate and orchestrate Memory operations without exposing storage details.""" @@ -380,6 +386,7 @@ async def search( memories: Sequence[Memory], limit: int = 10, mode: MemorySearchMode = "auto", + tag_filter: TagFilter | None = None, ) -> MemorySearchResult: """Search explicit current Memory heads with capability-safe fallback.""" @@ -397,6 +404,7 @@ async def search( selected_memories = tuple(memory.as_ref() for memory in selected) await self._validate_search_heads(selected) capabilities = await self._backend.capabilities() + _require_tag_filter(capabilities, tag_filter) selected_mode = await self._select_search_mode( mode, memories=selected_memories, @@ -434,6 +442,7 @@ async def search( mode=selected_mode, query_vector=query_vector, embedding_profile=profile, + tag_filter=tag_filter, ) channels = await self._backend.search(request) admitted_fts = admit_fts_candidates(normalized_query, channels.fts) @@ -505,11 +514,18 @@ async def expand( ) return versions - async def entries(self, memory: Memory, /) -> tuple[MemoryEntryVersion, ...]: + async def entries( + self, memory: Memory, /, *, tag_filter: TagFilter | None = None + ) -> tuple[MemoryEntryVersion, ...]: """Return the entry objects referenced by one exact current Memory head.""" canonical = await self._canonical_memory(memory) - return await self._validated_entries(canonical) + entries = await self._validated_entries(canonical) + if tag_filter is None: + return entries + _require_tag_filter(await self._backend.capabilities(), tag_filter) + matching = await self._backend.tagged_entry_ids(canonical.as_ref(), tag_filter) + return tuple(entry for entry in entries if entry.entry_id in matching) async def rebuild_projections(self, embedding_model: EmbeddingModel | None = None, /) -> None: """Rebuild current-head search projections from authoritative Memory revisions.""" diff --git a/src/powercontext/builtin/persistence/database.py b/src/powercontext/builtin/persistence/database.py index 0fd1ff7a8..355fc92f6 100644 --- a/src/powercontext/builtin/persistence/database.py +++ b/src/powercontext/builtin/persistence/database.py @@ -32,7 +32,7 @@ class AsyncDatabase: object. Closing an attached database leaves the caller's engine available. """ - def __init__(self, engine: AsyncEngine, *, owns_engine: bool) -> None: + def __init__(self, engine: AsyncEngine, *, owns_engine: bool, shared_connection: bool = False) -> None: self._engine = engine self._owns_engine = owns_engine self._closed = False @@ -40,6 +40,11 @@ def __init__(self, engine: AsyncEngine, *, owns_engine: bool) -> None: self._active_transactions = 0 self._state_changed = asyncio.Condition() self._close_lock = asyncio.Lock() + # In-memory SQLite shares one physical connection. Its transactions + # cannot overlap, including read snapshots used by tag pagination. + self._shared_connection_lock = asyncio.Lock() if shared_connection else None + self._transaction_owner: asyncio.Task[object] | None = None + self._shared_connection: AsyncConnection | None = None @classmethod def attach(cls, engine: AsyncEngine, /) -> AsyncDatabase: @@ -48,10 +53,10 @@ def attach(cls, engine: AsyncEngine, /) -> AsyncDatabase: return cls(engine, owns_engine=False) @classmethod - def own(cls, engine: AsyncEngine, /) -> AsyncDatabase: + def own(cls, engine: AsyncEngine, /, *, shared_connection: bool = False) -> AsyncDatabase: """Take disposal ownership of an already configured async engine.""" - return cls(engine, owns_engine=True) + return cls(engine, owns_engine=True, shared_connection=shared_connection) @property def engine(self) -> AsyncEngine: @@ -63,13 +68,27 @@ def engine(self) -> AsyncEngine: async def transaction(self) -> AsyncIterator[AsyncConnection]: """Yield a connection in a transaction owned by the calling use case.""" + owner = asyncio.current_task() + if self._shared_connection is not None and self._transaction_owner is owner: + # Nested lookups on a single-connection profile must join their + # caller's transaction, not acquire or commit that connection again. + yield self._shared_connection + return async with self._state_changed: if self._closed or self._closing: raise DatabaseClosedError self._active_transactions += 1 try: - async with self._engine.begin() as connection: - yield connection + guard = self._shared_connection_lock if self._shared_connection_lock is not None else nullcontext() + async with guard, self._engine.begin() as connection: + if self._shared_connection_lock is not None: + self._transaction_owner = owner + self._shared_connection = connection + try: + yield connection + finally: + self._transaction_owner = None + self._shared_connection = None finally: async with self._state_changed: self._active_transactions -= 1 diff --git a/src/powercontext/builtin/persistence/memory.py b/src/powercontext/builtin/persistence/memory.py index 0660ec450..0a2bc7941 100644 --- a/src/powercontext/builtin/persistence/memory.py +++ b/src/powercontext/builtin/persistence/memory.py @@ -63,6 +63,8 @@ MEMORY_ENTRY_HEADS_TABLE, MEMORY_ENTRY_VERSIONS_TABLE, ) +from powercontext.builtin.persistence.tags import tag_predicate +from powercontext.builtin.tags import TagFilter from powercontext.errors import ArtifactNotFoundError from powercontext.sources import SourceRef @@ -154,6 +156,28 @@ async def latest(self, artifact_id: str, /) -> Memory: raise ArtifactNotFoundError(artifact_id) from None return _require_memory(artifact) + async def tagged_entry_ids(self, memory: ArtifactRef, tag_filter: TagFilter) -> frozenset[str]: + canonical = await self.get(memory) + versions = tuple(entry.entry_version_id for entry in canonical.content.manifest.entries) + table = MEMORY_ENTRY_VERSIONS_TABLE + async with self._database.connection(self._bound_connection) as connection: + rows = await connection.scalars( + select(table.c.entry_id).where( + table.c.scope_id == self._scope_id, + table.c.memory_artifact_id == memory.artifact_id, + table.c.entry_version_id.in_(versions), + tag_predicate( + self._scope_id, + "memory", + table.c.memory_artifact_id, + "memory_entry", + table.c.entry_id, + tag_filter, + ), + ) + ) + return frozenset(rows) + async def entries(self, memory: ArtifactRef, /) -> tuple[MemoryEntryVersion, ...]: canonical = await self.get(memory) version_ids = tuple(item.entry_version_id for item in canonical.content.manifest.entries) diff --git a/src/powercontext/builtin/persistence/memory_index.py b/src/powercontext/builtin/persistence/memory_index.py index f6fa9af9e..d1f4b3806 100644 --- a/src/powercontext/builtin/persistence/memory_index.py +++ b/src/powercontext/builtin/persistence/memory_index.py @@ -161,6 +161,7 @@ def __init__(self, *indexes: MemoryIndex) -> None: vector=bool(vector_indexes), hybrid=fts and bool(vector_indexes), embedding_profile=profile, + tag_filter=all(index.capabilities.tag_filter for index in indexes), ) self.tables = tuple(table for index in indexes for table in index.tables) diff --git a/src/powercontext/builtin/persistence/oceanbase/memory_index.py b/src/powercontext/builtin/persistence/oceanbase/memory_index.py index d1dc5b81f..df277e9bb 100644 --- a/src/powercontext/builtin/persistence/oceanbase/memory_index.py +++ b/src/powercontext/builtin/persistence/oceanbase/memory_index.py @@ -50,6 +50,7 @@ MEMORY_ENTRY_VERSIONS_TABLE, identity_string, ) +from powercontext.builtin.persistence.tags import memory_tag_parameters, memory_tag_sql, tag_predicate from powercontext.limits import MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH _OCEANBASE_FTS_INDEX_NAME = "ix_pc_memory_entry_heads_fts" @@ -100,7 +101,7 @@ class OceanBaseMemoryFTSIndex: """OceanBase FULLTEXT strategy over the relational active-head projection.""" - capabilities = MemoryCapabilities(fts=True) + capabilities = MemoryCapabilities(fts=True, tag_filter=True) tables: tuple[Table, ...] = () async def initialize(self, connection: AsyncConnection, /) -> None: @@ -159,6 +160,20 @@ async def search( tuple(memory.artifact_id for memory in request.memories) ), score, + *( + () + if request.tag_filter is None + else ( + tag_predicate( + scope_id, + "memory", + MEMORY_ENTRY_HEADS_TABLE.c.memory_artifact_id, + "memory_entry", + MEMORY_ENTRY_HEADS_TABLE.c.entry_id, + request.tag_filter, + ), + ) + ), ) .order_by( score.desc(), @@ -204,6 +219,7 @@ def __init__(self, profile: EmbeddingProfile) -> None: ) self.profile = profile self.capabilities = MemoryCapabilities( + tag_filter=True, fts=False, vector=True, embedding_profile=profile, @@ -238,6 +254,16 @@ def __init__(self, profile: EmbeddingProfile) -> None: bindparam("memory_artifact_ids", expanding=True), bindparam("query_vector", type_=VECTOR(profile.dimension)), ) + self._tagged_search_sql = text( + _OCEANBASE_VECTOR_SEARCH_SQL.replace("ORDER BY", memory_tag_sql("m") + "ORDER BY").replace( + " APPROXIMATE", "" + ) + ).bindparams( + bindparam("memory_artifact_ids", expanding=True), + bindparam("tag_keys", expanding=True), + bindparam("tag_hashes", expanding=True), + bindparam("query_vector", type_=VECTOR(profile.dimension)), + ) async def initialize(self, connection: AsyncConnection, /) -> None: if connection.dialect.name != "mysql": @@ -304,7 +330,7 @@ async def search( raise CapabilityNotSupportedError("vector") rows = ( await connection.execute( - self._search_sql, + self._search_sql if request.tag_filter is None else self._tagged_search_sql, { "scope_id": scope_id, "memory_artifact_ids": tuple(memory.artifact_id for memory in request.memories), @@ -313,6 +339,7 @@ async def search( dimension=self.profile.dimension, ), "candidate_limit": request.candidate_limit, + **memory_tag_parameters(request.tag_filter), }, ) ).mappings() diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index 8060ac0d0..a0592c799 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -49,6 +49,7 @@ SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE, ) +from powercontext.builtin.persistence.tags import RelationalTagService, tag_predicate from powercontext.builtin.records import ( ArtifactCollectionItem, ArtifactCreated, @@ -74,6 +75,7 @@ ContentSourceInternal, ContentSourceTarget, ) +from powercontext.builtin.tags import ArtifactTagSet, TagFilter, TagQuery, TagQueryPage, TagTarget from powercontext.errors import RevisionConflictError from powercontext.sources import SourceMaterialization, SourceRef @@ -113,6 +115,24 @@ def __init__( 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._tags = RelationalTagService( + database, + artifacts, + cursor_secret=self._cursor_secret, + clock=self._clock, + cursor_ttl_seconds=cursor_ttl_seconds, + ) + + async def get_tags(self, scope_id: str, target: TagTarget) -> ArtifactTagSet: + return await self._tags.get(scope_id, target) + + async def replace_tags( + self, scope_id: str, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str + ) -> ArtifactTagSet: + return await self._tags.replace(scope_id, target, tags, expected_etag=expected_etag) + + async def query_tags(self, scope_id: str, query: TagQuery, *, caller: str = "runtime") -> TagQueryPage: + return await self._tags.query(scope_id, query, caller=caller) async def create_source( self, @@ -291,6 +311,7 @@ async def query_artifacts( *, limit: int, cursor: str | None, + tag_filter: TagFilter | None = None, ) -> ArtifactRecordPage: self._require_family(family) _require_limit(limit) @@ -301,6 +322,13 @@ async def query_artifacts( "family": family, "order": "artifact_id:asc", } + if tag_filter is not None: + expected_cursor["tag_filter"] = sha256( + rfc8785.dumps({ + "keys": list(tag_filter.keys), + "match": tag_filter.match, + }) + ).hexdigest() after = self._cursor_after_text(cursor, expected_cursor) async with self._database.transaction() as connection: statement = ( @@ -316,6 +344,17 @@ async def query_artifacts( .order_by(ARTIFACT_HEADS_TABLE.c.artifact_id) .limit(limit + 1) ) + if tag_filter is not None: + statement = statement.where( + tag_predicate( + scope_id, + family, + ARTIFACT_HEADS_TABLE.c.artifact_id, + "artifact", + ARTIFACT_HEADS_TABLE.c.artifact_id, + tag_filter, + ) + ) rows = (await connection.execute(statement)).all() selected_rows = rows[:limit] artifacts = await self._artifacts.get_many( diff --git a/src/powercontext/builtin/persistence/sqlite/memory_index.py b/src/powercontext/builtin/persistence/sqlite/memory_index.py index 1b5c059df..4448a76c0 100644 --- a/src/powercontext/builtin/persistence/sqlite/memory_index.py +++ b/src/powercontext/builtin/persistence/sqlite/memory_index.py @@ -29,6 +29,7 @@ Integer, Table, UniqueConstraint, + bindparam, delete, func, insert, @@ -60,6 +61,7 @@ SHARED_METADATA, identity_string, ) +from powercontext.builtin.persistence.tags import memory_tag_parameters, memory_tag_sql from powercontext.limits import MAX_ARTIFACT_ID_LENGTH, MAX_SCOPE_ID_LENGTH SQLITE_MEMORY_FTS_MARKER_TABLE = Table( @@ -151,6 +153,10 @@ ) """ ) +_TAGGED_FTS_SQL = text(str(_SEARCH_FTS_SQL).replace("ORDER BY", memory_tag_sql("f") + "ORDER BY")).bindparams( + bindparam("tag_keys", expanding=True), + bindparam("tag_hashes", expanding=True), +) _DELETE_VECTOR_SQL = text("DELETE FROM pc_memory_entry_vec WHERE rowid = :vector_id") _DELETE_ORPHAN_VECTORS_SQL = ( "DELETE FROM pc_memory_entry_vec WHERE rowid NOT IN (SELECT vector_id FROM pc_memory_vector_entries)" @@ -181,11 +187,31 @@ """ ) +# Exact distance evaluation over the eligible set avoids global KNN followed by +# post-filtering. The unfiltered path keeps its existing behavior. +_TAGGED_VECTOR_SEARCH_SQL = text( + """ + SELECT m.memory_artifact_id, m.head_revision, m.entry_id, m.entry_version_id, v.text, + vec_distance_L2(vec.embedding, :query_vector) AS distance + FROM pc_memory_vector_entries AS m + JOIN pc_memory_entry_vec AS vec ON vec.rowid = m.vector_id + JOIN pc_memory_entry_versions AS v + ON v.scope_id = m.scope_id + AND v.memory_artifact_id = m.memory_artifact_id + AND v.entry_version_id = m.entry_version_id + WHERE m.scope_id = :scope_id + AND m.memory_artifact_id IN (SELECT value FROM json_each(:memory_artifact_ids)) + /* tag-filter */ + ORDER BY distance, m.memory_artifact_id, m.entry_id, m.entry_version_id + LIMIT :candidate_limit +""".replace("/* tag-filter */", memory_tag_sql("m")) +).bindparams(bindparam("tag_keys", expanding=True), bindparam("tag_hashes", expanding=True)) + class SQLiteMemoryFTSIndex: """SQLite FTS5 strategy over rebuildable active-head projections.""" - capabilities = MemoryCapabilities(fts=True) + capabilities = MemoryCapabilities(fts=True, tag_filter=True) tables: tuple[Table, ...] = SQLITE_MEMORY_FTS_TABLES async def initialize(self, connection: AsyncConnection, /) -> None: @@ -253,7 +279,7 @@ async def search( return MemorySearchChannels() rows = ( await connection.execute( - _SEARCH_FTS_SQL, + _SEARCH_FTS_SQL if request.tag_filter is None else _TAGGED_FTS_SQL, { "query": query, "scope_id": scope_id, @@ -262,6 +288,7 @@ async def search( separators=(",", ":"), ), "candidate_limit": request.candidate_limit, + **memory_tag_parameters(request.tag_filter), }, ) ).mappings() @@ -319,7 +346,7 @@ def __init__(self, profile: EmbeddingProfile) -> None: "sqlite-vec requires a positive unit-normalized L2 embedding profile", ) self.profile = profile - self.capabilities = MemoryCapabilities(vector=True, embedding_profile=profile, fts=False) + self.capabilities = MemoryCapabilities(vector=True, embedding_profile=profile, fts=False, tag_filter=True) async def initialize(self, connection: AsyncConnection, /) -> None: if connection.dialect.name != "sqlite": @@ -422,10 +449,11 @@ async def search( return MemorySearchChannels() rows = ( await connection.execute( - _VECTOR_SEARCH_SQL, + _VECTOR_SEARCH_SQL if request.tag_filter is None else _TAGGED_VECTOR_SEARCH_SQL, { "query_vector": query_vector, "neighbor_limit": total, + **memory_tag_parameters(request.tag_filter), "scope_id": scope_id, "memory_artifact_ids": json.dumps( tuple(ref.artifact_id for ref in request.memories), diff --git a/src/powercontext/builtin/persistence/sqlite/profile.py b/src/powercontext/builtin/persistence/sqlite/profile.py index 40836c739..d2a22aa32 100644 --- a/src/powercontext/builtin/persistence/sqlite/profile.py +++ b/src/powercontext/builtin/persistence/sqlite/profile.py @@ -93,7 +93,7 @@ async def open( engine_options["poolclass"] = StaticPool engine = create_async_engine(config.url, **engine_options) _configure_sqlite(engine, config, load_vector_extension=load_vector_extension) - database = AsyncDatabase.own(engine) + database = AsyncDatabase.own(engine, shared_connection=config.is_in_memory) profile = cls(database=database, tables=tables) try: await _warm_sqlite(engine, config) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index a8edf4522..b74e03f4b 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -22,6 +22,7 @@ Date, DateTime, ForeignKeyConstraint, + Index, Integer, LargeBinary, MetaData, @@ -30,7 +31,7 @@ Text, UniqueConstraint, ) -from sqlalchemy.dialects.mysql import MEDIUMBLOB, MEDIUMTEXT, VARCHAR +from sqlalchemy.dialects.mysql import BINARY, MEDIUMBLOB, MEDIUMTEXT, VARCHAR from powercontext.limits import ( MAX_ARTIFACT_FAMILY_LENGTH, @@ -729,6 +730,44 @@ def _entry_text_type(): MEMORY_TABLES = (MEMORY_ENTRY_VERSIONS_TABLE, MEMORY_ENTRY_HEADS_TABLE) +# OceanBase requires FK column lengths to match the parent. A normalized-key +# fingerprint keeps composite indexes within 3072 bytes without narrowing any +# Unicode identity or label. Queries also compare the full key, not just its hash. +ARTIFACT_TAGS_TABLE = Table( + "pc_artifact_tags", + 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("target_type", identity_string(12), primary_key=True), + Column("target_id", identity_string(MAX_MEMORY_ENTRY_ID_LENGTH), primary_key=True), + Column("tag_key_hash", LargeBinary(32).with_variant(BINARY(32), "mysql"), primary_key=True), + Column("tag_key", identity_string(128), nullable=False), + Column("tag", String(64), nullable=False), + Column("assigned_at", DateTime(timezone=True), nullable=False), + ForeignKeyConstraint( + ("scope_id", "family", "artifact_id"), + ("pc_artifact_heads.scope_id", "pc_artifact_heads.family", "pc_artifact_heads.artifact_id"), + ondelete="CASCADE", + ), + CheckConstraint("family IN ('memory', 'experience', 'skill', 'handoff')", name="ck_pc_artifact_tags_family"), + CheckConstraint( + "(target_type = 'artifact' AND target_id = artifact_id) OR " + "(target_type = 'memory_entry' AND family = 'memory')", + name="ck_pc_artifact_tags_target", + ), + Index( + "ix_pc_artifact_tags_family_key", + "scope_id", + "family", + "tag_key_hash", + "target_type", + "artifact_id", + "target_id", + ), + Index("ix_pc_artifact_tags_key", "scope_id", "tag_key_hash", "family", "target_type", "artifact_id", "target_id"), +) + STATISTICS_TABLES = (MODEL_USAGE_DAILY_TABLE, RECALL_TOKEN_DAILY_TABLE) -BUILTIN_TABLES = SCOPE_TABLES + SHARED_TABLES + MEMORY_TABLES + STATISTICS_TABLES +BUILTIN_TABLES = SCOPE_TABLES + SHARED_TABLES + MEMORY_TABLES + STATISTICS_TABLES + (ARTIFACT_TAGS_TABLE,) diff --git a/src/powercontext/builtin/persistence/tags.py b/src/powercontext/builtin/persistence/tags.py new file mode 100644 index 000000000..7036dd122 --- /dev/null +++ b/src/powercontext/builtin/persistence/tags.py @@ -0,0 +1,351 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Transactional, scope-local tag storage over a single relational table.""" + +from __future__ import annotations + +import base64 +import binascii +import hmac +import json +import secrets +from collections.abc import Callable +from datetime import UTC, datetime +from hashlib import sha256 +from typing import Any, Literal, cast + +import rfc8785 +from sqlalchemy import ColumnElement, and_, delete, func, insert, literal, select, tuple_, update +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import Artifact, ArtifactRef +from powercontext.builtin.artifacts.memory.models import Memory +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.tables import ARTIFACT_HEADS_TABLE, ARTIFACT_TAGS_TABLE +from powercontext.builtin.records import BaseValueNotFoundError, CursorExpiredError, InvalidCursorError +from powercontext.builtin.tags import ( + ArtifactTagSet, + ArtifactTagTarget, + MemoryEntryTagTarget, + TagFilter, + TaggedMemoryCitation, + TaggedTarget, + TagPreconditionError, + TagQuery, + TagQueryPage, + TagTarget, + normalize_tags, + tag_set, +) + + +def tag_predicate( + scope_id: str, + family: str | ColumnElement[str], + artifact_id: ColumnElement[str], + target_type: str, + target_id: ColumnElement[str], + tag_filter: TagFilter, +) -> ColumnElement[bool]: + """A correlated exact match suitable for use *before* LIMIT/top-k.""" + + tags = ARTIFACT_TAGS_TABLE.alias() + count = ( + select(func.count()) + .where( + tags.c.scope_id == scope_id, + tags.c.family == family, + tags.c.artifact_id == artifact_id, + tags.c.target_type == target_type, + tags.c.target_id == target_id, + tags.c.tag_key_hash.in_(_key_hashes(tag_filter)), + tags.c.tag_key.in_(tag_filter.keys), + ) + .correlate_except(tags) + .scalar_subquery() + ) + return count == len(tag_filter.keys) if tag_filter.match == "all" else count > 0 + + +def memory_tag_sql(alias: Literal["f", "m"]) -> str: + """A parameterized predicate for native FTS/vector SQL (aliases are internal).""" + + return """ + AND (SELECT COUNT(*) FROM pc_artifact_tags AS tags + WHERE tags.scope_id = m.scope_id + AND tags.family = 'memory' + AND tags.artifact_id = m.memory_artifact_id + AND tags.target_type = 'memory_entry' + AND tags.target_id = m.entry_id + AND tags.tag_key_hash IN :tag_hashes + AND tags.tag_key IN :tag_keys) >= :tag_minimum + """.replace("m.", alias + ".") + + +def memory_tag_parameters(tag_filter: TagFilter | None) -> dict[str, Any]: + if tag_filter is None: + return {} + return { + "tag_keys": tag_filter.keys, + "tag_hashes": _key_hashes(tag_filter), + "tag_minimum": len(tag_filter.keys) if tag_filter.match == "all" else 1, + } + + +def _key_hashes(tag_filter: TagFilter) -> tuple[bytes, ...]: + return tuple(sha256(key.encode("utf-8")).digest() for key in tag_filter.keys) + + +def _identity(scope_id: str, target: TagTarget) -> dict[str, str]: + return { + "scope_id": scope_id, + "family": target.family, + "artifact_id": target.artifact_id, + "target_type": target.type, + "target_id": target.entry_id if isinstance(target, MemoryEntryTagTarget) else target.artifact_id, + } + + +def _where(identity: dict[str, str]) -> ColumnElement[bool]: + return and_(*(ARTIFACT_TAGS_TABLE.c[key] == value for key, value in identity.items())) + + +async def _begin_read_snapshot(connection: AsyncConnection) -> None: + # sqlite3's legacy mode does not begin a read transaction for SELECT. Pin + # the head, manifest, lifecycle and tag reads to one snapshot. + if connection.dialect.name == "sqlite": + await connection.exec_driver_sql("BEGIN") + + +class RelationalTagService: + """Tag writes serialize on the owning head, even for an empty tag set.""" + + def __init__( + self, + database: AsyncDatabase, + artifacts: ArtifactRepository, + *, + cursor_secret: bytes | None = None, + clock: Callable[[], datetime] | None = None, + cursor_ttl_seconds: int = 3600, + ) -> None: + self._database = database + self._artifacts = artifacts + self._cursor_secret = secrets.token_bytes(32) if cursor_secret is None else cursor_secret + self._clock = (lambda: datetime.now(UTC)) if clock is None else clock + self._cursor_ttl = cursor_ttl_seconds + + async def get(self, scope_id: str, target: TagTarget) -> ArtifactTagSet: + async with self._database.transaction() as connection: + await _begin_read_snapshot(connection) + await self._target_reference(connection, scope_id, target) + return await self._read(connection, scope_id, target) + + async def replace( + self, scope_id: str, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str + ) -> ArtifactTagSet: + desired = normalize_tags(tags) + async with self._database.transaction() as connection: + # Acquire the database write lock before any reads. In particular, + # SELECT FOR UPDATE alone cannot serialize empty-set writes on SQLite. + locked = await connection.execute( + update(ARTIFACT_HEADS_TABLE) + .where( + ARTIFACT_HEADS_TABLE.c.scope_id == scope_id, + ARTIFACT_HEADS_TABLE.c.family == target.family, + ARTIFACT_HEADS_TABLE.c.artifact_id == target.artifact_id, + ) + .values(revision=ARTIFACT_HEADS_TABLE.c.revision) + ) + if locked.rowcount != 1: + raise BaseValueNotFoundError("artifact", target) + await self._target_reference(connection, scope_id, target) + current = await self._read(connection, scope_id, target) + if not hmac.compare_digest(expected_etag.encode("utf-8"), current.etag.encode("utf-8")): + raise TagPreconditionError + previous = normalize_tags(current.tags) + identity = _identity(scope_id, target) + removed = previous.keys() - desired.keys() + if removed: + await connection.execute( + delete(ARTIFACT_TAGS_TABLE).where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key.in_(removed)) + ) + assigned_at = self._clock() + for key, label in desired.items(): + if key not in previous: + await connection.execute( + insert(ARTIFACT_TAGS_TABLE).values( + **identity, + tag_key=key, + tag_key_hash=sha256(key.encode("utf-8")).digest(), + tag=label, + assigned_at=assigned_at, + ) + ) + elif label != previous[key]: + await connection.execute( + update(ARTIFACT_TAGS_TABLE) + .where(_where(identity), ARTIFACT_TAGS_TABLE.c.tag_key == key) + .values(tag=label) + ) + return tag_set(scope_id, target, tags) + + async def query(self, scope_id: str, query: TagQuery, *, caller: str = "runtime") -> TagQueryPage: + binding = sha256( + rfc8785.dumps({ + "scope_id": scope_id, + "keys": list(query.keys), + "match": query.match, + "families": sorted(query.families), + "target_types": sorted(query.target_types), + "include_inactive": query.include_inactive, + "caller": caller, + }) + ).hexdigest() + after = self._decode_cursor(query.cursor, binding) + table = ARTIFACT_TAGS_TABLE + order = (table.c.family, table.c.target_type, table.c.artifact_id, table.c.target_id) + items: list[TaggedTarget] = [] + keys: list[tuple[str, ...]] = [] + async with self._database.transaction() as connection: + await _begin_read_snapshot(connection) + heads: dict[tuple[str, str], tuple[Artifact[Any], str]] = {} + while len(items) <= query.limit: + statement = ( + select(*order) + .where( + table.c.scope_id == scope_id, + table.c.tag_key_hash.in_(_key_hashes(query)), + table.c.tag_key.in_(query.keys), + table.c.family.in_(query.families), + table.c.target_type.in_(query.target_types), + tuple_(*order) > tuple_(*(literal(value) for value in after)), + ) + .group_by(*order) + .having(func.count() == len(query.keys) if query.match == "all" else func.count() > 0) + .order_by(*order) + .limit(100) + ) + rows = (await connection.execute(statement)).all() + for row in rows: + key = tuple(str(value) for value in row) + family, target_type, artifact_id, target_id = key + target: TagTarget = ( + MemoryEntryTagTarget(artifact_id=artifact_id, entry_id=target_id) + if target_type == "memory_entry" + else ArtifactTagTarget(family=cast(Any, family), artifact_id=artifact_id) + ) + head_key = (family, artifact_id) + if head_key not in heads: + head_row = ( + await connection.execute( + select( + ARTIFACT_HEADS_TABLE.c.revision, + ARTIFACT_HEADS_TABLE.c.lifecycle_state, + ).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 head_row is None: + continue + ref = ArtifactRef(family=family, artifact_id=artifact_id, revision=head_row.revision) + heads[head_key] = ( + await self._artifacts.get(connection, scope_id, ref), + str(head_row.lifecycle_state), + ) + artifact, lifecycle = heads[head_key] + if not query.include_inactive and lifecycle != "active": + continue + try: + reference = self._reference(artifact, target, include_inactive=query.include_inactive) + except BaseValueNotFoundError: + continue + labels = await self._read(connection, scope_id, target) + items.append(TaggedTarget(**labels.model_dump(), reference=reference)) + keys.append(key) + if len(items) > query.limit: + break + if not rows or len(rows) < 100: + break + after = tuple(str(value) for value in rows[-1]) + cursor = self._encode_cursor(keys[query.limit - 1], binding) if len(items) > query.limit else None + return TagQueryPage(items=tuple(items[: query.limit]), next_cursor=cursor) + + async def _read(self, connection: AsyncConnection, scope_id: str, target: TagTarget) -> ArtifactTagSet: + labels = await connection.scalars(select(ARTIFACT_TAGS_TABLE.c.tag).where(_where(_identity(scope_id, target)))) + return tag_set(scope_id, target, tuple(labels)) + + async def _target_reference( + self, connection: AsyncConnection, scope_id: str, target: TagTarget + ) -> ArtifactRef | TaggedMemoryCitation: + try: + artifact = await self._artifacts.latest(connection, scope_id, target.family, target.artifact_id) + except RepositoryNotFoundError: + raise BaseValueNotFoundError("artifact", target) from None + return self._reference(artifact, target, include_inactive=True) + + @staticmethod + def _reference( + artifact: Artifact[Any], target: TagTarget, *, include_inactive: bool + ) -> ArtifactRef | TaggedMemoryCitation: + if isinstance(target, ArtifactTagTarget): + return artifact.as_ref() + if isinstance(artifact, Memory): + for entry in artifact.content.manifest.entries: + if entry.entry_id == target.entry_id and (include_inactive or entry.state == "active"): + return TaggedMemoryCitation( + memory_ref=artifact.as_ref(), entry_id=entry.entry_id, entry_version_id=entry.entry_version_id + ) + raise BaseValueNotFoundError("artifact", target) + + def _encode_cursor(self, after: tuple[str, ...], binding: str) -> str: + payload = rfc8785.dumps({ + "after": list(after), + "binding": binding, + "expires": int(self._clock().timestamp()) + self._cursor_ttl, + }) + signature = hmac.digest(self._cursor_secret, payload, "sha256") + return base64.urlsafe_b64encode(signature + payload).decode("ascii") + + def _decode_cursor(self, cursor: str | None, binding: str) -> tuple[str, ...]: + if cursor is None: + return ("", "", "", "") + try: + raw = base64.b64decode(cursor, altchars=b"-_", validate=True) + signature, payload = raw[:32], raw[32:] + if not hmac.compare_digest(signature, hmac.digest(self._cursor_secret, payload, "sha256")): + raise InvalidCursorError + value = json.loads(payload) + if ( + not isinstance(value, dict) + or set(value) != {"after", "binding", "expires"} + or value["binding"] != binding + ): + raise InvalidCursorError + after = value["after"] + if not isinstance(after, list) or len(after) != 4 or not all(isinstance(item, str) for item in after): + raise InvalidCursorError + if not isinstance(value["expires"], int): + raise InvalidCursorError + if value["expires"] <= self._clock().timestamp(): + raise CursorExpiredError + return tuple(after) + except (ValueError, UnicodeError, binascii.Error) as error: + raise InvalidCursorError from error diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index 261745bae..c25f302ad 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -17,14 +17,17 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Literal, Protocol +from typing import TYPE_CHECKING, Literal, Protocol from pydantic import BaseModel, ConfigDict, JsonValue from powercontext.artifacts import ArtifactRef -from powercontext.builtin.artifacts.memory import MemoryEntryVersion from powercontext.sources import SourceRef +if TYPE_CHECKING: + from powercontext.builtin.artifacts.memory import MemoryEntryVersion + from powercontext.builtin.tags import ArtifactTagSet, TagFilter, TagQuery, TagQueryPage, TagTarget + BaseArtifactFamily = Literal["memory", "experience", "skill", "handoff"] @@ -246,8 +249,17 @@ async def query_artifacts( *, limit: int, cursor: str | None, + tag_filter: TagFilter | None = None, ) -> ArtifactRecordPage: ... + async def get_tags(self, scope_id: str, target: TagTarget) -> ArtifactTagSet: ... + + async def replace_tags( + self, scope_id: str, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str + ) -> ArtifactTagSet: ... + + async def query_tags(self, scope_id: str, query: TagQuery, *, caller: str = "runtime") -> TagQueryPage: ... + 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 bd307c73e..dc2e36e63 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -199,6 +199,7 @@ StatisticsPeriod, ) from powercontext.builtin.statistics.aggregation import aggregate_statistics +from powercontext.builtin.tags import ArtifactTagSet, TagFilter, TagQuery, TagQueryPage, TagTarget from powercontext.builtin.work import ( HANDOFF_BOUNDARY_SOURCE_KIND, HANDOFF_RECEIPT_SOURCE_KIND, @@ -406,15 +407,30 @@ async def query_artifacts( *, limit: int, cursor: str | None, + tag_filter: TagFilter | None = None, ) -> ArtifactRecordPage: async with self._runtime._scope_operation(self.scope_id): + filters = {} if tag_filter is None else {"tag_filter": tag_filter} return await self._runtime._records().query_artifacts( self.scope_id, family, limit=limit, cursor=cursor, + **filters, ) + async def get_tags(self, target: TagTarget) -> ArtifactTagSet: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().get_tags(self.scope_id, target) + + async def replace_tags(self, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str) -> ArtifactTagSet: + async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): + return await self._runtime._records().replace_tags(self.scope_id, target, tags, expected_etag=expected_etag) + + async def query_tags(self, query: TagQuery, *, caller: str = "runtime") -> TagQueryPage: + async with self._runtime._scope_operation(self.scope_id): + return await self._runtime._records().query_tags(self.scope_id, query, caller=caller) + async def replace_artifact( self, family: str, @@ -1538,6 +1554,7 @@ async def search(self, request: SearchMemoryRequest, /) -> MemorySearchPage: memories=(current,), limit=request.limit, mode=request.mode, + **({} if request.tag_filter is None else {"tag_filter": request.tag_filter}), ) except (CapabilityNotSupportedError, InvalidMemoryCitationError) as error: latest = await _head_or_none(service, context.artifacts.memory_artifact_id) @@ -1560,13 +1577,15 @@ async def search(self, request: SearchMemoryRequest, /) -> MemorySearchPage: rerank=result.rerank, ) - async def list(self, *, include_inactive: bool = False) -> MemoryEntriesPage: + async def list(self, *, include_inactive: bool = False, tag_filter: TagFilter | None = None) -> MemoryEntriesPage: async with self._runtime._context(self.scope_id) as context: service = context.artifacts.memory current = await _head_or_none(service, context.artifacts.memory_artifact_id) if current is None: return MemoryEntriesPage(memory_ref=None) - entries = tuple(_entry_record(current, entry) for entry in await service.entries(current)) + entries = tuple( + _entry_record(current, entry) for entry in await service.entries(current, tag_filter=tag_filter) + ) if not include_inactive: entries = tuple(entry for entry in entries if entry.state == "active") return MemoryEntriesPage( diff --git a/src/powercontext/builtin/runtime/models.py b/src/powercontext/builtin/runtime/models.py index 9285d4a0a..61bd45323 100644 --- a/src/powercontext/builtin/runtime/models.py +++ b/src/powercontext/builtin/runtime/models.py @@ -49,6 +49,7 @@ ) from powercontext.builtin.review.generation import SkillGenerationOrigin from powercontext.builtin.sources import ExternalSkillImportMode +from powercontext.builtin.tags import TagFilter from powercontext.sources import ConnectorBinding, SourceObservation, SourceRef PreparedContextSchema: TypeAlias = Literal["powercontext.prepared-context.v1"] @@ -153,6 +154,7 @@ class SearchMemoryRequest(BaseModel): query: str limit: int = 10 mode: MemorySearchMode = "auto" + tag_filter: TagFilter | None = None class MemorySearchPage(BaseModel): diff --git a/src/powercontext/builtin/tags.py b/src/powercontext/builtin/tags.py new file mode 100644 index 000000000..f46b78ca8 --- /dev/null +++ b/src/powercontext/builtin/tags.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Scope-local labels for logical Artifacts and Memory entries. + +Tags are discovery metadata, never Artifact content or authorization policy. +""" + +from __future__ import annotations + +import unicodedata +from hashlib import sha256 +from typing import Annotated, Literal + +import rfc8785 +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from powercontext.artifacts import ArtifactRef +from powercontext.builtin.records import BaseAccessError, BaseArtifactFamily, InvalidBaseAccessRequestError + +TagMatch = Literal["all", "any"] +TagTargetType = Literal["artifact", "memory_entry"] + + +class _TagModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, hide_input_in_errors=True) + + +class ArtifactTagTarget(_TagModel): + """A stable Artifact identity, independent of its current revision.""" + + type: Literal["artifact"] = "artifact" + family: BaseArtifactFamily + artifact_id: str = Field(min_length=1, max_length=128) + + +class MemoryEntryTagTarget(_TagModel): + """A logical entry in the current Memory manifest, including inactive entries.""" + + type: Literal["memory_entry"] = "memory_entry" + family: Literal["memory"] = "memory" + artifact_id: str = Field(min_length=1, max_length=128) + entry_id: str = Field(min_length=1, max_length=128) + + +TagTarget = Annotated[ArtifactTagTarget | MemoryEntryTagTarget, Field(discriminator="type")] + + +def normalize_tags(tags: tuple[str, ...], *, maximum: int = 32, allow_empty: bool = True) -> dict[str, str]: + """Validate atomically and return display labels in UTF-8 key order. + + Invalid input is never included in an exception or diagnostic message. + """ + + if len(tags) > maximum or (not tags and not allow_empty): + raise InvalidBaseAccessRequestError("tags", "has an invalid number of labels") + normalized: dict[str, str] = {} + for tag in tags: + if ( + not isinstance(tag, str) + or not 1 <= len(tag) <= 64 + or tag != tag.strip() + or any(unicodedata.category(char) in {"Cc", "Cs", "Cn"} for char in tag) + ): + raise InvalidBaseAccessRequestError("tags", "contains an invalid label") + key = unicodedata.normalize("NFC", tag).casefold() + if len(key) > 128 or key in normalized: + raise InvalidBaseAccessRequestError("tags", "contains an invalid or duplicate normalized label") + normalized[key] = tag + return dict(sorted(normalized.items(), key=lambda item: item[0].encode("utf-8"))) + + +class TagFilter(_TagModel): + """Exact label matching applied before candidate limits.""" + + tags: tuple[str, ...] + match: TagMatch = "all" + + @field_validator("tags") + @classmethod + def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]: + normalize_tags(value, maximum=16, allow_empty=False) + return value + + @property + def keys(self) -> tuple[str, ...]: + return tuple(normalize_tags(self.tags, maximum=16, allow_empty=False)) + + +class ArtifactTagSet(_TagModel): + """The complete current tag set for one target.""" + + scope_id: str + target: TagTarget + tags: tuple[str, ...] + tag_digest: str + + @property + def etag(self) -> str: + payload = {"scope_id": self.scope_id, "target": self.target.model_dump(), "tag_digest": self.tag_digest} + return '"tags:' + sha256(rfc8785.dumps(payload)).hexdigest() + '"' + + +def tag_set(scope_id: str, target: TagTarget, tags: tuple[str, ...]) -> ArtifactTagSet: + ordered = tuple(normalize_tags(tags).values()) + digest = "sha256:" + sha256(rfc8785.dumps({"tags": list(ordered)})).hexdigest() + return ArtifactTagSet(scope_id=scope_id, target=target, tags=ordered, tag_digest=digest) + + +class TagPreconditionError(BaseAccessError): + """The supplied ETag does not name the current target's tag state.""" + + +class TagQuery(TagFilter): + """A bounded exact query within one authorized Scope.""" + + families: tuple[BaseArtifactFamily, ...] = ("memory", "experience", "skill", "handoff") + target_types: tuple[TagTargetType, ...] = ("artifact", "memory_entry") + include_inactive: bool = False + limit: int = Field(default=50, ge=1, le=100) + cursor: str | None = None + + @field_validator("families", "target_types") + @classmethod + def validate_selection(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not value or len(set(value)) != len(value): + raise InvalidBaseAccessRequestError("filters", "must contain distinct nonempty selections") + return value + + +class TaggedMemoryCitation(_TagModel): + """The exact Memory citation accompanying a logical entry match.""" + + memory_ref: ArtifactRef + entry_id: str + entry_version_id: str + + +class TaggedTarget(ArtifactTagSet): + """A tag match pinned to the authoritative current content revision.""" + + reference: ArtifactRef | TaggedMemoryCitation + + +class TagQueryPage(_TagModel): + items: tuple[TaggedTarget, ...] + next_cursor: str | None diff --git a/src/powercontext/client/__init__.py b/src/powercontext/client/__init__.py index e7775ee21..0faaa4d41 100644 --- a/src/powercontext/client/__init__.py +++ b/src/powercontext/client/__init__.py @@ -35,9 +35,11 @@ SkillReceiverStateError, require_remote_skill_server_url, ) +from powercontext.client.tags import ArtifactTagSetResponse __all__ = [ "RECEIVER_VERSION", + "ArtifactTagSetResponse", "ClientError", "ForbiddenResponseError", "InvalidResponseError", diff --git a/src/powercontext/client/client.py b/src/powercontext/client/client.py index aa9a97549..8db31ea1c 100644 --- a/src/powercontext/client/client.py +++ b/src/powercontext/client/client.py @@ -26,6 +26,7 @@ from pydantic import TypeAdapter, ValidationError from powercontext.client.errors import InvalidResponseError, TransportError, server_response_error +from powercontext.client.tags import ArtifactTagSetResponse from powercontext.client.tracing import ClientSpan from powercontext.http import ( AccessAuditPage, @@ -165,6 +166,12 @@ UpdateSkillLifecycleRequest, WorkSourceReceipt, ) +from powercontext.http._generated.models import ( + ArtifactTagPage, + ArtifactTagSet, + QueryArtifactTagsRequest, + ReplaceArtifactTagsRequest, +) from powercontext.http._generated.operations import ( ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, @@ -192,6 +199,7 @@ GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, GET_ARTIFACT_REVISION, + GET_ARTIFACT_TAGS, GET_CAPABILITIES, GET_CONNECTOR_CHECKPOINT, GET_DEFAULT_SCOPE, @@ -199,6 +207,7 @@ GET_HANDOFF_REPORT, GET_LIVENESS, GET_MEMORY_ENTRY, + GET_MEMORY_ENTRY_TAGS, GET_READINESS, GET_SCOPE, GET_SKILL, @@ -226,6 +235,7 @@ PROPOSE_SKILL_PACKAGE, PUBLISH_ARTIFACT, PUBLISH_REMOTE_SKILL, + QUERY_ARTIFACT_TAGS, RECONCILE_REMOTE_SKILLS, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, @@ -236,6 +246,8 @@ RENAME_REMOTE_SKILL_TARGET, REPLACE_ACCESS_BINDING, REPLACE_ARTIFACT, + REPLACE_ARTIFACT_TAGS, + REPLACE_MEMORY_ENTRY_TAGS, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -530,6 +542,105 @@ async def get_artifact( extra_headers=None if if_none_match is None else {"If-None-Match": if_none_match}, ) + async def get_artifact_tags( + self, + scope_id: str, + family: str, + artifact_id: str, + *, + if_none_match: str | None = None, + ) -> ArtifactTagSetResponse | None: + """Read scope-local labels with the server-issued ETag; None means 304.""" + return await self._tag_request( + GET_ARTIFACT_TAGS, + None, + {"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, + headers={} if if_none_match is None else {"If-None-Match": if_none_match}, + ) + + async def replace_artifact_tags( + self, + scope_id: str, + family: str, + artifact_id: str, + request: ReplaceArtifactTagsRequest, + *, + expected_etag: str, + ) -> ArtifactTagSetResponse: + """Replace labels without revising content; use the ETag from a prior read.""" + result = await self._tag_request( + REPLACE_ARTIFACT_TAGS, + request, + {"scope_id": scope_id, "family": family, "artifact_id": artifact_id}, + headers={"If-Match": expected_etag}, + ) + if result is None: + raise InvalidResponseError(REPLACE_ARTIFACT_TAGS.path, request_id=None) + return result + + async def get_memory_entry_tags( + self, + scope_id: str, + artifact_id: str, + entry_id: str, + *, + if_none_match: str | None = None, + ) -> ArtifactTagSetResponse | None: + """Read one logical entry's labels, including an inactive manifest entry.""" + return await self._tag_request( + GET_MEMORY_ENTRY_TAGS, + None, + {"scope_id": scope_id, "artifact_id": artifact_id, "entry_id": entry_id}, + headers={} if if_none_match is None else {"If-None-Match": if_none_match}, + ) + + async def replace_memory_entry_tags( + self, + scope_id: str, + artifact_id: str, + entry_id: str, + request: ReplaceArtifactTagsRequest, + *, + expected_etag: str, + ) -> ArtifactTagSetResponse: + """Replace one logical entry's labels without changing its version.""" + result = await self._tag_request( + REPLACE_MEMORY_ENTRY_TAGS, + request, + {"scope_id": scope_id, "artifact_id": artifact_id, "entry_id": entry_id}, + headers={"If-Match": expected_etag}, + ) + if result is None: + raise InvalidResponseError(REPLACE_MEMORY_ENTRY_TAGS.path, request_id=None) + return result + + async def query_artifact_tags(self, scope_id: str, request: QueryArtifactTagsRequest) -> ArtifactTagPage: + """Find visible targets by exact tags within a Scope.""" + return await self._request(QUERY_ARTIFACT_TAGS, request, path_parameters={"scope_id": scope_id}) + + async def _tag_request( + self, + operation: Operation[Any, ArtifactTagSet], + request: ReplaceArtifactTagsRequest | None, + path_parameters: dict[str, str], + *, + headers: dict[str, str], + ) -> ArtifactTagSetResponse | None: + response_headers: dict[str, str] = {} + result = await self._request( + operation, + request, + path_parameters=path_parameters, + extra_headers=headers, + response_headers=response_headers, + ) + if result is None: + return None + etag = response_headers.get("etag") + if etag is None: + raise InvalidResponseError(operation.path, request_id=response_headers.get(REQUEST_ID_HEADER.lower())) + return ArtifactTagSetResponse(tag_set=result, etag=etag) + async def get_artifact_revision( self, scope_id: str, @@ -876,6 +987,7 @@ async def _request( path_parameters: Mapping[str, str | int] | None = None, query_parameters: Mapping[str, Any] | None = None, extra_headers: Mapping[str, str] | None = None, + response_headers: dict[str, str] | None = None, ) -> _ResponseT: path, json_payload, request_query = _prepare_request( operation, @@ -911,6 +1023,8 @@ async def _request( span.finish("success" if succeeded else "failure", status_code=response.status_code) request_id = response.headers.get(REQUEST_ID_HEADER) + if response_headers is not None: + response_headers.update(response.headers) if not succeeded: error = _decode_error(response.content) raise server_response_error( diff --git a/src/powercontext/client/tags.py b/src/powercontext/client/tags.py new file mode 100644 index 000000000..82aca7d83 --- /dev/null +++ b/src/powercontext/client/tags.py @@ -0,0 +1,27 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tag responses retain their server-issued optimistic concurrency validator.""" + +from dataclasses import dataclass + +from powercontext.http._generated.models import ArtifactTagSet + + +@dataclass(frozen=True) +class ArtifactTagSetResponse: + """A complete tag set and its opaque ETag, safe for a subsequent replacement.""" + + tag_set: ArtifactTagSet + etag: str diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index c1d501e1d..449339a4e 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -59,6 +59,9 @@ ArtifactPublication, ArtifactReference, ArtifactRevision, + ArtifactTagPage, + ArtifactTagSet, + ArtifactTagTarget, AssignableSubjectType, AuthorizationNote, BaseArtifactFamily, @@ -180,6 +183,7 @@ MemoryEntryAccessSelector, MemoryEntryInventoryStatistics, MemoryEntryState, + MemoryEntryTagTarget, MemoryInventoryStatistics, MemoryKindCount, MemoryMatchedBy, @@ -207,6 +211,7 @@ Provider, PublishArtifactRequest, PublishRemoteSkillRequest, + QueryArtifactTagsRequest, ReadinessResponse, ReadinessStatus, RecallTokenDay, @@ -241,6 +246,7 @@ RenameRemoteSkillTargetRequest, ReplaceAccessBindingRequest, ReplaceArtifactRequest, + ReplaceArtifactTagsRequest, ReplaceExperienceArtifactRequest, ReplaceHandoffArtifactRequest, ReplaceMemoryArtifactContent, @@ -301,6 +307,12 @@ StatsPeriod, SubmitSourceObservationRequest, SubtreeScopeSelection, + Tag, + TagFilter, + TaggedTarget, + TagMatch, + TagTarget, + TagTargetType, TaskCheck, TaskCheckStatus, TaskOutcome, @@ -366,6 +378,9 @@ "ArtifactPublication", "ArtifactReference", "ArtifactRevision", + "ArtifactTagPage", + "ArtifactTagSet", + "ArtifactTagTarget", "AssignableSubjectType", "AuthorizationNote", "BaseArtifactFamily", @@ -487,6 +502,7 @@ "MemoryEntryAccessSelector", "MemoryEntryInventoryStatistics", "MemoryEntryState", + "MemoryEntryTagTarget", "MemoryInventoryStatistics", "MemoryKindCount", "MemoryMatchedBy", @@ -514,6 +530,7 @@ "Provider", "PublishArtifactRequest", "PublishRemoteSkillRequest", + "QueryArtifactTagsRequest", "ReadinessResponse", "ReadinessStatus", "RecallTokenDay", @@ -548,6 +565,7 @@ "RenameRemoteSkillTargetRequest", "ReplaceAccessBindingRequest", "ReplaceArtifactRequest", + "ReplaceArtifactTagsRequest", "ReplaceExperienceArtifactRequest", "ReplaceHandoffArtifactRequest", "ReplaceMemoryArtifactContent", @@ -608,6 +626,12 @@ "StatsPeriod", "SubmitSourceObservationRequest", "SubtreeScopeSelection", + "Tag", + "TagFilter", + "TagMatch", + "TagTarget", + "TagTargetType", + "TaggedTarget", "TaskCheck", "TaskCheckStatus", "TaskOutcome", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 667db4381..8b064dfbb 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -1080,16 +1080,6 @@ class ListMemoryChangesRequest(BaseModel): ] = None -class ListMemoryEntriesRequest(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] - include_inactive: Annotated[ - StrictBool, Field(description="Include inactive entries from the current Memory head for explicit audit.") - ] = False - - class ListExternalSkillsRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1269,10 +1259,67 @@ class CreateSourceRequest(BaseModel): content: Annotated[Any, Field(description="JSON value persisted by the built-in content Source adapter.")] +class TagMatch(StrEnum): + ALL = "all" + ANY = "any" + + +class TagTargetType(StrEnum): + ARTIFACT = "artifact" + MEMORY_ENTRY = "memory_entry" + + +class Type(StrEnum): + ARTIFACT = "artifact" + + +class Type1(StrEnum): + MEMORY_ENTRY = "memory_entry" + + +class Family4(StrEnum): + MEMORY = "memory" + + +class MemoryEntryTagTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: Literal["memory_entry"] + family: Family4 + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + entry_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + + +class Tag(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=64, min_length=1)] + + +class TagFilter(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tags: Annotated[list[Tag], Field(max_length=16, min_length=1)] + match: TagMatch = TagMatch.ALL + + +class ReplaceArtifactTagsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tags: Annotated[list[Tag], Field(max_length=32, min_length=0)] + + +class TagItem(RootModel[StrictStr]): + root: Annotated[StrictStr, Field(max_length=64, min_length=1)] + + class ListArtifactsRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) + tag: Annotated[list[TagItem] | None, Field(max_length=16, min_length=1)] = None + tag_match: TagMatch | None = None limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None @@ -1448,7 +1495,7 @@ class PreparedHandoffSchema(StrEnum): POWERCONTEXT_PREPARED_HANDOFF_V1 = "powercontext.prepared-handoff.v1" -class Type(StrEnum): +class Type2(StrEnum): USER = "user" SERVICE = "service" @@ -1462,7 +1509,7 @@ class AccessPrincipal(BaseModel): description: Annotated[StrictStr | None, Field(max_length=255, min_length=1)] = None -class Type1(StrEnum): +class Type3(StrEnum): GROUP = "group" @@ -1523,7 +1570,7 @@ class AccessResourceType(StrEnum): ARTIFACT = "artifact" -class Type2(StrEnum): +class Type4(StrEnum): SERVER = "server" @@ -1535,7 +1582,7 @@ class ServerAccessResource(BaseModel): deployment_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] -class Type3(StrEnum): +class Type5(StrEnum): SCOPE = "scope" @@ -1547,7 +1594,7 @@ class ScopeAccessResource(BaseModel): scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] -class Type4(StrEnum): +class Type6(StrEnum): MEMORY_ENTRY = "memory_entry" @@ -1555,7 +1602,7 @@ class MemoryEntryAccessSelector(BaseModel): model_config = ConfigDict( extra="forbid", ) - type: Type4 + type: Type6 entry_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] @@ -1567,7 +1614,7 @@ class AccessArtifactIdentity(BaseModel): artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^[\\x21-\\x7E]+$")] -class Type5(StrEnum): +class Type7(StrEnum): ARTIFACT = "artifact" @@ -2210,6 +2257,17 @@ class HandoffReportResponse(BaseModel): report_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] +class ListMemoryEntriesRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tag_filter: TagFilter | None = None + scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] + include_inactive: Annotated[ + StrictBool, Field(description="Include inactive entries from the current Memory head for explicit audit.") + ] = False + + class ListArtifactCandidatesRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2433,6 +2491,7 @@ class SearchMemoryRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) + tag_filter: TagFilter | None = None scope_id: Annotated[StrictStr, Field(max_length=256, min_length=1, pattern=".*\\S.*")] query: Annotated[StrictStr, Field(max_length=8192, min_length=1)] limit: Annotated[StrictInt, Field(ge=1, le=50)] = 10 @@ -2463,6 +2522,73 @@ class CreateMemoryArtifactContent(BaseModel): entries: Annotated[list[CreateMemoryArtifactEntry], Field(max_length=100, min_length=1)] +class ArtifactTagTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + type: Literal["artifact"] + family: BaseArtifactFamily + artifact_id: Annotated[StrictStr, Field(max_length=128, min_length=1)] + + +class TagTarget(RootModel[ArtifactTagTarget | MemoryEntryTagTarget]): + root: Annotated[ArtifactTagTarget | MemoryEntryTagTarget, Field(discriminator="type")] + + +class QueryArtifactTagsRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tags: Annotated[list[Tag], Field(max_length=16, min_length=1)] + match: TagMatch = TagMatch.ALL + families: Annotated[list[BaseArtifactFamily] | None, Field(max_length=4, min_length=1)] = None + target_types: Annotated[list[TagTargetType] | None, Field(max_length=2, min_length=1)] = None + include_inactive: StrictBool = False + limit: Annotated[StrictInt, Field(ge=1, le=100)] = 50 + cursor: Annotated[StrictStr | None, Field(max_length=4096, min_length=1)] = None + + +class ArtifactTagSet(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: StrictStr + target: TagTarget + tags: Annotated[list[Tag], Field(max_length=32, min_length=0)] + tag_digest: Annotated[ + StrictStr, + Field( + description="Digest of canonical display labels; informational, not a mutation precondition.", + pattern="^sha256:[0-9a-f]{64}$", + ), + ] + + +class TaggedTarget(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + scope_id: StrictStr + target: TagTarget + tags: Annotated[list[Tag], Field(max_length=32, min_length=0)] + tag_digest: Annotated[ + StrictStr, + Field( + description="Digest of canonical display labels; informational, not a mutation precondition.", + pattern="^sha256:[0-9a-f]{64}$", + ), + ] + reference: ArtifactReference | MemoryCitation + + +class ArtifactTagPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + items: list[TaggedTarget] + next_cursor: Annotated[StrictStr | None, Field(...)] + + class ReplaceMemoryArtifactContent(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/operations.py b/src/powercontext/http/_generated/operations.py index 894e3e070..499ddeff7 100644 --- a/src/powercontext/http/_generated/operations.py +++ b/src/powercontext/http/_generated/operations.py @@ -25,6 +25,8 @@ ArtifactPage, ArtifactPublication, ArtifactRevision, + ArtifactTagPage, + ArtifactTagSet, Capabilities, CaptureContentSourceRequest, CaptureContentSourceResponse, @@ -95,6 +97,7 @@ ProposeSkillRequest, PublishArtifactRequest, PublishRemoteSkillRequest, + QueryArtifactTagsRequest, ReadinessResponse, ReconcileRemoteSkillsRequest, ReconcileRemoteSkillsResponse, @@ -112,6 +115,7 @@ RenameRemoteSkillTargetRequest, ReplaceAccessBindingRequest, ReplaceArtifactRequest, + ReplaceArtifactTagsRequest, ResolveExternalSkillRequest, ResolveScopeBindingRequest, ResolveScopeSelectionRequest, @@ -2198,6 +2202,167 @@ class AccessRequirement(BaseModel): access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="path_artifact_write_access"), ) +GET_ARTIFACT_TAGS = Operation[None, ArtifactTagSet]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", + operation_id="get_artifact_tags", + request_type=None, + request_location=None, + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactTagSet, + success_status=200, + summary="Read Artifact tags", + tags=("artifact-tags",), + scope_mode="none", + responses={ + 200: { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": {"description": "Opaque target-bound tag state validator.", "schema": {"type": "string"}} + }, + }, + 304: { + "description": "The target tag set has not changed.", + "headers": {"ETag": {"schema": {"type": "string"}}}, + }, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="path_artifact_read_access"), +) + +REPLACE_ARTIFACT_TAGS = Operation[ReplaceArtifactTagsRequest, ArtifactTagSet]( + method="PUT", + path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", + operation_id="replace_artifact_tags", + request_type=ReplaceArtifactTagsRequest, + request_location="body", + path_parameters=("scope_id", "family", "artifact_id"), + response_type=ArtifactTagSet, + success_status=200, + summary="Replace Artifact tags", + tags=("artifact-tags",), + scope_mode="none", + responses={ + 200: { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": {"description": "Opaque target-bound tag state validator.", "schema": {"type": "string"}} + }, + }, + 412: {"$ref": "#/components/responses/PreconditionFailed"}, + 428: {"$ref": "#/components/responses/PreconditionRequired"}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="path_artifact_tags_write_access" + ), +) + +GET_MEMORY_ENTRY_TAGS = Operation[None, ArtifactTagSet]( + method="GET", + path="/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + operation_id="get_memory_entry_tags", + request_type=None, + request_location=None, + path_parameters=("scope_id", "artifact_id", "entry_id"), + response_type=ArtifactTagSet, + success_status=200, + summary="Read Memory entry tags", + tags=("artifact-tags",), + scope_mode="none", + responses={ + 200: { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": {"description": "Opaque target-bound tag state validator.", "schema": {"type": "string"}} + }, + }, + 304: { + "description": "The target tag set has not changed.", + "headers": {"ETag": {"schema": {"type": "string"}}}, + }, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="path_memory_entry_read_access"), +) + +REPLACE_MEMORY_ENTRY_TAGS = Operation[ReplaceArtifactTagsRequest, ArtifactTagSet]( + method="PUT", + path="/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + operation_id="replace_memory_entry_tags", + request_type=ReplaceArtifactTagsRequest, + request_location="body", + path_parameters=("scope_id", "artifact_id", "entry_id"), + response_type=ArtifactTagSet, + success_status=200, + summary="Replace Memory entry tags", + tags=("artifact-tags",), + scope_mode="none", + responses={ + 200: { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": {"description": "Opaque target-bound tag state validator.", "schema": {"type": "string"}} + }, + }, + 412: {"$ref": "#/components/responses/PreconditionFailed"}, + 428: {"$ref": "#/components/responses/PreconditionRequired"}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 404: {"$ref": "#/components/responses/NotFound"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement( + action=None, resource=None, scope_id_field=None, resolver="path_memory_entry_write_access" + ), +) + +QUERY_ARTIFACT_TAGS = Operation[QueryArtifactTagsRequest, ArtifactTagPage]( + method="POST", + path="/v1/scopes/{scope_id}/artifact-tags/query", + operation_id="query_artifact_tags", + request_type=QueryArtifactTagsRequest, + request_location="body", + path_parameters=("scope_id",), + response_type=ArtifactTagPage, + success_status=200, + summary="Query targets by exact custom tags", + tags=("artifact-tags",), + scope_mode="none", + responses={ + 200: {"description": "Current visible matches in family, target type, Artifact ID, and target ID order."}, + 400: {"$ref": "#/components/responses/BadRequest"}, + 401: {"$ref": "#/components/responses/Unauthorized"}, + 403: {"$ref": "#/components/responses/Forbidden"}, + 410: {"$ref": "#/components/responses/CursorExpired"}, + 422: {"$ref": "#/components/responses/InvalidRequest"}, + 503: {"$ref": "#/components/responses/Unavailable"}, + 500: {"$ref": "#/components/responses/InternalError"}, + }, + access=AccessRequirement(action=None, resource=None, scope_id_field=None, resolver="path_scope_read_access"), +) + GET_ARTIFACT_REVISION = Operation[None, ArtifactRevision]( method="GET", path="/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/revisions/{revision}", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index db6c2ca6f..31e9f7fd9 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -2354,6 +2354,25 @@ "resource": {"type": "scope", "scope-id-from": "scope_id"}, }, "parameters": [ + { + "name": "tag", + "in": "query", + "required": False, + "style": "form", + "explode": True, + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": {"type": "string", "minLength": 1, "maxLength": 64}, + }, + }, + { + "name": "tag_match", + "in": "query", + "required": False, + "schema": {"$ref": "#/components/schemas/TagMatch"}, + }, { "name": "scope_id", "in": "path", @@ -2509,6 +2528,354 @@ }, }, }, + "/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags": { + "get": { + "tags": ["artifact-tags"], + "summary": "Read Artifact tags", + "description": "Scope-local " + "labels " + "follow " + "logical " + "identity " + "without " + "changing " + "content " + "revisions. " + "Inactive " + "manifest " + "entries " + "remain " + "valid " + "targets.", + "operationId": "get_artifact_tags", + "x-powercontext-access": {"resolver": "path_artifact_read_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "If-None-Match", + "in": "header", + "required": False, + "schema": {"type": "string", "minLength": 1}, + }, + ], + "responses": { + "200": { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": { + "schema": {"type": "string"}, + "description": "Opaque target-bound tag state validator.", + } + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactTagSet"}}}, + }, + "304": { + "description": "The target tag set has not changed.", + "headers": {"ETag": {"schema": {"type": "string"}}}, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + "put": { + "tags": ["artifact-tags"], + "summary": "Replace Artifact tags", + "description": "Scope-local " + "labels " + "follow " + "logical " + "identity " + "without " + "changing " + "content " + "revisions. " + "Inactive " + "manifest " + "entries " + "remain " + "valid " + "targets.", + "operationId": "replace_artifact_tags", + "x-powercontext-access": {"resolver": "path_artifact_tags_write_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + { + "name": "family", + "in": "path", + "required": True, + "schema": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "If-Match", + "in": "header", + "required": True, + "schema": {"type": "string", "minLength": 1}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ReplaceArtifactTagsRequest"}} + }, + }, + "responses": { + "200": { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": { + "schema": {"type": "string"}, + "description": "Opaque target-bound tag state validator.", + } + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactTagSet"}}}, + }, + "412": {"$ref": "#/components/responses/PreconditionFailed"}, + "428": {"$ref": "#/components/responses/PreconditionRequired"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + }, + "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags": { + "get": { + "tags": ["artifact-tags"], + "summary": "Read Memory entry tags", + "description": "Scope-local " + "labels " + "follow " + "logical " + "identity " + "without " + "changing " + "content " + "revisions. " + "Inactive " + "manifest " + "entries " + "remain " + "valid " + "targets.", + "operationId": "get_memory_entry_tags", + "x-powercontext-access": {"resolver": "path_memory_entry_read_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "entry_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "If-None-Match", + "in": "header", + "required": False, + "schema": {"type": "string", "minLength": 1}, + }, + ], + "responses": { + "200": { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": { + "schema": {"type": "string"}, + "description": "Opaque target-bound tag state validator.", + } + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactTagSet"}}}, + }, + "304": { + "description": "The target tag set has not changed.", + "headers": {"ETag": {"schema": {"type": "string"}}}, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + "put": { + "tags": ["artifact-tags"], + "summary": "Replace Memory entry tags", + "description": "Scope-local " + "labels " + "follow " + "logical " + "identity " + "without " + "changing " + "content " + "revisions. " + "Inactive " + "manifest " + "entries " + "remain " + "valid " + "targets.", + "operationId": "replace_memory_entry_tags", + "x-powercontext-access": {"resolver": "path_memory_entry_write_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + }, + { + "name": "artifact_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "entry_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 128}, + }, + { + "name": "If-Match", + "in": "header", + "required": True, + "schema": {"type": "string", "minLength": 1}, + }, + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/ReplaceArtifactTagsRequest"}} + }, + }, + "responses": { + "200": { + "description": "Complete current target-local tag set.", + "headers": { + "ETag": { + "schema": {"type": "string"}, + "description": "Opaque target-bound tag state validator.", + } + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactTagSet"}}}, + }, + "412": {"$ref": "#/components/responses/PreconditionFailed"}, + "428": {"$ref": "#/components/responses/PreconditionRequired"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "422": {"$ref": "#/components/responses/InvalidRequest"}, + "503": {"$ref": "#/components/responses/Unavailable"}, + "500": {"$ref": "#/components/responses/InternalError"}, + }, + }, + }, + "/v1/scopes/{scope_id}/artifact-tags/query": { + "post": { + "tags": ["artifact-tags"], + "summary": "Query targets by exact custom tags", + "description": "Match all or any " + "normalized " + "labels within " + "one Scope before " + "pagination. Tags " + "never grant " + "visibility or " + "enter model " + "prompts.", + "operationId": "query_artifact_tags", + "x-powercontext-access": {"resolver": "path_scope_read_access"}, + "parameters": [ + { + "name": "scope_id", + "in": "path", + "required": True, + "schema": {"type": "string", "minLength": 1, "maxLength": 256}, + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/QueryArtifactTagsRequest"}} + }, + }, + "responses": { + "200": { + "description": "Current " + "visible " + "matches " + "in " + "family, " + "target " + "type, " + "Artifact " + "ID, " + "and " + "target " + "ID " + "order.", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ArtifactTagPage"}}}, + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "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}/revisions/{revision}": { "get": { "tags": ["artifacts"], @@ -5071,6 +5438,7 @@ }, "ListMemoryEntriesRequest": { "properties": { + "tag_filter": {"$ref": "#/components/schemas/TagFilter"}, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "include_inactive": { "type": "boolean", @@ -5508,6 +5876,7 @@ }, "SearchMemoryRequest": { "properties": { + "tag_filter": {"$ref": "#/components/schemas/TagFilter"}, "scope_id": {"type": "string", "maxLength": 256, "minLength": 1, "pattern": ".*\\S.*"}, "query": {"type": "string", "maxLength": 8192, "minLength": 1}, "limit": {"type": "integer", "maximum": 50.0, "minimum": 1.0, "default": 10}, @@ -5705,8 +6074,191 @@ "type": "object", "required": ["content"], }, + "TagMatch": {"type": "string", "enum": ["all", "any"]}, + "TagTargetType": {"type": "string", "enum": ["artifact", "memory_entry"]}, + "ArtifactTagTarget": { + "properties": { + "type": {"type": "string", "enum": ["artifact"]}, + "family": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "family", "artifact_id"], + }, + "MemoryEntryTagTarget": { + "properties": { + "type": {"type": "string", "enum": ["memory_entry"]}, + "family": {"type": "string", "enum": ["memory"]}, + "artifact_id": {"type": "string", "maxLength": 128, "minLength": 1}, + "entry_id": {"type": "string", "maxLength": 128, "minLength": 1}, + }, + "additionalProperties": False, + "type": "object", + "required": ["type", "family", "artifact_id", "entry_id"], + }, + "TagTarget": { + "oneOf": [ + {"$ref": "#/components/schemas/ArtifactTagTarget"}, + {"$ref": "#/components/schemas/MemoryEntryTagTarget"}, + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "artifact": "#/components/schemas/ArtifactTagTarget", + "memory_entry": "#/components/schemas/MemoryEntryTagTarget", + }, + }, + }, + "TagFilter": { + "properties": { + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 16, + "minItems": 1, + }, + "match": {"$ref": "#/components/schemas/TagMatch", "default": "all"}, + }, + "additionalProperties": False, + "type": "object", + "required": ["tags"], + }, + "ReplaceArtifactTagsRequest": { + "properties": { + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + "minItems": 0, + } + }, + "additionalProperties": False, + "type": "object", + "required": ["tags"], + "description": "Replace all labels " + "atomically. Empty " + "clears the set. Labels " + "preserve display text, " + "use NFC then casefold " + "for exact matching, and " + "reject normalized " + "duplicates, outer " + "whitespace, and Unicode " + "control, surrogate or " + "unassigned characters.", + }, + "QueryArtifactTagsRequest": { + "properties": { + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 16, + "minItems": 1, + }, + "match": {"$ref": "#/components/schemas/TagMatch", "default": "all"}, + "families": { + "items": {"$ref": "#/components/schemas/BaseArtifactFamily"}, + "type": "array", + "maxItems": 4, + "minItems": 1, + "uniqueItems": True, + }, + "target_types": { + "items": {"$ref": "#/components/schemas/TagTargetType"}, + "type": "array", + "maxItems": 2, + "minItems": 1, + "uniqueItems": True, + }, + "include_inactive": {"type": "boolean", "default": False}, + "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": ["tags"], + }, + "ArtifactTagSet": { + "properties": { + "scope_id": {"type": "string"}, + "target": {"$ref": "#/components/schemas/TagTarget"}, + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + "minItems": 0, + }, + "tag_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "Digest " + "of " + "canonical " + "display " + "labels; " + "informational, " + "not a " + "mutation " + "precondition.", + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "target", "tags", "tag_digest"], + }, + "TaggedTarget": { + "properties": { + "scope_id": {"type": "string"}, + "target": {"$ref": "#/components/schemas/TagTarget"}, + "tags": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 32, + "minItems": 0, + }, + "tag_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "Digest " + "of " + "canonical " + "display " + "labels; " + "informational, " + "not a " + "mutation " + "precondition.", + }, + "reference": { + "oneOf": [ + {"$ref": "#/components/schemas/ArtifactReference"}, + {"$ref": "#/components/schemas/MemoryCitation"}, + ] + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["scope_id", "target", "tags", "tag_digest", "reference"], + }, + "ArtifactTagPage": { + "properties": { + "items": {"items": {"$ref": "#/components/schemas/TaggedTarget"}, "type": "array"}, + "next_cursor": {"type": "string", "nullable": True}, + }, + "additionalProperties": False, + "type": "object", + "required": ["items", "next_cursor"], + }, "ListArtifactsRequest": { "properties": { + "tag": { + "items": {"type": "string", "maxLength": 64, "minLength": 1}, + "type": "array", + "maxItems": 16, + "minItems": 1, + }, + "tag_match": {"$ref": "#/components/schemas/TagMatch"}, "limit": {"type": "integer", "maximum": 100.0, "minimum": 1.0, "default": 50}, "cursor": {"type": "string", "maxLength": 4096, "minLength": 1, "nullable": True}, }, diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index f19fd1380..607046992 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -301,6 +301,20 @@ ObservedValidation, SkillUsageCapture, ) +from powercontext.builtin.tags import ( + ArtifactTagSet as RuntimeArtifactTagSet, +) +from powercontext.builtin.tags import ( + ArtifactTagTarget, + MemoryEntryTagTarget, + TagPreconditionError, + TagQuery, + TagQueryPage, + TagTarget, +) +from powercontext.builtin.tags import ( + TagFilter as RuntimeTagFilter, +) from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -548,10 +562,17 @@ from powercontext.http._generated.models import ( ArtifactFamily as TransportArtifactFamily, ) +from powercontext.http._generated.models import ( + ArtifactTagPage, + ArtifactTagSet, + QueryArtifactTagsRequest, + ReplaceArtifactTagsRequest, + TagMatch, +) from powercontext.http._generated.models import ( ShareUnit as TransportShareUnit, ) -from powercontext.http._generated.models import Type4 as TransportMemoryEntrySelectorType +from powercontext.http._generated.models import Type6 as TransportMemoryEntrySelectorType from powercontext.http._generated.operations import ( ACKNOWLEDGE_HANDOFF, ACTIVATE_HANDOFF, @@ -582,6 +603,7 @@ GET_ARTIFACT, GET_ARTIFACT_CANDIDATE, GET_ARTIFACT_REVISION, + GET_ARTIFACT_TAGS, GET_CAPABILITIES, GET_CONNECTOR_CHECKPOINT, GET_DEFAULT_SCOPE, @@ -589,6 +611,7 @@ GET_HANDOFF_REPORT, GET_LIVENESS, GET_MEMORY_ENTRY, + GET_MEMORY_ENTRY_TAGS, GET_READINESS, GET_SCOPE, GET_SKILL, @@ -617,6 +640,7 @@ PROPOSE_SKILL_PACKAGE, PUBLISH_ARTIFACT, PUBLISH_REMOTE_SKILL, + QUERY_ARTIFACT_TAGS, RECONCILE_REMOTE_SKILLS, RECORD_REMOTE_SKILL_RECEIPT, RECORD_SKILL_USAGE, @@ -627,6 +651,8 @@ RENAME_REMOTE_SKILL_TARGET, REPLACE_ACCESS_BINDING, REPLACE_ARTIFACT, + REPLACE_ARTIFACT_TAGS, + REPLACE_MEMORY_ENTRY_TAGS, RESOLVE_EXTERNAL_SKILL, RESOLVE_SCOPE_BINDING, RESOLVE_SCOPE_SELECTION, @@ -725,6 +751,14 @@ def for_scope(self, scope_id: str, /) -> _ScopedSourceApplication: ... class _ScopedRecordApplication(Protocol): + async def get_tags(self, target: TagTarget) -> RuntimeArtifactTagSet: ... + + async def replace_tags( + self, target: TagTarget, tags: tuple[str, ...], *, expected_etag: str + ) -> RuntimeArtifactTagSet: ... + + async def query_tags(self, query: TagQuery, *, caller: str = "runtime") -> TagQueryPage: ... + async def create_source( self, source_type: str, @@ -760,6 +794,7 @@ async def query_artifacts( *, limit: int, cursor: str | None, + tag_filter: RuntimeTagFilter | None = None, ) -> RuntimeArtifactRecordPage: ... async def replace_artifact( @@ -1027,7 +1062,9 @@ async def remember(self, request: RuntimeRememberMemoryRequest, /) -> MemoryMuta async def search(self, request: RuntimeSearchMemoryRequest, /) -> MemorySearchPage: ... - async def list(self, *, include_inactive: bool = False) -> MemoryEntriesPage: ... + async def list( + self, *, include_inactive: bool = False, tag_filter: RuntimeTagFilter | None = None + ) -> MemoryEntriesPage: ... async def get(self, request: RuntimeGetMemoryEntryRequest, /) -> MemoryEntryRecord: ... @@ -1224,6 +1261,11 @@ async def unexpected_error(request: Request, error: Exception) -> JSONResponse: _add_route(app, CREATE_SOURCE, create_source) _add_route(app, GET_SOURCE, get_source) _add_route(app, CREATE_ARTIFACT, create_artifact) + _add_route(app, GET_MEMORY_ENTRY_TAGS, get_memory_entry_tags) + _add_route(app, REPLACE_MEMORY_ENTRY_TAGS, replace_memory_entry_tags) + _add_route(app, GET_ARTIFACT_TAGS, get_artifact_tags) + _add_route(app, REPLACE_ARTIFACT_TAGS, replace_artifact_tags) + _add_route(app, QUERY_ARTIFACT_TAGS, query_artifact_tags) _add_route(app, GET_ARTIFACT_REVISION, get_artifact_revision) _add_route(app, GET_ARTIFACT, get_artifact) _add_route(app, LIST_ARTIFACTS, list_artifacts) @@ -1965,8 +2007,12 @@ async def create_artifact( def _list_artifacts_query( limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, + tag: Annotated[list[str] | None, Query(min_length=1, max_length=16)] = None, + tag_match: Annotated[TagMatch | None, Query()] = None, ) -> ListArtifactsRequest: - return ListArtifactsRequest(limit=limit, cursor=cursor) + if tag is None and tag_match is not None: + raise InvalidBaseAccessRequestError("tag_match", "requires at least one tag") + return ListArtifactsRequest.model_validate({"limit": limit, "cursor": cursor, "tag": tag, "tag_match": tag_match}) async def list_artifacts( @@ -1979,6 +2025,16 @@ async def list_artifacts( family.value, limit=request.limit, cursor=request.cursor, + **( + {} + if request.tag is None + else { + "tag_filter": RuntimeTagFilter( + tags=tuple(tag.root for tag in request.tag), + match="all" if request.tag_match is None else request.tag_match.value, + ) + } + ), ) return ArtifactPage( items=[_artifact_collection_item_response(item) for item in result.items], @@ -1986,6 +2042,102 @@ async def list_artifacts( ) +async def get_artifact_tags( + scope_id: Annotated[str, Path(min_length=1, max_length=256)], + family: Annotated[BaseArtifactFamily, Path()], + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_none_match: Annotated[str | None, Header(alias="If-None-Match", min_length=1)] = None, +) -> ArtifactTagSet | Response: + target = ArtifactTagTarget(family=family.value, artifact_id=artifact_id) + result = await application.records.for_scope(scope_id).get_tags(target) + return _tag_response(result, response, if_none_match=if_none_match) + + +async def replace_artifact_tags( + scope_id: Annotated[str, Path(min_length=1, max_length=256)], + family: Annotated[BaseArtifactFamily, Path()], + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + request: ReplaceArtifactTagsRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, +) -> ArtifactTagSet: + target = ArtifactTagTarget(family=family.value, artifact_id=artifact_id) + result = await application.records.for_scope(scope_id).replace_tags( + target, + tuple(tag.root for tag in request.tags), + expected_etag=_require_artifact_etag(if_match), + ) + response.headers["ETag"] = result.etag + return ArtifactTagSet.model_validate(result.model_dump(mode="json")) + + +async def get_memory_entry_tags( + scope_id: Annotated[str, Path(min_length=1, max_length=256)], + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + entry_id: Annotated[str, Path(min_length=1, max_length=128)], + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_none_match: Annotated[str | None, Header(alias="If-None-Match", min_length=1)] = None, +) -> ArtifactTagSet | Response: + target = MemoryEntryTagTarget(artifact_id=artifact_id, entry_id=entry_id) + result = await application.records.for_scope(scope_id).get_tags(target) + return _tag_response(result, response, if_none_match=if_none_match) + + +async def replace_memory_entry_tags( + scope_id: Annotated[str, Path(min_length=1, max_length=256)], + artifact_id: Annotated[str, Path(min_length=1, max_length=128)], + entry_id: Annotated[str, Path(min_length=1, max_length=128)], + request: ReplaceArtifactTagsRequest, + response: Response, + application: Annotated[ServerApplication, Depends(_require_application)], + if_match: Annotated[str | None, Header(alias="If-Match")] = None, +) -> ArtifactTagSet: + target = MemoryEntryTagTarget(artifact_id=artifact_id, entry_id=entry_id) + result = await application.records.for_scope(scope_id).replace_tags( + target, + tuple(tag.root for tag in request.tags), + expected_etag=_require_artifact_etag(if_match), + ) + response.headers["ETag"] = result.etag + return ArtifactTagSet.model_validate(result.model_dump(mode="json")) + + +def _tag_response( + result: RuntimeArtifactTagSet, + response: Response, + *, + if_none_match: str | None, +) -> ArtifactTagSet | Response: + etag = result.etag + if if_none_match is not None and any( + value.strip().removeprefix("W/") == etag for value in if_none_match.split(",") + ): + return Response(status_code=304, headers={"ETag": etag}) + response.headers["ETag"] = etag + return ArtifactTagSet.model_validate(result.model_dump(mode="json")) + + +async def query_artifact_tags( + scope_id: Annotated[str, Path(min_length=1, max_length=256)], + request: QueryArtifactTagsRequest, + http_request: Request, + application: Annotated[ServerApplication, Depends(_require_application)], +) -> ArtifactTagPage: + principal = current_principal() + caller = ( + f"{principal.type}:{principal.id}" + if principal is not None + else sha256(http_request.headers.get("authorization", "anonymous").encode()).hexdigest() + ) + query = TagQuery.model_validate_json(request.model_dump_json(exclude_none=True)) + result = await application.records.for_scope(scope_id).query_tags(query, caller=caller) + return ArtifactTagPage.model_validate(result.model_dump(mode="json")) + + async def get_artifact( scope_id: Annotated[str, Path(min_length=1, max_length=256, pattern=r".*\S.*")], family: Annotated[BaseArtifactFamily, Path()], @@ -2420,6 +2572,11 @@ async def list_memory_entries( ) -> ListMemoryEntriesResponse: result = await application.memory.for_scope(request.scope_id).list( include_inactive=request.include_inactive, + **( + {} + if request.tag_filter is None + else {"tag_filter": RuntimeTagFilter.model_validate_json(request.tag_filter.model_dump_json())} + ), ) return mapping.entries_response(result) @@ -3564,6 +3721,8 @@ def _add_route( "list_artifacts", "get_artifact", "get_artifact_revision", + "get_artifact_tags", + "query_artifact_tags", "prepare_handoff", "activate_handoff", "finalize_handoff", @@ -3915,6 +4074,49 @@ def _path_artifact_write_access( return _path_artifact_access(payload, action=AccessAction.ARTIFACT_WRITE) +def _path_artifact_tags_write_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + if _path_artifact_family(payload) == BaseArtifactFamily.MEMORY.value: + # The Memory container has no single entry owner; its shared metadata + # belongs to the Scope administrator. + return _path_scope_access(payload, action=AccessAction.SCOPE_ADMIN) + return _path_artifact_access(payload, action=AccessAction.ARTIFACT_WRITE) + + +def _path_memory_entry_access( + payload: Mapping[str, Any], + *, + action: AccessAction, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return ( + ( + action, + ResourceRef.artifact( + _nested_request_value(payload, "scope_id"), + family="memory", + artifact_id=_nested_request_value(payload, "artifact_id"), + selector=MemoryEntrySelector(entry_id=_nested_request_value(payload, "entry_id")), + ), + ), + ) + + +def _path_memory_entry_read_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return _path_memory_entry_access(payload, action=AccessAction.ARTIFACT_READ) + + +def _path_memory_entry_write_access( + payload: Mapping[str, Any], + _deployment_id: str, +) -> tuple[tuple[AccessAction, ResourceRef], ...]: + return _path_memory_entry_access(payload, action=AccessAction.ARTIFACT_WRITE) + + def _base_memory_write_access( payload: Mapping[str, Any], ) -> tuple[tuple[AccessAction, ResourceRef], ...]: @@ -4023,6 +4225,9 @@ def _acknowledge_handoff_resolver( "path_scope_read_access": _path_scope_read_access, "path_artifact_read_access": _path_artifact_read_access, "path_artifact_write_access": _path_artifact_write_access, + "path_artifact_tags_write_access": _path_artifact_tags_write_access, + "path_memory_entry_read_access": _path_memory_entry_read_access, + "path_memory_entry_write_access": _path_memory_entry_write_access, "publish_artifact_access": _publish_artifact_access, "publish_remote_skill_access": _publish_remote_skill_access, "scope_selection_read_access": _scope_selection_read_access, @@ -4259,6 +4464,13 @@ def _map_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None]: def _map_service_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: # noqa: C901 + if isinstance(error, TagPreconditionError): + return ( + status.HTTP_412_PRECONDITION_FAILED, + "tag_precondition_failed", + "Tag ETag does not match the current target state.", + 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) diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index ee27c373b..3ed0e5d74 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -139,6 +139,7 @@ SubmitSourceObservation as RuntimeSubmitSourceObservation, ) from powercontext.builtin.sources import ExternalSkillImportMode as RuntimeExternalSkillImportMode +from powercontext.builtin.tags import TagFilter from powercontext.builtin.work import ( AcknowledgeHandoff as RuntimeAcknowledgeHandoff, ) @@ -620,7 +621,14 @@ def revise_candidate_request(value: ReviseArtifactCandidateRequest) -> RuntimeRe def search_request(value: SearchMemoryRequest) -> RuntimeSearchMemoryRequest: - return RuntimeSearchMemoryRequest(query=value.query, limit=value.limit, mode=value.mode.value) + return RuntimeSearchMemoryRequest( + query=value.query, + limit=value.limit, + mode=value.mode.value, + tag_filter=None + if value.tag_filter is None + else TagFilter.model_validate_json(value.tag_filter.model_dump_json()), + ) def prepare_context_request(value: TransportPrepareContextRequest) -> PrepareContextRequest: diff --git a/src/powercontext/server/static/artifact-tags.js b/src/powercontext/server/static/artifact-tags.js new file mode 100644 index 000000000..7496464d7 --- /dev/null +++ b/src/powercontext/server/static/artifact-tags.js @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +"use strict"; + +import {fetchWithBearer} from "./auth.js?v=optional-auth"; +import {createRequestGate} from "./page-ui.js?v=locale-complete"; + +export const tagTranslations = { + en: { + tagTitle: "Custom tags", tagIntro: "Choose one Scope and a logical target. Tags do not change content versions.", + tagScope: "Scope", tagTargetType: "Target", tagArtifact: "Artifact", tagEntry: "Memory entry", + tagLabels: "Labels, one per line (up to 32)", tagSave: "Save tags", tagReload: "Reload tags", + tagClearHint: "Save an empty field to clear all labels for this target.", + tagQuery: "Find by exact labels, one per line (up to 16)", tagMatch: "Match", tagAll: "All", tagAny: "Any", + tagInactive: "Include inactive", tagSearch: "Find targets", tagMore: "Load more", tagSaved: "Tags saved.", + tagConflict: "Tags changed elsewhere. Your text is preserved. Reload the current tags before saving again.", + tagFailure: "The request failed. Check the selected target, label format, and server connection.", + tagNoTargets: "No matching targets.", tagChoose: "Choose a target", tagLoaded: "Current tags loaded.", + }, + zh: { + tagTitle: "自定义标签", tagIntro: "选择一个 Scope 和逻辑制品或条目。标签不会修改内容版本。", + tagScope: "Scope", tagTargetType: "标签对象", tagArtifact: "制品", tagEntry: "记忆条目", + tagLabels: "标签,每行一个(最多 32 个)", tagSave: "保存标签", tagReload: "重新读取标签", + tagClearHint: "清空输入框并保存,即可清除当前对象的全部标签。", + tagQuery: "按精确标签查找,每行一个(最多 16 个)", tagMatch: "匹配方式", tagAll: "全部匹配", tagAny: "任一匹配", + tagInactive: "包含非活跃对象", tagSearch: "查找对象", tagMore: "加载更多", tagSaved: "标签已保存。", + tagConflict: "标签已被其他操作修改。输入内容已保留,请重新读取当前标签后再保存。", + tagFailure: "请求失败,请检查所选对象、标签格式和服务器连接。", + tagNoTargets: "没有匹配的对象。", tagChoose: "请选择对象", tagLoaded: "已读取当前标签。", + }, +}; + +export function createTagPanel(root, {translate, token}) { + const el = (id) => root.querySelector(`#tag-${id}`); + const editorRequests = createRequestGate(); + const queryRequests = createRequestGate(); + let etag = null; + let targetPath = null; + let artifactCursor = null; + let queryState = null; + let scopeSignature = null; + let statusKey = ""; + const status = (key) => { statusKey = key; el("status").textContent = key ? translate(key) : ""; }; + const labels = (value) => value === "" ? [] : value.split(/\r?\n/); + const base = () => `/v1/scopes/${encodeURIComponent(el("scope").value)}`; + const artifactPath = () => `${base()}/artifacts/${el("family").value}/${encodeURIComponent(el("artifact").value)}`; + const option = (value, label) => { + const node = document.createElement("option"); + node.value = value; + node.textContent = label; + return node; + }; + const resetEditor = () => { + editorRequests.cancel(); + etag = null; + targetPath = null; + el("labels").value = ""; + el("labels").disabled = true; + el("save").disabled = true; + }; + async function request(path, options = {}) { + const response = await fetchWithBearer(path, token(), options); + if (!response.ok) { + const error = new Error("Tag request failed"); + error.status = response.status; + throw error; + } + return {body: await response.json(), etag: response.headers.get("ETag")}; + } + async function loadTags() { + resetEditor(); + if (!el("artifact").value || (el("target-type").value === "memory_entry" && !el("entry").value)) return; + const path = artifactPath() + (el("target-type").value === "memory_entry" ? `/entries/${encodeURIComponent(el("entry").value)}` : "") + "/tags"; + const gate = editorRequests.start(); + try { + const result = await request(path); + if (!gate.isCurrent()) return; + targetPath = path; + etag = result.etag; + el("labels").value = result.body.tags.join("\n"); + el("labels").disabled = false; + el("save").disabled = !etag; + status("tagLoaded"); + } catch { if (gate.isCurrent()) status("tagFailure"); } + } + async function loadEntries(selected = "") { + resetEditor(); + const isEntry = el("target-type").value === "memory_entry"; + el("entry-field").hidden = !isEntry; + if (!isEntry) return loadTags(); + el("entry").replaceChildren(); + if (!el("artifact").value) return; + const gate = editorRequests.start(); + try { + const result = await request(artifactPath()); + if (!gate.isCurrent()) return; + for (const entry of result.body.content.manifest.entries) { + el("entry").append(option(entry.entry_id, `${entry.entry_id} (${entry.state})`)); + } + if (selected) el("entry").value = selected; + await loadTags(); + } catch { if (gate.isCurrent()) status("tagFailure"); } + } + async function loadArtifacts(more = false) { + resetEditor(); + const gate = editorRequests.start(); + if (!more) { artifactCursor = null; el("artifact").replaceChildren(); } + if (!el("scope").value) return; + const params = new URLSearchParams({limit: "100"}); + if (artifactCursor) params.set("cursor", artifactCursor); + try { + const result = await request(`${base()}/artifacts/${el("family").value}?${params}`); + if (!gate.isCurrent()) return; + for (const artifact of result.body.items) el("artifact").append(option(artifact.artifact_id, `${artifact.artifact_id} (r${artifact.revision})`)); + artifactCursor = result.body.next_cursor; + el("more-artifacts").hidden = !artifactCursor; + await loadEntries(); + } catch { if (gate.isCurrent()) status("tagFailure"); } + } + async function findTargets(more = false) { + const gate = queryRequests.start(); + if (!more) { + queryState = {tags: labels(el("query").value), match: el("match").value, include_inactive: el("inactive").checked, limit: 50}; + el("results").replaceChildren(); + } + el("next").hidden = true; + try { + const result = await request(`${base()}/artifact-tags/query`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(queryState)}); + if (!gate.isCurrent()) return; + for (const item of result.body.items) { + const li = document.createElement("li"); + const button = document.createElement("button"); + button.className = "secondary-button"; + button.type = "button"; + button.textContent = `${item.target.family} / ${item.target.artifact_id}${item.target.entry_id ? " / " + item.target.entry_id : ""} — ${item.tags.join(", ")}`; + button.addEventListener("click", async () => { + resetEditor(); + el("family").value = item.target.family; + el("target-type").value = item.target.type; + el("family").disabled = item.target.type === "memory_entry"; + el("artifact").replaceChildren(option(item.target.artifact_id, item.target.artifact_id)); + el("more-artifacts").hidden = true; + await loadEntries(item.target.entry_id); + }); + li.append(button); + el("results").append(li); + } + queryState.cursor = result.body.next_cursor; + el("next").hidden = !queryState.cursor; + status(el("results").children.length ? "" : "tagNoTargets"); + } catch { if (gate.isCurrent()) status("tagFailure"); } + } + el("scope").addEventListener("change", () => { + queryRequests.cancel(); queryState = null; + el("results").replaceChildren(); el("next").hidden = true; + loadArtifacts(); + }); + el("family").addEventListener("change", () => loadArtifacts()); + el("target-type").addEventListener("change", () => { + const isEntry = el("target-type").value === "memory_entry"; + if (isEntry) el("family").value = "memory"; + el("family").disabled = isEntry; + loadArtifacts(); + }); + el("artifact").addEventListener("change", () => loadEntries()); + el("entry").addEventListener("change", loadTags); + el("reload").addEventListener("click", loadTags); + el("more-artifacts").addEventListener("click", () => loadArtifacts(true)); + el("search").addEventListener("click", () => findTargets()); + el("next").addEventListener("click", () => findTargets(true)); + el("save").addEventListener("click", async () => { + if (!etag || !targetPath) return; + const gate = editorRequests.start(); + el("save").disabled = true; + el("labels").disabled = true; + try { + const result = await request(targetPath, {method: "PUT", headers: {"Content-Type": "application/json", "If-Match": etag}, body: JSON.stringify({tags: labels(el("labels").value)})}); + if (!gate.isCurrent()) return; + etag = result.etag; + el("labels").value = result.body.tags.join("\n"); + status("tagSaved"); + } catch (error) { + if (!gate.isCurrent()) return; + if (error.status === 412) etag = null; + status(error.status === 412 ? "tagConflict" : "tagFailure"); + } finally { + if (gate.isCurrent()) { + el("save").disabled = !etag; + el("labels").disabled = false; + } + } + }); + return { + updateScopes(scopes) { + status(statusKey); + const signature = JSON.stringify(scopes.map((scope) => [scope.scope_id, scope.display_name])); + if (signature === scopeSignature) return; + scopeSignature = signature; + queryRequests.cancel(); queryState = null; + el("results").replaceChildren(); el("next").hidden = true; + const selected = el("scope").value; + el("scope").replaceChildren(...scopes.map((scope) => option(scope.scope_id, scope.display_name))); + if (scopes.some((scope) => scope.scope_id === selected)) el("scope").value = selected; + loadArtifacts(); + }, + reset() { + resetEditor(); queryRequests.cancel(); scopeSignature = null; queryState = null; + el("results").replaceChildren(); el("scope").replaceChildren(); status(""); + }, + }; +} diff --git a/src/powercontext/server/static/dashboard.js b/src/powercontext/server/static/dashboard.js index 66b1904bc..43511d1aa 100644 --- a/src/powercontext/server/static/dashboard.js +++ b/src/powercontext/server/static/dashboard.js @@ -24,9 +24,11 @@ import { } from "./auth.js?v=optional-auth"; import {createPageUi, createRequestGate} from "./page-ui.js?v=locale-complete"; import {buildScopeSelectionChoices} from "./scope-selection.js?v=selection-v1"; +import {createTagPanel, tagTranslations} from "./artifact-tags.js?v=tags-v1"; const translations = { en: { + ...tagTranslations.en, pageTitle: "PowerContext Overview", dashboardTitle: "Overview", sharedTitle: "Shared with me", @@ -98,6 +100,7 @@ const translations = { scopeOverview: "Overview for the selected work" }, zh: { + ...tagTranslations.zh, pageTitle: "PowerContext 概览", dashboardTitle: "概览", sharedTitle: "与我共享", @@ -194,6 +197,7 @@ const ui = createPageUi(translations, () => { } }); const {formatDateTime, formatNumber, translate} = ui; +const tagPanel = createTagPanel(document.getElementById("artifact-tag-panel"), {translate, token: readServerToken}); const dashboardRequests = createRequestGate(); scopeSelect.addEventListener("change", async () => { @@ -315,6 +319,7 @@ async function loadStatistics(token, scopeId, request = null) { } function showLogin(messageKey = "", values = {}) { + tagPanel.reset(); dashboardRequests.cancel(); scopeSelect.disabled = false; currentView = null; @@ -360,6 +365,7 @@ function renderAuthError() { } function renderDashboard(view) { + tagPanel.updateScopes(view.scopes); currentView = view; currentPageStatus = null; const statistics = view.statistics; diff --git a/src/powercontext/server/static/site.css b/src/powercontext/server/static/site.css index 3af5b51fc..bfbae9121 100644 --- a/src/powercontext/server/static/site.css +++ b/src/powercontext/server/static/site.css @@ -4671,6 +4671,52 @@ button:disabled { } } +.tag-fields, +.tag-workspace { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px; + margin-block: 20px; +} + +.tag-fields label { + display: grid; + gap: 8px; + min-width: 0; +} + +#artifact-tag-panel select, +#artifact-tag-panel textarea { + max-width: 100%; + border: 1px solid var(--pc-rule); + border-radius: var(--pc-radius-control); + background: var(--pc-surface); + color: var(--pc-ink); + padding: 10px; +} + +#artifact-tag-panel textarea { + width: 100%; + resize: vertical; +} + +.tag-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + margin-top: 10px; +} + +#tag-results { + padding-left: 20px; +} + +#tag-results button { + text-align: left; + overflow-wrap: anywhere; +} + @media (max-width: 360px) { .primary-nav a { padding-inline: 4px; diff --git a/src/powercontext/server/templates/components/artifact_tags.html b/src/powercontext/server/templates/components/artifact_tags.html new file mode 100644 index 000000000..b449388ef --- /dev/null +++ b/src/powercontext/server/templates/components/artifact_tags.html @@ -0,0 +1,51 @@ + + +
+

Custom tags

+

Choose one Scope and a logical target. Tags do not change content versions.

+
+ + + + + +
+ +
+
+ + +
+ + +
+

Save an empty field to clear all labels for this target.

+
+
+ + +
+ + + +
+
    + +
    +
    +

    +
    diff --git a/src/powercontext/server/templates/pages/dashboard.html b/src/powercontext/server/templates/pages/dashboard.html index 866184746..ca9d1ea6e 100644 --- a/src/powercontext/server/templates/pages/dashboard.html +++ b/src/powercontext/server/templates/pages/dashboard.html @@ -87,6 +87,7 @@

    By type

    {% include "components/activity_heatmap.html" %} + {% include "components/artifact_tags.html" %} {% include "components/recall_trend.html" %} {% endblock %} diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index a9e846ec2..f9ef85ba7 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -15,7 +15,7 @@ import re from pathlib import Path -from sqlalchemy import BigInteger, Date, Integer, String, Table +from sqlalchemy import BigInteger, Date, Integer, LargeBinary, String, Table from sqlalchemy.dialects import mysql from sqlalchemy.schema import CreateTable, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint @@ -44,6 +44,9 @@ def _column_budget(column) -> int: return column.type.length * UTF8MB4_MAX_BYTES_PER_CHARACTER if isinstance(column.type, BigInteger): return 8 + if isinstance(column.type, LargeBinary): + assert column.type.length is not None + return column.type.length if isinstance(column.type, Integer): return 4 if isinstance(column.type, Date): @@ -147,5 +150,5 @@ def test_every_mysql_utf8mb4_key_stays_below_the_innodb_limit() -> None: budgets[name] = sum(_column_budget(column) for column in index.columns) assert budgets - assert max(budgets.values()) == 2560 + assert max(budgets.values()) == 2640 assert all(budget < INNODB_MAX_INDEX_BYTES for budget in budgets.values()) diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index 79093d6ba..f56327a4a 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -16,6 +16,7 @@ import asyncio from collections import defaultdict +from pathlib import Path import pytest from pydantic import JsonValue @@ -25,7 +26,7 @@ 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.memory import Memory, MemoryContent from powercontext.builtin.artifacts.skill import Skill from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.experience_index import ExperienceIndex, NoExperienceIndex @@ -56,9 +57,11 @@ ArtifactRevisionPreconditionError, ArtifactWrite, InvalidBaseAccessRequestError, + InvalidCursorError, ) from powercontext.builtin.source_eligibility import SourceNotEligibleError from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER, ContentSource +from powercontext.builtin.tags import ArtifactTagTarget, MemoryEntryTagTarget, TagFilter, TagPreconditionError, TagQuery class _FailingExperienceIndex(NoExperienceIndex): @@ -76,6 +79,111 @@ def _memory_content() -> dict[str, JsonValue]: return {"entries": [{"kind": "preference", "text": "用户偏好使用中文回答"}]} +def test_empty_tag_set_has_one_concurrent_winner_across_connections(tmp_path: Path) -> None: + async def scenario() -> None: + async with SQLiteProfile.open( + SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'tag-race.db'}"), tables=BUILTIN_TABLES + ) as profile: + first, _, _ = _services(profile) + second, _, _ = _services(profile) + created = await first.create_artifact("scope", "memory", ArtifactWrite(content=_memory_content())) + target = ArtifactTagTarget(family="memory", artifact_id=created.artifact_id) + empty = await first.get_tags("scope", target) + outcomes = await asyncio.gather( + first.replace_tags("scope", target, ("one",), expected_etag=empty.etag), + second.replace_tags("scope", target, ("two",), expected_etag=empty.etag), + return_exceptions=True, + ) + assert sum(isinstance(outcome, TagPreconditionError) for outcome in outcomes) == 1 + assert (await first.get_tags("scope", target)).tags in {("one",), ("two",)} + + asyncio.run(scenario()) + + +def test_tag_cursor_is_bound_to_filter_scope_and_caller() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + for _ in range(2): + created = await records.create_artifact("scope", "memory", ArtifactWrite(content=_memory_content())) + target = ArtifactTagTarget(family="memory", artifact_id=created.artifact_id) + empty = await records.get_tags("scope", target) + await records.replace_tags("scope", target, ("shared",), expected_etag=empty.etag) + page = await records.query_tags("scope", TagQuery(tags=("shared",), limit=1), caller="a") + assert page.next_cursor + for scope, label, caller in ( + ("other", "shared", "a"), + ("scope", "different", "a"), + ("scope", "shared", "b"), + ): + with pytest.raises(InvalidCursorError): + await records.query_tags(scope, TagQuery(tags=(label,), cursor=page.next_cursor), caller=caller) + + asyncio.run(scenario()) + + +def test_in_memory_sqlite_supports_concurrent_tag_reads() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + created = await records.create_artifact("scope", "memory", ArtifactWrite(content=_memory_content())) + target = ArtifactTagTarget(family="memory", artifact_id=created.artifact_id) + values = await asyncio.gather(*(records.get_tags("scope", target) for _ in range(3))) + assert len({value.etag for value in values}) == 1 + + asyncio.run(scenario()) + + +def test_artifact_and_entry_tags_preserve_content_and_query_independently() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records, _, _ = _services(profile) + created = await records.create_artifact("scope-a", "memory", ArtifactWrite(content=_memory_content())) + before = await records.get_artifact("scope-a", "memory", created.artifact_id) + artifact = ArtifactTagTarget(family="memory", artifact_id=created.artifact_id) + entry_id = MemoryContent.model_validate(before.content).manifest.entries[0].entry_id + entry = MemoryEntryTagTarget(artifact_id=created.artifact_id, entry_id=entry_id) + empty = await records.get_tags("scope-a", artifact) + tagged = await records.replace_tags("scope-a", artifact, ("Project", "中文"), expected_etag=empty.etag) + entry_empty = await records.get_tags("scope-a", entry) + assert entry_empty.tags == () + assert entry_empty.etag != empty.etag + await records.replace_tags("scope-a", entry, ("project",), expected_etag=entry_empty.etag) + assert await records.get_artifact("scope-a", "memory", created.artifact_id) == before + page = await records.query_tags("scope-a", TagQuery(tags=("PROJECT",), limit=1)) + assert len(page.items) == 1 and page.next_cursor + second = await records.query_tags("scope-a", TagQuery(tags=("project",), limit=1, cursor=page.next_cursor)) + assert {page.items[0].target.type, second.items[0].target.type} == {"artifact", "memory_entry"} + assert second.next_cursor is None + assert (await records.query_tags("other-scope", TagQuery(tags=("project",)))).items == () + all_tags = await records.query_tags("scope-a", TagQuery(tags=("project", "中文"))) + assert [item.target for item in all_tags.items] == [artifact] + assert ( + len((await records.query_tags("scope-a", TagQuery(tags=("project", "中文"), match="any"))).items) == 2 + ) + assert ( + await records.query_artifacts( + "scope-a", "memory", limit=1, cursor=None, tag_filter=TagFilter(tags=("missing",)) + ) + ).items == () + assert ( + len( + ( + await records.query_artifacts( + "scope-a", "memory", limit=1, cursor=None, tag_filter=TagFilter(tags=("project",)) + ) + ).items + ) + == 1 + ) + with pytest.raises(TagPreconditionError): + await records.replace_tags("scope-a", artifact, ("lost update",), expected_etag=empty.etag) + await records.replace_tags("scope-a", artifact, (), expected_etag=tagged.etag) + assert (await records.get_tags("scope-a", entry)).tags == ("project",) + + asyncio.run(scenario()) + + def _handoff_content(objective: str = "Transfer the API test result.") -> dict[str, JsonValue]: return { "schema": "powercontext.handoff.v1", diff --git a/tests/builtin/test_tags.py b/tests/builtin/test_tags.py new file mode 100644 index 000000000..a13aade89 --- /dev/null +++ b/tests/builtin/test_tags.py @@ -0,0 +1,54 @@ +# 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. + +import pytest + +from powercontext.builtin.records import InvalidBaseAccessRequestError +from powercontext.builtin.tags import ArtifactTagTarget, TagFilter, normalize_tags, tag_set + + +def test_unicode_normalization_preserves_display_and_canonical_order() -> None: + assert normalize_tags(("STRASSE", "Cafe\u0301", "中文")) == { + "café": "Cafe\u0301", + "strasse": "STRASSE", + "中文": "中文", + } + assert TagFilter(tags=("Straße",)).keys == ("strasse",) + target = ArtifactTagTarget(family="skill", artifact_id="skill-a") + assert tag_set("scope", target, ("B", "A")) == tag_set("scope", target, ("A", "B")) + assert tag_set("scope", target, ()).etag != tag_set("other", target, ()).etag + + +@pytest.mark.parametrize( + "tags", + [ + ("",), + (" label",), + ("label\n",), + ("a\x00b",), + ("\ud800",), + ("\u0378",), + ("x" * 65,), + ("Café", "Cafe\u0301"), + ("Straße", "STRASSE"), + ], +) +def test_invalid_or_duplicate_tags_fail_without_echoing_input(tags: tuple[str, ...]) -> None: + with pytest.raises(InvalidBaseAccessRequestError) as error: + normalize_tags(tags) + assert error.value.field == "tags" + assert str(error.value) in { + "tags contains an invalid label", + "tags contains an invalid or duplicate normalized label", + } diff --git a/tests/e2e/test_access_control_http.py b/tests/e2e/test_access_control_http.py index 23edbebdb..e89cd87ec 100644 --- a/tests/e2e/test_access_control_http.py +++ b/tests/e2e/test_access_control_http.py @@ -46,7 +46,9 @@ ListAccessResourcesRequest, ListArtifactsRequest, ListMemoryEntriesRequest, + QueryArtifactTagsRequest, ReplaceArtifactRequest, + ReplaceArtifactTagsRequest, RevokeAccessBindingRequest, ) from powercontext.server.authentication import StaticBearerAuthenticationProvider @@ -682,6 +684,146 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.parametrize("family", ["memory", "experience", "skill", "handoff"]) +def test_tag_owners_and_exact_viewers_preserve_scope_boundaries(tmp_path: Path, family: str) -> None: + async def scenario() -> None: + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'tags.db'}") + async with open_builtin_access_control( + database, bootstrap_administrators=(ADMIN,), deployment_id=DEPLOYMENT_ID + ) as access: + async with _client( + _app(database, access, ADMIN, "admin-token", tmp_path / "admin.db"), "admin-token" + ) as admin: + scope = await admin.create_scope( + CreateScopeRequest(title="Private tags", summary="Scoped tag access", idempotency_key="tags") + ) + await admin.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": RECEIVER.type, "id": RECEIVER.id}, + "resource": {"type": "scope", "scope_id": scope.scope_id}, + "role": "scope.contributor", + "idempotency_key": "tag-owner", + }) + ) + async with _client( + _app(database, access, RECEIVER, "owner-token", tmp_path / "owner.db"), "owner-token" + ) as owner: + source = await owner.create_source(scope.scope_id, CreateSourceRequest(content="Tag access evidence")) + content = { + "memory": {"entries": [{"kind": "fact", "text": "Private release rule"}]}, + "experience": { + "situation": "Release", + "action": "Test", + "outcome": "Passed", + "lesson": "Test first", + }, + "skill": { + "name": "release-check", + "description": "Check release", + "instructions": "Run tests", + "validation": ["Tests pass"], + }, + "handoff": { + "schema": "powercontext.handoff.v1", + "objective": "Release checks", + "state": [ + { + "text": "Tag access evidence", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": source.source_id}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + }, + }[family] + artifact = await owner.create_artifact( + scope.scope_id, CreateArtifactRequest.model_validate({"family": family, "content": content}) + ) + entry_id = None + if family == "memory": + head = await owner.get_artifact(scope.scope_id, family, artifact.artifact_id) + assert head is not None + entry_id = head.content["manifest"]["entries"][0]["entry_id"] + + async def read_tags(client): + if entry_id is not None: + return await client.get_memory_entry_tags(scope.scope_id, artifact.artifact_id, entry_id) + return await client.get_artifact_tags(scope.scope_id, family, artifact.artifact_id) + + async def write_tags(client, etag): + request = ReplaceArtifactTagsRequest.model_validate({"tags": ["release", "客户A"]}) + if entry_id is not None: + return await client.replace_memory_entry_tags( + scope.scope_id, artifact.artifact_id, entry_id, request, expected_etag=etag + ) + return await client.replace_artifact_tags( + scope.scope_id, family, artifact.artifact_id, request, expected_etag=etag + ) + + empty = await read_tags(owner) + assert empty is not None + saved = await write_tags(owner, empty.etag) + if family == "memory": + container = await owner.get_artifact_tags(scope.scope_id, family, artifact.artifact_id) + assert container is not None + with pytest.raises(ForbiddenResponseError): + await owner.replace_artifact_tags( + scope.scope_id, + family, + artifact.artifact_id, + ReplaceArtifactTagsRequest(tags=[]), + expected_etag=container.etag, + ) + binding = await owner.create_access_binding( + CreateAccessBindingRequest.model_validate({ + "subject": {"type": VIEWER.type, "id": VIEWER.id}, + "resource": { + "type": "artifact", + "scope_id": scope.scope_id, + "identity": {"family": family, "artifact_id": artifact.artifact_id}, + "selector": None if entry_id is None else {"type": "memory_entry", "entry_id": entry_id}, + }, + "role": "handoff.viewer" if family == "handoff" else "artifact.viewer", + "idempotency_key": "tag-viewer", + }) + ) + async with _client( + _app(database, access, VIEWER, "viewer-token", tmp_path / "viewer.db"), "viewer-token" + ) as viewer: + visible = await read_tags(viewer) + assert visible is not None and visible.tag_set.tags == saved.tag_set.tags + with pytest.raises(ForbiddenResponseError): + await write_tags(viewer, visible.etag) + with pytest.raises(ForbiddenResponseError): + await viewer.query_artifact_tags( + scope.scope_id, QueryArtifactTagsRequest.model_validate({"tags": ["release"]}) + ) + if entry_id is not None: + with pytest.raises(ForbiddenResponseError): + await viewer.get_artifact_tags(scope.scope_id, "memory", artifact.artifact_id) + async with _client( + _app(database, access, ADMIN, "admin-token", tmp_path / "admin.db"), "admin-token" + ) as admin: + await admin.revoke_access_binding( + RevokeAccessBindingRequest( + binding_id=binding.binding_id, expected_version=binding.version, idempotency_key="revoke-tags" + ) + ) + async with _client( + _app(database, access, VIEWER, "viewer-token", tmp_path / "viewer.db"), "viewer-token" + ) as viewer: + with pytest.raises(ForbiddenResponseError): + await read_tags(viewer) + + asyncio.run(scenario()) + + def _app( database: SQLiteConfig, access_control: AccessControlService, diff --git a/tests/e2e/test_artifact_tags.py b/tests/e2e/test_artifact_tags.py new file mode 100644 index 000000000..45c4e764d --- /dev/null +++ b/tests/e2e/test_artifact_tags.py @@ -0,0 +1,322 @@ +# 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. + +"""Observable tag lifecycle through the Server and Python Client.""" + +import asyncio +from pathlib import Path + +import httpx +import pytest + +from powercontext.builtin.artifacts.memory import EmbeddingProfile, MemoryEntryInput +from powercontext.builtin.inference import EmbeddingResult +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts +from powercontext.builtin.tags import MemoryEntryTagTarget, TagFilter +from powercontext.client import PowerContextClient +from powercontext.http import QueryArtifactTagsRequest, ReplaceArtifactTagsRequest +from powercontext.server.authentication import StaticBearerAuthenticationProvider +from powercontext.server.authz import PrincipalRef +from powercontext.server.factory import create_server_app +from powercontext.server.settings import AccessControlConfig, McpConfig, ServerSettings + + +class _EmbeddingModel: + profile = EmbeddingProfile(profile_id="tag-test", model="tag-test", dimension=3) + + async def embed(self, texts: tuple[str, ...], /) -> EmbeddingResult: + return EmbeddingResult(vectors=tuple((1.0, 0.0, 0.0) for _ in texts)) + + +@pytest.mark.parametrize( + ("method", "path", "payload"), + [ + ("GET", "/artifacts/experience/private/tags", None), + ("PUT", "/artifacts/experience/private/tags", {"tags": ["private"]}), + ("GET", "/artifacts/memory/private/entries/private/tags", None), + ("PUT", "/artifacts/memory/private/entries/private/tags", {"tags": ["private"]}), + ("POST", "/artifact-tags/query", {"tags": ["private"]}), + ], +) +def test_tag_routes_reject_principals_without_access(tmp_path: Path, method: str, path: str, payload) -> None: + async def scenario() -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'access.db'}"), + access=AccessControlConfig(mode="enforced"), + mcp=McpConfig(enabled=False), + ), + authentication_provider=StaticBearerAuthenticationProvider( + "tag-test-token", PrincipalRef(type="user", id="outsider") + ), + ) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client, + ): + url = "/v1/scopes/private" + path + anonymous = await client.request(method, url, json=payload) + assert anonymous.status_code == 401 + denied = await client.request( + method, + url, + json=payload, + headers={"Authorization": "Bearer tag-test-token", "If-Match": '"unknown"'}, + ) + assert denied.status_code == 403, denied.text + + asyncio.run(scenario()) + + +def test_tag_search_filters_before_candidate_limits_and_survives_rebuild(tmp_path: Path) -> None: + async def scenario() -> None: + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'candidates.db'}")) + async with open_builtin_contexts(config, embedding_model=_EmbeddingModel()) as contexts: + service = (await contexts.get("project")).artifacts.memory + memory = await service.remember( + memory=None, + entries=tuple(MemoryEntryInput(kind="fact", text=f"Compatibility test {i:02d}.") for i in range(48)), + mode="append", + ) + assert memory is not None + # Equal vector distances and text ranks leave this entry beyond the + # unfiltered candidate window. Filtering after top-k would lose it. + entry = max(memory.content.manifest.entries, key=lambda item: item.entry_id) + target = MemoryEntryTagTarget(artifact_id=memory.artifact_id, entry_id=entry.entry_id) + empty = await contexts.records.get_tags("project", target) + tagged = await contexts.records.replace_tags("project", target, ("chosen",), expected_etag=empty.etag) + for mode in ("fts", "vector", "hybrid"): + unfiltered = await service.search("compatibility test", memories=(memory,), mode=mode, limit=32) + assert len(unfiltered.hits) == 32 + assert entry.entry_id not in {hit.entry_id for hit in unfiltered.hits} + result = await service.search( + "compatibility test", memories=(memory,), mode=mode, limit=1, tag_filter=TagFilter(tags=("chosen",)) + ) + assert [hit.entry_id for hit in result.hits] == [entry.entry_id] + await service.rebuild_projections() + assert await contexts.records.get_tags("project", target) == tagged + rebuilt = await service.search( + "compatibility test", memories=(memory,), mode="fts", limit=1, tag_filter=TagFilter(tags=("chosen",)) + ) + assert [hit.entry_id for hit in rebuilt.hits] == [entry.entry_id] + + asyncio.run(scenario()) + + +def test_http_tags_cover_families_entries_filters_and_inactive_lifecycle(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'tags.db'}"), mcp=McpConfig(enabled=False) + ), + embedding_model=_EmbeddingModel(), + ) + + asyncio.run(exercise_tag_http(app)) + + +async def exercise_tag_http(app, *, token: str | None = None) -> str: + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers={} if token is None else {"Authorization": f"Bearer {token}"}, + ) as http, + ): + client = PowerContextClient("http://testserver", token=token, http_client=http, trust_transport_security=True) + scope_response = await http.post( + "/v1/scopes", + json={ + "title": "Tag acceptance", + "summary": "Disposable tag acceptance scope", + "idempotency_key": "tag-test", + }, + ) + assert scope_response.status_code == 201, scope_response.text + scope = scope_response.json()["scope_id"] + source = await http.post( + f"/v1/scopes/{scope}/sources", json={"source_type": "content", "content": "Tag acceptance evidence"} + ) + assert source.status_code == 201, source.text + contents = { + "memory": { + "entries": [ + {"kind": "decision", "text": "alpha compatibility check"}, + {"kind": "decision", "text": "alpha fallback check"}, + ] + }, + "experience": { + "situation": "Compatibility failure", + "action": "Run tests", + "outcome": "Passed", + "lesson": "Test before release", + }, + "skill": { + "name": "tag-test", + "description": "Check release", + "instructions": "Run compatibility tests", + "validation": ["Tests pass"], + }, + "handoff": { + "schema": "powercontext.handoff.v1", + "objective": "Tag acceptance", + "state": [ + { + "text": "Tag acceptance evidence", + "citations": [ + { + "kind": "source", + "source_ref": {"name": "content", "source_id": source.json()["source_id"]}, + } + ], + } + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + }, + } + artifacts = {} + for family, content in contents.items(): + if family == "memory": + for text in ("alpha compatibility check", "alpha fallback check"): + remembered = await http.post( + "/v1/memory/remember", json={"scope_id": scope, "kind": "decision", "text": text} + ) + assert remembered.status_code == 200, remembered.text + artifact_id = remembered.json()["memory"]["artifact_id"] + else: + created = await http.post(f"/v1/scopes/{scope}/artifacts", json={"family": family, "content": content}) + assert created.status_code == 201, created.text + artifact_id = created.json()["artifact_id"] + artifacts[family] = artifact_id + path = f"/v1/scopes/{scope}/artifacts/{family}/{artifact_id}" + before = (await http.get(path)).json() + current = await client.get_artifact_tags(scope, family, artifact_id) + assert current is not None and current.tag_set.tags == [] + missing = await http.put(path + "/tags", json={"tags": ["release"]}) + assert missing.status_code == 428 + tagged = await client.replace_artifact_tags( + scope, + family, + artifact_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["Release", "客户A"]}), + expected_etag=current.etag, + ) + assert (await http.get(path)).json() == before + assert await client.get_artifact_tags(scope, family, artifact_id, if_none_match=tagged.etag) is None + conflict = await http.put(path + "/tags", json={"tags": []}, headers={"If-Match": current.etag}) + assert conflict.status_code == 412 + duplicate = await http.put( + path + "/tags", json={"tags": ["Straße", "STRASSE"]}, headers={"If-Match": tagged.etag} + ) + assert duplicate.status_code == 422 + reloaded = await client.get_artifact_tags(scope, family, artifact_id) + assert reloaded is not None and reloaded.etag == tagged.etag + filtered = await http.get(f"/v1/scopes/{scope}/artifacts/{family}", params={"tag": "release", "limit": 1}) + assert filtered.status_code == 200 and len(filtered.json()["items"]) == 1 + if family == "experience": + competing = await asyncio.gather( + *( + http.put( + path + "/tags", + json={"tags": ["Release", "客户A", label]}, + headers={"If-Match": tagged.etag}, + ) + for label in ("writer-a", "writer-b") + ) + ) + assert sorted(response.status_code for response in competing) == [200, 412] + matches = await client.query_artifact_tags( + scope, QueryArtifactTagsRequest.model_validate({"tags": ["RELEASE"]}) + ) + assert len(matches.items) == 4 + destination = await http.post( + "/v1/scopes", + json={"title": "Publication target", "summary": "Independent tags", "idempotency_key": "tag-copy"}, + ) + assert destination.status_code == 201 + target_scope = destination.json()["scope_id"] + published = await http.post( + "/v1/artifact-publications", + json={ + "source": { + "scope_id": scope, + "artifact": {"family": "experience", "artifact_id": artifacts["experience"], "revision": 1}, + }, + "target_scope_id": target_scope, + "idempotency_key": "tag-copy", + }, + ) + assert published.status_code == 201, published.text + copy_id = published.json()["target"]["artifact"]["artifact_id"] + copy_tags = await client.get_artifact_tags(target_scope, "experience", copy_id) + assert copy_tags is not None and copy_tags.tag_set.tags == [] + memory_id = artifacts["memory"] + listed = await http.post("/v1/memory/entries/list", json={"scope_id": scope}) + assert listed.status_code == 200, listed.text + entries = listed.json()["entries"] + entry = entries[-1] + entry_id = entry["citation"]["entry_id"] + empty = await client.get_memory_entry_tags(scope, memory_id, entry_id) + assert empty is not None + state = await client.replace_memory_entry_tags( + scope, + memory_id, + entry_id, + ReplaceArtifactTagsRequest.model_validate({"tags": ["selected"]}), + expected_etag=empty.etag, + ) + for mode in ("fts", "vector", "hybrid"): + response = await http.post( + "/v1/memory/search", + json={ + "scope_id": scope, + "query": entry["text"], + "mode": mode, + "limit": 1, + "tag_filter": {"tags": ["SELECTED"]}, + }, + ) + assert response.status_code == 200, response.text + assert [hit["citation"]["entry_id"] for hit in response.json()["hits"]] == [entry_id] + filtered = await http.post( + "/v1/memory/entries/list", json={"scope_id": scope, "tag_filter": {"tags": ["selected"]}} + ) + assert [item["citation"]["entry_id"] for item in filtered.json()["entries"]] == [entry_id] + retired = await http.post( + "/v1/memory/entries/retire", + json={"scope_id": scope, "citation": entry["citation"], "reason": "Tag lifecycle acceptance"}, + ) + assert retired.status_code == 200, retired.text + reloaded_entry = await client.get_memory_entry_tags(scope, memory_id, entry_id) + assert reloaded_entry is not None and reloaded_entry.etag == state.etag + hidden = await client.query_artifact_tags( + scope, QueryArtifactTagsRequest.model_validate({"tags": ["selected"]}) + ) + assert hidden.items == [] + inactive = await client.query_artifact_tags( + scope, QueryArtifactTagsRequest.model_validate({"tags": ["selected"], "include_inactive": True}) + ) + assert len(inactive.items) == 1 + assert ( + inactive.items[0].model_dump(mode="json")["reference"]["memory_ref"]["revision"] + == retired.json()["memory"]["revision"] + ) + cleared = await client.replace_memory_entry_tags( + scope, memory_id, entry_id, ReplaceArtifactTagsRequest(tags=[]), expected_etag=state.etag + ) + assert cleared.tag_set.tags == [] + return scope diff --git a/tests/e2e/test_real_artifact_tags.py b/tests/e2e/test_real_artifact_tags.py new file mode 100644 index 000000000..03add00fc --- /dev/null +++ b/tests/e2e/test_real_artifact_tags.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. + +"""Opt-in real .env model/database acceptance with disposable storage. + +Run: uv run pytest tests/e2e/test_real_artifact_tags.py --run-real-e2e -q +OceanBase requires permission to create and drop an isolated test database. +""" + +import asyncio +import json +from pathlib import Path +from uuid import uuid4 + +import httpx +import pytest +from pydantic import SecretStr +from sqlalchemy.engine import make_url + +from powercontext.builtin.persistence.oceanbase import OceanBaseConfig, OceanBaseProfile +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.http._generated.operations import CAPTURE_CONTENT_SOURCE +from powercontext.server.configuration import server_settings_context +from powercontext.server.factory import create_server_app +from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings +from tests.e2e.test_artifact_tags import exercise_tag_http + + +async def _generated_journey(settings: ServerSettings, *, token: str | None = None) -> None: + app = create_server_app(settings=settings) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + timeout=120, + headers={} if token is None else {"Authorization": f"Bearer {token}"}, + ) as client, + ): + created = await client.post( + "/v1/scopes", + json={ + "title": "Real tag generation", + "summary": "Isolated provider acceptance", + "idempotency_key": uuid4().hex, + }, + ) + assert created.status_code == 201, "scope creation failed" + scope = created.json()["scope_id"] + source = await client.post( + CAPTURE_CONTENT_SOURCE.path, + json={ + "scope_id": scope, + "source_id": "engineering-rule", + "content": "For this project, always run the compatibility test suite before every release. This is a permanent engineering rule, not a temporary task.", + }, + ) + assert source.status_code == CAPTURE_CONTENT_SOURCE.success_status, ( + f"source capture status {source.status_code}" + ) + flushed = await client.post("/v1/memory/flush", json={"scope_id": scope}) + assert flushed.status_code == 200, f"real generation status {flushed.status_code}" + listed = await client.post("/v1/memory/entries/list", json={"scope_id": scope}) + assert listed.status_code == 200 + entries = listed.json()["entries"] + assert entries, "real model produced no durable entries" + entry = entries[0] + citation = entry["citation"] + path = f"/v1/scopes/{scope}/artifacts/memory/{citation['memory_ref']['artifact_id']}/entries/{citation['entry_id']}/tags" + current = await client.get(path) + assigned = await client.put( + path, json={"tags": ["real-generated", "客户验收"]}, headers={"If-Match": current.headers["ETag"]} + ) + assert assigned.status_code == 200 + for mode in ("fts", "vector", "hybrid"): + result = await client.post( + "/v1/memory/search", + json={ + "scope_id": scope, + "query": entry["text"], + "mode": mode, + "limit": 1, + "tag_filter": {"tags": ["REAL-GENERATED"]}, + }, + ) + assert result.status_code == 200, f"{mode} search status {result.status_code}" + assert [item["citation"]["entry_id"] for item in result.json()["hits"]] == [citation["entry_id"]], ( + f"{mode} lost eligible generated entry" + ) + print( + json.dumps({ + "database": settings.database.kind, + "access_mode": settings.access.mode, + "real_generation": "passed", + "generated_entries": len(entries), + "real_embedding": "passed", + "tagged_search_modes": ["fts", "vector", "hybrid"], + }), + flush=True, + ) + + +@pytest.mark.parametrize("backend", ["oceanbase", "sqlite"]) +@pytest.mark.parametrize("access_mode", ["disabled", "enforced"]) +def test_real_models_and_tags(backend: str, access_mode: str, tmp_path: Path, pytestconfig: pytest.Config) -> None: + if not pytestconfig.getoption("run_real_e2e"): + pytest.skip("pass --run-real-e2e to use .env models and disposable databases") + env_file = pytestconfig.getoption("real_e2e_env_file") + with server_settings_context(env_file=env_file, data_dir=tmp_path / "state") as configured: + assert configured.inference.generation_model and configured.inference.embedding_model + + async def exercise(database: OceanBaseConfig | SQLiteConfig) -> None: + token = "isolated-tag-acceptance" if access_mode == "enforced" else None + settings = configured.model_copy( + update={ + "database": database, + "mcp": McpConfig(enabled=False), + "auth": BearerAuthConfig(token=None if token is None else SecretStr(token)), + "access": AccessControlConfig.model_validate({"mode": access_mode}), + } + ) + await exercise_tag_http(create_server_app(settings=settings), token=token) + await _generated_journey(settings, token=token) + + async def scenario() -> None: + if backend == "sqlite": + await exercise(SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'real-tags.db'}")) + return + assert isinstance(configured.database, OceanBaseConfig) + database_name = "pc_tag_accept_" + uuid4().hex[:16] + async with OceanBaseProfile.open(configured.database, tables=()) as admin: + async with admin.database.transaction() as connection: + await connection.exec_driver_sql( + f"CREATE DATABASE `{database_name}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin" + ) + try: + url = make_url(configured.database.url.get_secret_value()).set(database=database_name) + await exercise(OceanBaseConfig(url=SecretStr(url.render_as_string(hide_password=False)))) + finally: + async with admin.database.transaction() as connection: + await connection.exec_driver_sql(f"DROP DATABASE `{database_name}`") + + asyncio.run(scenario()) diff --git a/tests/pydantic_ai_adapter/test_toolset.py b/tests/pydantic_ai_adapter/test_toolset.py index 8ca9ce846..ca31dd413 100644 --- a/tests/pydantic_ai_adapter/test_toolset.py +++ b/tests/pydantic_ai_adapter/test_toolset.py @@ -85,6 +85,7 @@ async def scenario() -> Any: client = RecordingClient.instances[0] assert [request.explicit_scope_id for request in client.resolve_scope_requests] == ["project:tools"] assert client.search_requests[0].model_dump(mode="json") == { + "tag_filter": None, "scope_id": "project:tools", "query": "public response", "limit": 4, diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index f0caa329b..b9ac640cd 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -689,11 +689,21 @@ def test_generated_transport_rejects_values_outside_openapi( model.model_validate(value) -def test_base_access_contract_uses_only_the_seven_scoped_operations() -> None: +def test_base_access_and_tag_contract_use_scoped_operations() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) paths = contract["paths"] expected_operations = { + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", "get"): "get_artifact_tags", + ("/v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/tags", "put"): "replace_artifact_tags", + ( + "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + "get", + ): "get_memory_entry_tags", + ( + "/v1/scopes/{scope_id}/artifacts/memory/{artifact_id}/entries/{entry_id}/tags", + "put", + ): "replace_memory_entry_tags", ("/v1/scopes/{scope_id}/sources", "post"): "create_source", ("/v1/scopes/{scope_id}/sources/{source_type}/{source_id}", "get"): "get_source", ("/v1/scopes/{scope_id}/artifacts", "post"): "create_artifact", @@ -775,14 +785,19 @@ def test_base_access_create_requests_leave_identity_generation_to_the_server() - model.model_validate(payload) -def test_artifact_collection_only_accepts_pagination() -> None: +def test_artifact_collection_accepts_pagination_and_exact_tag_filters() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) paths = contract["paths"] assert "/v1/scopes/{scope_id}/sources/{source_type}" not in paths parameters = paths["/v1/scopes/{scope_id}/artifacts/{family}"]["get"]["parameters"] - assert [parameter["name"] for parameter in parameters if parameter["in"] == "query"] == ["limit", "cursor"] - assert ListArtifactsRequest().model_dump() == {"limit": 50, "cursor": None} + assert {parameter["name"] for parameter in parameters if parameter["in"] == "query"} == { + "limit", + "cursor", + "tag", + "tag_match", + } + assert ListArtifactsRequest().model_dump() == {"limit": 50, "cursor": None, "tag": None, "tag_match": None} def test_base_access_uses_a_dedicated_source_type_reference() -> None: