From 718f6b52deae5a31666a6babb7ec77d21e6c0c3c Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Wed, 28 Oct 2026 17:50:03 +0800 Subject: [PATCH 01/13] feat(sources): preserve citation position locators --- .../src-tauri/src/transport/operations.json | 2 +- desktop/src-tauri/src/transport/wire.rs | 97 ++++++ desktop/ui/src/generated/api.d.ts | 25 ++ desktop/ui/src/generated/operations.ts | 2 +- docs/en/docs/workflows/sources.md | 50 +++ docs/zh/docs/workflows/sources.md | 44 +++ openapi/powercontext.yaml | 51 +++ release/upgrade-support.json | 4 +- scripts/generate_api.py | 62 ++++ src/powercontext/__init__.py | 6 + .../artifacts/atomic_memory/extraction.py | 22 +- .../artifacts/atomic_memory/reconciliation.py | 2 +- src/powercontext/builtin/artifacts/merge.py | 2 +- src/powercontext/builtin/evidence/resolver.py | 2 +- .../builtin/persistence/artifacts.py | 68 +++- .../migrations/atomic_projection.py | 2 + .../migrations/server_resources/manifest.json | 17 +- .../mysql/versions/v1_4_1_005.py | 24 ++ .../schemas/mysql-v1_4_1_005.json | 141 ++++++++ .../schemas/sqlite-v1_4_1_005.json | 3 + .../versions/v1_4_1_005_source_locators.py | 28 ++ .../server_sources/1.4.0/manifest.json | 17 +- .../persistence/migrations/vector_profile.py | 5 + .../builtin/persistence/sources.py | 23 +- .../builtin/persistence/tables.py | 24 ++ .../builtin/portability/bundle.py | 30 +- src/powercontext/builtin/review/service.py | 4 +- .../builtin/sources/observation_validation.py | 25 +- src/powercontext/http/__init__.py | 6 + src/powercontext/http/_generated/models.py | 196 ++++++----- src/powercontext/http/_generated/schema.py | 58 ++++ src/powercontext/server/app.py | 8 + src/powercontext/server/mapping.py | 25 +- src/powercontext/sources/__init__.py | 13 +- src/powercontext/sources/models.py | 34 +- src/powercontext/sources/projections.py | 3 +- .../atomic_memory/test_extraction_evidence.py | 13 + .../test_extraction_evidence_projection.py | 9 +- tests/builtin/persistence/test_artifacts.py | 66 +++- .../test_full_server_migrations.py | 4 +- .../test_mysql_migration_runner.py | 16 +- .../test_owner_recovery_migration.py | 11 +- .../test_portable_storage_migration.py | 11 +- .../test_release140_server_migrations.py | 2 +- tests/builtin/portability/test_bundle.py | 30 +- tests/builtin/review/test_service.py | 31 ++ tests/e2e/test_artifact_dreaming.py | 52 ++- .../test_atomic_memory_flush_concurrency.py | 13 +- .../test_atomic_memory_generation_merge.py | 58 +++- ...est_atomic_memory_supervisor_generation.py | 5 +- tests/e2e/test_atomic_memory_wire_evidence.py | 56 ++++ tests/e2e/test_source_citation_locators.py | 159 +++++++++ tests/e2e/test_zcode_service_chain.py | 8 +- ...ext_evidence_manifest_before_locators.json | 311 ++++++++++++++++++ .../native/test_oceanbase_engine_recovery.py | 2 +- .../native/test_personal_service_lifecycle.py | 2 +- tests/test_api_contract.py | 6 +- tests/test_managed_atomic_maintenance.py | 4 +- tests/test_managed_processing_maintenance.py | 2 +- tests/test_migration_revision_names.py | 14 +- .../test_registered_source_maintenance_cli.py | 2 +- tests/test_source_locators.py | 97 ++++++ tests/test_source_observations.py | 24 ++ 63 files changed, 1965 insertions(+), 168 deletions(-) create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json create mode 100644 src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_source_locators.py create mode 100644 tests/e2e/test_source_citation_locators.py create mode 100644 tests/fixtures/text_evidence_manifest_before_locators.json create mode 100644 tests/test_source_locators.py diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index b041e70422..830d7e430d 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a", + "contractSha256": "8f04516fa14119300005366b7de26a42f5fa0a43119209b7e4654987b611aaf9", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 70912c8814..022654303f 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -1467,6 +1467,94 @@ pub enum Product { Powercontext, } +/// SourceLineRangeLocator : Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct SourceLineRangeLocator { + #[serde(rename = "kind")] + pub kind: SourceLineRangeLocatorKind, + #[serde(rename = "start_line")] + pub start_line: i64, + #[serde(rename = "end_line")] + pub end_line: i64, + /// Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. + #[ts(optional)] + #[ts(type = "string | null")] + #[serde( + rename = "heading_path", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub heading_path: Option>, +} + +impl SourceLineRangeLocator { + /// Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. + #[allow(clippy::too_many_arguments)] + pub fn new( + kind: SourceLineRangeLocatorKind, + start_line: i64, + end_line: i64, + ) -> SourceLineRangeLocator { + SourceLineRangeLocator { + kind, + start_line, + end_line, + heading_path: None, + } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum SourceLineRangeLocatorKind { + #[serde(rename = "line_range")] + #[default] + LineRange, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "kind")] +pub enum SourceLocator { + #[serde(rename = "sequence")] + Sequence(SourceSequenceLocator), + #[serde(rename = "line_range")] + LineRange(SourceLineRangeLocator), +} + +impl Default for SourceLocator { + fn default() -> Self { + Self::Sequence(Default::default()) + } +} + +/// SourceSequenceLocator : Position of this exact Source in its scope journal; zero is distinct from absence. +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct SourceSequenceLocator { + #[serde(rename = "kind")] + pub kind: SourceSequenceLocatorKind, + #[serde(rename = "sequence")] + pub sequence: i64, +} + +impl SourceSequenceLocator { + /// Position of this exact Source in its scope journal; zero is distinct from absence. + #[allow(clippy::too_many_arguments)] + pub fn new(kind: SourceSequenceLocatorKind, sequence: i64) -> SourceSequenceLocator { + SourceSequenceLocator { kind, sequence } + } +} +#[derive( + Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, TS, Default, +)] +pub enum SourceSequenceLocatorKind { + #[serde(rename = "sequence")] + #[default] + Sequence, +} + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct SourceTypeReference { @@ -1476,6 +1564,9 @@ pub struct SourceTypeReference { /// Source identity as accepted at ingestion, including Unicode and interior spaces. #[serde(rename = "source_id")] pub source_id: String, + #[ts(optional)] + #[serde(rename = "locator", skip_serializing_if = "Option::is_none")] + pub locator: Option, } impl SourceTypeReference { @@ -1484,6 +1575,7 @@ impl SourceTypeReference { SourceTypeReference { source_type, source_id, + locator: None, } } } @@ -1582,6 +1674,11 @@ pub fn declarations(config: &ts_rs::Config) -> Vec { ::decl(config), ::decl(config), ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), + ::decl(config), ::decl(config), ::decl(config), ::decl(config), diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index f2ef1ceadf..30a8d9547c 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -2778,6 +2778,7 @@ export interface components { DreamSourceReference: { source_type: string; source_id: string; + locator?: components["schemas"]["SourceLocator"]; }; ListDreamRunsRequest: { status?: components["schemas"]["DreamStatus"]; @@ -4853,16 +4854,40 @@ export interface components { items: components["schemas"]["SourceRecord"][]; next_cursor: string | null; }; + /** @description Position of this exact Source in its scope journal; zero is distinct from absence. */ + SourceSequenceLocator: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "sequence"; + sequence: number; + }; + /** @description Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. */ + SourceLineRangeLocator: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "line_range"; + start_line: number; + end_line: number; + /** @description Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. */ + heading_path?: string | null; + }; + SourceLocator: components["schemas"]["SourceSequenceLocator"] | components["schemas"]["SourceLineRangeLocator"]; SourceTypeReference: { /** @description Stable Source type, including dynamically registered Source names. */ source_type: string; /** @description Source identity as accepted at ingestion, including Unicode and interior spaces. */ source_id: string; + locator?: components["schemas"]["SourceLocator"]; }; SourceReference: { /** @description Stable Source type. */ name: string; source_id: string; + locator?: components["schemas"]["SourceLocator"]; }; /** @enum {string} */ CaptureStatus: "accepted"; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 06889f38b5..c518a9af77 100644 --- a/desktop/ui/src/generated/operations.ts +++ b/desktop/ui/src/generated/operations.ts @@ -15,7 +15,7 @@ */ // Generated from openapi/powercontext.yaml. Do not edit. -export const contractSha256 = "c12901b88b8e958c9cd63469298b9a04b9d5e2b0b4564d83b50f7ae95542519a"; +export const contractSha256 = "8f04516fa14119300005366b7de26a42f5fa0a43119209b7e4654987b611aaf9"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/docs/workflows/sources.md b/docs/en/docs/workflows/sources.md index 78de15d770..4ee9eb8125 100644 --- a/docs/en/docs/workflows/sources.md +++ b/docs/en/docs/workflows/sources.md @@ -34,3 +34,53 @@ content under the same identity as if it were a new observation. - Application or evaluation events: use the application's adapter; [Bub capture](../integrations/evaluation.md) is opt-in. Source writes and reads are subject to the selected [Scope and access policy](scopes-and-access.md). + +## Cite a position within a Source + +A Source reference can carry an optional `locator`. The Source identity remains `(source_type, source_id)` within +its Scope; a locator describes evidence position and never selects another Source or Artifact Revision. +HTTP inputs that use `name` instead of `source_type` accept the same locator. + +```json +{ + "source_type": "document", + "source_id": "design-note", + "locator": { + "kind": "line_range", + "start_line": 40, + "end_line": 55, + "heading_path": "Architecture / Constraints" + } +} +``` + +- `kind: sequence` carries a strict nonnegative integer `sequence`, the Source's position in the Scope journal. + The stored journal currently starts at 1. A supplied position must match that Source's recorded position; + a mismatched value is rejected, not used to redirect the reference. A serialized zero is distinct from no locator. +- `kind: line_range` carries strict positive integers `start_line` and `end_line`. Both endpoints are included; + the range must contain between 1 and 200 lines. Invalid ranges are rejected with `invalid_request` on HTTP, + with `source_locator_range` in validation details for reversed or excessive spans; they are never truncated. +- `heading_path` is optional on a line range and contains at most 1024 Unicode characters, not UTF-8 bytes. + Lines refer to the exact decoded text supplied by the adapter. These locators introduce no byte offsets. + +Atomic Memory extraction derives a journal locator from the persisted capture position for built-in captured +Content Sources and other Sources without a finer position. An adapter may report a line range in +`TextEvidence.locator`; worker observations preserve it in the text-evidence projection, and extraction keeps it +instead of guessing an offset. Adapters must report a location in their exact evidence text, not in a subsequently +edited file. Opaque sources and ordinary caller-supplied references may omit the locator. Other generation APIs +preserve explicitly supplied locators; they do not infer line numbers or heading paths. No read-time text scan or +historical backfill is performed. Existing worker manifests retain their exact fingerprints; a worker adopting +the expanded text-evidence schema must increment its Source Definition version before registering it. + +Locators survive candidate payloads, Artifact lineage, Handoff citations, HTTP/MCP reads and portable bundle +export/restore. Distinct positions in the same Source remain distinct citations. A missing locator stays omitted +in serialized references, preserving existing records and canonical encodings. Exact Artifact Revision and Source +eligibility checks still apply. + +Server database revision `v1_4_1_005` adds the optional `pc_artifact_source_locators` relation keyed by the existing +Artifact Revision and Source-edge ordinal. Upgrade existing managed databases through the normal +[database migration workflow](../operate/database-migrations.md) before Server startup; existing lineage rows and +historical payloads are not rewritten. Atomic Memory embeddings are bound to its content, not this lineage metadata, +so this upgrade does not invalidate existing vectors or require re-embedding. A Handoff that includes a locator in +its content has a corresponding content digest, as for any other content change. Older binaries reject portable +bundles containing the new `artifact_source_locator` record kind; bundles without it remain supported. diff --git a/docs/zh/docs/workflows/sources.md b/docs/zh/docs/workflows/sources.md index 98f7b7519a..8cdb46b728 100644 --- a/docs/zh/docs/workflows/sources.md +++ b/docs/zh/docs/workflows/sources.md @@ -32,3 +32,47 @@ Work Contract 和 Task Outcome 也会保留 Source 证据。 - 应用或评测事件:使用应用对应适配器;[Bub 采集](../integrations/evaluation.md)需要显式启用。 Source 读写受所选 [Scope 与访问策略](scopes-and-access.md)约束。 + +## 引用 Source 中的具体位置 + +Source 引用可携带可选的 `locator`。Source 在 Scope 内的身份仍由 `(source_type, source_id)` 确定; +locator 只描述证据位置,不会选择其他 Source 或 Artifact Revision。 +使用 `name` 代替 `source_type` 的 HTTP 输入同样接受该字段。 + +```json +{ + "source_type": "document", + "source_id": "design-note", + "locator": { + "kind": "line_range", + "start_line": 40, + "end_line": 55, + "heading_path": "架构 / 约束" + } +} +``` + +- `kind: sequence` 使用严格的非负整数 `sequence`,表示 Source 在当前 Scope journal 中的位置。 + 当前 journal 从 1 开始。提交的位置必须与该 Source 的记录一致,否则明确拒绝,不会跳转到其他记录。 + 序列化后的零值与没有 locator 可以区分。 +- `kind: line_range` 使用严格的正整数 `start_line` 和 `end_line`,包含首尾两行,范围必须为 1 至 200 行。 + 非法范围在 HTTP 返回 `invalid_request`;范围倒置或跨度超限时,校验详情包含 `source_locator_range`,不会静默截断。 +- 行范围可附带 `heading_path`,最多 1024 个 Unicode 字符,按字符而非 UTF-8 字节计数。 + 行号对应适配器提供的准确解码文本;此功能不引入字节偏移量。 + +Atomic Memory 提取会为内置 Content Source 及没有更细位置的 Source,根据持久化的采集位置生成 journal locator。 +适配器可通过 `TextEvidence.locator` 报告行范围;远程 worker observation 在文本证据投影中保存该信息,提取时原样保留, +不会猜测偏移。适配器报告的位置必须对应准确的证据文本,不能指向随后被编辑的文件。 +不透明来源和调用方提供的普通引用可以省略 locator。其他生成接口保留显式传入的 locator,不推测行号或标题路径。 +读取时不会扫描文本定位,也不会给历史引用回填推测的位置。已有 worker manifest 保留原指纹; +worker 采用扩展后的文本证据 schema 时,须先提高 Source Definition 版本再注册。 + +locator 会经过 Candidate 负载、Artifact 血缘、Handoff 引用、HTTP/MCP 读取以及便携备份导出恢复而保留。 +同一 Source 的不同位置是不同的引用。缺失的 locator 在序列化时仍被省略,已有记录及其规范编码保持兼容。 +精确 Artifact Revision 校验及 Source 使用资格校验继续生效。 + +Server 数据库修订 `v1_4_1_005` 增加可选的 `pc_artifact_source_locators` 关系表,按现有 Artifact Revision +及来源引用序号关联。已有托管数据库须在 Server 启动前按[数据库迁移流程](../operate/database-migrations.md)升级; +迁移不会重写已有血缘行或历史负载。Atomic Memory 向量绑定记忆内容,不包含这份血缘位置元数据,因此升级不会导致既有向量失效, +也无需重新嵌入。Handoff 内容中新增 locator 时,内容摘要随之变化,与其他内容修改一致。 +旧版本程序会拒绝含新 `artifact_source_locator` 记录类型的便携备份;不含该记录的旧备份继续受支持。 diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index f08902602e..0f40317e6d 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -6603,6 +6603,9 @@ components: type: string minLength: 1 maxLength: 256 + locator: + $ref: "#/components/schemas/SourceLocator" + nullable: true ListDreamRunsRequest: type: object additionalProperties: false @@ -11953,6 +11956,48 @@ components: next_cursor: type: string nullable: true + SourceSequenceLocator: + type: object + additionalProperties: false + required: [kind, sequence] + description: Position of this exact Source in its scope journal; zero is distinct from absence. + properties: + kind: + type: string + enum: [sequence] + sequence: + type: integer + minimum: 0 + SourceLineRangeLocator: + type: object + additionalProperties: false + required: [kind, start_line, end_line] + description: Inclusive one-based lines in adapter-supplied text, at most 200 lines. Never a revision redirect. + x-powercontext-source-line-range: true + properties: + kind: + type: string + enum: [line_range] + start_line: + type: integer + minimum: 1 + end_line: + type: integer + minimum: 1 + heading_path: + type: string + nullable: true + maxLength: 1024 + description: Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes. + SourceLocator: + oneOf: + - $ref: "#/components/schemas/SourceSequenceLocator" + - $ref: "#/components/schemas/SourceLineRangeLocator" + discriminator: + propertyName: kind + mapping: + sequence: "#/components/schemas/SourceSequenceLocator" + line_range: "#/components/schemas/SourceLineRangeLocator" SourceTypeReference: type: object additionalProperties: false @@ -11966,6 +12011,9 @@ components: minLength: 1 maxLength: 256 description: Source identity as accepted at ingestion, including Unicode and interior spaces. + locator: + $ref: "#/components/schemas/SourceLocator" + nullable: true SourceReference: type: object additionalProperties: false @@ -11976,6 +12024,9 @@ components: description: Stable Source type. source_id: type: string + locator: + $ref: "#/components/schemas/SourceLocator" + nullable: true CaptureStatus: type: string enum: [accepted] diff --git a/release/upgrade-support.json b/release/upgrade-support.json index a34cf9674f..4dcb3af39c 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.4.1", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "99d448c2b9f3a3ef23a5e348a747d9e277c4cca13ad3bee475b26d10faabac98", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB full-server-code and full-server-vector-code are temporarily manual-only due to native DDL waits with the available engine. Main and publication require the other ten; no manual result is needed for publication. The two deferred configurations remain unverified and are excluded from generated supported upgrade declarations. Reports retain their identities and explicitly record incomplete full-matrix acceptance. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the same automatic matrix, manual-only configurations and publication restrictions." + "persistence_sha256": "4cb30e6ef6e1b7167f5e10c6711352ae44cc0771ed0de94ff578c383573e8e87", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB full-server-code and full-server-vector-code are temporarily manual-only due to native DDL waits with the available engine. Main and publication require the other ten; no manual result is needed for publication. The two deferred configurations remain unverified and are excluded from generated supported upgrade declarations. Reports retain their identities and explicitly record incomplete full-matrix acceptance. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the same automatic matrix, manual-only configurations and publication restrictions. Revision v1_4_1_005 adds the optional pc_artifact_source_locators relation keyed by the existing Artifact Revision and Source-edge ordinal for both registered source releases and vector/Code profiles. Explicit maintenance creates the empty relation without rewriting historical lineage, payloads, Source identities, earlier revision resources or checksums. Missing locators remain omitted in legacy encodings; located citations survive persistence, HTTP/MCP and portable export/restore, and Source sequence mismatches are rejected without redirecting identity. Atomic Memory content and vector bindings are unchanged, so locator metadata does not require re-embedding. Local SQLite migration, restart, citation and portable round-trip tests pass; MySQL layout checks do not constitute native seekDB/OceanBase or installed-release acceptance. The upgrade-required policy, required automatic matrix and publication restrictions remain unchanged." }, "cases": [], "automatic_sources": { diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 06142b13e0..9079df875d 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -158,6 +158,7 @@ def _generate_models( evidence_models = _candidate_evidence_models(transport_contract.components.schemas) source = _with_nested_model_defaults(f"{result.rstrip()}\n") source = _with_code_validation(source, transport_contract.components.schemas) + source = _with_source_locator_validation(source, transport_contract.components.schemas) source = _with_exclusive_fields(source, exclusive_schemas) source = _with_artifact_search_validation(source, transport_contract.components.schemas) return _with_candidate_evidence_limits(source, evidence_models) @@ -279,6 +280,67 @@ def _validate_code_{kind}(self): return source +def _with_source_locator_validation(source: str, schemas: dict[str, Schema | Reference]) -> str: + """Keep Source positions bounded and absent locators byte-compatible with old references.""" + + for name, schema in schemas.items(): + if not isinstance(schema, Schema): + continue + is_range = (schema.model_extra or {}).get("x-powercontext-source-line-range") + field = "heading_path" if is_range else "locator" + if not is_range and name not in {"SourceReference", "SourceTypeReference", "DreamSourceReference"}: + continue + header = f"class {name}(BaseModel):" + start = source.find(header) + if start < 0: + raise ContractGenerationError("generated model class", name) # noqa: TRY003 + next_class = source.find("\nclass ", start + len(header)) + end = next_class if next_class >= 0 else len(source.rstrip()) + block = source[start:end] + lines = block.splitlines(keepends=True) + tree = ast.parse(block) + model = next(node for node in tree.body if isinstance(node, ast.ClassDef)) + declaration = next( + node + for node in model.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == field + ) + # The generator may wrap a field in Annotated/Field for its description or bounds. + # Only replace its default, retaining all generated constraints. + if declaration.value is None: + raise ContractGenerationError("generated model default", field) # noqa: TRY003 + line = declaration.value.lineno - 1 + old = lines[line] + lines[line] = ( + old[: declaration.value.col_offset] + + "Field(default=None, exclude_if=lambda value: value is None)" + + old[declaration.value.end_col_offset :] + ) + block = "".join(lines) + if is_range: + source = _with_model_validator_import(source) + # Import insertion changes offsets; locate the same class again. + start = source.index(header) + next_class = source.find("\nclass ", start + len(header)) + end = next_class if next_class >= 0 else len(source.rstrip()) + block = ( + block.rstrip() + + """ + + @model_validator(mode="after") + def _validate_source_line_range(self): + from powercontext.sources import SourceLineRangeLocator + + SourceLineRangeLocator.model_validate(self.model_dump(mode="json")) + return self + + +""" + ) + source = source[:start] + block + source[end:] + return source + + def _with_nested_model_defaults(source: str) -> str: """Construct typed nested defaults instead of assigning raw JSON to list[Model]. diff --git a/src/powercontext/__init__.py b/src/powercontext/__init__.py index 7448a04133..9bfdf39415 100644 --- a/src/powercontext/__init__.py +++ b/src/powercontext/__init__.py @@ -57,10 +57,13 @@ SourceCatalogBackend, SourceDefinition, SourceDefinitionRegistry, + SourceLineRangeLocator, + SourceLocator, SourceMaterialization, SourceProjection, SourceProjectionKey, SourceRef, + SourceSequenceLocator, SourceStore, ) from powercontext.triggers import PolicyTransition, Trigger @@ -104,12 +107,15 @@ "SourceDefinitionNotFoundError", "SourceDefinitionRegistry", "SourceError", + "SourceLineRangeLocator", + "SourceLocator", "SourceMaterialization", "SourceNotFoundError", "SourceProjection", "SourceProjectionKey", "SourceProjectionNotFoundError", "SourceRef", + "SourceSequenceLocator", "SourceStore", "Sources", "Trigger", diff --git a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py index de6b4ec783..f51511f661 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py @@ -27,7 +27,14 @@ from powercontext.builtin.inference import InvalidInferenceOutputError, StructuredGenerator, TokenEstimator from powercontext.builtin.persistence.records import _canonical_source_text from powercontext.builtin.persistence.sources import SourceRepository, StoredSource -from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, Source, SourceObservation, SourceRef, TextEvidence +from powercontext.sources import ( + TEXT_EVIDENCE_PROJECTION_KEY, + Source, + SourceObservation, + SourceRef, + SourceSequenceLocator, + TextEvidence, +) _JSON = TypeAdapter(JsonValue) _TEXT_EVIDENCE_KEY = TEXT_EVIDENCE_PROJECTION_KEY.model_dump(mode="json") @@ -233,23 +240,32 @@ async def project_atomic_memory_evidence( """Expose source-provided dates in content/metadata without synthesizing time.""" value = stored.value + locator = None body_from_text_projection = False if isinstance(value, SourceObservation): content = value.payload for projection in value.projections: if projection.key == TEXT_EVIDENCE_PROJECTION_KEY: - content = TextEvidence.model_validate(projection.value).model_dump(mode="json") + text_evidence = TextEvidence.model_validate(projection.value) + locator = text_evidence.locator + content = text_evidence.model_dump(mode="json") body_from_text_projection = True break else: materialized = await sources.read_value(value) + if isinstance(materialized, TextEvidence): + locator = materialized.locator content = ( materialized.model_dump(mode="json", by_alias=True) if isinstance(materialized, BaseModel) else materialized ) body = _JSON.validate_python(content) return AtomicMemoryEvidence( evidence_id=evidence_id, - source_ref=stored.ref, + source_ref=SourceRef( + source_type=stored.ref.source_type, + source_id=stored.ref.source_id, + locator=locator or SourceSequenceLocator(sequence=stored.journal_position), + ), journal_position=stored.journal_position, content=body, source_metadata=_compact_source_metadata(value, body=body, body_from_text_projection=body_from_text_projection), diff --git a/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py b/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py index 260038f513..674a69c125 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/reconciliation.py @@ -229,7 +229,7 @@ def apply( selected = tuple(evidence_by_id[identifier] for identifier in dict.fromkeys(output.evidence_ids)) sources = tuple( { - (item.source_ref.source_type, item.source_ref.source_id): item.source_ref + item.source_ref.model_dump_json(): item.source_ref for item in selected if isinstance(item, AtomicMemoryEvidence) and item.via_artifact is None }.values() diff --git a/src/powercontext/builtin/artifacts/merge.py b/src/powercontext/builtin/artifacts/merge.py index 087110f459..7a3b34cbe8 100644 --- a/src/powercontext/builtin/artifacts/merge.py +++ b/src/powercontext/builtin/artifacts/merge.py @@ -786,7 +786,7 @@ async def _commit_writes( # noqa: C901 - One boundary owns validation, persiste ) sources = system_sources[write.artifact_id] if sources: - unique = {(source.source_type, source.source_id): source for source in (*draft.sources, *sources)} + unique = {source.model_dump_json(): source for source in (*draft.sources, *sources)} draft = draft.model_copy(update={"sources": tuple(unique.values())}) validated = await self._validate_draft( connection, scope_id, target, draft, context, system_sources=sources, source_read_set=source_read_set diff --git a/src/powercontext/builtin/evidence/resolver.py b/src/powercontext/builtin/evidence/resolver.py index d2592d1363..4ab970db0c 100644 --- a/src/powercontext/builtin/evidence/resolver.py +++ b/src/powercontext/builtin/evidence/resolver.py @@ -476,7 +476,7 @@ async def _read_source( identity = evidence_id(ref) if eligible: attested = None if self.root_identity is None else self.root_identity(ref, source) - group_key = reference_key(ref) if attested is None else attested + group_key = ref.model_dump_json(exclude={"locator"}) if attested is None else attested state.groups[identity] = ("root_" + content_digest(group_key.encode())[7:31], attested is not None) return ( EvidenceNode( diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index 8456aa5cbb..16221a023e 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -21,7 +21,7 @@ from datetime import UTC, datetime from typing import Any -from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError +from pydantic import BaseModel, ConfigDict, JsonValue, RootModel, ValidationError from sqlalchemy import and_, insert, literal, select, true, tuple_, union_all, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncTransaction @@ -55,6 +55,7 @@ ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACTS_TABLE, ) from powercontext.builtin.source_eligibility import ArtifactLineageTarget, require_source_eligible @@ -63,7 +64,11 @@ RevisionConflictError, ) from powercontext.limits import MAX_SCOPE_ID_LENGTH -from powercontext.sources import SourceRef +from powercontext.sources import SourceLocator, SourceRef + + +class _StoredSourceLocator(RootModel[SourceLocator]): + pass def with_artifact_content(statement: Select[Any], heads: FromClause = ARTIFACT_HEADS_TABLE) -> Select[Any]: @@ -752,7 +757,7 @@ async def _insert_revisions( scope_id: str, revisions: Sequence[tuple[type[Artifact[Any]], ArtifactRef, object, ArtifactLineage]], ) -> tuple[Artifact[Any], ...]: - bodies, sources, artifact_refs, artifacts = [], [], [], [] + bodies, sources, source_locators, artifact_refs, artifacts = [], [], [], [], [] for artifact_type, ref, content, lineage in revisions: if not isinstance(content, BaseModel): raise TypeError("artifact content must be a BaseModel") # noqa: TRY003 @@ -774,6 +779,15 @@ async def _insert_revisions( {**identity, "ordinal": ordinal, "source_type": source.source_type, "source_id": source.source_id} for ordinal, source in enumerate(lineage.sources) ) + source_locators.extend( + { + **identity, + "ordinal": ordinal, + "locator": dump_model(_StoredSourceLocator(source.locator), kind="source", name="locator"), + } + for ordinal, source in enumerate(lineage.sources) + if source.locator is not None + ) artifact_refs.extend( { **identity, @@ -794,6 +808,8 @@ async def _insert_revisions( await connection.execute(insert(ARTIFACTS_TABLE), bodies) if sources: await connection.execute(insert(ARTIFACT_LINEAGE_SOURCES_TABLE), sources) + if source_locators: + await connection.execute(insert(ARTIFACT_SOURCE_LOCATORS_TABLE), source_locators) if artifact_refs: await connection.execute(insert(ARTIFACT_LINEAGE_ARTIFACTS_TABLE), artifact_refs) return tuple(artifacts) @@ -844,6 +860,15 @@ async def _load_lineage_many( ARTIFACT_LINEAGE_ARTIFACTS_TABLE.c, ARTIFACT_PUBLICATIONS_TABLE.c, ) + located_sources = ARTIFACT_LINEAGE_SOURCES_TABLE.outerjoin( + ARTIFACT_SOURCE_LOCATORS_TABLE, + and_( + *( + source[name] == ARTIFACT_SOURCE_LOCATORS_TABLE.c[name] + for name in ("scope_id", "family", "artifact_id", "revision", "ordinal") + ) + ), + ) columns = ( "relation", "family", @@ -855,21 +880,33 @@ async def _load_lineage_many( "ref_id", "ref_revision", "digest", + "locator", ) - def project(relation, table, *, prefix="", **references): + def project(relation, table, *, prefix="", from_clause=None, **references): fields = { "relation": literal(relation), **{name: table[prefix + name] for name in ("family", "artifact_id", "revision")}, **references, } - return select(*(fields.get(name, literal(None)).label(name) for name in columns)).where( + statement = select(*(fields.get(name, literal(None)).label(name) for name in columns)) + if from_clause is not None: + statement = statement.select_from(from_clause) + return statement.where( table[prefix + "scope_id"] == scope_id, tuple_(*(table[prefix + name] for name in ("family", "artifact_id", "revision"))).in_(keys), ) relations = union_all( - project("source", source, ordinal=source.ordinal, ref_family=source.source_type, ref_id=source.source_id), + project( + "source", + source, + ordinal=source.ordinal, + ref_family=source.source_type, + ref_id=source.source_id, + from_clause=located_sources, + locator=ARTIFACT_SOURCE_LOCATORS_TABLE.c.locator, + ), project( "artifact", artifact, @@ -907,7 +944,23 @@ def project(relation, table, *, prefix="", **references): for row in rows: key = (str(row["family"]), str(row["artifact_id"]), int(row["revision"])) if row["relation"] == "source": - sources[key].append(SourceRef(source_type=str(row["ref_family"]), source_id=str(row["ref_id"]))) + locator = ( + None + if row["locator"] is None + else load_model( + _StoredSourceLocator, + stored_bytes(row["locator"], column="locator"), + kind="source", + name="locator", + ).root + ) + sources[key].append( + SourceRef( + source_type=str(row["ref_family"]), + source_id=str(row["ref_id"]), + locator=locator, + ) + ) elif row["relation"] == "artifact": artifacts[key].append( ArtifactRef( @@ -1093,6 +1146,7 @@ async def _validate_lineage_sources( stored = None if source_read_set is None else source_read_set.find(connection, scope_id, source_ref) if stored is None: stored = fetched[(source_ref.source_type, source_ref.source_id)] + stored.validate_locator(source_ref) require_source_eligible( source_ref, stored.value, diff --git a/src/powercontext/builtin/persistence/migrations/atomic_projection.py b/src/powercontext/builtin/persistence/migrations/atomic_projection.py index 13108742dd..e5673b459d 100644 --- a/src/powercontext/builtin/persistence/migrations/atomic_projection.py +++ b/src/powercontext/builtin/persistence/migrations/atomic_projection.py @@ -30,6 +30,7 @@ ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACTS_TABLE, ) @@ -120,6 +121,7 @@ def _rows_digest(self, connection, current): ARTIFACT_HEADS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, current, ): diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json index 65c518659d..48aae1c8d0 100644 --- a/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_resources/manifest.json @@ -24,6 +24,10 @@ "v1_4_1_004": [ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" + ], + "v1_4_1_005": [ + "schemas/sqlite-v1_4_1_005.json", + "schemas/mysql-v1_4_1_005.json" ] }, "sqlite_baselines": { @@ -44,6 +48,9 @@ ], "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" + ], + "v1_4_1_005": [ + "729f3c4768f1454d57db8858a8c147a64a0c3cf1f8eadf98eef42fde31cc2236" ] }, "affected_objects": [ @@ -138,7 +145,8 @@ "pc_retained_dream_candidate_versions", "pc_memory_artifact_archive", "pc_portable_restore_receipts", - "pc_access_owner_recovery" + "pc_access_owner_recovery", + "pc_artifact_source_locators" ], "readiness_scope": "server", "retained_objects": [ @@ -195,6 +203,10 @@ "v1_4_1_004": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_005": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -210,7 +222,8 @@ "schemas/mysql-s1400.json", "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", - "schemas/mysql-v1_4_1_004.json" + "schemas/mysql-v1_4_1_004.json", + "schemas/mysql-v1_4_1_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py new file mode 100644 index 0000000000..ee6828e820 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/mysql/versions/v1_4_1_005.py @@ -0,0 +1,24 @@ +# 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. + +"""Add Source position metadata through the shared DDL executor.""" + +from alembic import context + +revision = "v1_4_1_005" +down_revision = "v1_4_1_004" + + +def upgrade() -> None: + context.config.attributes["execute_revision"](revision) diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json new file mode 100644 index 0000000000..def90c60e0 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/mysql-v1_4_1_005.json @@ -0,0 +1,141 @@ +{ + "format": "powercontext.mysql-state-layouts.v1", + "revisions": { + "v1_4_1_005": { + "statements": [ + "CREATE TABLE pc_artifact_source_locators (\n\tscope_id VARCHAR(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tfamily VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\tartifact_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, \n\trevision INTEGER NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tlocator MEDIUMBLOB NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision, ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision, ordinal) REFERENCES pc_artifact_lineage_sources (scope_id, family, artifact_id, revision, ordinal) ON DELETE CASCADE\n)" + ], + "changes": [ + { + "pc_artifact_source_locators": { + "columns": { + "scope_id": { + "type": [ + "varchar", + [ + "256" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "family": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "artifact_id": { + "type": [ + "varchar", + [ + "128" + ] + ], + "nullable": false, + "default": null, + "charset": "utf8mb4", + "collation": "utf8mb4_bin", + "extra": [] + }, + "revision": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "ordinal": { + "type": [ + "int", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + }, + "locator": { + "type": [ + "mediumblob", + [] + ], + "nullable": false, + "default": null, + "charset": null, + "collation": null, + "extra": [] + } + }, + "primary_key": [ + [ + "scope_id", + null, + "asc" + ], + [ + "family", + null, + "asc" + ], + [ + "artifact_id", + null, + "asc" + ], + [ + "revision", + null, + "asc" + ], + [ + "ordinal", + null, + "asc" + ] + ], + "foreign_keys": [ + { + "columns": [ + "scope_id", + "family", + "artifact_id", + "revision", + "ordinal" + ], + "referred_schema": null, + "referred_table": "pc_artifact_lineage_sources", + "referred_columns": [ + "scope_id", + "family", + "artifact_id", + "revision", + "ordinal" + ], + "ondelete": "cascade", + "onupdate": "restrict" + } + ], + "checks": {}, + "indexes": {} + } + } + ] + } + } +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json new file mode 100644 index 0000000000..7eb7dced4f --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/schemas/sqlite-v1_4_1_005.json @@ -0,0 +1,3 @@ +{ + "create": "CREATE TABLE pc_artifact_source_locators (\n\tscope_id VARCHAR(256) NOT NULL, \n\tfamily VARCHAR(128) NOT NULL, \n\tartifact_id VARCHAR(128) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tlocator BLOB NOT NULL, \n\tPRIMARY KEY (scope_id, family, artifact_id, revision, ordinal), \n\tFOREIGN KEY(scope_id, family, artifact_id, revision, ordinal) REFERENCES pc_artifact_lineage_sources (scope_id, family, artifact_id, revision, ordinal) ON DELETE CASCADE\n)" +} diff --git a/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_source_locators.py b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_source_locators.py new file mode 100644 index 0000000000..21b66144c9 --- /dev/null +++ b/src/powercontext/builtin/persistence/migrations/server_resources/versions/v1_4_1_005_source_locators.py @@ -0,0 +1,28 @@ +# 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. + +"""Add position metadata for exact Source lineage edges.""" + +import json +from pathlib import Path + +from alembic import op + +revision = "v1_4_1_005" +down_revision = "v1_4_1_004" + + +def upgrade(): + resource = Path(__file__).parents[1] / "schemas/sqlite-v1_4_1_005.json" + op.get_bind().exec_driver_sql(json.loads(resource.read_text(encoding="utf-8"))["create"]) diff --git a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json index 0e52e9ff79..3687484adb 100644 --- a/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json +++ b/src/powercontext/builtin/persistence/migrations/server_sources/1.4.0/manifest.json @@ -23,6 +23,10 @@ "v1_4_1_004": [ "schemas/sqlite-v1_4_1_004.json", "schemas/mysql-v1_4_1_004.json" + ], + "v1_4_1_005": [ + "schemas/sqlite-v1_4_1_005.json", + "schemas/mysql-v1_4_1_005.json" ] }, "sqlite_baselines": { @@ -43,6 +47,9 @@ ], "v1_4_1_004": [ "84d736b5e722a56ebe8a32318732543d43548a63a43709dbf0c571e7e4af83b5" + ], + "v1_4_1_005": [ + "729f3c4768f1454d57db8858a8c147a64a0c3cf1f8eadf98eef42fde31cc2236" ] }, "affected_objects": [ @@ -137,7 +144,8 @@ "pc_retained_dream_candidate_versions", "pc_memory_artifact_archive", "pc_portable_restore_receipts", - "pc_access_owner_recovery" + "pc_access_owner_recovery", + "pc_artifact_source_locators" ], "readiness_scope": "server", "retained_objects": [ @@ -199,6 +207,10 @@ "v1_4_1_004": [ "server_upgrade", "portable_storage" + ], + "v1_4_1_005": [ + "server_upgrade", + "portable_storage" ] }, "retained_fingerprints": { @@ -214,7 +226,8 @@ "schemas/mysql-s1400.json", "schemas/mysql-s1401.json", "schemas/mysql-s1402.json", - "schemas/mysql-v1_4_1_004.json" + "schemas/mysql-v1_4_1_004.json", + "schemas/mysql-v1_4_1_005.json" ], "mysql_history": "mysql", "mysql_schema_extensions": { diff --git a/src/powercontext/builtin/persistence/migrations/vector_profile.py b/src/powercontext/builtin/persistence/migrations/vector_profile.py index f2a4d2814d..1f82efba2a 100644 --- a/src/powercontext/builtin/persistence/migrations/vector_profile.py +++ b/src/powercontext/builtin/persistence/migrations/vector_profile.py @@ -85,6 +85,11 @@ def sqlite_layouts(self, directory: Path) -> dict[str, list[list[Any]]]: layouts["v1_4_1_004"] = layouts["s1402"] + [ ["table", "pc_access_owner_recovery", "pc_access_owner_recovery", owner] ] + if "v1_4_1_005" in manifest["revision_resources"]: + locator = json.loads((directory / "schemas/sqlite-v1_4_1_005.json").read_text(encoding="utf-8"))["create"] + layouts["v1_4_1_005"] = layouts["v1_4_1_004"] + [ + ["table", "pc_artifact_source_locators", "pc_artifact_source_locators", locator] + ] for entries in layouts.values(): entries.sort(key=lambda row: (row[0], row[1])) return layouts diff --git a/src/powercontext/builtin/persistence/sources.py b/src/powercontext/builtin/persistence/sources.py index 9b57917df0..0714628bf2 100644 --- a/src/powercontext/builtin/persistence/sources.py +++ b/src/powercontext/builtin/persistence/sources.py @@ -43,9 +43,16 @@ from powercontext.builtin.persistence.tables import SOURCE_JOURNAL_HEADS_TABLE, SOURCES_TABLE from powercontext.builtin.sources.content import ContentSource from powercontext.builtin.triggers import SOURCE_WINDOW_TRIGGER_NAME -from powercontext.errors import SourceDefinitionNotFoundError +from powercontext.errors import InvalidSourceReferenceError, SourceDefinitionNotFoundError from powercontext.limits import MAX_SCOPE_ID_LENGTH -from powercontext.sources import Source, SourceAdapter, SourceDefinitionRegistry, SourceObservation, SourceRef +from powercontext.sources import ( + Source, + SourceAdapter, + SourceDefinitionRegistry, + SourceObservation, + SourceRef, + SourceSequenceLocator, +) _AnySourceAdapter = SourceAdapter[Any, Any, Any] _JSON_OBJECT = TypeAdapter(dict[str, JsonValue]) @@ -73,6 +80,12 @@ class StoredSource(BaseModel): value: Source journal_position: int + def validate_locator(self, ref: SourceRef, /) -> None: + """Reject a position that names another journal entry instead of redirecting.""" + + if isinstance(ref.locator, SourceSequenceLocator) and ref.locator.sequence != self.journal_position: + raise InvalidSourceReferenceError("locator.sequence", "does not match the cited Source journal position") + @dataclass(frozen=True, slots=True) class _SourceReadSet: @@ -226,7 +239,9 @@ async def get( row = await self._find_row(connection, scope_id, ref) if row is None: raise RepositoryNotFoundError("source", (scope_id, ref)) - return self.decode_source(row) + stored = self.decode_source(row) + stored.validate_locator(ref) + return stored async def get_many( self, @@ -252,6 +267,8 @@ async def get_many( for ref in ordered: if (ref.source_type, ref.source_id) not in decoded: raise RepositoryNotFoundError("source", (scope_id, ref)) + for ref in refs: + decoded[(ref.source_type, ref.source_id)].validate_locator(ref) return tuple(decoded[(ref.source_type, ref.source_id)] for ref in ordered) async def _existing_many( diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index a548e5f4e6..474633c439 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -293,6 +293,29 @@ def _entry_text_type(): ), ) +ARTIFACT_SOURCE_LOCATORS_TABLE = Table( + "pc_artifact_source_locators", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("family", identity_string(MAX_ARTIFACT_FAMILY_LENGTH), primary_key=True), + Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), + Column("revision", Integer, primary_key=True), + Column("ordinal", Integer, primary_key=True), + Column("locator", _canonical_payload_type(), nullable=False), + ForeignKeyConstraint( + ("scope_id", "family", "artifact_id", "revision", "ordinal"), + ( + "pc_artifact_lineage_sources.scope_id", + "pc_artifact_lineage_sources.family", + "pc_artifact_lineage_sources.artifact_id", + "pc_artifact_lineage_sources.revision", + "pc_artifact_lineage_sources.ordinal", + ), + ondelete="CASCADE", + ), +) + + ARTIFACT_LINEAGE_ARTIFACTS_TABLE = Table( "pc_artifact_lineage_artifacts", SHARED_METADATA, @@ -1064,6 +1087,7 @@ def _entry_text_type(): ARTIFACT_HANDLES_TABLE, ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_PUBLICATIONS_TABLE, CANDIDATE_VERSIONS_TABLE, diff --git a/src/powercontext/builtin/portability/bundle.py b/src/powercontext/builtin/portability/bundle.py index 853da1b1a9..3f85f01a89 100644 --- a/src/powercontext/builtin/portability/bundle.py +++ b/src/powercontext/builtin/portability/bundle.py @@ -44,7 +44,7 @@ from typing import Any, Literal, cast from uuid import NAMESPACE_URL, UUID, uuid5 -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from sqlalchemy import ( BigInteger, DateTime, @@ -89,6 +89,7 @@ ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACTS_TABLE, CANDIDATE_HEADS_TABLE, @@ -117,7 +118,7 @@ ) from powercontext.builtin.tags import normalize_tags from powercontext.errors import SourceError -from powercontext.sources import SourceDefinitionManifest, SourceObservation +from powercontext.sources import SourceDefinitionManifest, SourceLocator, SourceObservation, SourceRef FORMAT_VERSION = 1 _RECORDS_NAME = "records.ndjson" @@ -173,6 +174,7 @@ "artifact_revision", "artifact_handle", "artifact_lineage_source", + "artifact_source_locator", "artifact_lineage_artifact", "artifact_publication", "artifact_head", @@ -340,6 +342,11 @@ class _RecordSpec: ("scope_id", "family", "artifact_id", "revision", "ordinal"), ("source_type", "source_id"), ), + "artifact_source_locator": _RecordSpec( + ARTIFACT_SOURCE_LOCATORS_TABLE, + ("scope_id", "family", "artifact_id", "revision", "ordinal"), + ("locator",), + ), "artifact_lineage_artifact": _RecordSpec( ARTIFACT_LINEAGE_ARTIFACTS_TABLE, ("scope_id", "family", "artifact_id", "revision", "ordinal"), @@ -1525,6 +1532,12 @@ def bytes_values(*fields: str, nullable: tuple[str, ...] = ()) -> None: raise BundleFormatError("artifact handle is invalid") from error elif kind == "artifact_lineage_source": strings("source_type", "source_id") + elif kind == "artifact_source_locator": + bytes_values("locator") + try: + TypeAdapter(SourceLocator).validate_python(_decoded_bytes_json(payload["locator"], "Source locator")) + except ValidationError as error: + raise BundleFormatError("Source locator has invalid domain values") from error elif kind == "artifact_lineage_artifact": strings("upstream_family", "upstream_artifact_id") integers("upstream_revision", positive=True) @@ -2083,6 +2096,9 @@ def _validate_record_dependencies( # noqa: C901 - logical record kinds have dis {"scope_id": scope, "memory_artifact_id": identity["artifact_id"], "entry_id": identity["target_id"]}, ): raise BundleFormatError("artifact tag references missing memory entry") + elif record.record_type == "artifact_source_locator": + if not contains("artifact_lineage_source", identity): + raise BundleFormatError("Source locator references missing lineage") elif record.record_type == "artifact_lineage_source": if not contains( "artifact_revision", @@ -2495,6 +2511,15 @@ def _reference_items(value: object, kind: Literal["source", "artifact"], /) -> t fields = {"source_type", "source_id"} if kind == "source" else {"family", "artifact_id", "revision"} references: list[Mapping[str, object]] = [] for item in decoded: + if kind == "source": + if not isinstance(item, dict) or set(item) not in (fields, fields | {"locator"}): + raise BundleFormatError("source reference has invalid fields") + try: + SourceRef.model_validate(item, strict=True) + except ValidationError as error: + raise BundleFormatError("source reference has invalid domain values") from error + references.append(cast(Mapping[str, object], item)) + continue if ( not isinstance(item, dict) or set(item) != fields @@ -2514,6 +2539,7 @@ def _required_artifact_families(records: Iterable[_Record]) -> set[str]: "artifact_revision", "artifact_handle", "artifact_lineage_source", + "artifact_source_locator", "artifact_lineage_artifact", "artifact_head", "topic_memory_publication", diff --git a/src/powercontext/builtin/review/service.py b/src/powercontext/builtin/review/service.py index 6843cdbc32..f22684844c 100644 --- a/src/powercontext/builtin/review/service.py +++ b/src/powercontext/builtin/review/service.py @@ -1387,10 +1387,10 @@ def _candidate_draft(candidate: ReviewedCandidate) -> ReviewedDraft: def _unique_sources(values: tuple[SourceRef, ...]) -> tuple[SourceRef, ...]: - seen: set[tuple[str, str]] = set() + seen: set[str] = set() result: list[SourceRef] = [] for value in values: - key = (value.source_type, value.source_id) + key = value.model_dump_json() if key not in seen: seen.add(key) result.append(value) diff --git a/src/powercontext/builtin/sources/observation_validation.py b/src/powercontext/builtin/sources/observation_validation.py index 2145b55b60..7ef273511d 100644 --- a/src/powercontext/builtin/sources/observation_validation.py +++ b/src/powercontext/builtin/sources/observation_validation.py @@ -28,6 +28,26 @@ from powercontext.limits import MAX_SOURCE_OBSERVATION_BYTES from powercontext.sources import TEXT_EVIDENCE_PROJECTION_KEY, SourceDefinitionManifest, SourceObservation, TextEvidence +# Registered v1 manifests are immutable and content-addressed. Keep the original +# schema accepted without rewriting its declaration or fingerprint on upgrade. +_TEXT_EVIDENCE_V1_WITHOUT_LOCATOR = { + "$defs": {"JsonValue": {}}, + "description": "Canonical JSON shape consumed as textual Artifact evidence.", + "properties": { + "source_type": {"title": "Source Type", "type": "string"}, + "source_id": {"title": "Source Id", "type": "string"}, + "content": {"title": "Content", "type": "string"}, + "metadata": { + "additionalProperties": {"$ref": "#/$defs/JsonValue"}, + "title": "Metadata", + "type": "object", + }, + }, + "required": ["source_type", "source_id", "content"], + "title": "TextEvidence", + "type": "object", +} + def validate_source_definition_manifest(manifest: SourceDefinitionManifest, /) -> None: """Validate the bounded, network-independent schemas declared by a worker.""" @@ -38,7 +58,10 @@ def validate_source_definition_manifest(manifest: SourceDefinitionManifest, /) - standard_text_schema = TextEvidence.model_json_schema() for projection in manifest.projections: json_schema_validator(projection.key.name, projection.schema_) - if projection.key == TEXT_EVIDENCE_PROJECTION_KEY and projection.schema_ != standard_text_schema: + if projection.key == TEXT_EVIDENCE_PROJECTION_KEY and projection.schema_ not in ( + standard_text_schema, + _TEXT_EVIDENCE_V1_WITHOUT_LOCATOR, + ): raise InvalidSourceDefinitionError( type(manifest), "projection", f"{projection.key.name!r} must use the standard schema" ) diff --git a/src/powercontext/http/__init__.py b/src/powercontext/http/__init__.py index dcb6b7c740..eebb212b13 100644 --- a/src/powercontext/http/__init__.py +++ b/src/powercontext/http/__init__.py @@ -424,6 +424,8 @@ SkillValidationItem, SourceDefinitionManifest, SourceInventoryStatistics, + SourceLineRangeLocator, + SourceLocator, SourceObservation, SourceObservationReceipt, SourcePage, @@ -432,6 +434,7 @@ SourceProjectionValue, SourceRecord, SourceReference, + SourceSequenceLocator, SourceType, SourceTypeReference, StatsPeriod, @@ -887,6 +890,8 @@ "SkillValidationItem", "SourceDefinitionManifest", "SourceInventoryStatistics", + "SourceLineRangeLocator", + "SourceLocator", "SourceObservation", "SourceObservationReceipt", "SourcePage", @@ -895,6 +900,7 @@ "SourceProjectionValue", "SourceRecord", "SourceReference", + "SourceSequenceLocator", "SourceType", "SourceTypeReference", "StatsPeriod", diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index f97992dcb7..2faa05906b 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -556,14 +556,6 @@ class DreamEvidenceIndependence(StrEnum): UNKNOWN = "unknown" -class DreamSourceReference(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - source_type: Annotated[StrictStr, Field(max_length=128, min_length=1)] - source_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] - - class ListDreamRunsRequest(BaseModel): model_config = ConfigDict( extra="forbid", @@ -619,28 +611,6 @@ class DreamEvidenceEdge(BaseModel): upstream_id: StrictStr -class DreamEvidenceNode(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - evidence_id: StrictStr - kind: DreamEvidenceKind - digest: StrictStr - source: DreamSourceReference | None = None - artifact: ArtifactReference | None = None - role: DreamEvidenceRole - historical: StrictBool = False - - -class DreamRootEvidenceGroup(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - group_id: StrictStr - sources: list[DreamSourceReference] - independence: DreamEvidenceIndependence = DreamEvidenceIndependence.UNKNOWN - - class CandidateKind(StrEnum): ARTIFACT = "artifact" TAG = "tag" @@ -2862,6 +2832,49 @@ class ReplaceMemoryArtifactEntry(BaseModel): text: Annotated[StrictStr, Field(max_length=8192, min_length=1, pattern=".*\\S.*")] +class Kind10(StrEnum): + SEQUENCE = "sequence" + + +class SourceSequenceLocator(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Literal["sequence"] + sequence: Annotated[StrictInt, Field(ge=0)] + + +class Kind11(StrEnum): + LINE_RANGE = "line_range" + + +class SourceLineRangeLocator(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + kind: Literal["line_range"] + start_line: Annotated[StrictInt, Field(ge=1)] + end_line: Annotated[StrictInt, Field(ge=1)] + heading_path: Annotated[ + StrictStr | None, + Field( + description="Optional adapter-supplied heading path, bounded in Unicode characters rather than UTF-8 bytes.", + max_length=1024, + ), + ] = Field(default=None, exclude_if=lambda value: value is None) + + @model_validator(mode="after") + def _validate_source_line_range(self): + from powercontext.sources import SourceLineRangeLocator + + SourceLineRangeLocator.model_validate(self.model_dump(mode="json")) + return self + + +class SourceLocator(RootModel[SourceSequenceLocator | SourceLineRangeLocator]): + root: Annotated[SourceSequenceLocator | SourceLineRangeLocator, Field(discriminator="kind")] + + class SourceTypeReference(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2877,6 +2890,7 @@ class SourceTypeReference(BaseModel): min_length=1, ), ] + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) class SourceReference(BaseModel): @@ -2885,6 +2899,7 @@ class SourceReference(BaseModel): ) name: Annotated[StrictStr, Field(description="Stable Source type.")] source_id: StrictStr + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) class CaptureStatus(StrEnum): @@ -3820,71 +3835,47 @@ class HandoffReceiptIdentity(BaseModel): receiver_identity_matches: StrictBool -class CreateDreamRunRequest(BaseModel): +class DreamSourceReference(BaseModel): model_config = ConfigDict( extra="forbid", ) - tag_target: TagDreamTarget | None = None - operation: DreamOperation - artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] - sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] - target: ArtifactReference | None = None - idempotency_key: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^\\S(?:[\\s\\S]*\\S)?$")] + source_type: Annotated[StrictStr, Field(max_length=128, min_length=1)] + source_id: Annotated[StrictStr, Field(max_length=256, min_length=1)] + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) -class DreamInputManifest(BaseModel): +class CreateDreamRunRequest(BaseModel): model_config = ConfigDict( extra="forbid", ) - transform_version: StrictStr = "powercontext.dream.evidence.v1" + tag_target: TagDreamTarget | None = None + operation: DreamOperation artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] - nodes: Annotated[list[DreamEvidenceNode], Field(validate_default=True)] = [] - edges: Annotated[list[DreamEvidenceEdge], Field(validate_default=True)] = [] - root_groups: Annotated[list[DreamRootEvidenceGroup], Field(validate_default=True)] = [] - projection_digest: StrictStr - projection_bytes: StrictInt - incomplete: StrictBool = False + target: ArtifactReference | None = None + idempotency_key: Annotated[StrictStr, Field(max_length=128, min_length=1, pattern="^\\S(?:[\\s\\S]*\\S)?$")] -class DreamRun(BaseModel): +class DreamEvidenceNode(BaseModel): model_config = ConfigDict( extra="forbid", ) - tag_target: TagDreamTarget | None = None - reused: StrictBool = False - scope_id: StrictStr - run_id: StrictStr - operation: DreamOperation - status: DreamStatus = DreamStatus.QUEUED - outcome: DreamOutcome | None = None - target: ArtifactReference | None = None - candidate: DreamCandidateRef | None = None - reason: StrictStr | None = None - error: StrictStr | None = None - accepted_at: AwareDatetime - started_at: AwareDatetime | None = None - completed_at: AwareDatetime | None = None - attempt_count: StrictInt = 0 - input_manifest: DreamInputManifest | None = None - usage: DreamUsage | None = None - budget: DreamBudget | None = None - prompt_version: StrictStr = "powercontext.dream.v1.4" - model_config_id: Annotated[StrictStr | None, Field(...)] - historical_data: Annotated[ - dict[str, Any] | None, - Field( - description="Original request and input manifest of a terminal run migrated from the legacy Memory format. Present only on such runs; never accepted as evidence." - ), - ] = None + evidence_id: StrictStr + kind: DreamEvidenceKind + digest: StrictStr + source: DreamSourceReference | None = None + artifact: ArtifactReference | None = None + role: DreamEvidenceRole + historical: StrictBool = False -class DreamRunPage(BaseModel): +class DreamRootEvidenceGroup(BaseModel): model_config = ConfigDict( extra="forbid", ) - runs: list[DreamRun] - next_cursor: StrictStr | None = None + group_id: StrictStr + sources: list[DreamSourceReference] + independence: DreamEvidenceIndependence = DreamEvidenceIndependence.UNKNOWN class ExtractionObservation(BaseModel): @@ -4724,6 +4715,61 @@ class CreateSubjectSourceResponse(BaseModel): sources: Annotated[list[SourceRecord], Field(max_length=2, min_length=2)] +class DreamInputManifest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + transform_version: StrictStr = "powercontext.dream.evidence.v1" + artifacts: Annotated[list[ArtifactReference], Field(validate_default=True)] = [] + sources: Annotated[list[DreamSourceReference], Field(validate_default=True)] = [] + nodes: Annotated[list[DreamEvidenceNode], Field(validate_default=True)] = [] + edges: Annotated[list[DreamEvidenceEdge], Field(validate_default=True)] = [] + root_groups: Annotated[list[DreamRootEvidenceGroup], Field(validate_default=True)] = [] + projection_digest: StrictStr + projection_bytes: StrictInt + incomplete: StrictBool = False + + +class DreamRun(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + tag_target: TagDreamTarget | None = None + reused: StrictBool = False + scope_id: StrictStr + run_id: StrictStr + operation: DreamOperation + status: DreamStatus = DreamStatus.QUEUED + outcome: DreamOutcome | None = None + target: ArtifactReference | None = None + candidate: DreamCandidateRef | None = None + reason: StrictStr | None = None + error: StrictStr | None = None + accepted_at: AwareDatetime + started_at: AwareDatetime | None = None + completed_at: AwareDatetime | None = None + attempt_count: StrictInt = 0 + input_manifest: DreamInputManifest | None = None + usage: DreamUsage | None = None + budget: DreamBudget | None = None + prompt_version: StrictStr = "powercontext.dream.v1.4" + model_config_id: Annotated[StrictStr | None, Field(...)] + historical_data: Annotated[ + dict[str, Any] | None, + Field( + description="Original request and input manifest of a terminal run migrated from the legacy Memory format. Present only on such runs; never accepted as evidence." + ), + ] = None + + +class DreamRunPage(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + runs: list[DreamRun] + next_cursor: StrictStr | None = None + + class ExtractionStatus(BaseModel): model_config = ConfigDict( extra="forbid", diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 3f4c08e56b..311b23f275 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -6527,6 +6527,7 @@ "properties": { "source_type": {"type": "string", "maxLength": 128, "minLength": 1}, "source_id": {"type": "string", "maxLength": 256, "minLength": 1}, + "locator": {"$ref": "#/components/schemas/SourceLocator", "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -11871,6 +11872,61 @@ "type": "object", "required": ["items", "next_cursor"], }, + "SourceSequenceLocator": { + "properties": { + "kind": {"type": "string", "enum": ["sequence"]}, + "sequence": {"type": "integer", "minimum": 0.0}, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "sequence"], + "description": "Position of this exact Source in its scope journal; zero is distinct from absence.", + }, + "SourceLineRangeLocator": { + "properties": { + "kind": {"type": "string", "enum": ["line_range"]}, + "start_line": {"type": "integer", "minimum": 1.0}, + "end_line": {"type": "integer", "minimum": 1.0}, + "heading_path": { + "type": "string", + "maxLength": 1024, + "description": "Optional " + "adapter-supplied " + "heading " + "path, " + "bounded " + "in " + "Unicode " + "characters " + "rather " + "than " + "UTF-8 " + "bytes.", + "nullable": True, + }, + }, + "additionalProperties": False, + "type": "object", + "required": ["kind", "start_line", "end_line"], + "description": "Inclusive one-based lines " + "in adapter-supplied text, " + "at most 200 lines. Never a " + "revision redirect.", + "x-powercontext-source-line-range": True, + }, + "SourceLocator": { + "oneOf": [ + {"$ref": "#/components/schemas/SourceSequenceLocator"}, + {"$ref": "#/components/schemas/SourceLineRangeLocator"}, + ], + "discriminator": { + "propertyName": "kind", + "mapping": { + "sequence": "#/components/schemas/SourceSequenceLocator", + "line_range": "#/components/schemas/SourceLineRangeLocator", + }, + }, + }, "SourceTypeReference": { "properties": { "source_type": { @@ -11893,6 +11949,7 @@ "interior " "spaces.", }, + "locator": {"$ref": "#/components/schemas/SourceLocator", "nullable": True}, }, "additionalProperties": False, "type": "object", @@ -11902,6 +11959,7 @@ "properties": { "name": {"type": "string", "description": "Stable Source type."}, "source_id": {"type": "string"}, + "locator": {"$ref": "#/components/schemas/SourceLocator", "nullable": True}, }, "additionalProperties": False, "type": "object", diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 980349239f..12a4e58294 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -384,6 +384,7 @@ InvalidConnectorRunError, InvalidSourceDefinitionError, InvalidSourceObservationError, + InvalidSourceReferenceError, PowerContextError, RevisionConflictError, SourceConflictError, @@ -6205,6 +6206,13 @@ def _invalid_request_details(error: Exception) -> dict[str, Any] | None: def _map_source_ingestion_error(error: Exception) -> tuple[int, str, str, dict[str, Any] | None] | None: + if isinstance(error, InvalidSourceReferenceError): + return ( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "invalid_request", + "The Source reference is invalid.", + {"field": error.field}, + ) if isinstance(error, SourceConflictError): return status.HTTP_409_CONFLICT, "source_conflict", "The Source identity has different content.", None if isinstance(error, InvalidConnectorRunError): diff --git a/src/powercontext/server/mapping.py b/src/powercontext/server/mapping.py index 4a31e2040a..3d67d88a5b 100644 --- a/src/powercontext/server/mapping.py +++ b/src/powercontext/server/mapping.py @@ -192,6 +192,7 @@ CommittedHandoff, ConnectorCheckpointState, CreateWorkContractRequest, + DreamSourceReference, EntryChange, EntryChangeOperation, ExperienceArtifact, @@ -514,7 +515,7 @@ def _runtime_task_check(value: TaskCheck) -> RuntimeTaskCheck: def capture_response(value: SourceReceipt) -> CaptureContentSourceResponse: return CaptureContentSourceResponse( status=CaptureStatus.ACCEPTED, - source=SourceReference(name=value.source_ref.source_type, source_id=value.source_ref.source_id), + source=source_reference(value.source_ref), position=value.sequence, ) @@ -691,9 +692,7 @@ def revise_candidate_request(value: ReviseCandidateRequest) -> RuntimeReviseCand sources = ( (None if "source_refs" not in value.model_fields_set else sources) if value.sources is None - else tuple( - SourceRef(source_type=source.source_type, source_id=source.source_id) for source in value.sources - ) + else tuple(runtime_source_type_reference(source) for source in value.sources) ) artifacts = ( (None if "artifact_refs" not in value.model_fields_set else artifacts) @@ -1208,19 +1207,27 @@ def runtime_artifact_reference(value: ArtifactReference) -> ArtifactRef: def source_reference(value: SourceRef) -> SourceReference: - return SourceReference(name=value.source_type, source_id=value.source_id) + return SourceReference.model_validate({ + "name": value.source_type, + "source_id": value.source_id, + "locator": None if value.locator is None else value.locator.model_dump(mode="json"), + }) def runtime_source_reference(value: SourceReference) -> SourceRef: - return SourceRef(source_type=value.name, source_id=value.source_id) + return SourceRef.model_validate({ + "source_type": value.name, + "source_id": value.source_id, + "locator": None if value.locator is None else value.locator.model_dump(mode="json"), + }) def source_type_reference(value: SourceRef) -> SourceTypeReference: - return SourceTypeReference(source_type=value.source_type, source_id=value.source_id) + return SourceTypeReference.model_validate(value.model_dump(mode="json")) -def runtime_source_type_reference(value: SourceTypeReference) -> SourceRef: - return SourceRef(source_type=value.source_type, source_id=value.source_id) +def runtime_source_type_reference(value: SourceTypeReference | DreamSourceReference) -> SourceRef: + return SourceRef.model_validate(value.model_dump(mode="json")) def runtime_handoff_citation(value: TransportHandoffCitation) -> RuntimeHandoffCitation: diff --git a/src/powercontext/sources/__init__.py b/src/powercontext/sources/__init__.py index 91e1a27b08..4d57a28718 100644 --- a/src/powercontext/sources/__init__.py +++ b/src/powercontext/sources/__init__.py @@ -30,7 +30,15 @@ SourceDefinitionRegistry, SourceProjection, ) -from powercontext.sources.models import Source, SourceMaterialization, SourceProjectionKey, SourceRef +from powercontext.sources.models import ( + Source, + SourceLineRangeLocator, + SourceLocator, + SourceMaterialization, + SourceProjectionKey, + SourceRef, + SourceSequenceLocator, +) from powercontext.sources.observations import ( SourceDefinitionManifest, SourceObservation, @@ -60,6 +68,8 @@ "SourceDefinition", "SourceDefinitionManifest", "SourceDefinitionRegistry", + "SourceLineRangeLocator", + "SourceLocator", "SourceMaterialization", "SourceObservation", "SourceProjection", @@ -67,6 +77,7 @@ "SourceProjectionManifest", "SourceProjectionValue", "SourceRef", + "SourceSequenceLocator", "SourceStore", "TextEvidence", "manifest_for_definition", diff --git a/src/powercontext/sources/models.py b/src/powercontext/sources/models.py index 6e2cd48df6..f2a0660da3 100644 --- a/src/powercontext/sources/models.py +++ b/src/powercontext/sources/models.py @@ -15,8 +15,10 @@ from __future__ import annotations from enum import StrEnum +from typing import Annotated, Literal, TypeAlias -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator, model_validator +from pydantic_core import PydanticCustomError from powercontext.errors import InvalidSourceReferenceError from powercontext.limits import MAX_SOURCE_ID_LENGTH, MAX_SOURCE_TYPE_LENGTH @@ -29,11 +31,41 @@ class SourceMaterialization(StrEnum): REFERENCED = "referenced" +class SourceSequenceLocator(BaseModel): + """A Source's zero-based-or-greater position in its scope's journal.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["sequence"] = "sequence" + sequence: StrictInt = Field(ge=0) + + +class SourceLineRangeLocator(BaseModel): + """Inclusive, one-based lines in the exact text supplied by the Source adapter.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["line_range"] = "line_range" + start_line: StrictInt = Field(ge=1) + end_line: StrictInt = Field(ge=1) + heading_path: str | None = Field(default=None, max_length=1024, exclude_if=lambda value: value is None) + + @model_validator(mode="after") + def validate_span(self) -> SourceLineRangeLocator: + if self.end_line < self.start_line or self.end_line - self.start_line + 1 > 200: + raise PydanticCustomError("source_locator_range", "Source line range must contain 1 to 200 inclusive lines") + return self + + +SourceLocator: TypeAlias = Annotated[SourceSequenceLocator | SourceLineRangeLocator, Field(discriminator="kind")] + + class SourceRef(BaseModel): """A stable reference to one Source in the current catalog view.""" source_type: str source_id: str + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) @field_validator("source_type", "source_id") @classmethod diff --git a/src/powercontext/sources/projections.py b/src/powercontext/sources/projections.py index 3864a75343..f5c8d399a8 100644 --- a/src/powercontext/sources/projections.py +++ b/src/powercontext/sources/projections.py @@ -16,7 +16,7 @@ from pydantic import BaseModel, Field, JsonValue -from powercontext.sources.models import SourceProjectionKey +from powercontext.sources.models import SourceLocator, SourceProjectionKey TEXT_EVIDENCE_PROJECTION_KEY = SourceProjectionKey(name="powercontext.text-evidence", version="1") @@ -28,6 +28,7 @@ class TextEvidence(BaseModel): source_id: str content: str metadata: dict[str, JsonValue] = Field(default_factory=dict) + locator: SourceLocator | None = Field(default=None, exclude_if=lambda value: value is None) __all__ = ["TEXT_EVIDENCE_PROJECTION_KEY", "TextEvidence"] diff --git a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py index 518a0cbcbf..73f4ca2f93 100644 --- a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py +++ b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py @@ -296,3 +296,16 @@ def test_another_projection_keeps_its_keys_but_not_a_repeated_body() -> None: "value": {"database": "OceanBase", "shards": 12}, } ] + + +def test_extraction_cites_the_recorded_journal_position() -> None: + evidence = asyncio.run(_evidence(_native_source())) + assert evidence.source_ref.model_dump(mode="json")["locator"] == {"kind": "sequence", "sequence": 1} + + +def test_extraction_preserves_adapter_reported_text_position() -> None: + projection = _text_projection().model_dump(mode="json") + locator = {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "部署 / 决策"} + projection["value"]["locator"] = locator + evidence = asyncio.run(_evidence(_observation((SourceProjectionValue.model_validate(projection),)))) + assert evidence.source_ref.model_dump(mode="json")["locator"] == locator diff --git a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py index 01a49c7fcf..0e74766318 100644 --- a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py +++ b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence_projection.py @@ -35,6 +35,7 @@ SourceProjectionKey, SourceProjectionValue, SourceRef, + SourceSequenceLocator, TextEvidence, project_source_for_transport, ) @@ -119,7 +120,9 @@ async def scenario() -> None: assert evidence.evidence_id == "source:1" assert evidence.journal_position == 1 - assert evidence.source_ref == SourceRef(source_type="content", source_id="turn-1") + assert evidence.source_ref == SourceRef( + source_type="content", source_id="turn-1", locator=SourceSequenceLocator(sequence=1) + ) assert isinstance(evidence.content, dict) assert evidence.content["content"] == BODY @@ -149,7 +152,9 @@ async def scenario() -> None: assert evidence.evidence_id == "source:1" assert evidence.journal_position == 1 - assert evidence.source_ref == SourceRef(source_type="content", source_id="turn-1") + assert evidence.source_ref == SourceRef( + source_type="content", source_id="turn-1", locator=SourceSequenceLocator(sequence=1) + ) assert isinstance(evidence.content, dict) assert evidence.content["content"] == BODY diff --git a/tests/builtin/persistence/test_artifacts.py b/tests/builtin/persistence/test_artifacts.py index 596c970be1..6ab1f9e9cd 100644 --- a/tests/builtin/persistence/test_artifacts.py +++ b/tests/builtin/persistence/test_artifacts.py @@ -22,11 +22,12 @@ from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.topic_memory import TopicMemory, TopicMemoryContent, TopicMemoryDraft +from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, ) from powercontext.builtin.runtime.relational import RelationalContexts -from powercontext.errors import RevisionConflictError +from powercontext.errors import InvalidSourceReferenceError, RevisionConflictError from powercontext.sources import SourceDefinitionRegistry, SourceMaterialization, SourceRef from tests.builtin.persistence.contract import ( SOURCE_ADAPTERS, @@ -132,6 +133,69 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_source_citation_positions_survive_separate_transactions() -> None: + async def scenario() -> None: + async with repository_profile() as (profile, repositories): + async with profile.database.transaction() as connection: + stored = await repositories.sources.add( + connection, + "scope-a", + NoteSource(name="positioned", materialization=SourceMaterialization.CAPTURED, body="证据"), + ) + refs = tuple( + SourceRef.model_validate({ + **stored.ref.model_dump(), + "locator": locator, + }) + for locator in ( + {"kind": "sequence", "sequence": stored.journal_position}, + {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "设计"}, + {"kind": "line_range", "start_line": 60, "end_line": 65}, + ) + ) + await repositories.artifacts.create( + connection, + "scope-a", + "positioned-report", + ReportDraft(content=ReportContent(status="green"), sources=(*refs, stored.ref)), + ) + async with profile.database.transaction() as connection: + loaded = await repositories.artifacts.revisions(connection, "scope-a", "report", "positioned-report") + assert loaded[0].lineage.sources == (*refs, stored.ref) + assert loaded[0].lineage.sources[-1].model_dump() == stored.ref.model_dump() + + asyncio.run(scenario()) + + +def test_source_locator_cannot_redirect_to_another_journal_entry() -> None: + async def scenario() -> None: + async with repository_profile() as (profile, repositories), profile.database.transaction() as connection: + stored = await repositories.sources.add( + connection, + "scope-a", + NoteSource(name="first", materialization=SourceMaterialization.CAPTURED, body="first fact"), + ) + other = await repositories.sources.add( + connection, + "scope-a", + NoteSource(name="second", materialization=SourceMaterialization.CAPTURED, body="unrelated fact"), + ) + wrong = SourceRef.model_validate({ + **stored.ref.model_dump(), + "locator": {"kind": "sequence", "sequence": other.journal_position}, + }) + with pytest.raises(InvalidSourceReferenceError, match=r"locator\.sequence"): + await ArtifactRepository((Report,), sources=repositories.sources).create( + connection, + "scope-a", + "wrong-position", + ReportDraft(content=ReportContent(status="green"), sources=(wrong,)), + ) + assert await repositories.artifacts.revisions(connection, "scope-a", "report", "wrong-position") == () + + asyncio.run(scenario()) + + def test_relational_contexts_persist_topic_memory_revisions_and_exact_lineage() -> None: async def scenario() -> None: async with repository_profile() as (profile, repositories): diff --git a/tests/builtin/persistence/test_full_server_migrations.py b/tests/builtin/persistence/test_full_server_migrations.py index 8a764c7b55..fcdb7c7529 100644 --- a/tests/builtin/persistence/test_full_server_migrations.py +++ b/tests/builtin/persistence/test_full_server_migrations.py @@ -73,8 +73,8 @@ def dump(path): def test_complete_upgrade_preserves_history_backup_and_repeated_apply(source): before = dump(source.database) plan = source.plan() - assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_004" - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004") + assert plan.source_revision == "s1300" and plan.target_revision == "v1_4_1_005" + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") assert dump(source.database) == before result = apply(source) assert result.readiness_scope == "server" and result.backup_state == "completed" diff --git a/tests/builtin/persistence/test_mysql_migration_runner.py b/tests/builtin/persistence/test_mysql_migration_runner.py index 61cb536104..e66ca5ec1a 100644 --- a/tests/builtin/persistence/test_mysql_migration_runner.py +++ b/tests/builtin/persistence/test_mysql_migration_runner.py @@ -561,7 +561,7 @@ def test_oceanbase_server_upgrade_with_separate_code_cache_preserves_history( ) assert runner.plan(backup_policy="manual").source_revision == "s1300" result = _apply(runner, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert runner.verify().state == "ready" prepare_server_storage(settings) assert not _apply(runner, policy="manual").changed @@ -692,7 +692,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') (main_path,) = backend.evidence_directory.glob("*/maintenance.json") evidence = json.loads(main_path.read_text()) @@ -702,7 +702,7 @@ def inspect_history(connection, identity, guard): for inspect in (runner.plan, runner.verify, runner.apply): with pytest.raises(MigrationError, match="checksum_conflict"): inspect() - assert backend.connections().run(inspect_history) == ("v1_4_1_004", "dedicated", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", "dedicated", b'{"history":"unchanged"}') assert { path: path.read_bytes() for path in backend.evidence_directory.rglob("maintenance.json") } == original_evidence @@ -769,7 +769,7 @@ def inspect_history(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_history) == ("v1_4_1_004", b'{"history":"unchanged"}') + assert backend.connections().run(inspect_history) == ("v1_4_1_005", b'{"history":"unchanged"}') def test_mysql_registered_projection_repair_preserves_authority_and_revision(backend: _BackendTarget) -> None: @@ -837,7 +837,7 @@ def inspect_content(connection, identity, guard): ).scalar_one(), ) - assert backend.connections().run(inspect_content) == ("v1_4_1_004", content, "Preserve immutable nebula evidence.") + assert backend.connections().run(inspect_content) == ("v1_4_1_005", content, "Preserve immutable nebula evidence.") @pytest.mark.parametrize("origin", ("empty", "released")) @@ -992,7 +992,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan _load_full_server_baseline(backend) runner = _full_server_runner(backend) original = runner.plan(backup_policy="manual") - assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_004" + assert original.source_revision == "s1300" and original.target_revision == "v1_4_1_005" _interrupt_after_ddl(runner, interruption) journals = list(backend.evidence_directory.rglob("maintenance.json")) assert len(journals) == 1 @@ -1014,7 +1014,7 @@ def test_mysql_full_server_interruption_preserves_history_and_original_maintenan result = _apply(resumed, policy="manual") - assert result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert result.backup_state == "user_confirmed" assert result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" @@ -1094,7 +1094,7 @@ def client_active(connection: Connection | None, _identity: BackendIdentity, _gu # interruption. An arbitrary remote failure cannot be acknowledged blindly. resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_004" + assert result.state == "ready" and result.readiness_scope == "server" and result.revision == "v1_4_1_005" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journals[0].read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_owner_recovery_migration.py b/tests/builtin/persistence/test_owner_recovery_migration.py index e91b321acb..b12e7b4507 100644 --- a/tests/builtin/persistence/test_owner_recovery_migration.py +++ b/tests/builtin/persistence/test_owner_recovery_migration.py @@ -39,6 +39,13 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + locator_resources = manifest["revision_resources"].pop("v1_4_1_005") + for resource in (*locator_resources, "versions/v1_4_1_005_source_locators.py", "mysql/versions/v1_4_1_005.py"): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["v1_4_1_005"] + del manifest["domain_readiness"]["v1_4_1_005"] + manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") + manifest["affected_objects"].remove("pc_artifact_source_locators") resources = manifest["revision_resources"].pop("v1_4_1_004") for resource in (*resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): (directory / resource).unlink() @@ -59,8 +66,8 @@ def test_registered_owner_recovery_upgrade_preserves_history_and_revision_checks original_evidence = json.loads(old.evidence_path.read_text(encoding="utf-8")) upgraded = SQLiteMigrationRunner(database, current, configuration=configuration(embedding)) plan = upgraded.plan() - assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004",) - assert apply(upgraded).revision == "v1_4_1_004" + assert plan.source_revision == "s1402" and plan.revisions == ("v1_4_1_004", "v1_4_1_005") + assert apply(upgraded).revision == "v1_4_1_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed evidence = json.loads(upgraded.evidence_path.read_text(encoding="utf-8")) diff --git a/tests/builtin/persistence/test_portable_storage_migration.py b/tests/builtin/persistence/test_portable_storage_migration.py index 16724dd2b2..180953af89 100644 --- a/tests/builtin/persistence/test_portable_storage_migration.py +++ b/tests/builtin/persistence/test_portable_storage_migration.py @@ -35,6 +35,13 @@ def previous_bundle(directory, embedding): shutil.copytree(current.directory, directory, ignore=shutil.ignore_patterns("__pycache__")) manifest_path = directory / "manifest.json" manifest = json.loads(manifest_path.read_text()) + locator_resources = manifest["revision_resources"].pop("v1_4_1_005") + for resource in (*locator_resources, "versions/v1_4_1_005_source_locators.py", "mysql/versions/v1_4_1_005.py"): + (directory / resource).unlink() + del manifest["sqlite_fingerprints"]["v1_4_1_005"] + del manifest["domain_readiness"]["v1_4_1_005"] + manifest["mysql_state_layouts"].remove("schemas/mysql-v1_4_1_005.json") + manifest["affected_objects"].remove("pc_artifact_source_locators") owner_resources = manifest["revision_resources"].pop("v1_4_1_004") for resource in (*owner_resources, "versions/v1_4_1_004_owner_recovery.py", "mysql/versions/v1_4_1_004.py"): (directory / resource).unlink() @@ -93,8 +100,8 @@ def test_registered_s1401_upgrade_preserves_completed_evidence_and_matches_fresh upgraded = SQLiteMigrationRunner(database, bundle, configuration=config) plan = upgraded.plan() assert plan.source_revision == "s1401" - assert plan.revisions == ("s1402", "v1_4_1_004") - assert apply(upgraded).revision == "v1_4_1_004" + assert plan.revisions == ("s1402", "v1_4_1_004", "v1_4_1_005") + assert apply(upgraded).revision == "v1_4_1_005" assert upgraded.verify().state == "ready" assert not apply(upgraded).changed fresh = SQLiteMigrationRunner(tmp_path / "fresh.db", bundle, configuration=config) diff --git a/tests/builtin/persistence/test_release140_server_migrations.py b/tests/builtin/persistence/test_release140_server_migrations.py index fcaeef4336..9d3743b7ad 100644 --- a/tests/builtin/persistence/test_release140_server_migrations.py +++ b/tests/builtin/persistence/test_release140_server_migrations.py @@ -134,7 +134,7 @@ def test_registered_sources_choose_distinct_lineages(tmp_path, release, expected _settings, _embedding, runner = _source(tmp_path, release=release) plan = runner.plan() assert plan.source_revision == expected - assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004") + assert plan.revisions == ("s1400", "s1401", "s1402", "v1_4_1_004", "v1_4_1_005") @pytest.mark.parametrize(("vectors", "code"), [(False, False), (True, False), (False, True), (True, True)]) diff --git a/tests/builtin/portability/test_bundle.py b/tests/builtin/portability/test_bundle.py index 0b8307a197..221e1ab2ae 100644 --- a/tests/builtin/portability/test_bundle.py +++ b/tests/builtin/portability/test_bundle.py @@ -38,7 +38,9 @@ from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, + ARTIFACT_LINEAGE_SOURCES_TABLE, ARTIFACT_PUBLICATIONS_TABLE, + ARTIFACT_SOURCE_LOCATORS_TABLE, ARTIFACT_TAGS_TABLE, ARTIFACTS_TABLE, BUILTIN_TABLES, @@ -570,10 +572,30 @@ async def scenario() -> None: searchable_text="not portable", ) ) + edge = { + "scope_id": "project:one", + "family": "handoff", + "artifact_id": "handoff", + "revision": 1, + "ordinal": 0, + } + await connection.execute( + insert(ARTIFACT_LINEAGE_SOURCES_TABLE).values( + **edge, + source_type="content", + source_id="source-1", + ) + ) + await connection.execute( + insert(ARTIFACT_SOURCE_LOCATORS_TABLE).values( + **edge, + locator=b'{"kind":"line_range","start_line":1,"end_line":1}', + ) + ) receipt = await PortableBundleService(source.database).export( ["project:one"], archive, authorize=_authorize_export ) - assert receipt.record_count == 5 + assert receipt.record_count == 7 async with SQLiteProfile.open( SQLiteConfig(url=f"sqlite+aiosqlite:///{target_path}"), tables=BUILTIN_TABLES @@ -582,11 +604,13 @@ async def scenario() -> None: inspection = await service.validate(archive, supported_source_types=("content",)) assert inspection.scopes == ("project:one",) restored = await service.restore(archive, supported_source_types=("content",)) - assert restored.inserted == 5 + assert restored.inserted == 7 repeated = await service.restore(archive, supported_source_types=("content",)) - assert repeated.already_present == 5 + assert repeated.already_present == 7 async with target.database.transaction() as connection: assert await connection.scalar(select(ARTIFACT_HEADS_TABLE.c.searchable_text)) is None + locator = await connection.scalar(select(ARTIFACT_SOURCE_LOCATORS_TABLE.c.locator)) + assert json.loads(locator) == {"kind": "line_range", "start_line": 1, "end_line": 1} asyncio.run(scenario()) diff --git a/tests/builtin/review/test_service.py b/tests/builtin/review/test_service.py index e6f74a2eea..124a05e7a0 100644 --- a/tests/builtin/review/test_service.py +++ b/tests/builtin/review/test_service.py @@ -871,3 +871,34 @@ async def fail_status_update(*_args, **_kwargs): ) asyncio.run(scenario()) + + +def test_candidate_preserves_distinct_positions_from_one_source() -> None: + async def scenario() -> None: + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + scope = await _create_scope(runtime) + captured = await runtime.sources.for_scope(scope).capture( + CaptureSource(source_id="positioned", content="Two independent observations.", metadata={}) + ) + refs = tuple( + SourceRef.model_validate({ + **captured.source_ref.model_dump(), + "locator": {"kind": "line_range", "start_line": line, "end_line": line}, + }) + for line in (1, 2) + ) + candidate = await runtime.experience.for_scope(scope).propose( + ProposeExperienceRequest(proposal=_proposal(), sources=refs) + ) + loaded = await runtime.review.for_scope(scope).get(GetCandidateRequest(candidate_id=candidate.candidate_id)) + assert loaded.sources == refs + approved = await runtime.review.for_scope(scope).approve( + ApproveCandidateRequest(candidate_id=candidate.candidate_id, expected_version=candidate.version) + ) + assert approved.result_artifact is not None + result = await runtime.experience.for_scope(scope).get( + GetExperienceRequest(artifact=approved.result_artifact) + ) + assert result.lineage.sources == refs + + asyncio.run(scenario()) diff --git a/tests/e2e/test_artifact_dreaming.py b/tests/e2e/test_artifact_dreaming.py index 68d5e92be3..169dde1cef 100644 --- a/tests/e2e/test_artifact_dreaming.py +++ b/tests/e2e/test_artifact_dreaming.py @@ -84,7 +84,7 @@ from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import ContentSource from powercontext.server.app import ServerApplication -from powercontext.sources import Source, SourceRef +from powercontext.sources import Source, SourceRef, SourceSequenceLocator from tests.e2e.dream_support import atomic_memory_pipeline, process_pending from tests.e2e.dream_support import open_dream_runtime as open_builtin_runtime @@ -1223,7 +1223,11 @@ async def seed(runtime: BuiltinRuntime): )) entries = await runtime.atomic_memory.for_scope(scope.scope_id).list() citation = next(item.ref for item in entries.items if item.artifact.content.kind == "working_note") - return scope.scope_id, captured.source_ref, citation + return ( + scope.scope_id, + captured.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=captured.sequence)}), + citation, + ) async def forget_memory(runtime: BuiltinRuntime, scope_id: str, ref): @@ -2501,7 +2505,7 @@ async def scenario() -> None: result = await runtime.review.for_scope(scope).get( GetCandidateRequest(candidate_id=run.candidate.candidate_id) ) - assert result.sources == (root,) + assert result.sources == (root.model_copy(update={"locator": SourceSequenceLocator(sequence=1)}),) assert {ref.model_dump_json() for ref in result.artifacts} == { ref.model_dump_json() for ref in (approved.result_artifact, *citations) } @@ -2775,7 +2779,15 @@ async def scenario() -> None: target = approved.result_artifact assert target is not None and target.revision == revision stored = await runtime.experience.for_scope(scope).get(GetExperienceRequest(artifact=target)) - assert stored.lineage.sources == (source.source_ref,) + assert stored.lineage.sources == ( + ( + source.source_ref.model_copy( + update={"locator": SourceSequenceLocator(sequence=source.sequence)} + ) + if with_memory and revision == 1 + else source.source_ref + ), + ) assert stored.lineage.artifacts == (*refs, *(citations if revision == 1 else ())) with pytest.raises(EvidenceResolutionError, match="evidence_limit_exceeded"): await runtime.dream.for_scope(scope).create( @@ -3217,3 +3229,35 @@ async def delayed_request(): assert wrong_version.status_code == 409, wrong_version.text asyncio.run(scenario()) + + +def test_source_positions_share_one_root_without_losing_their_locations(database: DatabaseConfig) -> None: + async def scenario() -> None: + generator = Generator() + async with open_builtin_runtime( + config(database), candidate_pipeline=atomic_memory_pipeline(MemoryPipeline()), dream_generator=generator + ) as runtime: + scope, root, citation = await seed(runtime) + refs = tuple( + SourceRef.model_validate({ + **root.model_dump(), + "locator": {"kind": "line_range", "start_line": line, "end_line": line}, + }) + for line in (1, 2) + ) + accepted = await runtime.dream.for_scope(scope).create( + CreateDreamRunRequest( + operation="refine_experience", + sources=refs, + artifacts=(citation,), + idempotency_key="positioned-root", + ) + ) + await process_pending(runtime) + run = await runtime.dream.for_scope(scope).get(GetDreamRunRequest(run_id=accepted.run_id)) + assert run.input_manifest is not None + assert len(run.input_manifest.root_groups) == 1 + assert all(ref in run.input_manifest.root_groups[0].sources for ref in refs) + assert run.input_manifest.root_groups[0].independence == "unknown" + + asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_flush_concurrency.py b/tests/e2e/test_atomic_memory_flush_concurrency.py index ee66cf9063..aa96d515f6 100644 --- a/tests/e2e/test_atomic_memory_flush_concurrency.py +++ b/tests/e2e/test_atomic_memory_flush_concurrency.py @@ -45,6 +45,7 @@ open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator from tests.e2e.dream_support import atomic_memory_pipeline, memory_source_text @@ -125,7 +126,9 @@ async def scenario() -> None: assert first.current_cursor == first.high_watermark == original.sequence (record,) = (await memory.list()).items assert record.artifact.content.text == "First fact." - assert record.artifact.lineage.sources == (original.source_ref,) + assert record.artifact.lineage.sources == ( + original.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=original.sequence)}), + ) following = await memory.flush(limit=1) assert following.previous_cursor == original.sequence assert following.current_cursor == later.sequence @@ -166,7 +169,9 @@ async def scenario() -> None: assert second_result.previous_cursor == second_result.current_cursor == source.sequence (record,) = (await memory.list()).items assert record.ref.revision == 1 - assert record.artifact.lineage.sources == (source.source_ref,) + assert record.artifact.lineage.sources == ( + source.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=source.sequence)}), + ) asyncio.run(scenario()) @@ -209,7 +214,9 @@ async def scenario() -> None: assert (await other_memory.cursor()).sequence == source.sequence (record,) = (await memory.list()).items assert record.ref.revision == 1 - assert record.artifact.lineage.sources == (source.source_ref,) + assert record.artifact.lineage.sources == ( + source.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=source.sequence)}), + ) asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_generation_merge.py b/tests/e2e/test_atomic_memory_generation_merge.py index 3e3c040785..0513c4d2a1 100644 --- a/tests/e2e/test_atomic_memory_generation_merge.py +++ b/tests/e2e/test_atomic_memory_generation_merge.py @@ -40,7 +40,7 @@ from powercontext.builtin.runtime import BuiltinConfig, CaptureSource, RuntimeConfig, open_builtin_contexts from powercontext.builtin.runtime.composition import open_builtin_runtime from powercontext.builtin.scope import ScopeDraft -from powercontext.sources import SourceRef +from powercontext.sources import SourceRef, SourceSequenceLocator from tests.e2e.dream_support import memory_source_text SCOPE = "project" @@ -191,7 +191,9 @@ async def scenario() -> None: assert merged.ref.revision == 1 assert len(merged.artifact.lineage.artifacts) == len(originals) assert all(original.ref in merged.artifact.lineage.artifacts for original in originals) - assert merged.artifact.lineage.sources == (receipt.source_ref,) + assert merged.artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) for original in originals: current = await memory.get(original.ref.artifact_id) assert current.state.state == "merged" @@ -234,7 +236,11 @@ async def scenario() -> None: assert record.ref.revision == position assert record.artifact.content.text == fact assert record.artifact.lineage.sources == ( - SourceRef(source_type="content", source_id=source.source_id), + SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ), ) if originals: assert record.ref.artifact_id == originals[0].ref.artifact_id @@ -258,7 +264,11 @@ async def scenario() -> None: for request in reconciler.requests: (new_source,) = tuple(item for item in request.evidence if isinstance(item, AtomicMemoryEvidence)) source, raw = sources[new_source.journal_position - 1] - assert new_source.source_ref == SourceRef(source_type="content", source_id=source.source_id) + assert new_source.source_ref == SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ) assert memory_source_text(new_source) == raw assert request.proposal.evidence_ids == (new_source.evidence_id,) assert tuple( @@ -313,11 +323,21 @@ async def scenario() -> None: assert evidence.content.text == original.artifact.content.text assert evidence.content.text != continued.proposal.text else: - assert evidence.source_ref == SourceRef(source_type="content", source_id=source.source_id) + assert evidence.source_ref == SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ) assert memory_source_text(evidence) == latest (merged,) = (await memory.list()).items assert merged.artifact.content.text == latest - assert merged.artifact.lineage.sources == (SourceRef(source_type="content", source_id=source.source_id),) + assert merged.artifact.lineage.sources == ( + SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ), + ) assert len(merged.artifact.lineage.artifacts) == len(published) assert all(record.ref in merged.artifact.lineage.artifacts for record in published.values()) for original in originals: @@ -360,7 +380,13 @@ async def scenario() -> None: record for record in (await memory.list()).items if record.artifact.content.text == latest ) assert created.ref.artifact_id not in {original.ref.artifact_id for original in originals} - assert created.artifact.lineage.sources == (SourceRef(source_type="content", source_id=source.source_id),) + assert created.artifact.lineage.sources == ( + SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ), + ) assert len(created.artifact.lineage.artifacts) == len(originals) for original in originals: assert original.ref in created.artifact.lineage.artifacts @@ -392,12 +418,20 @@ async def scenario() -> None: assert flush.current_cursor == position original = next(item for item in (await memory.list()).items if item.artifact.content.text == text) assert original.state.state == "active" - assert original.artifact.lineage.sources == (SourceRef(source_type="content", source_id=source_id),) + assert original.artifact.lineage.sources == ( + SourceRef( + source_type="content", source_id=source_id, locator=SourceSequenceLocator(sequence=position) + ), + ) originals.append(original) assert len((await memory.list()).items) == 2 source = await contexts.records.capture_source(SCOPE, "content", "consolidate-rules", CONSOLIDATION, {}) - source_ref = SourceRef(source_type="content", source_id=source.source_id) + source_ref = SourceRef( + source_type="content", + source_id=source.source_id, + locator=SourceSequenceLocator(sequence=source.position), + ) result = await context.triggers.flush(limit=1) assert result.previous_cursor == 2 assert result.current_cursor == result.high_watermark == source.position == 3 @@ -513,7 +547,11 @@ async def fail_projection(connection, scope_id, artifact_id): assert retry.previous_cursor == 2 and retry.current_cursor == 3 (result,) = (await memory.list()).items assert result.artifact.content.text == POLICY - assert result.artifact.lineage.sources == (SourceRef(source_type="content", source_id="consolidate-rules"),) + assert result.artifact.lineage.sources == ( + SourceRef( + source_type="content", source_id="consolidate-rules", locator=SourceSequenceLocator(sequence=3) + ), + ) for original in originals: frozen = await memory.get(original.ref.artifact_id) assert frozen.state.merged_into_id == result.ref.artifact_id diff --git a/tests/e2e/test_atomic_memory_supervisor_generation.py b/tests/e2e/test_atomic_memory_supervisor_generation.py index dcadda850b..c52a6e6f5d 100644 --- a/tests/e2e/test_atomic_memory_supervisor_generation.py +++ b/tests/e2e/test_atomic_memory_supervisor_generation.py @@ -33,6 +33,7 @@ from powercontext.builtin.runtime.config import BuiltinConfig, InferenceConfig, RuntimeConfig from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator _MEMORY_TEXT = "The user prefers acceptance tests that exercise spawned workers." @@ -142,7 +143,9 @@ async def scenario() -> None: assert record.artifact.family == "atomic-memory" assert record.artifact.content.kind == "preference" assert record.artifact.content.text == _MEMORY_TEXT - assert record.artifact.lineage.sources == (receipt.source_ref,) + assert record.artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) assert record.state.state == "active" and record.state.state_version == 0 assert await memory.get(record.ref.artifact_id, revision=record.ref.revision) == record found = await memory.search("spawned workers", mode="text") diff --git a/tests/e2e/test_atomic_memory_wire_evidence.py b/tests/e2e/test_atomic_memory_wire_evidence.py index 0f3f744ae6..c5986be8a3 100644 --- a/tests/e2e/test_atomic_memory_wire_evidence.py +++ b/tests/e2e/test_atomic_memory_wire_evidence.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from powercontext.builtin.artifacts.atomic_memory.extraction import ( + AtomicMemoryCandidate, AtomicMemoryExtractionInput, AtomicMemoryExtractionOutput, ) @@ -36,6 +37,7 @@ AdapterSourceDefinition, Source, SourceDefinitionRegistry, + SourceLineRangeLocator, SourceMaterialization, TextEvidence, ) @@ -160,3 +162,57 @@ async def scenario() -> None: serialized = extractor.requests[0].model_dump_json() assert serialized.count(BULK_BODY) == 1 assert character_token_estimator().estimate(serialized) < 24_000 + + +def test_adapter_text_position_survives_memory_generation_and_reopen(tmp_path) -> None: + locator = SourceLineRangeLocator(start_line=40, end_line=55, heading_path="部署 / 约束") + + class LocatedProjection(_TextProjection): + def project(self, source: _WireSource) -> TextEvidence: + return TextEvidence(source_type="wire-note", source_id=source.name, content=source.content, locator=locator) + + class Extractor: + async def generate(self, request: AtomicMemoryExtractionInput, /): + assert request.evidence[0].source_ref.locator == locator + return GenerationResult( + output=AtomicMemoryExtractionOutput( + candidates=( + AtomicMemoryCandidate(kind="fact", text=BODY, evidence_ids=(request.evidence[0].evidence_id,)), + ) + ) + ) + + async def scenario() -> None: + registry = SourceDefinitionRegistry(( + *BUILTIN_SOURCE_REGISTRY.definitions, + AdapterSourceDefinition(_WireAdapter(), projections=(LocatedProjection(),)), + )) + observation = project_source_for_transport( + registry, + _WireSource( + name="located", + materialization=SourceMaterialization.CAPTURED, + content=BODY, + wire_content=BODY, + ), + ) + config = BuiltinConfig(database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'located-memory.db'}")) + async with open_builtin_contexts(config, candidate_pipeline=atomic_memory_pipeline(Extractor())) as contexts: + await contexts.register_source_definition( + manifest_for_definition(registry.definition_for_name("wire-note")) + ) + await contexts.submit_source_observation( + SubmitSourceObservation(scope_id="scope-a", observation=observation) + ) + await contexts.process_memory("scope-a", 1) + (record,) = (await contexts.atomic_memory.for_scope("scope-a").list()).items + reference = record.ref + assert record.artifact.lineage.sources[0].locator == locator + async with open_builtin_contexts(config) as contexts: + loaded = await contexts.atomic_memory.for_scope("scope-a").get( + reference.artifact_id, revision=reference.revision + ) + assert loaded.artifact == record.artifact + assert loaded.artifact.lineage.sources[0].locator == locator + + asyncio.run(scenario()) diff --git a/tests/e2e/test_source_citation_locators.py b/tests/e2e/test_source_citation_locators.py new file mode 100644 index 0000000000..d5d37572b8 --- /dev/null +++ b/tests/e2e/test_source_citation_locators.py @@ -0,0 +1,159 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Source citations survive real Server startup, persistence and both transports.""" + +import json +import sqlite3 + +from fastapi.testclient import TestClient + +from powercontext.builtin.persistence.migrations.deployment import deployment_runner +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.server.factory import create_server_app +from powercontext.server.settings import BearerAuthConfig, ServerSettings + + +def _handoff(refs): + return { + "family": "handoff", + "content": { + "schema": "powercontext.handoff.v1", + "objective": "核实项目约束", + "state": [ + {"text": "项目使用 Python。", "citations": [{"kind": "source", "source_ref": ref} for ref in refs]} + ], + "disposition": "complete", + "next_action": None, + "omissions": [], + }, + } + + +def _settings(database): + return ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{database}"), + auth=BearerAuthConfig(enabled=False), + ) + + +def _app(database): + return create_server_app(settings=_settings(database)) + + +def test_locators_survive_upgrade_reopen_http_and_mcp(tmp_path): + database = tmp_path / "locators.db" + with TestClient(_app(database)) as client: + scope_response = client.post("/v1/scope-bindings/resolve", json={"binding_keys": []}) + assert scope_response.is_success, scope_response.text + scope = scope_response.json()["scope_id"] + response = client.post(f"/v1/scopes/{scope}/sources", json={"content": "项目使用 Python。"}) + assert response.is_success, response.text + source = response.json() + bare = {"name": "content", "source_id": source["source_id"]} + legacy = client.post(f"/v1/scopes/{scope}/artifacts", json=_handoff([bare])) + assert legacy.is_success, legacy.text + legacy_id = legacy.json()["artifact_id"] + + # Reconstruct the previous registered layout, preserving its real historical evidence. + with sqlite3.connect(database) as connection: + connection.execute("DROP TABLE pc_artifact_source_locators") + connection.execute("UPDATE pc_schema_revision SET version_num = 'v1_4_1_004'") + + runner = deployment_runner(_settings(database)) + plan = runner.plan() + assert plan.revisions == ("v1_4_1_005",) + runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) + + locators = [ + {"kind": "sequence", "sequence": source["position"]}, + {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "设计 / 约束"}, + {"kind": "line_range", "start_line": 60, "end_line": 65}, + ] + references = [bare | {"locator": locator} for locator in locators] + [bare] + expected = [ + { + "source_type": "content", + "source_id": bare["source_id"], + **({"locator": ref["locator"]} if "locator" in ref else {}), + } + for ref in references + ] + with TestClient(_app(database)) as client: + old = client.get(f"/v1/scopes/{scope}/artifacts/handoff/{legacy_id}") + assert old.is_success, old.text + assert [ref for ref in old.json()["sources"] if ref["source_id"] == bare["source_id"]] == [expected[-1]] + created = client.put( + f"/v1/scopes/{scope}/artifacts/handoff/{legacy_id}", + json={"content": _handoff(references)["content"]}, + headers={"If-Match": '"revision:1"'}, + ) + assert created.is_success, created.text + assert [ref for ref in created.json()["sources"] if ref["source_id"] == bare["source_id"]] == expected + artifact_id = created.json()["artifact_id"] + invalid = client.post( + f"/v1/scopes/{scope}/artifacts", + json=_handoff([bare | {"locator": {"kind": "line_range", "start_line": 1, "end_line": 201}}]), + ) + assert invalid.status_code == 422 + assert invalid.json()["error"]["code"] == "invalid_request" + wrong_position = client.put( + f"/v1/scopes/{scope}/artifacts/handoff/{legacy_id}", + json={ + "content": _handoff([ + bare | {"locator": {"kind": "sequence", "sequence": source["position"] + 100}}, + ])["content"] + }, + headers={"If-Match": '"revision:2"'}, + ) + assert wrong_position.status_code == 422, wrong_position.text + assert wrong_position.json()["error"]["code"] == "invalid_request" + + with TestClient(_app(database)) as client: + path = f"/v1/scopes/{scope}/artifacts/handoff/{artifact_id}" + read = client.get(path) + assert read.is_success, read.text + assert [ref for ref in read.json()["sources"] if ref["source_id"] == bare["source_id"]] == expected + assert read.json()["content"]["state"][0]["citations"] == [ + {"kind": "source", "source_ref": ref} for ref in expected + ] + result = client.post( + "/mcp/", + headers={"Accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_artifact", + "arguments": { + "scope_id": scope, + "family": "handoff", + "artifact_id": artifact_id, + }, + }, + }, + ) + assert result.is_success, result.text + message = next( + json.loads(line.removeprefix("data:")) for line in result.text.splitlines() if line.startswith("data:") + ) + assert "error" not in message, message + payload = message["result"] + assert not payload.get("isError"), payload + assert payload["structuredContent"]["artifact"] == read.json() + historical = client.get(path + "/revisions/1") + assert historical.is_success, historical.text + assert historical.json()["content"] == old.json()["content"] + assert historical.json()["sources"] == old.json()["sources"] diff --git a/tests/e2e/test_zcode_service_chain.py b/tests/e2e/test_zcode_service_chain.py index b01c89ccd7..66a5f74d5d 100644 --- a/tests/e2e/test_zcode_service_chain.py +++ b/tests/e2e/test_zcode_service_chain.py @@ -248,7 +248,13 @@ def run_scope(action: str, *extra: str) -> dict[str, Any]: f"/revisions/{reference['revision']}" ) revision.raise_for_status() - assert revision.json()["sources"] == [{"source_type": "content", "source_id": captured[0]["source_id"]}] + assert revision.json()["sources"] == [ + { + "source_type": "content", + "source_id": captured[0]["source_id"], + "locator": {"kind": "sequence", "sequence": captured[0]["position"]}, + } + ] second = _invoke_hook( node=node, diff --git a/tests/fixtures/text_evidence_manifest_before_locators.json b/tests/fixtures/text_evidence_manifest_before_locators.json new file mode 100644 index 0000000000..e99572530b --- /dev/null +++ b/tests/fixtures/text_evidence_manifest_before_locators.json @@ -0,0 +1,311 @@ +{ + "name": "content", + "version": "1", + "source_schema": { + "$defs": { + "ArtifactRef": { + "description": "A stable reference to one exact artifact revision.", + "properties": { + "family": { + "title": "Family", + "type": "string" + }, + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "family", + "artifact_id", + "revision" + ], + "title": "ArtifactRef", + "type": "object" + }, + "ArtifactRestorationOutcome": { + "additionalProperties": false, + "description": "One successful restoration group retained with its primary revision.", + "properties": { + "operation": { + "enum": [ + "restore", + "undo_merge" + ], + "title": "Operation", + "type": "string" + }, + "target": { + "$ref": "#/$defs/ArtifactRef" + }, + "writes": { + "items": { + "$ref": "#/$defs/ArtifactRestorationWrite" + }, + "title": "Writes", + "type": "array" + }, + "undo_merge_results": { + "items": { + "$ref": "#/$defs/ArtifactRef" + }, + "title": "Undo Merge Results", + "type": "array" + } + }, + "required": [ + "operation", + "target", + "writes", + "undo_merge_results" + ], + "title": "ArtifactRestorationOutcome", + "type": "object" + }, + "ArtifactRestorationWrite": { + "additionalProperties": false, + "description": "Exact content source and committed result of one restoration write.", + "properties": { + "before_ref": { + "$ref": "#/$defs/ArtifactRef" + }, + "content_from_ref": { + "$ref": "#/$defs/ArtifactRef" + }, + "after_ref": { + "$ref": "#/$defs/ArtifactRef" + }, + "lifecycle_state": { + "enum": [ + "active", + "retired" + ], + "title": "Lifecycle State", + "type": "string" + } + }, + "required": [ + "before_ref", + "content_from_ref", + "after_ref", + "lifecycle_state" + ], + "title": "ArtifactRestorationWrite", + "type": "object" + }, + "ContentSourceInternal": { + "description": "Server-owned Source purpose data never exposed by the base REST API.", + "properties": { + "role": { + "const": "lineage_only", + "title": "Role", + "type": "string" + }, + "operation": { + "enum": [ + "artifact_create", + "artifact_replace", + "artifact_restore", + "artifact_undo_merge" + ], + "title": "Operation", + "type": "string" + }, + "target": { + "$ref": "#/$defs/ContentSourceTarget" + }, + "restoration_outcome": { + "anyOf": [ + { + "$ref": "#/$defs/ArtifactRestorationOutcome" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "role", + "operation", + "target" + ], + "title": "ContentSourceInternal", + "type": "object" + }, + "ContentSourceTarget": { + "description": "Exact Artifact revision to which one system Source is bound.", + "properties": { + "scope_id": { + "title": "Scope Id", + "type": "string" + }, + "family": { + "maxLength": 128, + "minLength": 1, + "title": "Family", + "type": "string" + }, + "artifact_id": { + "title": "Artifact Id", + "type": "string" + }, + "revision": { + "default": 1, + "minimum": 1, + "title": "Revision", + "type": "integer" + } + }, + "required": [ + "scope_id", + "family", + "artifact_id" + ], + "title": "ContentSourceTarget", + "type": "object" + }, + "JsonValue": {}, + "SourceMaterialization": { + "description": "Describe where the value read for a Source comes from.", + "enum": [ + "captured", + "referenced" + ], + "title": "SourceMaterialization", + "type": "string" + } + }, + "description": "Captured content that can be used as Artifact evidence.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "definition_version": { + "default": "1", + "title": "Definition Version", + "type": "string" + }, + "materialization": { + "$ref": "#/$defs/SourceMaterialization" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "content": { + "title": "Content", + "type": "string" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + }, + "working_note": { + "default": false, + "title": "Working Note", + "type": "boolean" + }, + "wire_content": { + "anyOf": [ + { + "$ref": "#/$defs/JsonValue" + }, + { + "type": "null" + } + ], + "default": null + }, + "wire_content_present": { + "default": false, + "title": "Wire Content Present", + "type": "boolean" + }, + "internal": { + "anyOf": [ + { + "$ref": "#/$defs/ContentSourceInternal" + }, + { + "type": "null" + } + ], + "default": null + }, + "handoff_receipt": { + "default": false, + "title": "Handoff Receipt", + "type": "boolean" + } + }, + "required": [ + "name", + "materialization", + "content" + ], + "title": "ContentSource", + "type": "object" + }, + "projections": [ + { + "key": { + "name": "powercontext.text-evidence", + "version": "1" + }, + "schema": { + "$defs": { + "JsonValue": {} + }, + "description": "Canonical JSON shape consumed as textual Artifact evidence.", + "properties": { + "source_type": { + "title": "Source Type", + "type": "string" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "metadata": { + "additionalProperties": { + "$ref": "#/$defs/JsonValue" + }, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "source_type", + "source_id", + "content" + ], + "title": "TextEvidence", + "type": "object" + } + } + ], + "fingerprint": "sha256:4baf43bbfbfe384d33ecb2c8bfb79fca303a065bf9a51545af1de3fb34a87c99" +} diff --git a/tests/native/test_oceanbase_engine_recovery.py b/tests/native/test_oceanbase_engine_recovery.py index 7f144e8f6b..0503d987f1 100644 --- a/tests/native/test_oceanbase_engine_recovery.py +++ b/tests/native/test_oceanbase_engine_recovery.py @@ -175,7 +175,7 @@ def ping(connection, _identity, _guard): _apply(_full_server_runner(backend), policy="manual") resumed = _full_server_runner(backend, acknowledge_previous_run=token) result = _apply(resumed, policy="manual") - assert result.state == "ready" and result.revision == "v1_4_1_004" and result.readiness_scope == "server" + assert result.state == "ready" and result.revision == "v1_4_1_005" and result.readiness_scope == "server" assert result.backup_state == "user_confirmed" and result.backup_ref == interrupted["manual_ref"] assert resumed.verify().state == "ready" completed = json.loads(journal.read_text()) diff --git a/tests/native/test_personal_service_lifecycle.py b/tests/native/test_personal_service_lifecycle.py index 6d17946926..ccdea630d3 100644 --- a/tests/native/test_personal_service_lifecycle.py +++ b/tests/native/test_personal_service_lifecycle.py @@ -401,7 +401,7 @@ def test_native_public_migration_switches_an_installed_130_service( if failure in {"startup", "switch"}: assert applied.exit_code == 1, applied.output assert result["error"] == "service_start_failed" - assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_004" + assert result["database_state"] == "ready" and result["revision"] == "v1_4_1_005" assert adapter.manager_state() in {ManagerState.INACTIVE, ManagerState.FAILED} phase = "switching" if failure == "switch" else "start_failed" assert json.loads(controller.maintenance_path.read_text())["phase"] == phase diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 92e89a677f..26c9dcbff6 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -808,7 +808,7 @@ def test_source_reference_keeps_name_as_the_source_type() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) properties = contract["components"]["schemas"]["SourceReference"]["properties"] - assert set(properties) == {"name", "source_id"} + assert set(properties) == {"name", "source_id", "locator"} def test_memory_transport_has_no_legacy_citation_shape() -> None: @@ -1034,8 +1034,8 @@ def test_base_access_uses_a_dedicated_source_type_reference() -> None: contract = yaml.safe_load(CONTRACT_PATH.read_text()) schemas = contract["components"]["schemas"] - assert set(schemas["SourceReference"]["properties"]) == {"name", "source_id"} - assert set(schemas["SourceTypeReference"]["properties"]) == {"source_type", "source_id"} + assert set(schemas["SourceReference"]["properties"]) == {"name", "source_id", "locator"} + assert set(schemas["SourceTypeReference"]["properties"]) == {"source_type", "source_id", "locator"} assert schemas["SourcePage"]["required"] == ["items", "next_cursor"] assert schemas["SourcePage"]["properties"]["items"]["items"] == {"$ref": "#/components/schemas/SourceRecord"} for schema_name in ("ArtifactCreated", "ArtifactRevision", "ArtifactCollectionItem"): diff --git a/tests/test_managed_atomic_maintenance.py b/tests/test_managed_atomic_maintenance.py index 68f5f77e5c..17dcdcf851 100644 --- a/tests/test_managed_atomic_maintenance.py +++ b/tests/test_managed_atomic_maintenance.py @@ -76,7 +76,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b assert inspected.exit_code == 0, inspected.output plan = json.loads(inspected.output) assert plan["source_revision"] == "s1300" - assert plan["target_revision"] == "v1_4_1_004" + assert plan["target_revision"] == "v1_4_1_005" assert plan["readiness_scope"] == "server" assert historical.read_bytes() == original @@ -103,7 +103,7 @@ def test_atomic_alias_upgrades_complete_historical_server_with_shared_plan_and_b with closing(sqlite3.connect(result["backup_ref"])) as connection: assert tuple(connection.iterdump()) == original_dump with closing(sqlite3.connect(historical)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] assert connection.execute("SELECT content FROM pc_artifacts").fetchall() == [(b'{"history":"unchanged"}',)] verified = CliRunner().invoke(cli, [*arguments, "--action", "verify"]) assert verified.exit_code == 0, verified.output diff --git a/tests/test_managed_processing_maintenance.py b/tests/test_managed_processing_maintenance.py index d1d06578f0..76aa1849d9 100644 --- a/tests/test_managed_processing_maintenance.py +++ b/tests/test_managed_processing_maintenance.py @@ -75,7 +75,7 @@ def test_registered_mode_switch_backs_up_preserves_revision_and_fences_old_worke assert result.backup_state == "completed" assert result.backup_ref and Path(result.backup_ref).is_file() with closing(sqlite3.connect(path)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] assert ( json.loads(connection.execute("SELECT config_manifest FROM pc_artifact_processing_schema").fetchone()[0])[ "mode" diff --git a/tests/test_migration_revision_names.py b/tests/test_migration_revision_names.py index 3db38c5731..83e3b1171b 100644 --- a/tests/test_migration_revision_names.py +++ b/tests/test_migration_revision_names.py @@ -59,9 +59,10 @@ def invoke(action, *options): "v1_4_1_002", "v1_4_1_003", "v1_4_1_004", + "v1_4_1_005", ] assert [item["revision"] for item in plan["pending_migrations"]] == list(reviewed.revisions) - target = {"revision": "v1_4_1_004", "name": "v1_4_1_004", "software_version": "1.4.1", "sequence": 4} + target = {"revision": "v1_4_1_005", "name": "v1_4_1_005", "software_version": "1.4.1", "sequence": 5} assert plan["target_migration"] == target applied = invoke( @@ -76,11 +77,18 @@ def invoke(action, *options): ) assert applied["migration"] == target and applied["server_ready"] with closing(sqlite3.connect(runner.database)) as connection: - assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_004",)] + assert connection.execute("SELECT version_num FROM pc_schema_revision").fetchall() == [("v1_4_1_005",)] evidence = runner.evidence_path.read_bytes() recorded = json.loads(evidence) assert recorded["source_revision"] == expected_source["revision"] - assert set(recorded["release_checksums"]) == {expected_source["revision"], "s1400", "s1401", "s1402", "v1_4_1_004"} + assert set(recorded["release_checksums"]) == { + expected_source["revision"], + "s1400", + "s1401", + "s1402", + "v1_4_1_004", + "v1_4_1_005", + } assert runner.verify().state == "ready" assert invoke("verify")["migration"] == target repeated = invoke("apply") diff --git a/tests/test_registered_source_maintenance_cli.py b/tests/test_registered_source_maintenance_cli.py index 7cdbb5ce2c..deda3e6d2b 100644 --- a/tests/test_registered_source_maintenance_cli.py +++ b/tests/test_registered_source_maintenance_cli.py @@ -63,7 +63,7 @@ def test_release140_compatibility_commands_preserve_source_and_share_full_upgrad planned = CliRunner().invoke(cli, [*arguments, "plan", "--backup", "skip"]) assert planned.exit_code == 0, planned.output plan = json.loads(planned.output) - assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_004" + assert plan["source_revision"] == "r1400" and plan["target_revision"] == "v1_4_1_005" assert plan["readiness_scope"] == "server" full = CliRunner().invoke(cli, ["server", "db-migrate", "plan", "--backup", "skip"]) assert full.exit_code == 0, full.output diff --git a/tests/test_source_locators.py b/tests/test_source_locators.py new file mode 100644 index 0000000000..39db3a4634 --- /dev/null +++ b/tests/test_source_locators.py @@ -0,0 +1,97 @@ +# 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 pydantic import ValidationError + +from powercontext.sources import SourceRef + + +@pytest.mark.parametrize( + "locator", + [ + {"kind": "sequence", "sequence": 0}, + {"kind": "sequence", "sequence": 12}, + {"kind": "line_range", "start_line": 40, "end_line": 55, "heading_path": "设计 / 引用"}, + {"kind": "line_range", "start_line": 1, "end_line": 200}, + ], +) +def test_source_locator_round_trip(locator) -> None: + reference = {"source_type": "document", "source_id": "doc", "locator": locator} + value = SourceRef.model_validate(reference) + assert value.model_dump(mode="json", exclude_none=True) == reference + assert SourceRef.model_validate_json(value.model_dump_json()) == value + + +def test_absent_source_locator_preserves_legacy_encoding() -> None: + reference = {"source_type": "content", "source_id": "doc"} + value = SourceRef.model_validate(reference) + assert value.locator is None + assert value.model_dump(mode="json") == reference + origin = SourceRef.model_validate(reference | {"locator": {"kind": "sequence", "sequence": 0}}) + assert origin != value + assert origin.model_dump(mode="json")["locator"]["sequence"] == 0 + + +@pytest.mark.parametrize( + "locator", + [ + {"kind": "sequence", "sequence": -1}, + {"kind": "sequence", "sequence": True}, + {"kind": "sequence", "sequence": "1"}, + {"kind": "sequence", "sequence": 1, "start_line": 1}, + {"kind": "line_range", "start_line": 0, "end_line": 1}, + {"kind": "line_range", "start_line": 2, "end_line": 1}, + {"kind": "line_range", "start_line": 1, "end_line": 201}, + {"kind": "line_range", "start_line": 1.0, "end_line": 1}, + {"kind": "line_range", "start_line": 1, "end_line": 1, "heading_path": "x" * 1025}, + {"kind": "unknown", "sequence": 0}, + ], +) +def test_invalid_source_locator_is_rejected(locator) -> None: + with pytest.raises(ValidationError): + SourceRef.model_validate({"source_type": "document", "source_id": "doc", "locator": locator}) + + +def test_source_locator_round_trips_both_transport_spellings() -> None: + from powercontext.server.mapping import ( + runtime_source_reference, + runtime_source_type_reference, + source_reference, + source_type_reference, + ) + + reference = SourceRef.model_validate({ + "source_type": "document", + "source_id": "doc", + "locator": {"kind": "line_range", "start_line": 1, "end_line": 200}, + }) + for encode, decode in ( + (source_reference, runtime_source_reference), + (source_type_reference, runtime_source_type_reference), + ): + assert decode(encode(reference)) == reference + bare = SourceRef(source_type="content", source_id="old") + assert "locator" not in encode(bare).model_dump(mode="json") + + +def test_transport_rejects_excessive_source_line_span() -> None: + from powercontext.http import SourceReference + + with pytest.raises(ValidationError): + SourceReference.model_validate({ + "name": "document", + "source_id": "doc", + "locator": {"kind": "line_range", "start_line": 1, "end_line": 201}, + }) diff --git a/tests/test_source_observations.py b/tests/test_source_observations.py index b4933a7934..a80c24e59c 100644 --- a/tests/test_source_observations.py +++ b/tests/test_source_observations.py @@ -109,3 +109,27 @@ def reject_request(*_args: object, **_kwargs: object) -> None: fingerprint=f"sha256:{'0' * 64}", source_schema={"$dynamicRef": "https://example.invalid/schema"}, ) + + +def test_pre_locator_text_evidence_manifest_retains_its_fingerprint() -> None: + import json + from pathlib import Path + + from powercontext.builtin.sources.observation_validation import ( + validate_source_definition_manifest, + validate_source_observation, + ) + + payload = json.loads((Path(__file__).parent / "fixtures/text_evidence_manifest_before_locators.json").read_text()) + manifest = SourceDefinitionManifest.model_validate(payload) + validate_source_definition_manifest(manifest) + assert manifest.model_dump(mode="json", by_alias=True) == payload + + async def scenario() -> None: + registry = SourceDefinitionRegistry((CONTENT_SOURCE_DEFINITION,)) + source = await registry.resolve(ContentCapture(source_id="legacy", content="原始证据")) + observation = project_source_for_transport(registry, source) + historical = observation.model_copy(update={"definition_fingerprint": manifest.fingerprint}) + validate_source_observation(historical, manifest) + + asyncio.run(scenario()) From 20192d92491db0aba6e87fae7ed54d5f4ab531a3 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Fri, 30 Oct 2026 16:25:09 +0800 Subject: [PATCH 02/13] fix(ci): retry transient macOS DMG detach failures --- .github/workflows/desktop.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index eb719dd4f5..6cd4327f25 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -182,17 +182,33 @@ jobs: - name: Verify native Keychain across processes run: cargo run --locked --manifest-path src-tauri/Cargo.toml --example credential_probe - name: Build unsigned macOS app and DMG + shell: bash run: | mkdir -p .artifacts set -o pipefail - pnpm tauri build --ci --verbose -- --locked 2>&1 | tee .artifacts/macos-build.log + for attempt in 1 2 3; do + log=".artifacts/macos-build-${attempt}.log" + if pnpm tauri build --ci --verbose -- --locked 2>&1 | tee "$log"; then + exit 0 + else + status=$? + fi + + # Retry only the observed transient DMG detach failure. + if [ "$attempt" -eq 3 ] || + ! grep -Eq "hdiutil: couldn't eject .*Resource busy" "$log"; then + exit "$status" + fi + echo "DMG detach was busy; retrying build in 15 seconds (attempt $attempt/3)." + sleep 15 + done - name: Upload failed macOS build diagnostics if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: desktop-macos-${{ matrix.runner }}-build-diagnostics path: | - desktop/.artifacts/macos-build.log + desktop/.artifacts/macos-build-*.log desktop/src-tauri/target/release/bundle/dmg/bundle_dmg.sh if-no-files-found: warn - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 From cedaa17e063241669dbb071f558f16b09140a98b Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Fri, 30 Oct 2026 16:50:18 +0800 Subject: [PATCH 03/13] revert(ci): remove unrelated DMG build retry --- .github/workflows/desktop.yml | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 6cd4327f25..eb719dd4f5 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -182,33 +182,17 @@ jobs: - name: Verify native Keychain across processes run: cargo run --locked --manifest-path src-tauri/Cargo.toml --example credential_probe - name: Build unsigned macOS app and DMG - shell: bash run: | mkdir -p .artifacts set -o pipefail - for attempt in 1 2 3; do - log=".artifacts/macos-build-${attempt}.log" - if pnpm tauri build --ci --verbose -- --locked 2>&1 | tee "$log"; then - exit 0 - else - status=$? - fi - - # Retry only the observed transient DMG detach failure. - if [ "$attempt" -eq 3 ] || - ! grep -Eq "hdiutil: couldn't eject .*Resource busy" "$log"; then - exit "$status" - fi - echo "DMG detach was busy; retrying build in 15 seconds (attempt $attempt/3)." - sleep 15 - done + pnpm tauri build --ci --verbose -- --locked 2>&1 | tee .artifacts/macos-build.log - name: Upload failed macOS build diagnostics if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: desktop-macos-${{ matrix.runner }}-build-diagnostics path: | - desktop/.artifacts/macos-build-*.log + desktop/.artifacts/macos-build.log desktop/src-tauri/target/release/bundle/dmg/bundle_dmg.sh if-no-files-found: warn - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 From d50ba73ae50c228113ca79792586634c01633174 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Fri, 30 Oct 2026 16:53:06 +0800 Subject: [PATCH 04/13] docs(migrations): document source locator revision --- docs/en/docs/operate/database-migrations.md | 16 ++++++++++------ docs/zh/docs/operate/database-migrations.md | 16 ++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/en/docs/operate/database-migrations.md b/docs/en/docs/operate/database-migrations.md index e9ca6724c0..699807f2cd 100644 --- a/docs/en/docs/operate/database-migrations.md +++ b/docs/en/docs/operate/database-migrations.md @@ -16,8 +16,8 @@ The design follows [RFC #1771](https://github.com/oceanbase/powercontext/pull/17 The full Server framework registers two installed-release sources for the PowerContext 1.4.1 target (stored compatibility IDs shown below): -- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004`. -- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004` through a separate frozen source bridge. +- **1.3.0:** `s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`. +- **1.4.0:** `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005` through a separate frozen source bridge. Application release numbers and schema revisions are distinct. The 1.4.0 release is not the `s1400` checkpoint: its existing Candidate schema is current, but its Topic attempt storage still needs the registered bridge. @@ -51,6 +51,8 @@ maintenance for existing managed storage. Runtime startup does not add the recei Revision `v1_4_1_004` registers `pc_access_owner_recovery` for durable Artifact and Candidate ownership intents. Both installed-release source paths and vector profiles include this table. Existing managed databases require explicit maintenance; fresh databases create the registered empty table. Pending intents retain their original deployment, principal and idempotency identity across Server restarts. Earlier revision resources and checksums remain unchanged. Unregistered database layouts are rejected rather than adopted from a table name alone. This registration does not establish installed-release or external-Provider upgrade acceptance. +Revision `v1_4_1_005` registers `pc_artifact_source_locators`, an optional relation keyed by the existing Artifact Revision and Source-edge ordinal. Existing managed databases require explicit migration before Server startup. The migration preserves existing lineage rows, exact identities and historical payloads without rewriting them or backfilling locators. Atomic Memory embeddings remain unchanged; no re-embedding is required. See [Source citation locators](../workflows/sources.md) for locator behavior. + An interrupted [portable restore](portable-archive.md) keeps normal startup and workers blocked until its projections are complete. Retry `powercontext archive restore --yes` with the same validated bundle. Only this explicit recovery path can open an incomplete projection set, after validating the registered layout and the configured vector @@ -97,12 +99,14 @@ Sequence `000` identifies a registered historical baseline; upgrade steps start | `v1_4_1_001` | 1.4.1 | 1 | `s1400` | Complete Server storage transition | | `v1_4_1_002` | 1.4.1 | 2 | `s1401` | Offline Memory history archive | | `v1_4_1_003` | 1.4.1 | 3 | `s1402` | Portable restore receipts and timestamp adaptation | +| `v1_4_1_004` | 1.4.1 | 4 | `v1_4_1_004` | Durable Artifact and Candidate ownership intents | +| `v1_4_1_005` | 1.4.1 | 5 | `v1_4_1_005` | Optional Source citation position locators | -Both registered source paths reach `v1_4_1_003`; only 1.3.0 is selected for CI upgrade acceptance: +Both registered source paths reach `v1_4_1_005`; only 1.3.0 is selected for CI upgrade acceptance: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 ``` `status` and `plan` show `source_migration`, `target_migration` and `pending_migrations` @@ -124,7 +128,7 @@ existing plans and original backups usable. Partial prototype bundles keep their For a new Server migration, use the release-based name directly as its Alembic `revision` and register its schema, domain steps and recovery checkpoints as usual. `down_revision` must reference -the actual preceding ID (`s1402` when appending directly to the current head), even if that ID has +the actual preceding ID (`v1_4_1_005` when appending directly to the current head), even if that ID has a display alias. The framework follows this registered chain, not lexical name ordering. Already registered names and release assignments stay immutable; a version bump must not rename history. diff --git a/docs/zh/docs/operate/database-migrations.md b/docs/zh/docs/operate/database-migrations.md index f511938a7e..4e464ddf04 100644 --- a/docs/zh/docs/operate/database-migrations.md +++ b/docs/zh/docs/operate/database-migrations.md @@ -14,8 +14,8 @@ description: 了解数据库迁移命令、备份策略、服务启停及当前 完整 Server 框架登记了两个真实安装包来源: -- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004`。 -- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004`。 +- **1.3.0:**`s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 +- **1.4.0:**通过独立冻结的来源桥接,执行 `r1400 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005`。 应用发布版本与 schema revision 分别编号。1.4.0 发行包不等于 `s1400` 检查点:其 Candidate 结构已经是当前格式, 但 Topic 尝试状态仍需登记的桥接。两条路径保留既有引用和历史,到达相同的受管目标结构;原有资源摘要分别保留, @@ -44,6 +44,8 @@ payload 身份及待处理 intent。兼容的当前任务原样保留,不套 修订 `v1_4_1_004` 将 `pc_access_owner_recovery` 纳入管理,用于持久保存 Artifact 和 Candidate 的所有权写入意图。两条已登记发布源路径及向量配置均包含此表。已有受管数据库需要显式维护,新数据库创建已登记的空表。待恢复意图在 Server 重启后保留原有 deployment、Principal 与幂等身份;历史修订资源及校验和保持不变。未登记布局不会仅凭表名被自动接纳。此登记不代表已通过安装发布版或外部 Provider 的升级验收。 +修订 `v1_4_1_005` 登记可选关系表 `pc_artifact_source_locators`,以既有 Artifact Revision 和 Source 边序号为键。已有受管数据库必须在 Server 启动前显式迁移。迁移保留既有血缘行、精确身份及历史负载,不重写这些记录,也不回填定位信息。Atomic Memory 的历史向量保持不变,无需重新生成 embedding。定位信息的行为参见 [Source 引用位置](../workflows/sources.md)。 + [可移植归档恢复](portable-archive.md) 中断后,普通启动和 worker 会继续拒绝未完成的检索投影。 请使用同一个通过校验的归档,重新执行 `powercontext archive restore --yes`。 仅此显式恢复路径可在验证已登记结构和配置的向量 profile 后打开投影尚未完整的数据库,且不启动 worker。 @@ -82,12 +84,14 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 | `v1_4_1_001` | 1.4.1 | 第一步 | `s1400` | 完整 Server 存储转换 | | `v1_4_1_002` | 1.4.1 | 第二步 | `s1401` | 离线 Memory 历史归档 | | `v1_4_1_003` | 1.4.1 | 第三步 | `s1402` | 可移植恢复回执与时间戳适配 | +| `v1_4_1_004` | 1.4.1 | 第四步 | `v1_4_1_004` | Artifact 和 Candidate 的持久所有权写入意图 | +| `v1_4_1_005` | 1.4.1 | 第五步 | `v1_4_1_005` | 可选的 Source 引用位置定位信息 | -两条已登记来源路径都升级到 `v1_4_1_003`;CI 升级验收只选择 1.3.0: +两条已登记来源路径都升级到 `v1_4_1_005`;CI 升级验收只选择 1.3.0: ```text -1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 +1.3.0: v1_3_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 +1.4.0: v1_4_0_000 -> v1_4_1_001 -> v1_4_1_002 -> v1_4_1_003 -> v1_4_1_004 -> v1_4_1_005 ``` `status` 和 `plan` 在原有机器字段之前显示 `source_migration`、`target_migration`、 @@ -106,7 +110,7 @@ MySQL 兼容后端的结构反射支持 CHECK 表达式中的二元 `+`、`-`、 这样可以继续使用已有计划和原始备份。局部原型 bundle 保留自己的独立编号。 后续新增 Server 迁移直接使用该命名作为 Alembic `revision`,并正常登记结构、领域步骤和恢复检查点。 -`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,仍引用 `s1402`,即使它已有展示别名。 +`down_revision` 必须引用前一步的实际 ID;若直接追加在当前 head 之后,引用 `v1_4_1_005`;历史 revision 即使已有展示别名,也必须引用其实际 ID。 框架按照登记的链执行,不按名称字典序推算。已登记的名称和所属发布版本保持不变,版本升级不能重命名历史。 ## 初始化与维护职责 From bbf0c761b4089e3fe564a414906fdb80bd146a09 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 11:20:14 +0800 Subject: [PATCH 05/13] test(desktop): qualify locator contract against exact server wheel --- desktop/VALIDATION.md | 47 ++++++++++++++++++- .../src/connections/compatibility.json | 22 +++++++++ desktop/ui/tests/connections.test.tsx | 9 ++-- docs/en/docs/workflows/sources.md | 2 +- docs/zh/docs/workflows/sources.md | 2 +- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index de914525d3..cdde6b7d26 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -541,7 +541,12 @@ The historical Server c535d816 protocol qualification below records the specific ## Compatibility scope -[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The bundled contract combines Source locators and Atomic Memory descriptions and has no matching product qualification record. `sqlite-atomic-cc5980f0-v1` retains its original ten-operation qualification below. The `sqlite-atomic-dd62723d-v1` and `sqlite-atomic-4ffdf4e9-v1` records retain their migration-framework and release-preparation source qualifications with their original contract digest. Other historical records also retain their exact identities and evidence. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest are disabled in the selector, and saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. +[compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, +normalized OpenAPI contract digest, qualification-wheel digest and operation set. +`sqlite-locators-ccfa6f4d-v1` matches the bundled contract and qualifies its thirteen operations +for the exact Server source and wheel recorded below. Historical records retain their original +identities, operation sets and evidence. Selecting a record does not qualify another Server source. +CI fixture identities remain distinct from product qualification. [compatibility.json](src-tauri/src/connections/compatibility.json) preserves each recorded Server source, normalized OpenAPI contract digest, qualification-wheel digest and operation set. The `sqlite-atomic-112db11e-v1` record qualifies its eleven recorded SQLite protocol operations against the exact Server source and wheel below. Separate `authenticatedClients` evidence qualifies those operations for the current client contract in Bearer mode with a valid Principal; it does not replace the original Server contract or qualify anonymous access. Historical records retain their exact identities and evidence, including the separate `sqlite-atomic-1da99747-linux-v1` qualification. Ordinary builds require explicitly selecting the record whose tested Server source and wheel match the deployment, then checking the connection before business operations become available. Records with a different contract digest remain disabled unless separate authenticated-client evidence covers the current contract and selected authentication mode. Saved unavailable selections remain visible until explicitly replaced. Selecting a profile does not extend its recorded operation set or qualify a different Server source. CI builds have their own artifact identities and do not change product qualification. @@ -2482,3 +2487,43 @@ All 736 packaged runtime files match the recorded Git source bytes. Evidence is under ignored `desktop/.artifacts/real-server.json` and `desktop/.artifacts/server-wheel/`. This record covers local SQLite protocol behavior. It does not establish installed UI, native credential persistence, Windows execution, external production acceptance or release readiness. + + +#### Server ccfa6f4d protocol qualification + +`sqlite-locators-ccfa6f4d-v1` qualifies Server source +`ccfa6f4d221f78b38c5e6a76f21535eb40100226` with OpenAPI digest +`18af205a886552b6c2d9f41fb72a52cbf4453ddc70a98a47103212b4c16f2a6f`. +The wheel is `powercontext-0.1.dev1471+gccfa6f4d2-py3-none-any.whl`, SHA-256 +`1229552d6e8054c2ee3fbb22eb143ae56ea996fa8fe2fe5873a2474b0ddf6a6c`. +All 743 packaged runtime files match the recorded Git source bytes, with no missing or extra files. +The sorted runtime manifest digest is +`a9bf3cc8f6b440283d945e3ba60cef7ed0e2f1803d9e1c9f25379bedf3088d3b` +(one `wheel/path sha256` line per file, with a final newline). + +On 2026-11-07, macOS 26.6.2 ARM64 with Python 3.13.12 ran the unchanged +`desktop/tests/real_server.py` against this extracted wheel and independent SQLite Server processes. +Anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and injected-provider/enforced-access +modes passed. The thirteen catalogued operations include connection identity, Server discovery, +statistics, Scope selection, Memory save/search and exact revision reads. The harness also checks +committed writes with lost responses without replay and Server survival after client exit. The provider +mode verifies identity changes, same-identity revocation, 51 same-title Scopes and scoped authorization. + +The native probe was built from the product Cargo lock without `ci-fixtures`. Its SHA-256 is +`22218a7f37312264a9912b24dd5252edab70b77884274045e27e5ee8eef17020`. +The report records Desktop commit `ccfa6f4d221f78b38c5e6a76f21535eb40100226` and a dirty working tree +containing the added qualification record, UI test fixture and documentation updates. Runtime source +bytes match the recorded commit. Ignored run evidence is retained in `desktop/.artifacts/real-server.json`. + +Reproduce using the recorded wheel and the client containing this qualification record: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-locators-ccfa6f4d-v1 \ + --wheel desktop/.artifacts/server-wheel/powercontext-0.1.dev1471+gccfa6f4d2-py3-none-any.whl +``` + +本记录绑定上述精确源码、契约和 wheel,743 个运行时文件逐字节核对一致,四种独立真实 SQLite/HTTP +连接模式全部通过。该记录验证所列协议操作;定位信息升级由单独的迁移和 HTTP/MCP 回归测试覆盖。 +它不替代安装后 GUI、原生凭据持久化、Windows/Intel、签名或 seekDB/OceanBase 升级验收。 diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index 4f6fbfb40c..edce0d1ed9 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1157,5 +1157,27 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-d9686bba-protocol-qualification" + }, + { + "id": "sqlite-locators-ccfa6f4d-v1", + "serverCommit": "ccfa6f4d221f78b38c5e6a76f21535eb40100226", + "contractSha256": "18af205a886552b6c2d9f41fb72a52cbf4453ddc70a98a47103212b4c16f2a6f", + "artifactSha256": "1229552d6e8054c2ee3fbb22eb143ae56ea996fa8fe2fe5873a2474b0ddf6a6c", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-ccfa6f4d-protocol-qualification" } ] diff --git a/desktop/ui/tests/connections.test.tsx b/desktop/ui/tests/connections.test.tsx index 423500c912..526203157a 100644 --- a/desktop/ui/tests/connections.test.tsx +++ b/desktop/ui/tests/connections.test.tsx @@ -415,9 +415,12 @@ test("new connections offer only current-contract profiles and keep no selection }); test("a saved authenticated legacy profile remains selectable only in its qualified authentication mode", async () => { const user = userEvent.setup(); - const legacy = qualifications.find( - (p) => p.id === "sqlite-atomic-112db11e-v1", - )!; + const legacy: CompatibilityProfile = { + ...qualifications.find((p) => p.id === "sqlite-atomic-112db11e-v1")!, + authenticatedClients: { + [contractSha256]: "Synthetic authenticated client qualification", + }, + }; const saved = { ...profile, compatibility: legacy.id }; vi.mocked(desktopApi.invalidate).mockResolvedValue(state(saved)); render( diff --git a/docs/en/docs/workflows/sources.md b/docs/en/docs/workflows/sources.md index d5eed1fe3b..eed62b7ac0 100644 --- a/docs/en/docs/workflows/sources.md +++ b/docs/en/docs/workflows/sources.md @@ -129,7 +129,7 @@ export/restore. Distinct positions in the same Source remain distinct citations. in serialized references, preserving existing records and canonical encodings. Exact Artifact Revision and Source eligibility checks still apply. -Server database revision `v1_4_1_006` adds the optional `pc_artifact_source_locators` relation keyed by the existing +Server database revision `v1_5_0_004` adds the optional `pc_artifact_source_locators` relation keyed by the existing Artifact Revision and Source-edge ordinal. Upgrade existing managed databases through the normal [database migration workflow](../operate/database-migrations.md) before Server startup; existing lineage rows and historical payloads are not rewritten. Atomic Memory embeddings are bound to its content, not this lineage metadata, diff --git a/docs/zh/docs/workflows/sources.md b/docs/zh/docs/workflows/sources.md index 16e4c65206..ff2e15c6ff 100644 --- a/docs/zh/docs/workflows/sources.md +++ b/docs/zh/docs/workflows/sources.md @@ -120,7 +120,7 @@ locator 会经过 Candidate 负载、Artifact 血缘、Handoff 引用、HTTP/MCP 同一 Source 的不同位置是不同的引用。缺失的 locator 在序列化时仍被省略,已有记录及其规范编码保持兼容。 精确 Artifact Revision 校验及 Source 使用资格校验继续生效。 -Server 数据库修订 `v1_4_1_006` 增加可选的 `pc_artifact_source_locators` 关系表,按现有 Artifact Revision +Server 数据库修订 `v1_5_0_004` 增加可选的 `pc_artifact_source_locators` 关系表,按现有 Artifact Revision 及来源引用序号关联。已有托管数据库须在 Server 启动前按[数据库迁移流程](../operate/database-migrations.md)升级; 迁移不会重写已有血缘行或历史负载。Atomic Memory 向量绑定记忆内容,不包含这份血缘位置元数据,因此升级不会导致既有向量失效, 也无需重新嵌入。Handoff 内容中新增 locator 时,内容摘要随之变化,与其他内容修改一致。 From b71cdfb388b19dc88d9efeb8ba5c72ad49b24765 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 18:21:00 +0800 Subject: [PATCH 06/13] test(migrations): recover evidence through locator revision --- .../persistence/test_mysql_initialization_preflight.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/builtin/persistence/test_mysql_initialization_preflight.py b/tests/builtin/persistence/test_mysql_initialization_preflight.py index 085a49aef0..87abc9190a 100644 --- a/tests/builtin/persistence/test_mysql_initialization_preflight.py +++ b/tests/builtin/persistence/test_mysql_initialization_preflight.py @@ -253,7 +253,7 @@ def test_evidence_recovery_resumes_before_final_schema_marker(tmp_path, monkeypa before = database.read_bytes(), journal.path.read_bytes() plan = runner.plan() assert plan.state == "recovery_required" and plan.source_revision == predecessor - assert plan.revisions == ("v1_5_0_002", "v1_5_0_003") + assert plan.revisions == ("v1_5_0_002", "v1_5_0_003", "v1_5_0_004") assert (database.read_bytes(), journal.path.read_bytes()) == before assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [] @@ -278,7 +278,7 @@ def execute_revision(connection, _config, _source, target, guard): monkeypatch.setattr(runner, "_upgrade", execute_revision) result = runner.apply(plan_id=plan.plan_id, accepted=True, maintenance_confirmed=True) - assert result.state == "ready" and result.revision == "v1_5_0_003" + assert result.state == "ready" and result.revision == runner.bundle.head assert result.backup_state == "not_required" assert runner.verify().state == "ready" assert connection.exec_driver_sql("SELECT schema_version FROM pc_atomic_memory_evidence_schema").all() == [ @@ -288,7 +288,7 @@ def execute_revision(connection, _config, _source, target, guard): assert completed is not None and completed.state == "complete" assert completed.maintenance_window_id == "interrupted-evidence-ddl" assert completed.source_revision is None and completed.backup_state == "not_required" - assert completed.last_revision == "v1_5_0_003" and completed.pending_revision is None + assert completed.last_revision == runner.bundle.head and completed.pending_revision is None assert completed.release_checksums == runner.checksums assert not runner.apply(plan_id=runner.plan().plan_id, accepted=True, maintenance_confirmed=True).changed From bae2f3e788c1eb7845e46d8b26de784366bb9ced Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 18:26:45 +0800 Subject: [PATCH 07/13] fix(sources): derive sequence citations from stored journal position --- docs/en/docs/workflows/sources.md | 8 +- docs/zh/docs/workflows/sources.md | 2 + .../artifacts/atomic_memory/extraction.py | 7 +- .../atomic_memory/test_extraction_evidence.py | 36 ++++++++ .../e2e/test_atomic_memory_source_evidence.py | 85 ++++++++++++++++++- 5 files changed, 132 insertions(+), 6 deletions(-) diff --git a/docs/en/docs/workflows/sources.md b/docs/en/docs/workflows/sources.md index eed62b7ac0..b55c8539c4 100644 --- a/docs/en/docs/workflows/sources.md +++ b/docs/en/docs/workflows/sources.md @@ -114,9 +114,11 @@ HTTP inputs that use `name` instead of `source_type` accept the same locator. Lines refer to the exact decoded text supplied by the adapter. These locators introduce no byte offsets. Atomic Memory extraction derives a journal locator from the persisted capture position for built-in captured -Content Sources and other Sources without a finer position. An adapter may report a line range in -`TextEvidence.locator`; worker observations preserve it in the text-evidence projection, and extraction keeps it -instead of guessing an offset. Adapters must report a location in their exact evidence text, not in a subsequently +Content Sources and other Sources without a finer position. If an adapter reports a sequence locator in +`TextEvidence.locator`, extraction uses the persisted Source journal position for the citation, including for +previously accepted observations; the adapter's evidence is retained unchanged. An adapter may also report a line +range in `TextEvidence.locator`; worker observations preserve it in the text-evidence projection, and extraction +keeps it instead of guessing an offset. Adapters must report a location in their exact evidence text, not in a subsequently edited file. Opaque sources and ordinary caller-supplied references may omit the locator. Other generation APIs preserve explicitly supplied locators; they do not infer line numbers or heading paths. No read-time text scan or historical backfill is performed. Existing worker manifests retain their exact fingerprints; a worker adopting diff --git a/docs/zh/docs/workflows/sources.md b/docs/zh/docs/workflows/sources.md index ff2e15c6ff..40df84ff1d 100644 --- a/docs/zh/docs/workflows/sources.md +++ b/docs/zh/docs/workflows/sources.md @@ -108,6 +108,8 @@ locator 只描述证据位置,不会选择其他 Source 或 Artifact Revision 行号对应适配器提供的准确解码文本;此功能不引入字节偏移量。 Atomic Memory 提取会为内置 Content Source 及没有更细位置的 Source,根据持久化的采集位置生成 journal locator。 +若适配器在 `TextEvidence.locator` 中报告 sequence locator,提取时会使用 Source 实际持久化的 journal 位置生成引用, +已有 observation 也遵循此规则;适配器提供的证据保持原样。 适配器可通过 `TextEvidence.locator` 报告行范围;远程 worker observation 在文本证据投影中保存该信息,提取时原样保留, 不会猜测偏移。适配器报告的位置必须对应准确的证据文本,不能指向随后被编辑的文件。 不透明来源和调用方提供的普通引用可以省略 locator。其他生成接口保留显式传入的 locator,不推测行号或标题路径。 diff --git a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py index d76cdeb553..0cb306cd40 100644 --- a/src/powercontext/builtin/artifacts/atomic_memory/extraction.py +++ b/src/powercontext/builtin/artifacts/atomic_memory/extraction.py @@ -305,7 +305,12 @@ async def project_atomic_memory_evidence( source_ref=SourceRef( source_type=stored.ref.source_type, source_id=stored.ref.source_id, - locator=locator or SourceSequenceLocator(sequence=stored.journal_position), + # Journal positions belong to the persisted Source, not the adapter. + locator=( + SourceSequenceLocator(sequence=stored.journal_position) + if locator is None or isinstance(locator, SourceSequenceLocator) + else locator + ), ), journal_position=stored.journal_position, content=body, diff --git a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py index 73f4ca2f93..229d9a7ffd 100644 --- a/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py +++ b/tests/builtin/artifacts/atomic_memory/test_extraction_evidence.py @@ -26,6 +26,7 @@ import json from typing import Any, cast +import pytest from pydantic import JsonValue from powercontext.builtin.artifacts.atomic_memory.extraction import project_atomic_memory_evidence @@ -39,6 +40,7 @@ SourceProjectionKey, SourceProjectionValue, SourceRef, + SourceSequenceLocator, TextEvidence, ) @@ -309,3 +311,37 @@ def test_extraction_preserves_adapter_reported_text_position() -> None: projection["value"]["locator"] = locator evidence = asyncio.run(_evidence(_observation((SourceProjectionValue.model_validate(projection),)))) assert evidence.source_ref.model_dump(mode="json")["locator"] == locator + + +@pytest.mark.parametrize("representation", ["native", "observation"]) +@pytest.mark.parametrize("sequence", [0, 1, 42]) +def test_extraction_uses_stored_journal_position_over_adapter_sequence(representation, sequence) -> None: + async def scenario() -> None: + text = TextEvidence( + source_type="tool-result", + source_id="observation-1", + content=_BODY, + locator=SourceSequenceLocator(sequence=sequence), + ) + observation = _observation(( + SourceProjectionValue(key=TEXT_EVIDENCE_PROJECTION_KEY, value=text.model_dump(mode="json")), + )) + value = observation if representation == "observation" else _native_source() + stored = StoredSource( + ref=SourceRef(source_type="tool-result", source_id=value.name), + value=value, + journal_position=1, + ) + + class AdapterRead: + async def read_value(self, source, /): + return text + + evidence = await project_atomic_memory_evidence( + stored, cast("SourceRepository", AdapterRead()), evidence_id="source:1" + ) + assert evidence.source_ref == stored.ref.model_copy(update={"locator": SourceSequenceLocator(sequence=1)}) + stored.validate_locator(evidence.source_ref) + assert evidence.content == text.model_dump(mode="json") + + asyncio.run(scenario()) diff --git a/tests/e2e/test_atomic_memory_source_evidence.py b/tests/e2e/test_atomic_memory_source_evidence.py index fdf2688cd4..e2bec32817 100644 --- a/tests/e2e/test_atomic_memory_source_evidence.py +++ b/tests/e2e/test_atomic_memory_source_evidence.py @@ -22,7 +22,7 @@ from typing import cast import pytest -from pydantic import Field, field_serializer, field_validator +from pydantic import BaseModel, Field, field_serializer, field_validator from sqlalchemy import event, text, update from sqlalchemy.exc import SQLAlchemyError @@ -49,7 +49,15 @@ from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import CONTENT_SOURCE_DEFINITION -from powercontext.sources import AdapterSourceDefinition, SourceDefinitionRegistry, SourceMaterialization, SourceRef +from powercontext.sources import ( + TEXT_EVIDENCE_PROJECTION_KEY, + AdapterSourceDefinition, + SourceDefinitionRegistry, + SourceMaterialization, + SourceRef, + SourceSequenceLocator, + TextEvidence, +) from powercontext.sources.evidence import MemoryEvidenceDeclaration from powercontext.sources.observations import manifest_for_definition, project_source_for_transport from tests.builtin.persistence.contract import NoteAdapter, NoteInput, NoteSource @@ -77,6 +85,79 @@ async def _commit(contexts, scope_id, artifact_id, text_value, *, sources=(), ar return result.primary +@pytest.mark.parametrize("representation", ["native", "observation"]) +def test_accepted_adapter_sequence_can_finish_memory_window(migration_backend, representation) -> None: + class SequenceAdapter(NoteAdapter): + async def read(self, source, /): + return TextEvidence( + source_type=self.name, + source_id=source.name, + content=source.body, + locator=SourceSequenceLocator(sequence=0), + ) + + adapter = SequenceAdapter() + + class SequenceProjection: + name = TEXT_EVIDENCE_PROJECTION_KEY.name + version = TEXT_EVIDENCE_PROJECTION_KEY.version + source_class: type[NoteSource] = NoteSource + output_class: type[BaseModel] = TextEvidence + + def project(self, source, /): + return TextEvidence( + source_type=adapter.name, + source_id=source.name, + content=source.body, + locator=SourceSequenceLocator(sequence=0), + ) + + definition = AdapterSourceDefinition(adapter, projections=(SequenceProjection(),)) + worker = SourceDefinitionRegistry((definition,)) + + class Extractor: + async def generate(self, request): + return GenerationResult( + output=AtomicMemoryExtractionOutput( + candidates=( + AtomicMemoryCandidate( + kind="fact", + text="Preserve the accepted remote fact.", + description="Accepted source evidence", + evidence_ids=tuple(item.evidence_id for item in request.evidence), + ), + ) + ) + ) + + async def scenario(): + config = BuiltinConfig(database=migration_backend.config) + registry = worker if representation == "native" else SourceDefinitionRegistry(()) + async with open_builtin_contexts( + config, source_registry=registry, candidate_pipeline=atomic_memory_pipeline(Extractor()) + ) as contexts: + context = await contexts.get("scope") + source = await worker.resolve(NoteInput(note_id="accepted", body="Preserve the accepted remote fact.")) + if representation == "observation": + await contexts.register_source_definition(manifest_for_definition(definition)) + await contexts.submit_source_observation( + SubmitSourceObservation(scope_id="scope", observation=project_source_for_transport(worker, source)) + ) + else: + await context.sources.add(source) + flush = await context.triggers.flush(limit=1) + assert flush.source_count == 1 and not flush.remaining_work + assert flush.current_cursor == 1 + memory = contexts.atomic_memory.for_scope("scope") + (record,) = (await memory.list()).items + assert record.artifact.lineage.sources == ( + SourceRef(source_type=adapter.name, source_id=source.name, locator=SourceSequenceLocator(sequence=1)), + ) + assert not (await context.triggers.flush(limit=1)).processed + + asyncio.run(scenario()) + + @pytest.mark.parametrize("declared", [False, True]) def test_unverifiable_native_serializer_keeps_memory_evidence_neutral(migration_backend, declared) -> None: class SerializedNote(NoteSource): From 83b6ade89ee6fefa6a9b1bd5b729a4968b66bca9 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 18:40:11 +0800 Subject: [PATCH 08/13] refactor(sources): centralize locator limits --- src/powercontext/limits.py | 2 ++ src/powercontext/sources/models.py | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index 1897a293b4..347adbbfee 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -26,6 +26,8 @@ MAX_SOURCE_ID_LENGTH = 256 MAX_SOURCE_TYPE_LENGTH = 128 MAX_SOURCE_OBSERVATION_BYTES = 4 * 1024 * 1024 +MAX_SOURCE_LOCATOR_LINES = 200 +MAX_SOURCE_HEADING_PATH_LENGTH = 1_024 MAX_ARTIFACT_FAMILY_LENGTH = 128 MAX_ARTIFACT_ID_LENGTH = 128 MAX_ARTIFACT_REVISION_ACTOR_LENGTH = 2_048 diff --git a/src/powercontext/sources/models.py b/src/powercontext/sources/models.py index 9c11a8fb11..8d5fc7d05a 100644 --- a/src/powercontext/sources/models.py +++ b/src/powercontext/sources/models.py @@ -21,7 +21,12 @@ from pydantic_core import PydanticCustomError from powercontext.errors import InvalidSourceReferenceError -from powercontext.limits import MAX_SOURCE_ID_LENGTH, MAX_SOURCE_TYPE_LENGTH +from powercontext.limits import ( + MAX_SOURCE_HEADING_PATH_LENGTH, + MAX_SOURCE_ID_LENGTH, + MAX_SOURCE_LOCATOR_LINES, + MAX_SOURCE_TYPE_LENGTH, +) class SourceMaterialization(StrEnum): @@ -48,12 +53,17 @@ class SourceLineRangeLocator(BaseModel): kind: Literal["line_range"] = "line_range" start_line: StrictInt = Field(ge=1) end_line: StrictInt = Field(ge=1) - heading_path: str | None = Field(default=None, max_length=1024, exclude_if=lambda value: value is None) + heading_path: str | None = Field( + default=None, max_length=MAX_SOURCE_HEADING_PATH_LENGTH, exclude_if=lambda value: value is None + ) @model_validator(mode="after") def validate_span(self) -> SourceLineRangeLocator: - if self.end_line < self.start_line or self.end_line - self.start_line + 1 > 200: - raise PydanticCustomError("source_locator_range", "Source line range must contain 1 to 200 inclusive lines") + if self.end_line < self.start_line or self.end_line - self.start_line + 1 > MAX_SOURCE_LOCATOR_LINES: + raise PydanticCustomError( + "source_locator_range", + f"Source line range must contain 1 to {MAX_SOURCE_LOCATOR_LINES} inclusive lines", + ) return self From 3d10c77caa3fc227296f6fa19b87dfc449d0fb09 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 18:54:21 +0800 Subject: [PATCH 09/13] fix(opendal): support legacy text evidence exports --- integrations/opendal/README.md | 3 ++ .../powercontext_connector_opendal/source.py | 10 +++--- .../opendal/tests/test_opendal_connector.py | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/integrations/opendal/README.md b/integrations/opendal/README.md index af95157d3d..3070c5a209 100644 --- a/integrations/opendal/README.md +++ b/integrations/opendal/README.md @@ -30,6 +30,9 @@ The process registers its immutable Definition manifest before each run. It adva after every accepted snapshot has a durable Server receipt and the scan completes without rejected or failed items. Set `POWERCONTEXT_TOKEN` when the Server requires bearer authentication. +The worker uses the frozen `TextEvidenceV1` projection when the runtime exports it. On pre-locator runtimes, +it uses the original `TextEvidence` schema instead, preserving the version-1 Definition manifest and fingerprint. + Snapshot identity is derived from the configured namespace, path, and content digest. Identical content is accepted idempotently; changed content creates a different immutable snapshot identity. This evaluation representation does not provide one logical Source with multiple observation IDs, current heads, deletion semantics, referenced diff --git a/integrations/opendal/src/powercontext_connector_opendal/source.py b/integrations/opendal/src/powercontext_connector_opendal/source.py index 77eeca4f6a..920719eccc 100644 --- a/integrations/opendal/src/powercontext_connector_opendal/source.py +++ b/integrations/opendal/src/powercontext_connector_opendal/source.py @@ -21,17 +21,19 @@ from pathlib import PurePosixPath from typing import Literal, cast +import powercontext.sources as source_models from powercontext.sources import ( TEXT_EVIDENCE_PROJECTION_KEY, MemoryEvidenceDeclaration, Source, SourceMaterialization, SourceProjection, - TextEvidenceV1, ) from pydantic import BaseModel, JsonValue, field_validator TEXT_FILE_SNAPSHOT_SOURCE_NAME = "text-file-snapshot" +# Older runtimes expose the same frozen schema under TextEvidence alone. +_TEXT_EVIDENCE_MODEL: type[BaseModel] = getattr(source_models, "TextEvidenceV1", source_models.TextEvidence) class TextFileSnapshotCapture(BaseModel): @@ -138,9 +140,9 @@ class TextFileEvidenceProjection: name = TEXT_EVIDENCE_PROJECTION_KEY.name version = TEXT_EVIDENCE_PROJECTION_KEY.version source_class = TextFileSnapshotSource - output_class: type[BaseModel] = TextEvidenceV1 + output_class: type[BaseModel] = _TEXT_EVIDENCE_MODEL - def project(self, source: TextFileSnapshotSource, /) -> TextEvidenceV1: + def project(self, source: TextFileSnapshotSource, /) -> BaseModel: metadata: dict[str, JsonValue] = { "namespace": source.namespace, "path": source.path, @@ -155,7 +157,7 @@ def project(self, source: TextFileSnapshotSource, /) -> TextEvidenceV1: metadata["provider_version"] = source.provider_version if source.modified_at is not None: metadata["modified_at"] = source.modified_at.isoformat() - return TextEvidenceV1( + return _TEXT_EVIDENCE_MODEL( source_type=TEXT_FILE_SNAPSHOT_SOURCE_NAME, source_id=source.name, content=source.content, diff --git a/integrations/opendal/tests/test_opendal_connector.py b/integrations/opendal/tests/test_opendal_connector.py index f63fd43237..e93d11d34e 100644 --- a/integrations/opendal/tests/test_opendal_connector.py +++ b/integrations/opendal/tests/test_opendal_connector.py @@ -15,10 +15,14 @@ from __future__ import annotations import asyncio +import importlib.util +import json +import sys from pathlib import Path import httpx import opendalfs +import powercontext.sources as source_models import pytest from fastapi import FastAPI from powercontext.builtin.artifacts.atomic_memory.extraction import ( @@ -283,6 +287,36 @@ def test_text_file_definition_declares_its_executable_contract() -> None: assert len(definition.projections) == 1 +def test_worker_preserves_manifest_when_runtime_only_exports_legacy_text_evidence(monkeypatch) -> None: + """Simulate the pre-locator export surface; real installation checks are separate.""" + + legacy_model = source_models.TextEvidenceV1 + monkeypatch.setattr(source_models, "TextEvidence", legacy_model) + monkeypatch.delattr(source_models, "TextEvidenceV1") + source_path = Path(__file__).parents[1] / "src/powercontext_connector_opendal/source.py" + spec = importlib.util.spec_from_file_location("opendal_legacy_runtime_source", source_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + + definition = module.TEXT_FILE_SNAPSHOT_SOURCE_DEFINITION + expected = json.loads((Path(__file__).parent / "fixtures/text_file_manifest_before_locators.json").read_text()) + assert manifest_for_definition(definition).model_dump(mode="json", by_alias=True) == expected + + async def scenario(): + registry = SourceDefinitionRegistry((definition,)) + source = await registry.resolve( + module.TextFileSnapshotCapture(namespace="workspace", path="decision.md", content="保留旧版本兼容。") + ) + observation = project_source_for_transport(registry, source) + (projection,) = observation.projections + assert projection.value["content"] == "保留旧版本兼容。" + assert "locator" not in projection.value + + asyncio.run(scenario()) + + def test_remote_opendal_worker_completes_the_source_to_memory_loop(tmp_path: Path) -> None: async def scenario() -> None: filesystem = MemoryFileSystem() From 971dbd64366528e1c03be2c76a03a38b60f9797c Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 20:40:04 +0800 Subject: [PATCH 10/13] fix(sources): close CI and compatibility review gaps --- .github/workflows/master.yml | 6 +++ .gitignore | 4 ++ dev-fresh.db | Bin 192512 -> 0 bytes pyproject.toml | 2 +- .../builtin/runtime/atomic_memory_rebuild.py | 36 +++++++----------- .../e2e/test_atomic_memory_source_evidence.py | 31 ++++++++++++++- tests/e2e/test_memory_write_gate_privacy.py | 5 ++- tests/e2e/test_rfc2086_worker_acceptance.py | 5 ++- uv.lock | 2 +- 9 files changed, 64 insertions(+), 27 deletions(-) delete mode 100644 dev-fresh.db diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 1f8fc3201b..c5fb9d9762 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -291,6 +291,12 @@ jobs: PYTEST_ADDOPTS: --require-dsh-config-runtime --require-claude-code-python-floor run: make unit-test + - name: Check Source serialization with minimum Pydantic + if: matrix.suite == 'unit' && matrix.python-version == '3.11' + run: >- + uv run --with 'pydantic==2.12.0' python -m pytest + tests/test_source_locators.py tests/test_source_observations.py tests/test_api_contract.py -q + - name: Run the ZCode service chain with native Hook deadlines if: matrix.suite == 'e2e' # Preserve the real Hook deadlines without competing with xdist workers. diff --git a/.gitignore b/.gitignore index 6d7cca108a..5619e2ce6c 100644 --- a/.gitignore +++ b/.gitignore @@ -273,6 +273,10 @@ integrations/pi/plugins/powercontext/*.tgz # Local acceptance databases and redacted run artifacts .artifacts/ +dev-*.db +dev-*.db-journal +dev-*.db-wal +dev-*.db-shm # pnpm package store is a local cache, never committed. .pnpm-store/ diff --git a/dev-fresh.db b/dev-fresh.db deleted file mode 100644 index 0b0f02b0d2aefb9eb08997d0b854efc4e1b83d81..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 192512 zcmeI5+ix4$eaA^j7A4uToXur6*<6mb*@#@zHc9K|BwB}(#x`S#v?*%6#&$6n4G*P> zNDe(Sl%?G@3i*P~1qjfG76pnv?H|yGpy*43B7F>k0xi&&0!3S(Kpz662wJo$^3rqW zrWukmq`V7zSNqj2W$~QfxqW_@b7tm{XXDPAYEn|u^oDGbE3tF2p`qCK2#LjF!|ZjL zz1*Mk?2i%m4f{9l`#bE<;n<}=`qxt|cX;ZzT;^L-zn}V_so$RYaQvl-Uyb~F>=(m} zBYztId0d$KU04+_B>nK!vk9S8iXYCI@>ZR;6iGI-q9Ma!b6ICR=q)*(J+srRCJ4 z{mFzNiepdOeR~x9HaF$ysI_? z9Wm*?=`U)nGBNH4?h0jdi$Ng||8n65v#dY`hSt^weIi<6j@0wgSHNw0C^XfP=X81OuZ}SupX9CFUx} zsPYI#gPJm*wp`~+dRe5fVMP^^FRqeafc^PiDL-9DAVo@L(owGyu_EjC%Y@eN2&Gt{ zC{2tC%F@7r!o}MiC!Czqj~5aNVPz%$)SFc8;3&BvS=wSjh?$(hB1Kr%SZA;Z6C5mF zIHk>l7WFK=E518IY$|pQylb9dYdor}a$Tyb+tfJnmW6CEFjOh?Db<=}n{tD8yQxpW zox8gnwjnpw8f&Gef3g<2r zHH2l3KYDn|S_?i=d(RtQbvvqIvc+N71kx!m+G!lYwPmOHU?%b@Tmm?}`Yi?I(NUpY zico7bK1;dLB8kJ5;e@ch9zQ(jqhdqZp^fgJd3QNdV0J}A2zQJidvQ;&>zLdfUiPwu;zoXa84`1tZ#-Q%m?kes1TG$C?> z8GTJ|sPzLc-gSjUuV22P*d^r6*zV<)e`W2gqq-Ny?u8%=3Ost*OJ^R^yt#GIcxDeG zY)w6p+87!Yw(}8c4MAurv@4HYPJd&D{d(!^?AN)oll=1f9RGzM5C8!X009sH0T2KI z5C8!X009sHfs;yLf)5PW|0h+w7#9eD00@8p2!H?xfB*=900@8p2v`L8^Z)qN|Hjz= z_yGYB009sH0T2KI5C8!X009sH0T4J@1QPKfp_R#G=J@k}T>qaevziAOHd&00JNY0w4eaAOHd&00JO@=l`$)5C8!X009sH0T2KI5C8!X009s<`2_I% z|K#f!BLo2u009sH0T2KI5C8!X009sH0sj1dXzD*>Q@>}w@B;!M00JNY0w4eaAOHd& z00JNY0w8b_2uw^2$G+kWWH>$?JA>!{CqbQ&25o5ifuyu`oi_Q+DmvdVzO$anx;2o)1SGkHUk|o>3$&Jpt7#)a9fmk z7BdZbTiT@uhQAWE%Gy=U1Ig5OX_LwDf7EyB(oDOlDq5A!XJ=A(&WsB5T0F)Yr2EGG zy2?f&w@uA@FLl9VBWJZLRLX{CO+I?(bV9gxE&im?Tbphjp*5Td*jT6-NluJ=T6;5J zUb&esCv$W2DN-y|NO5y*ElnoI{W^4ovbn{ekf+h*!VPAufeH+*tt-^9EH#kfb3x*n zPrzu>#H~2RyRuR$ZdA&7-hi?j!PYHAR&I(bw@A`^{|3pVCQ=`4j3lOIMqw*B~1z7x4;7YO~dL zqKVrnN2b3$4q}DwT;>^FFC!eln%#PWfzhcfnE1vLv+HA2c?6?DO_|R!uBRluEK=C8 zLIBAZS4l6x{(P^LpROa2BBe6vsMiTEk#+lJLhE;gQp~GM6QhE%G;knx@pi{yC+GCz zg+xMFS&2XOCRICohi#fGv&8{MmYPi1!g$bbsArEUH4dcAe9>%doXLBH&ns{Cb`{YxXGg0X&lP zIN1`X9vSh3kk7}T+;=fK7jmBQ@#VF;$FKB4a)vt5gvbqM^fkGm)(^aR*A)`Ie))o8 zmylvF8B`gaHJbKydTprTAxpmNZW)C840X&h~7#bC}^ATzdL1-y7 zByX5q&X4g+dVK%iv)tG~84v&g5C8!X009sH0T2KI5C8!X_;eBA*Z=2YcVko6&+bkB za&mg&U&f|J|Lv4A@;4)YFf0!J*U(7(&trcdyL1byc=XJ`G8w+< zz^Ni_Cup^rK}|O?b^9x$!i5X*d)8o`NB0K3-{#NfJKnw=7(_>Al0Ow+T%R};J2|3h6dZw;37F9=yKo?+2d5oo~x!?{4OfL z$C=5b_vE@-WqTt!?}yqN45p^!mydPh-tTQ{v!?YiUM}*bLE{nkc3vQoI~VxkF{pF- zX_937N7xo4w$X$=fo{n90bvPg!br6#k(&ppNAG>f-lg+iU+akKp3|`bcj>SJJG*q) zUm3QU$llXp`;E`D*b_8&1CQlWxAWynp;9On`3|Wx@t*sqiT5y;)asgSzMY-BGXGAR zT=3sx=T)$z&O7QXDfr~t^-pra+d_~gw()YiTw?d=$>rAx&1G`g#l@@2T!t>rZ)N9a z=N4vjx%q|K73E5GcB`7pEN(3d`uZB%0vyJQ_J zC(9z+G2;qeFPGLsMfvtPa`&cK7Ky7ydutL|&sSD%lH`m!L*9R%)U^jxPwvqDq*kw9 za@ERqwz0gK{T?Dd&81SQ?+~%L`gHvZ7Ofw@7DcPwVw(p(5-qz1ht}rpRlcq7Q%0*$ z+z`tZ9zsjes_VDMiMgPEHxF|J-_|#_Z?SX3o9&f+TO;4W*0H1JXiHnV!&*}8G1=Ot zxIvOl`oJ@fG~w@jmS88i9&Eqs=pQc;>?2R<(NCVtBZys#-Cv&8)n}gQ?)*uUE_=4h zIAJQpp1Fu`6s|o#`QGO(d;a0+D0^0$2Gz|lR~^Lr?HU|=b}Xa6J;Q|D9iFy@d?HQg z@U@q$#ob|_OCCOrFt4bo+hK-e?Q^z8zTg_w09%>u5;MtN|7wmcFgmv^r#_buHa7>` zvaBh5o{j8i)t34EF?#A03E#VHylX8HQ-|y4?7lvXUJ^M>`9|Q#zBk50#vez7Y&9m16do8lpRrbneQlH+9?s)$H>AGKo-hu!KfB*=900@8p z2!H?xfB*=9z_SqG-~Sh%`tKO~A3q=f0w4eaAOHd&00JNY0w4eaAOHd7MuEyslS{0OZEqTKmY_l00ck)1V8`;KmY_l00ck)1fC~>mq&)@;`S#R zq&LO#2K!K^ben(TZ(JB2o*VDTSuYezWnpM|&iU$DugH4-`z#|qoN_*q?Ply{E@dxW z<eXbl8F z00ck)1V8`;KmY_l00ck)1YQIIT>rla>Sz-LKmY_l00ck)1V8`;KmY_l00f>d0si^_ z*tzH1CRzgl5C8!X009sH0T2KI5C8!X009tqz69|6|M~JpYajpuAOHd&00JNY0w4ea zAOHd&@FED{`TvWcjy6F61V8`;KmY_l00ck)1V8`;K;Zcj!1Mp-%NwnM00@8p2!H?x zfB*=900@8p2!OzgAb{upFM>MS1OX5L0T2KI5C8!X009sH0T2Lz=SzS;{~wQEj!j)T z`&X0yH+gvGFUEg15+C|!mh?0B-zV`$PbGxIQ&K#pHmh{sxL;RID#>kAv))TBMUr(> zt;vchwRBCPhM_jMrG~n#%ciO|C7mi%ZJCCY9a!|WSZwH@IE%*@W)i}U8}X-`ro2_B zAr%)n!g8(l zq&=z|Y=FH*w6?C$E|FF7dVX`QLT0kFS2Ht#N?SX!LCKr>^2*J8Ie8_Q3gjN)RI8rQ z{A^H2(V8`tp_iL#jT$DYh;Ok@`#OHRTv*SS?~z;LJ(BdsnBrqvDHS&=lGj?H^r4(z8c69$)+Y!kIsK9@yg25*yD9mSGTvR&N-V}Lsg^(ZD{%dvzOFWzC`PSJF;!jnSzk=(W58%f9T`#^;1π|1*!0F-E0EzgFm4S#nO>GOfXH{>ATuN8uPSpHx;Vd;ou8dsn9b$p7iw3OE7jSpYA&<5wXmqvwiagRay4afexbUMtzBKr z*5rl7*{vD3pWRl;t8ei@&1OSB)`Rweh z9kHJteA9~9KYkb$vDKzQb+c=_I>xMK*Wg5K$E5quYOsA~+OU0lX8qW0|FHCGLb!J? zet6)mzk4m(taY8--dF3YtRI9qK~QSiUFlgX^63E!C5f&*U`t@Ow4iK_LHsM)G})8u zZJNore`MW1a_%2jQhU?vYGz!Cx|#_cVS}17TbHr*(7u_;*w-@y3ZFmx&iRC(DDlIk z{v?C#M3M=Ll4CFpmYh!7Tzx##gpbv_h)BIl7`4gmk3>z`X*YL`fw{TjUqAf*Hxk13 zcKmQ-aB^7 zb!|(oPd~)-|Id(fW2hhi0w4eaAOHd&00JNY0w4eapIHL<{QqZG^%yt^fB*=900@8p z2!H?xfB*=9z-NfS8X8f0@gmF4PHd2WH$?(5Vevc)`_zOQ40W~)& zJiHrqqG_vCB_xT}}vh?!caNB`Hl(@)kV*qn=^Fax_lkDKd`)zg%ptMcd zCL(K_5Lqs;lkkFP0@~YvBy&gC+S@z5B-mygv#ryrRI9g*oj%oRTkIU9JtkaLb@QN) zu$zeV@e{f;vm`2jgi)7|eR8Qd1wo-uwPN)NZO zrHu=Y(H4rU;#*|UI^6^P!t3o+Zi`y`*}Pt*K4@M@2!%rYr*~ZkjOb2#fA>g7pcKH(h;+HoeRQm1mWhYy9xJ08!xuiNLt%J6-G^<$N&%Pgqy17IaXIac@B ziEIW{+NQckC2MCQI~~-dY~P^a!V2r;GY1M^h;Tf@^#nIobm@(Sh~7b`yDm$gZbecl@9-V(ZA*`>*AJ0XFEZ5rj zilNt#y!@F#!j>mJz!}9(z;`|C<2VM`t+LngzxJ%^N;os&i#R&^aUq!yBq{!s9bOiG zWc1N_%I@*hAqwgi{N^B<>Mq7rc9a@%inD(meh_KmAF*%cH<4m)_EPZp@25-IgmCw6{73hqwCh%AUj>9{ zHYjvy5UmD_48D3eij@y!HuouPuRK2kyUON2!cS(d6xKzq5lfDrwq0$rW0cvy%5IAt zv(9GQpxiO%T}xbT@*};sb#^{GSuT~<#5_L|+c}QBW8GmDXt&r04W!Q4?I@r3wpDL$ zvs!(o%&pXyQt diff --git a/pyproject.toml b/pyproject.toml index 9d9d2ac25b..757c940f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ keywords = ['python'] requires-python = ">=3.11,<4.0" dependencies = [ "jsonschema>=4.23,<5", - "pydantic>=2.10,<3", + "pydantic>=2.12,<3", "rfc8785>=0.1.4,<1", "typing-extensions>=4.12,<5", ] diff --git a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py index cc40a1205d..09cfe81ab1 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_rebuild.py +++ b/src/powercontext/builtin/runtime/atomic_memory_rebuild.py @@ -284,31 +284,23 @@ async def _record( # Projection repair needs only exact content and authoritative state. Historical # migration checkpoints must not depend on lineage tables added by later steps. heads, bodies = ARTIFACT_HEADS_TABLE, ARTIFACTS_TABLE - row = ( - ( - await connection.execute( - select(heads.c.artifact_id, heads.c.family, heads.c.revision, bodies.c.content) - .select_from( - heads.outerjoin( - bodies, - and_( - *( - heads.c[name] == bodies.c[name] - for name in ("scope_id", "family", "artifact_id", "revision") - ) - ), - ) - ) - .where( - heads.c.scope_id == scope_id, - heads.c.family == AtomicMemory.family, - heads.c.artifact_id == artifact_id, - ) + statement = ( + select(heads.c.artifact_id, heads.c.family, heads.c.revision, bodies.c.content) + .select_from( + heads.outerjoin( + bodies, + and_(*(heads.c[name] == bodies.c[name] for name in ("scope_id", "family", "artifact_id", "revision"))), ) ) - .mappings() - .one_or_none() + .where( + heads.c.scope_id == scope_id, + heads.c.family == AtomicMemory.family, + heads.c.artifact_id == artifact_id, + ) ) + if for_update: + statement = statement.with_for_update() + row = (await connection.execute(statement)).mappings().one_or_none() if row is None or row["content"] is None: raise AtomicMemoryIndexError("authority-content", "Atomic Memory head has no authoritative content") artifact = AtomicMemory( diff --git a/tests/e2e/test_atomic_memory_source_evidence.py b/tests/e2e/test_atomic_memory_source_evidence.py index e2bec32817..1e7c4f65bf 100644 --- a/tests/e2e/test_atomic_memory_source_evidence.py +++ b/tests/e2e/test_atomic_memory_source_evidence.py @@ -23,7 +23,8 @@ import pytest from pydantic import BaseModel, Field, field_serializer, field_validator -from sqlalchemy import event, text, update +from sqlalchemy import Select, event, text, update +from sqlalchemy.dialects import mysql from sqlalchemy.exc import SQLAlchemyError from powercontext.artifacts import ArtifactLineage @@ -581,3 +582,31 @@ async def scenario() -> None: assert await memory.evidence("rebuild", revision=1) == expected asyncio.run(scenario()) + + +def test_current_rebuild_locks_authoritative_heads(migration_backend: MigrationBackend) -> None: + """Check emitted locking semantics; SQLite execution does not certify native row locks.""" + + async def scenario(): + async with open_builtin_contexts(BuiltinConfig(database=migration_backend.config)) as contexts: + await _commit(contexts, "scope", "locked-rebuild", "Retain the current head.") + head_reads = [] + + def capture(_connection, clause, _multiparams, _params, _options): + if not isinstance(clause, Select): + return + statement = str(clause.compile(dialect=mysql.dialect())) + if "pc_artifact_heads LEFT OUTER JOIN pc_artifacts" in statement: + head_reads.append(statement) + + engine = contexts.database.engine.sync_engine + event.listen(engine, "before_execute", capture) + try: + async with contexts.database.transaction() as connection: + report = await rebuild_atomic_memory_current(connection, contexts.atomic_memory.index) + assert report.ready, report.errors + finally: + event.remove(engine, "before_execute", capture) + assert head_reads and head_reads[0].endswith("FOR UPDATE") + + asyncio.run(scenario()) diff --git a/tests/e2e/test_memory_write_gate_privacy.py b/tests/e2e/test_memory_write_gate_privacy.py index 9d46ef176a..428fd3e7d2 100644 --- a/tests/e2e/test_memory_write_gate_privacy.py +++ b/tests/e2e/test_memory_write_gate_privacy.py @@ -37,6 +37,7 @@ open_builtin_runtime, ) from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator _FORBIDDEN = "synthetic-gate-forbidden-rationale" _MEMORY = "The user prefers SQLite restart regression tests." @@ -173,7 +174,9 @@ async def scenario(): assert flushed.processed and flushed.source_count == 1 records = (await memory.list()).items assert len(records) == 1 and records[0].artifact.content.text == _MEMORY - assert records[0].artifact.lineage.sources == (receipt.source_ref,) + assert records[0].artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) assert not (await memory.flush()).processed try: diff --git a/tests/e2e/test_rfc2086_worker_acceptance.py b/tests/e2e/test_rfc2086_worker_acceptance.py index fadae6225e..ffb43721ed 100644 --- a/tests/e2e/test_rfc2086_worker_acceptance.py +++ b/tests/e2e/test_rfc2086_worker_acceptance.py @@ -45,6 +45,7 @@ from powercontext.builtin.runtime.atomic_memory_input import ATOMIC_MEMORY_INPUT_SCHEMA from powercontext.builtin.runtime.family_processing import FAMILY_BINDINGS from powercontext.builtin.scope import ScopeDraft +from powercontext.sources import SourceSequenceLocator _FORBIDDEN = "synthetic-worker-forbidden-value" _MEMORY = "The user prefers tests exercising spawned worker recovery." @@ -224,7 +225,9 @@ async def scenario() -> None: records = (await reopened.atomic_memory.for_scope(scope.scope_id).list()).items assert len(records) == 1 assert records[0].artifact.content.text == _MEMORY - assert records[0].artifact.lineage.sources == (receipt.source_ref,) + assert records[0].artifact.lineage.sources == ( + receipt.source_ref.model_copy(update={"locator": SourceSequenceLocator(sequence=receipt.sequence)}), + ) assert server.requests sent = [ json.loads(item["content"]) diff --git a/uv.lock b/uv.lock index 914a0d950f..61d5e392c1 100644 --- a/uv.lock +++ b/uv.lock @@ -3098,7 +3098,7 @@ requires-dist = [ { name = "pycasbin", marker = "extra == 'ops'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'seekdb'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, - { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'cli'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'ops'", specifier = ">=2.27.1,<3" }, From b9c986140e5e5840b06af661cc24983061c5058c Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 20:54:57 +0800 Subject: [PATCH 11/13] fix(deps): sync nested locks with pydantic floor --- e2e/bub/uv.lock | 6 +++--- evaluation/uv.lock | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/bub/uv.lock b/e2e/bub/uv.lock index 28bef7cda9..bc77b557ab 100644 --- a/e2e/bub/uv.lock +++ b/e2e/bub/uv.lock @@ -894,8 +894,8 @@ name = "httpcore2" version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform != 'emscripten'" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ @@ -1719,7 +1719,7 @@ requires-dist = [ { name = "pycasbin", marker = "extra == 'ops'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'seekdb'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, - { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'cli'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'ops'", specifier = ">=2.27.1,<3" }, diff --git a/evaluation/uv.lock b/evaluation/uv.lock index 70bcc1c97f..9067bea047 100644 --- a/evaluation/uv.lock +++ b/evaluation/uv.lock @@ -1774,7 +1774,7 @@ requires-dist = [ { name = "pycasbin", marker = "extra == 'ops'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'seekdb'", specifier = ">=2.8,<3" }, { name = "pycasbin", marker = "extra == 'server'", specifier = ">=2.8,<3" }, - { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'builtin'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'cli'", specifier = ">=2.27.1,<3" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'ops'", specifier = ">=2.27.1,<3" }, From e3baeafc5e154eaa54909f24230e1119c26b4488 Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 21:30:45 +0800 Subject: [PATCH 12/13] test(desktop): qualify merged source plugin contract --- desktop/VALIDATION.md | 33 +++++++++++++++++++ .../src/connections/compatibility.json | 22 +++++++++++++ 2 files changed, 55 insertions(+) diff --git a/desktop/VALIDATION.md b/desktop/VALIDATION.md index ca24ea8f21..0e41515ebd 100644 --- a/desktop/VALIDATION.md +++ b/desktop/VALIDATION.md @@ -2597,3 +2597,36 @@ including the bundled-record Principal fence and rejected unauthenticated mode. All 195 UI tests, generated-contract checks, lint, TypeScript checking and the frontend production build pass. The native fixtures and UI tests do not establish installed-platform acceptance. + + +## Server c00dbf14 Source plugin protocol qualification + +`sqlite-source-plugins-c00dbf14-v1` qualifies all thirteen Desktop protocol operations against +Server source `c00dbf1455e1985f6a782b4d39c9ee8cde6e74b7`. + +- Normalized OpenAPI SHA-256: `3ea9f0661702254563e72d2291469b84a961730f572a32467f15458e7a979650`. +- Wheel: `powercontext-0.1.dev1509+gc00dbf145-py3-none-any.whl`. +- Wheel SHA-256: `5555c090e6efa7b2194bd643b6f95e495aec17e9ed87e55a0cbc744ab2b7c529`. +- Native debug probe SHA-256: `777c6d62d605da84e2039fb74ea1542fdb3b2d333880232643a99c877aeb8e9f`. + +All 744 packaged runtime files match the recorded Git source after CRLF normalization, with no +missing or extra runtime files. On 2026-11-07, macOS 26.6.2 ARM64 and Python 3.13.12 passed +anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and enforced Provider modes +through the ordinary product ConnectionManager. The probe was built with the product Cargo lock +without `ci-fixtures` and selected this bundled qualification. + +The unchanged `desktop/tests/real_server.py` exercised identity, discovery, exact Scope selection, +statistics, Memory save/search/revision reads, committed writes with lost responses without replay, +Principal changes, same-identity revocation, scoped authorization and 51 same-title Scopes. +The wheel and report are retained under ignored `desktop/.artifacts/source-framework-upgrade/`. +This qualifies the observed local SQLite protocol; it does not establish installed WebView, +native credential persistence, other operating systems, native database upgrades or release acceptance. + +Reproduce against the recorded artifact: + +```sh +cargo build --locked --manifest-path desktop/src-tauri/Cargo.toml --example server_probe +uv run --no-sync python desktop/tests/real_server.py \ + --product-qualification sqlite-source-plugins-c00dbf14-v1 \ + --wheel desktop/.artifacts/source-framework-upgrade/powercontext-0.1.dev1509+gc00dbf145-py3-none-any.whl +``` diff --git a/desktop/src-tauri/src/connections/compatibility.json b/desktop/src-tauri/src/connections/compatibility.json index ac677412d2..f1810c88f9 100644 --- a/desktop/src-tauri/src/connections/compatibility.json +++ b/desktop/src-tauri/src/connections/compatibility.json @@ -1224,5 +1224,27 @@ "search_memory" ], "evidence": "desktop/VALIDATION.md#server-ccfa6f4d-protocol-qualification" + }, + { + "id": "sqlite-source-plugins-c00dbf14-v1", + "serverCommit": "c00dbf1455e1985f6a782b4d39c9ee8cde6e74b7", + "contractSha256": "3ea9f0661702254563e72d2291469b84a961730f572a32467f15458e7a979650", + "artifactSha256": "5555c090e6efa7b2194bd643b6f95e495aec17e9ed87e55a0cbc744ab2b7c529", + "operations": [ + "get_access_principal", + "get_artifact_revision", + "get_capabilities", + "get_connection_identity", + "get_default_scope", + "get_liveness", + "get_readiness", + "get_scope", + "get_server_info", + "get_stats", + "list_scopes", + "remember_memory", + "search_memory" + ], + "evidence": "desktop/VALIDATION.md#server-c00dbf14-source-plugin-protocol-qualification" } ] From aa77ac809facd58b4aa6ac741e3a9d9c09d1dd5f Mon Sep 17 00:00:00 2001 From: "609400870@qq.com" <609400870@qq.com> Date: Sat, 7 Nov 2026 22:11:59 +0800 Subject: [PATCH 13/13] test(memory): refresh live evidence for locator dataset v2 --- .../report.json | 0 .../report.md | 0 .../run-manifest.json | 0 .../report.json | 0 .../report.md | 0 .../run-manifest.json | 0 .../report.json | 80 +++++++++++++++++++ .../report.md | 23 ++++++ .../run-manifest.json | 42 ++++++++++ 9 files changed, 145 insertions(+) rename evaluation/memory/atomic_extraction/reports/{ => archive}/atomic-extraction-20261030-merged-locators/report.json (100%) rename evaluation/memory/atomic_extraction/reports/{ => archive}/atomic-extraction-20261030-merged-locators/report.md (100%) rename evaluation/memory/atomic_extraction/reports/{ => archive}/atomic-extraction-20261030-merged-locators/run-manifest.json (100%) rename evaluation/memory/atomic_extraction/reports/{ => archive}/atomic-extraction-20261101-review-2359-v2-glm/report.json (100%) rename evaluation/memory/atomic_extraction/reports/{ => archive}/atomic-extraction-20261101-review-2359-v2-glm/report.md (100%) rename evaluation/memory/atomic_extraction/reports/{ => archive}/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json (100%) create mode 100644 evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json create mode 100644 evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md create mode 100644 evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/run-manifest.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261030-merged-locators/report.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261030-merged-locators/report.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261030-merged-locators/report.md b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.md similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261030-merged-locators/report.md rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/report.md diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261030-merged-locators/run-manifest.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/run-manifest.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261030-merged-locators/run-manifest.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261030-merged-locators/run-manifest.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.md b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.md similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/report.md rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/report.md diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json b/evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json similarity index 100% rename from evaluation/memory/atomic_extraction/reports/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json rename to evaluation/memory/atomic_extraction/reports/archive/atomic-extraction-20261101-review-2359-v2-glm/run-manifest.json diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json new file mode 100644 index 0000000000..b543343c70 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.json @@ -0,0 +1,80 @@ +{ + "content_policy": "aggregate_only_no_evidence_or_generated_candidate_text", + "dataset": { + "case_count": 9, + "dataset_version": "powercontext.atomic-memory-extraction.synthetic.v2", + "manifest_sha256": "853d22abc12ede5ccdd46e380cb935b9746c8003fc484d0934366bb8f5df22c6", + "schema_version": "powercontext.atomic-memory-extraction-cases.v1" + }, + "dispositions": { + "atomic_memory": { + "failed": 0, + "passed": 6, + "total": 6 + }, + "experience_or_skill": { + "failed": 0, + "passed": 1, + "total": 1 + }, + "reject": { + "failed": 0, + "passed": 2, + "total": 2 + } + }, + "factual_retention": { + "failed": 0, + "false_rejections": 0, + "passed": 6, + "total": 6, + "unobserved": 0 + }, + "failure_classes": {}, + "instruction_leakage": { + "leaks": 0, + "passed": 9, + "total": 9, + "unobserved": 0 + }, + "overall": { + "failed": 0, + "passed": 9, + "total": 9 + }, + "prompts": { + "coding": { + "builtin_version": "powercontext.atomic-memory.extract.coding.v4", + "compiled_digest": "5545efec2f457d908a2717dca85157db3faab320a378837bd1eea3ecbbaf0d51", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "da5de7be4ac0e23f5e718076dc51b5f1825f376bfe2f071278ff5be2dc096a97", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v4", + "compiled_digest": "cbfaf95eb4ffa83531b9f0b84864fda33b9a83f102acdbad2e7466c44aa2759d", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "503f212db750b5d0db4b5e84d6c88ad42f90e8c51bbd0af3d1a9a50e7bb6afc4", + "key": "atomic_memory.extract" + } + }, + "provider": { + "base_url_configured": true, + "base_url_sha256": "0413b53d28826c51b400bc9ebc578639bf6a4ff94d3e43fbcbc468bd51945602", + "header_names": [ + "Authorization" + ], + "max_requests": 2, + "model": "openai-chat:glm-5.3", + "model_settings": { + "temperature": 0 + }, + "model_settings_sha256": "e4ff491169b8a9ea78518a7972422b5a24d5e3790e0d9a9e5cbfb384d9b621e0", + "provider": "openai-chat", + "timeout_seconds": 120.0 + }, + "run_id": "atomic-extraction-20261107-locators-v2-deterministic", + "run_manifest_sha256": "377d036c3a3fe1d29158cc3eef2b8d2ada2674a363e9e8c87cec73ce1edccc5a", + "schema_version": "powercontext.atomic-memory-extraction-report.v1", + "status": "passed" +} diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md new file mode 100644 index 0000000000..4ea81f0af1 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/report.md @@ -0,0 +1,23 @@ +# Atomic Memory extraction live-run report + +- Status: `passed` +- Run: `atomic-extraction-20261107-locators-v2-deterministic` +- Dataset: `powercontext.atomic-memory-extraction.synthetic.v2` (`853d22abc12ede5ccdd46e380cb935b9746c8003fc484d0934366bb8f5df22c6`) +- Provider/model: `openai-chat` / `openai-chat:glm-5.3` +- Overall: 9/9 passed + +## Quality gates + +- Factual retention: 6/6 passed; false rejections: 0 +- Instruction leakage: 0 leaks across 9 cases; unobserved: 0 + +## Prompt identities + +- `coding`: `powercontext.atomic-memory.extract.coding.v4` / `da5de7be4ac0e23f5e718076dc51b5f1825f376bfe2f071278ff5be2dc096a97` +- `conversation`: `powercontext.atomic-memory.extract.conversation.v4` / `503f212db750b5d0db4b5e84d6c88ad42f90e8c51bbd0af3d1a9a50e7bb6afc4` + +## Failure classes + +- None + +This report contains aggregate counts and run identities only. Evidence and generated candidate text remain in the ignored private run artifact. diff --git a/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/run-manifest.json b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/run-manifest.json new file mode 100644 index 0000000000..5beea51b08 --- /dev/null +++ b/evaluation/memory/atomic_extraction/reports/atomic-extraction-20261107-locators-v2-deterministic/run-manifest.json @@ -0,0 +1,42 @@ +{ + "dataset": { + "case_count": 9, + "dataset_version": "powercontext.atomic-memory-extraction.synthetic.v2", + "manifest_sha256": "853d22abc12ede5ccdd46e380cb935b9746c8003fc484d0934366bb8f5df22c6", + "schema_version": "powercontext.atomic-memory-extraction-cases.v1" + }, + "prompts": { + "coding": { + "builtin_version": "powercontext.atomic-memory.extract.coding.v4", + "compiled_digest": "5545efec2f457d908a2717dca85157db3faab320a378837bd1eea3ecbbaf0d51", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "da5de7be4ac0e23f5e718076dc51b5f1825f376bfe2f071278ff5be2dc096a97", + "key": "atomic_memory.extract" + }, + "conversation": { + "builtin_version": "powercontext.atomic-memory.extract.conversation.v4", + "compiled_digest": "cbfaf95eb4ffa83531b9f0b84864fda33b9a83f102acdbad2e7466c44aa2759d", + "definition_version": "powercontext.prompt.atomic-memory.extract.v2", + "instructions_sha256": "503f212db750b5d0db4b5e84d6c88ad42f90e8c51bbd0af3d1a9a50e7bb6afc4", + "key": "atomic_memory.extract" + } + }, + "provider": { + "base_url_configured": true, + "base_url_sha256": "0413b53d28826c51b400bc9ebc578639bf6a4ff94d3e43fbcbc468bd51945602", + "header_names": [ + "Authorization" + ], + "max_requests": 2, + "model": "openai-chat:glm-5.3", + "model_settings": { + "temperature": 0 + }, + "model_settings_sha256": "e4ff491169b8a9ea78518a7972422b5a24d5e3790e0d9a9e5cbfb384d9b621e0", + "provider": "openai-chat", + "timeout_seconds": 120.0 + }, + "run_id": "atomic-extraction-20261107-locators-v2-deterministic", + "schema_version": "powercontext.atomic-memory-extraction-run.v1", + "started_at": "2026-11-07T14:07:17.591786Z" +}