diff --git a/desktop/scripts/generate.mjs b/desktop/scripts/generate.mjs index 14668cfbb9..aa90199fd8 100644 --- a/desktop/scripts/generate.mjs +++ b/desktop/scripts/generate.mjs @@ -73,6 +73,8 @@ for (const item of Object.values(contract.paths)) if (wanted.includes(op?.operationId)) collectSchemas(op); function rustType(schema) { if (schema.$ref) return schema.$ref.split("/").at(-1); + // A single-member allOf is how the contract annotates a nullable or described reference. + if (schema.allOf?.length === 1) return rustType(schema.allOf[0]); switch (schema.type) { case "string": return "String"; diff --git a/desktop/src-tauri/src/transport/operations.json b/desktop/src-tauri/src/transport/operations.json index 277f8a507c..90283c028f 100644 --- a/desktop/src-tauri/src/transport/operations.json +++ b/desktop/src-tauri/src/transport/operations.json @@ -1,5 +1,5 @@ { - "contractSha256": "e43c7034b6930688c350c68af5e7bd6083b123783b92d0a50eebbebd45aa7da7", + "contractSha256": "dae5feab227a6c16a8199455a27c82b444c9c17b43bec7de0fca4790c7bed14f", "operations": { "get_liveness": { "method": "GET", diff --git a/desktop/src-tauri/src/transport/wire.rs b/desktop/src-tauri/src/transport/wire.rs index 70b3f2297d..4b3ebf0b72 100644 --- a/desktop/src-tauri/src/transport/wire.rs +++ b/desktop/src-tauri/src/transport/wire.rs @@ -181,6 +181,18 @@ pub struct ArtifactRevision { pub r#sources: Vec, pub r#artifacts: Vec, pub r#content_digest: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub r#created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub r#created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub r#restored_from_revision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub r#reason: Option, } #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)] diff --git a/desktop/ui/src/generated/api.d.ts b/desktop/ui/src/generated/api.d.ts index ffcec8b3df..4f2fa60b7d 100644 --- a/desktop/ui/src/generated/api.d.ts +++ b/desktop/ui/src/generated/api.d.ts @@ -1915,7 +1915,7 @@ export interface paths { get: operations["get_artifact"]; /** * Replace the current Artifact head - * @description Commit a complete next revision when If-Match identifies the current head. Replacing a Prompt requires current scope.admin authority, including switching to Auto and restoring an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation. + * @description Commit a complete next revision when If-Match identifies the current head. Replacing a Prompt requires current scope.admin authority, including switching to Auto and restoring an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation. Profile replacement uses the same conditional write. To roll back Profile, Prompt, Experience, Skill, Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted canonical content matches that revision and differs from the current head, then commits a new revision. Atomic Memory replacement rejects both fields; restore Atomic Memory through its restoration flow. */ put: operations["replace_artifact"]; post?: never; @@ -2493,6 +2493,10 @@ export interface components { }; ReplaceProfileArtifactRequest: { content: components["schemas"]["ProfileWriteContent"]; + /** @description Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set. */ + restored_from_revision?: number | null; + /** @description Required when restored_from_revision or content.restored_from_revision is set. */ + reason?: string | null; }; ActivateHandoffRequest: { scope_id: string; @@ -2524,6 +2528,11 @@ export interface components { published_at?: string | null; /** @description Family-provided number of direct Source inputs when available. */ source_count?: number | null; + /** Format: date-time */ + created_at?: string | null; + created_by?: components["schemas"]["AccessPrincipal"] | null; + restored_from_revision?: number | null; + reason?: string | null; }; ArtifactCreated: { scope_id: string; @@ -2552,6 +2561,17 @@ export interface components { sources: components["schemas"]["SourceTypeReference"][]; artifacts: components["schemas"]["ArtifactReference"][]; content_digest: string; + /** + * Format: date-time + * @description When this revision was committed. Absent for revisions stored before this field existed. + */ + created_at?: string | null; + /** @description Principal that committed a rollback. Ordinary revisions omit it. */ + created_by?: components["schemas"]["AccessPrincipal"] | null; + /** @description Historical revision restored by this rollback. */ + restored_from_revision?: number | null; + /** @description Reason recorded for a rollback. */ + reason?: string | null; }; ArtifactReference: { family: string; @@ -4442,6 +4462,8 @@ export interface components { }; ReplaceTopicMemoryArtifactRequest: { content: components["schemas"]["TopicMemoryWriteContent"]; + restored_from_revision?: number | null; + reason?: string | null; }; CreatePromptArtifactRequest: { /** @@ -4583,6 +4605,8 @@ export interface components { ReplaceArtifactRequest: components["schemas"]["ReplaceAtomicMemoryArtifactRequest"] | components["schemas"]["ReplaceTopicMemoryArtifactRequest"] | components["schemas"]["ReplaceMemoryArtifactRequest"] | components["schemas"]["ReplaceExperienceArtifactRequest"] | components["schemas"]["ReplaceSkillArtifactRequest"] | components["schemas"]["ReplaceHandoffArtifactRequest"] | components["schemas"]["ReplacePromptArtifactRequest"] | components["schemas"]["ReplaceProfileArtifactRequest"]; ReplacePromptArtifactRequest: { content: components["schemas"]["PromptContent"]; + restored_from_revision?: number | null; + reason?: string | null; }; ListArtifactRevisionsRequest: { /** @default 50 */ @@ -4683,12 +4707,18 @@ export interface components { }; ReplaceExperienceArtifactRequest: { content: components["schemas"]["ExperienceProposal"]; + restored_from_revision?: number | null; + reason?: string | null; }; ReplaceSkillArtifactRequest: { content: components["schemas"]["SkillProposal"]; + restored_from_revision?: number | null; + reason?: string | null; }; ReplaceHandoffArtifactRequest: { content: components["schemas"]["HandoffContent"]; + restored_from_revision?: number | null; + reason?: string | null; }; SourceRecord: { /** @description Server-owned identity attestation when this Source contains an enforced-mode Handoff Receipt. */ @@ -8485,7 +8515,7 @@ export interface operations { }; path: { scope_id: string; - family: "memory" | "atomic-memory" | "experience" | "skill" | "handoff" | "prompt" | "topic-memory"; + family: "memory" | "atomic-memory" | "experience" | "skill" | "handoff" | "prompt" | "topic-memory" | "profile"; artifact_id: string; }; cookie?: never; diff --git a/desktop/ui/src/generated/ipc.ts b/desktop/ui/src/generated/ipc.ts index 93cd4b05a9..d0385a8356 100644 --- a/desktop/ui/src/generated/ipc.ts +++ b/desktop/ui/src/generated/ipc.ts @@ -57,7 +57,7 @@ export type ArtifactDreamingOperationCapability = { operation: DreamOperation, o export type ArtifactFamilyAccessCapability = { family: string, enabled: boolean, share_unit: string, actions: Array, grantable_roles: Array, }; export type ArtifactReadFamily = "memory" | "atomic-memory" | "experience" | "skill" | "handoff" | "profile" | "prompt" | "topic-memory"; export type ArtifactReference = { family: string, artifact_id: string, revision: number, }; -export type ArtifactRevision = { scope_id: string, family: ArtifactReadFamily, artifact_id: string, revision: number, content: Record, sources: Array, artifacts: Array, content_digest: string, }; +export type ArtifactRevision = { scope_id: string, family: ArtifactReadFamily, artifact_id: string, revision: number, content: Record, sources: Array, artifacts: Array, content_digest: string, created_at?: string | null, created_by?: AccessPrincipal | null, restored_from_revision?: number | null, reason?: string | null, }; export type AtomicMemoryRecord = { artifact: ArtifactReference, kind: string, text: string, state: AtomicMemoryState, state_version: number, merged_into_id: string | null, }; export type AtomicMemorySearchHit = { memory: AtomicMemoryRecord, score: number, matched_by: Array, }; export type AtomicMemorySearchMode = "text" | "vector" | "hybrid"; diff --git a/desktop/ui/src/generated/operations.ts b/desktop/ui/src/generated/operations.ts index 4190b5abe7..fbf8bf71e7 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 = "e43c7034b6930688c350c68af5e7bd6083b123783b92d0a50eebbebd45aa7da7"; +export const contractSha256 = "dae5feab227a6c16a8199455a27c82b444c9c17b43bec7de0fca4790c7bed14f"; export const operations = { "get_liveness": { "method": "GET", diff --git a/docs/en/development/dashboard.md b/docs/en/development/dashboard.md index 4c54ac32e3..580d4d62e1 100644 --- a/docs/en/development/dashboard.md +++ b/docs/en/development/dashboard.md @@ -6,7 +6,7 @@ The Dashboard is a content viewer for personal use and demonstrations, disabled A user may open the Dashboard to check an agreement, find an experience relevant to a similar problem, or resume interrupted work. The page needs to make the current scope clear, help them find the relevant record, and provide access to source material when they need to check it. -The core experience for 1.0 is reading saved content. Memories, experiences, skills and handoffs can exist independently. A scope containing only memories should work normally, and an empty scope should still have a clear entry point and page structure. Existing tools and APIs handle generation, review and saving. Reading their results should not require generation configuration. +The core experience for 1.0 is reading saved content. Existing pages also list revision history, compare two revisions, and roll an exact stored revision forward through the same conditional replace the API already uses. Each Atomic Memory shows its own revisions and comparison read-only. Restoring an Atomic Memory belongs to its restoration flow, which previews every affected memory before confirmation. Memories, experiences, skills and handoffs can exist independently. A scope containing only memories should work normally, and an empty scope should still have a clear entry point and page structure. Existing tools and APIs handle generation, review and saving. Reading their results should not require generation configuration. Scope names, summaries and saved content supply the business topic. A payment service, customer interviews and personal research can use the same interface without separate navigation for each domain. Before adding a section, explain which task it helps the user complete and whether the available data supports what it claims. diff --git a/docs/en/rfcs/1468_scope_owned_prompt_management.md b/docs/en/rfcs/1468_scope_owned_prompt_management.md index a1ec688e7d..1057a53bdb 100644 --- a/docs/en/rfcs/1468_scope_owned_prompt_management.md +++ b/docs/en/rfcs/1468_scope_owned_prompt_management.md @@ -267,12 +267,15 @@ Rollback is deliberately not a history rewrite and does not require a third endp 1. read the old exact revision; 2. read the current head and ETag; -3. replace the current head with the old revision's `content` and the current ETag; and +3. replace the current head with the old revision's `content`, the current ETag, `restored_from_revision`, and a + non-blank `reason`; and 4. receive a new revision whose content digest matches the restored revision. For example, restoring revision 2 while revision 5 is current creates revision 6. Revisions 2 through 5 remain -readable. Generic request audit identifies the actor and request; equality with the restored content is visible through -the content digest. The Server never moves the head pointer backward. +readable. The new revision stores the source revision, the reason, and the authenticated principal. Every new revision +stores `created_at`. Equality with the restored content is visible through the content digest. The Server never moves +the head pointer backward. Omitting both rollback fields remains an ordinary replace. Naming a source without a valid +reason, naming the current head, or submitting content that does not match that source returns 422. # Reference-level explanation @@ -815,7 +818,8 @@ as every update. - **Draft, review, and activation workflows:** duplicate Candidate/Review concepts and are not needed for the first administrative vertical slice. - **A dedicated rollback endpoint:** adds an action whose safe semantics are already expressed by exact read plus - conditional replacement. + conditional replacement. The replace request may name the source revision and a reason; it does not add a route or + a permission. Doing nothing leaves operational prompts fixed at deployment composition and forces customers to fork Runtime code or run separate deployments for different prompt behavior. diff --git a/docs/en/rfcs/1485_profile_artifact.md b/docs/en/rfcs/1485_profile_artifact.md index 847926c1d6..63c2cb9a03 100644 --- a/docs/en/rfcs/1485_profile_artifact.md +++ b/docs/en/rfcs/1485_profile_artifact.md @@ -428,17 +428,20 @@ ordinal=0, ETag/If-Match, and lineage rules. Create uses artifact_id=profile and Automatic commits use the same Family validation and repository but cite actual evidence rather than inventing a manual system Source. -Rollback needs no new API: GET a historical Revision, GET the current Head ETag, then PUT the complete Markdown with -optional `restored_from_revision` in ProfileWriteContent. The server verifies that the historical Revision exists -and its normalized Markdown matches, then creates a new rollback Revision. It neither moves Head back to an older -Revision nor rewinds the Cursor. Omitting the field is an ordinary manual Replace; Create forbids it. Clients compare -two Revisions themselves, with no new diff or history-list endpoint. +Rollback needs no new API: GET a historical Revision, GET the current Head ETag, then PUT the complete Markdown. +`restored_from_revision` may appear beside `content` and inside ProfileWriteContent; when both are set they are the +same integer. A source revision requires a non-blank `reason` of at most 1024 characters. The server verifies that the +historical Revision exists and its normalized Markdown matches, then creates a new rollback Revision that records the +source, the reason, and the authenticated principal. It neither moves Head back to an older Revision nor rewinds the +Cursor. Omitting both source fields is an ordinary manual Replace; Create forbids the source field. Clients compare +two Revisions themselves. The Dashboard renders that comparison; there is no separate diff endpoint. ## Persistence inventory and example rows BLOB examples below show decoded JSON. Foreign keys and lineage use complete identities. Timestamps and digests are readable examples; omitted default columns are still written according to existing definitions. Existing tables -have no DDL changes; only Profile BLOB schemas and code branches are extended. +keep their existing constraints. Artifact rows gain nullable `created_at`, `created_by`, `restored_from_revision`, and +`rollback_reason`. Profile BLOB schemas and code branches are extended as specified below. ```mermaid erDiagram diff --git a/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md b/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md index 56316b634e..c763a29d12 100644 --- a/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md +++ b/docs/en/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md @@ -36,7 +36,7 @@ New Handoff writes use a Scope singleton, while detail links can select historic - Distinguish absent content, read errors, historical content, and failed downloads. - Preserve collection return context and recover exact reading destinations after authentication. - Preserve existing Prompt and Topic Memory routes, layouts, search, and pagination without duplicate entry points. -- Exclude profile editing, rollback, regeneration, review, and processing policy management. +- Exclude profile editing, regeneration, review, and processing policy management. History, comparison, and rollback of one exact stored revision stay on the existing page and reuse conditional replace. - Exclude an all-artifacts browser, cross-Scope aggregation, batch downloads, PDF, public sharing, imports, and a new machine-readable Agent handoff contract. @@ -320,8 +320,8 @@ public selection and digest contract would broaden the feature, so use a Dashboa Allowlisted relative destinations survive refreshes and copied links; browser history and Referer cannot reliably distinguish normal navigation, direct entry, and authentication. Returning to exact detail after login preserves an -explicit download action. Profile editing, generation, and rollback require conditional writes, review, and conflict -recovery and remain available through existing tools and APIs. +explicit download action. Profile editing, generation, and review remain available through existing tools and APIs. +Rolling back one stored revision reuses conditional replace on the page that already shows that revision. # Prior art @@ -340,6 +340,6 @@ aggregation, Profile editing, and batch reports require separate designs. # Future possibilities -Future work can add conditional Profile editing, historical comparisons, and explicitly named latest-Scope report +Future work can add conditional Profile editing and explicitly named latest-Scope report downloads. Improvements to existing Prompt and Topic Memory pages should address demonstrated gaps separately while retaining Scope permissions and exact references. diff --git a/docs/zh/development/dashboard.md b/docs/zh/development/dashboard.md index 349cf80ed2..6b9e65b105 100644 --- a/docs/zh/development/dashboard.md +++ b/docs/zh/development/dashboard.md @@ -6,7 +6,7 @@ Dashboard 是 PowerContext 中供个人使用和演示的内容查看器,默 用户进入 Dashboard 时,可能想确认某项约定,查找解决过类似问题的经验,或继续一项中断的工作。页面需要让用户确定正在查看哪个范围,找到相关记录,并在需要时核对原始材料。 -1.0 的核心是阅读已有内容。记忆、经验、技能和交接可以分别存在:只有记忆的范围也应当能够正常使用;没有内容的范围仍有明确的入口和页面结构。生成、审核和保存由现有工具与接口完成,阅读这些结果不应要求用户先配置生成能力。 +1.0 的核心是阅读已有内容。现有页面同时列出修订历史、比较两个修订,并通过接口已有的条件替换把某一份已保存修订写成新的当前版本。每条原子记忆可以查看和比较自己的修订,但只读。恢复原子记忆走它自己的恢复流程,确认前会预览所有受影响的记忆。记忆、经验、技能和交接可以分别存在:只有记忆的范围也应当能够正常使用;没有内容的范围仍有明确的入口和页面结构。生成、审核和保存由现有工具与接口完成,阅读这些结果不应要求用户先配置生成能力。 页面的业务主题来自范围名称、摘要和保存的内容。支付服务、客户访谈或个人研究可以使用同一套界面,不需要为每种业务另造导航。新增区块时,应先说明它帮助用户完成哪项工作,以及现有数据能否支持它表达的含义。 diff --git a/docs/zh/rfcs/1468_scope_owned_prompt_management.md b/docs/zh/rfcs/1468_scope_owned_prompt_management.md index 1dcf2832fb..e01b61d359 100644 --- a/docs/zh/rfcs/1468_scope_owned_prompt_management.md +++ b/docs/zh/rfcs/1468_scope_owned_prompt_management.md @@ -262,11 +262,10 @@ GET /v1/scopes/project:payments/artifacts/prompt/memory.extract/revisions/2 1. 读取旧的 exact revision; 2. 读取 current head 与 ETag; -3. 使用旧 revision 的 `content` 和 current ETag 替换 current head; +3. 使用旧 revision 的 `content`、current ETag、`restored_from_revision` 和非空 `reason` 替换 current head; 4. 得到一个新 revision,其 content digest 与被恢复 revision 相同。 -例如,revision 5 为 current 时恢复 revision 2,会创建 revision 6。Revision 2 到 5 继续可读。通用 request audit -标识 actor 与 request;与旧 content 相同可通过 content digest 确认。Server 绝不把 head pointer 向后移动。 +例如,revision 5 为 current 时恢复 revision 2,会创建 revision 6。Revision 2 到 5 继续可读。新 revision 记下源修订、原因和已认证主体。此后每个新 revision 都写入 `created_at`。与旧 content 相同可通过 content digest 确认。Server 绝不把 head pointer 向后移动。两个回滚字段都不提供时仍是普通替换。给出源修订但没有有效原因、源修订就是当前 head,或正文与该源修订不一致,返回 422。 # Reference-level explanation @@ -770,7 +769,7 @@ conditional replacement 中,可以保留单调历史,并使用与所有更 - **Mutable cross-Scope Prompt reference:** 允许一个 Scope 在没有本地 revision 的情况下改变另一个 Scope 的行为。 - **Prompt DSL 或 variables:** 在出现具体需求前增加 compiler 与 injection 复杂度。 - **Draft、review 与 activation workflow:** 重复 Candidate/Review 概念,且首个 administrative vertical slice 不需要。 -- **专用 rollback endpoint:** exact read 加 conditional replacement 已经表达其安全语义。 +- **专用 rollback endpoint:** exact read 加 conditional replacement 已经表达其安全语义。替换请求可以带上源修订和原因,不新增路由,也不新增权限。 如果不做此设计,operational prompt 仍固定在 deployment composition 阶段,客户只能 fork Runtime code 或运行多个部署 来获得不同 prompt 行为。 diff --git a/docs/zh/rfcs/1485_profile_artifact.md b/docs/zh/rfcs/1485_profile_artifact.md index f1ee7fdeec..7aca45c868 100644 --- a/docs/zh/rfcs/1485_profile_artifact.md +++ b/docs/zh/rfcs/1485_profile_artifact.md @@ -380,15 +380,12 @@ Approve/Reject 失败不部分推进 Cursor;终态重复请求遵循既有 Can 及现有 lineage 规则。Create 固定 artifact_id=profile,已有 Head 返回 409。自动提交使用同一 Family 校验与 repository, 但引用真实 evidence Source,不伪造人工系统 Source。 -回退无需新 API:先 GET 历史 Revision,再 GET 当前 Head 的 ETag,PUT 完整正文并可在 ProfileWriteContent 中带 -`restored_from_revision`。服务端校验历史 Revision 存在且规范化正文相同,写入新的 rollback Revision;不移动 Head 回旧版本, -不回退 Cursor。未提供该可选字段时视为普通人工 Replace。Create 不允许指定 restored_from_revision。 -历史比较由客户端读取两个 Revision 完成,不新增 diff/history-list 接口。 +回退无需新 API:先 GET 历史 Revision,再 GET 当前 Head 的 ETag,然后 PUT 完整正文。`restored_from_revision` 可以放在 `content` 旁边,也可以放在 ProfileWriteContent 里;两处都给出时必须是同一个整数。给出源修订时必须同时给出非空 `reason`,最长 1024 个字符。服务端校验历史 Revision 存在且规范化正文相同,写入新的 rollback Revision,并记下源修订、原因和已认证主体;不移动 Head 回旧版本,不回退 Cursor。两处源修订都不提供时视为普通人工 Replace。Create 不允许指定 restored_from_revision。历史比较由客户端读取两个 Revision 完成,Dashboard 负责展示,不另设 diff 接口。 ## Persistence inventory and example rows 下面示例中的 BLOB 展示解码后的 JSON,外键和 lineage 使用实际完整身份。时间、摘要使用可读示例值;省略的默认列 -在实现中仍按现有表定义写入。现有表均无 DDL 变动,只扩展 Profile 对应的类型化 BLOB schema 和代码分支。 +在实现中仍按现有表定义写入。现有表约束保持不变。Artifact 行增加可空的 `created_at`、`created_by`、`restored_from_revision` 和 `rollback_reason`。Profile 的类型化 BLOB schema 和代码分支按下文扩展。 ```mermaid erDiagram diff --git a/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md b/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md index 3af6e7537c..bfc336c045 100644 --- a/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md +++ b/docs/zh/rfcs/1629_dashboard_profile_and_handoff_markdown_export.md @@ -25,7 +25,7 @@ Dashboard 已能阅读记忆、经验、技能、交接、提示词和主题记 - 明确空内容、读取错误、历史版本和下载失败的不同状态。 - 完成新阅读流程需要的目录返回状态传递,以及登录后的精确记录恢复。 - 保留现有提示词与主题记忆的路由、页面、搜索和分页行为,不增加重复入口,也不重构其布局。 -- 不实现画像编辑、回滚、重新生成、审核和处理策略管理。 +- 不实现画像编辑、重新生成、审核和处理策略管理。历史、比较,以及把某一份已保存修订写成新版本,留在现有页面上,并复用条件替换。 - 不增加统一所有制品页、跨 Scope 聚合、批量下载、PDF、公开分享、导入或 Agent 交接的新机器契约。 # Guide-level explanation @@ -240,7 +240,7 @@ Profile 采用独立阅读入口符合其单个完整画像的内容模型,不 返回目录采用白名单站内 URL,能够在刷新和复制链接后恢复位置;依赖 `history.back()` 或 Referer 无法稳定区分正常浏览、直接链接和登录跳转。登录后返回详情并由用户再次下载,可以保留明确的操作对象。 -画像编辑、生成和回滚需要条件写入、审核与冲突恢复,会扩大本次工作范围;只读查看仍可与现有工具及 API 配合完成这些任务。 +画像编辑、生成和审核仍通过现有工具及 API 完成。把某一份已保存修订写成新版本,复用该页已经展示的条件替换。 # Prior art @@ -256,4 +256,4 @@ Artifact REST API 已提供当前 Head、历史目录、精确 Revision、digest # Future possibilities -可后续增加画像条件编辑、历史比较,以及明确命名的“导出最新范围报告”。已有提示词和主题记忆页面的增强应依据实际差距独立提出,继续复用 Scope 权限与精确引用。 +可后续增加画像条件编辑,以及明确命名的“导出最新范围报告”。已有提示词和主题记忆页面的增强应依据实际差距独立提出,继续复用 Scope 权限与精确引用。 diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index fd9ada15b4..859947ee0f 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -432,7 +432,8 @@ "skill", "handoff", "prompt", - "topic-memory" + "topic-memory", + "profile" ] } }, @@ -825,6 +826,40 @@ }, "components": { "schemas": { + "AccessPrincipal": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "user", + "service" + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + } + } + }, "ActivateHandoffRequest": { "type": "object", "additionalProperties": false, @@ -965,6 +1000,57 @@ "content_digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "description": "When this revision was committed. Absent for revisions stored before this field existed." + }, + { + "type": "null" + } + ] + }, + "created_by": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/AccessPrincipal" + } + ], + "description": "Principal that committed a rollback. Ordinary revisions omit it." + }, + { + "type": "null" + } + ] + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "description": "Historical revision restored by this rollback." + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024, + "description": "Reason recorded for a rollback." + }, + { + "type": "null" + } + ] } } }, @@ -3579,6 +3665,30 @@ "properties": { "content": { "$ref": "#/components/schemas/ExperienceProposal" + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": ".*\\S.*" + }, + { + "type": "null" + } + ] } } }, @@ -3591,6 +3701,30 @@ "properties": { "content": { "$ref": "#/components/schemas/HandoffContent" + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": ".*\\S.*" + }, + { + "type": "null" + } + ] } } }, @@ -3669,6 +3803,32 @@ "properties": { "content": { "$ref": "#/components/schemas/ProfileWriteContent" + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "description": "Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set." + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": ".*\\S.*", + "description": "Required when restored_from_revision or content.restored_from_revision is set." + }, + { + "type": "null" + } + ] } } }, @@ -3681,6 +3841,30 @@ "properties": { "content": { "$ref": "#/components/schemas/PromptContent" + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": ".*\\S.*" + }, + { + "type": "null" + } + ] } } }, @@ -3693,6 +3877,30 @@ "properties": { "content": { "$ref": "#/components/schemas/SkillProposal" + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": ".*\\S.*" + }, + { + "type": "null" + } + ] } } }, @@ -3705,6 +3913,30 @@ "properties": { "content": { "$ref": "#/components/schemas/TopicMemoryWriteContent" + }, + "restored_from_revision": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": ".*\\S.*" + }, + { + "type": "null" + } + ] } } }, diff --git a/integrations/dify/plugin/tools/pc_memory_get.yaml b/integrations/dify/plugin/tools/pc_memory_get.yaml index 7eb4f8aba0..c02c011377 100644 --- a/integrations/dify/plugin/tools/pc_memory_get.yaml +++ b/integrations/dify/plugin/tools/pc_memory_get.yaml @@ -138,6 +138,23 @@ output_schema: content_digest: $ref: '#/definitions/Result/properties/content_digest' type: string + created_at: + $ref: '#/definitions/Result/properties/created_at' + type: string + description: When this revision was committed. Absent for revisions stored + before this field existed. + created_by: + $ref: '#/definitions/Result/properties/created_by' + description: Principal that committed a rollback. Ordinary revisions omit + it. + restored_from_revision: + $ref: '#/definitions/Result/properties/restored_from_revision' + type: number + description: Historical revision restored by this rollback. + reason: + $ref: '#/definitions/Result/properties/reason' + type: string + description: Reason recorded for a rollback. etag: $ref: '#/definitions/Result/properties/etag' type: string @@ -224,6 +241,52 @@ output_schema: content_digest: type: string pattern: ^sha256:[0-9a-f]{64}$ + created_at: + anyOf: + - type: string + format: date-time + description: When this revision was committed. Absent for revisions stored + before this field existed. + - type: 'null' + created_by: + anyOf: + - allOf: + - type: object + additionalProperties: false + required: + - type + - id + properties: + type: + type: string + enum: + - user + - service + id: + type: string + minLength: 1 + maxLength: 255 + description: + anyOf: + - type: string + minLength: 1 + maxLength: 255 + - type: 'null' + description: Principal that committed a rollback. Ordinary revisions omit + it. + - type: 'null' + restored_from_revision: + anyOf: + - type: integer + minimum: 1 + description: Historical revision restored by this rollback. + - type: 'null' + reason: + anyOf: + - type: string + maxLength: 1024 + description: Reason recorded for a rollback. + - type: 'null' etag: type: string description: Actual current content ETag; absent on historical reads. diff --git a/integrations/dify/plugin/tools/pc_memory_revise.yaml b/integrations/dify/plugin/tools/pc_memory_revise.yaml index 726c0fca4b..2353764748 100644 --- a/integrations/dify/plugin/tools/pc_memory_revise.yaml +++ b/integrations/dify/plugin/tools/pc_memory_revise.yaml @@ -191,6 +191,23 @@ output_schema: content_digest: $ref: '#/definitions/Result/properties/content_digest' type: string + created_at: + $ref: '#/definitions/Result/properties/created_at' + type: string + description: When this revision was committed. Absent for revisions stored + before this field existed. + created_by: + $ref: '#/definitions/Result/properties/created_by' + description: Principal that committed a rollback. Ordinary revisions omit + it. + restored_from_revision: + $ref: '#/definitions/Result/properties/restored_from_revision' + type: number + description: Historical revision restored by this rollback. + reason: + $ref: '#/definitions/Result/properties/reason' + type: string + description: Reason recorded for a rollback. description: Complete successful HTTP response; empty object on error or unknown outcome. required: @@ -269,6 +286,52 @@ output_schema: content_digest: type: string pattern: ^sha256:[0-9a-f]{64}$ + created_at: + anyOf: + - type: string + format: date-time + description: When this revision was committed. Absent for revisions stored + before this field existed. + - type: 'null' + created_by: + anyOf: + - allOf: + - type: object + additionalProperties: false + required: + - type + - id + properties: + type: + type: string + enum: + - user + - service + id: + type: string + minLength: 1 + maxLength: 255 + description: + anyOf: + - type: string + minLength: 1 + maxLength: 255 + - type: 'null' + description: Principal that committed a rollback. Ordinary revisions omit + it. + - type: 'null' + restored_from_revision: + anyOf: + - type: integer + minimum: 1 + description: Historical revision restored by this rollback. + - type: 'null' + reason: + anyOf: + - type: string + maxLength: 1024 + description: Reason recorded for a rollback. + - type: 'null' description: Complete successful HTTP response; empty object on error or unknown outcome. extra: diff --git a/openapi/powercontext.yaml b/openapi/powercontext.yaml index d2e19cefe7..f34ef634b4 100644 --- a/openapi/powercontext.yaml +++ b/openapi/powercontext.yaml @@ -4190,6 +4190,10 @@ paths: an earlier revision. Artifact ownership does not authorize Prompt replacement after Scope role revocation. Topic Memory replacement also requires scope.admin and preserves independently versioned tags. Complete title, summary, and detail text replaces the head without semantic generation. + Profile replacement uses the same conditional write. To roll back Profile, Prompt, Experience, Skill, + Handoff, or Topic Memory, set restored_from_revision and reason. The server checks that the submitted + canonical content matches that revision and differs from the current head, then commits a new revision. + Atomic Memory replacement rejects both fields; restore Atomic Memory through its restoration flow. operationId: replace_artifact x-powercontext-scope-mode: current x-powercontext-access: {resolver: path_artifact_write_access} @@ -4207,7 +4211,7 @@ paths: required: true schema: type: string - enum: [memory, atomic-memory, experience, skill, handoff, prompt, topic-memory] + enum: [memory, atomic-memory, experience, skill, handoff, prompt, topic-memory, profile] - name: artifact_id in: path required: true @@ -5840,6 +5844,18 @@ components: properties: content: $ref: "#/components/schemas/ProfileWriteContent" + restored_from_revision: + type: integer + minimum: 1 + nullable: true + description: Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set. + reason: + type: string + minLength: 1 + maxLength: 1024 + pattern: '.*\S.*' + nullable: true + description: Required when restored_from_revision or content.restored_from_revision is set. ActivateHandoffRequest: type: object additionalProperties: false @@ -5923,6 +5939,22 @@ components: minimum: 0 nullable: true description: Family-provided number of direct Source inputs when available. + created_at: + type: string + format: date-time + nullable: true + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + restored_from_revision: + type: integer + minimum: 1 + nullable: true + reason: + type: string + maxLength: 1024 + nullable: true ArtifactCreated: type: object required: [scope_id, family, artifact_id, revision, sources, artifacts] @@ -6006,6 +6038,26 @@ components: content_digest: type: string pattern: '^sha256:[0-9a-f]{64}$' + created_at: + type: string + format: date-time + nullable: true + description: When this revision was committed. Absent for revisions stored before this field existed. + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/AccessPrincipal" + description: Principal that committed a rollback. Ordinary revisions omit it. + restored_from_revision: + type: integer + minimum: 1 + nullable: true + description: Historical revision restored by this rollback. + reason: + type: string + maxLength: 1024 + nullable: true + description: Reason recorded for a rollback. ArtifactReference: type: object additionalProperties: false @@ -11080,6 +11132,8 @@ components: required: [content] properties: content: {$ref: '#/components/schemas/TopicMemoryWriteContent'} + restored_from_revision: {type: integer, minimum: 1, nullable: true} + reason: {type: string, minLength: 1, maxLength: 1024, pattern: '.*\S.*', nullable: true} CreatePromptArtifactRequest: type: object additionalProperties: false @@ -11345,6 +11399,16 @@ components: properties: content: $ref: "#/components/schemas/PromptContent" + restored_from_revision: + type: integer + minimum: 1 + nullable: true + reason: + type: string + minLength: 1 + maxLength: 1024 + pattern: '.*\S.*' + nullable: true ListArtifactRevisionsRequest: type: object additionalProperties: false @@ -11610,6 +11674,16 @@ components: properties: content: $ref: "#/components/schemas/ExperienceProposal" + restored_from_revision: + type: integer + minimum: 1 + nullable: true + reason: + type: string + minLength: 1 + maxLength: 1024 + pattern: '.*\S.*' + nullable: true ReplaceSkillArtifactRequest: type: object additionalProperties: false @@ -11617,6 +11691,16 @@ components: properties: content: $ref: "#/components/schemas/SkillProposal" + restored_from_revision: + type: integer + minimum: 1 + nullable: true + reason: + type: string + minLength: 1 + maxLength: 1024 + pattern: '.*\S.*' + nullable: true ReplaceHandoffArtifactRequest: type: object additionalProperties: false @@ -11624,6 +11708,16 @@ components: properties: content: $ref: "#/components/schemas/HandoffContent" + restored_from_revision: + type: integer + minimum: 1 + nullable: true + reason: + type: string + minLength: 1 + maxLength: 1024 + pattern: '.*\S.*' + nullable: true SourceRecord: type: object required: [scope_id, source_type, source_id, content, position, content_digest] diff --git a/src/powercontext/builtin/persistence/artifacts.py b/src/powercontext/builtin/persistence/artifacts.py index ac052f62b7..b4e613f122 100644 --- a/src/powercontext/builtin/persistence/artifacts.py +++ b/src/powercontext/builtin/persistence/artifacts.py @@ -17,6 +17,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence +from datetime import UTC, datetime from typing import Any from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError @@ -40,6 +41,11 @@ RepositoryNotFoundError, StoredPayloadConflictError, ) +from powercontext.builtin.persistence.revision_meta import ( + attach_revision_meta, + meta_from_row, + rollback_columns, +) from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, ARTIFACT_LINEAGE_ARTIFACTS_TABLE, @@ -597,6 +603,8 @@ async def _insert_revision( if not isinstance(content, BaseModel): raise TypeError("artifact content must be a BaseModel") # noqa: TRY003 payload = dump_model(content, kind="artifact", name=artifact_type.family) + created_at = datetime.now(UTC) + stamped = rollback_columns(ref.family, ref.artifact_id, ref.revision) await connection.execute( insert(ARTIFACTS_TABLE).values( scope_id=scope_id, @@ -604,6 +612,8 @@ async def _insert_revision( artifact_id=ref.artifact_id, revision=ref.revision, content=payload, + created_at=created_at, + **stamped, ) ) if lineage.sources: @@ -645,6 +655,7 @@ async def _insert_revision( content=content, lineage=lineage, ) + attach_revision_meta(artifact, meta_from_row({"created_at": created_at, **stamped})) if artifact.as_ref() != ref: raise IdentityMismatchError("artifact", ref, artifact.as_ref()) return artifact @@ -687,6 +698,7 @@ def _decode_artifact( content=content, lineage=lineage, ) + attach_revision_meta(artifact, meta_from_row(row)) if artifact.as_ref() != ref: raise IdentityMismatchError("artifact", ref, artifact.as_ref()) return artifact diff --git a/src/powercontext/builtin/persistence/family_management.py b/src/powercontext/builtin/persistence/family_management.py index 48f758b58d..5794c5e61c 100644 --- a/src/powercontext/builtin/persistence/family_management.py +++ b/src/powercontext/builtin/persistence/family_management.py @@ -318,6 +318,15 @@ async def _canonical_content( ) return canonical, package + async def canonical_content( + self, + connection: AsyncConnection, + scope_id: str, + content: BaseModel, + ) -> SkillContent: + canonical, _package = await self._canonical_content(connection, scope_id, content) + return canonical + async def create( self, connection: AsyncConnection, diff --git a/src/powercontext/builtin/persistence/records.py b/src/powercontext/builtin/persistence/records.py index b37225319e..b069e48e79 100644 --- a/src/powercontext/builtin/persistence/records.py +++ b/src/powercontext/builtin/persistence/records.py @@ -20,7 +20,7 @@ from contextlib import aclosing from datetime import UTC, datetime from hashlib import sha256 -from typing import Any, cast +from typing import Any, Literal, cast from uuid import uuid4 import rfc8785 @@ -45,6 +45,18 @@ PreparingFamilyManagementWriter, ) from powercontext.builtin.persistence.processing import ArtifactProcessingPendingRepository +from powercontext.builtin.persistence.revision_meta import ( + PendingRollback, + bind_rollback, + reset_rollback, + revision_meta, +) +from powercontext.builtin.persistence.rollback import ( + merged_source_revision, + normalize_reason, + prepare_profile_command, + require_matching_source, +) from powercontext.builtin.persistence.sources import SourceRepository, StoredSource from powercontext.builtin.persistence.tables import ( ARTIFACT_HEADS_TABLE, @@ -59,6 +71,7 @@ ArtifactListReader, ArtifactRecord, ArtifactRecordPage, + ArtifactRevisionActor, ArtifactRevisionPage, ArtifactRevisionPreconditionError, ArtifactWrite, @@ -646,6 +659,8 @@ async def replace_artifact( # noqa: C901 write: ArtifactWrite, /, *, + actor_type: str | None = None, + actor_id: str | None = None, execution_context=None, ) -> ArtifactRecord: if write.prompt_key is not None: @@ -654,6 +669,9 @@ async def replace_artifact( # noqa: C901 raise BaseOperationNotSupportedError("artifact_family", family, "collection writes") writer = self._family_writers.get(family) command = writer.validate_replace(write.content) + source_revision = merged_source_revision(family, write, command) + command = prepare_profile_command(command, source_revision) + reason = None if source_revision is None or write.reason is None else normalize_reason(write.reason) prepared = command if isinstance(writer, AtomicMemoryManagementWriter): record = await self.get_artifact(scope_id, family, artifact_id) @@ -703,7 +721,26 @@ async def replace_artifact( # noqa: C901 ) try: stored = await self._sources.add(connection, scope_id, source) - revised = await writer.replace(connection, scope_id, current, prepared, stored.ref) + token = await _bind_rollback( + self._artifacts, + connection, + scope_id, + family, + artifact_id, + current, + command, + source_revision, + reason, + writer, + next_revision, + actor_type, + actor_id, + ) + try: + revised = await writer.replace(connection, scope_id, current, prepared, stored.ref) + finally: + if token is not None: + reset_rollback(token) except StoredPayloadConflictError as error: raise BaseValueConflictError("source", (scope_id, CONTENT_SOURCE_NAME, source.name)) from error except RevisionConflictError: @@ -830,6 +867,7 @@ def _artifact_record( sources=artifact.lineage.sources, artifacts=artifact.lineage.artifacts, content_digest=_content_digest(content), + **_revision_fields(artifact), ) @@ -854,9 +892,67 @@ def _artifact_collection_item(scope_id: str, artifact: Artifact[Any]) -> Artifac sources=artifact.lineage.sources, artifacts=artifact.lineage.artifacts, content_digest=_content_digest(content), + **_revision_fields(artifact), ) +async def _bind_rollback( + artifacts: ArtifactRepository, + connection: AsyncConnection, + scope_id: str, + family: str, + artifact_id: str, + current: Artifact[Any], + command: Any, + source_revision: int | None, + reason: str | None, + writer: object, + next_revision: int, + actor_type: str | None, + actor_id: str | None, +) -> Any: + if source_revision is None or reason is None: + return None + await require_matching_source( + artifacts, + connection, + scope_id, + family, + artifact_id, + current, + command, + source_revision, + writer, + ) + return bind_rollback( + PendingRollback( + family=family, + artifact_id=artifact_id, + revision=next_revision, + restored_from_revision=source_revision, + reason=reason, + created_by_type=actor_type, + created_by_id=actor_id, + ) + ) + + +def _revision_fields(artifact: Artifact[Any]) -> dict[str, Any]: + meta = revision_meta(artifact) + actor = None + if meta.created_by_type in {"user", "service"} and meta.created_by_id is not None: + actor = ArtifactRevisionActor( + type=cast(Literal["user", "service"], meta.created_by_type), + id=meta.created_by_id, + ) + return { + "created_at": meta.created_at, + "created_by": actor, + "restored_from_revision": meta.restored_from_revision, + "reason": meta.reason, + } + + def _artifact_etag(revision: int) -> str: return f'"revision:{revision}"' diff --git a/src/powercontext/builtin/persistence/revision_meta.py b/src/powercontext/builtin/persistence/revision_meta.py new file mode 100644 index 0000000000..86df958e3a --- /dev/null +++ b/src/powercontext/builtin/persistence/revision_meta.py @@ -0,0 +1,120 @@ +# 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. + +"""Immutable revision metadata stored beside artifact content.""" + +from __future__ import annotations + +import json +from contextvars import ContextVar, Token +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from powercontext.artifacts import Artifact + +_ROLLBACK_STAMP: ContextVar[PendingRollback | None] = ContextVar("powercontext_rollback_stamp", default=None) + + +@dataclass(frozen=True, slots=True) +class RevisionMeta: + """Read-back metadata for one stored revision. Missing history stays empty.""" + + created_at: datetime | None = None + created_by_type: str | None = None + created_by_id: str | None = None + restored_from_revision: int | None = None + reason: str | None = None + + +@dataclass(frozen=True, slots=True) +class PendingRollback: + """Rollback identity applied only to the matching inserted revision.""" + + family: str + artifact_id: str + revision: int + restored_from_revision: int + reason: str + created_by_type: str | None + created_by_id: str | None + + +def bind_rollback(stamp: PendingRollback) -> Token[PendingRollback | None]: + return _ROLLBACK_STAMP.set(stamp) + + +def reset_rollback(token: Token[PendingRollback | None]) -> None: + _ROLLBACK_STAMP.reset(token) + + +def revision_meta(artifact: Artifact[Any]) -> RevisionMeta: + meta = getattr(artifact, "_revision_meta", None) + return meta if isinstance(meta, RevisionMeta) else RevisionMeta() + + +def attach_revision_meta(artifact: Artifact[Any], meta: RevisionMeta) -> None: + object.__setattr__(artifact, "_revision_meta", meta) + + +def rollback_columns(family: str, artifact_id: str, revision: int) -> dict[str, Any]: + """Return rollback columns when this insert is the bound rollback revision.""" + + stamp = _ROLLBACK_STAMP.get() + if stamp is None or stamp.family != family or stamp.artifact_id != artifact_id or stamp.revision != revision: + return {} + created_by = None + if stamp.created_by_type is not None and stamp.created_by_id is not None: + created_by = json.dumps( + {"type": stamp.created_by_type, "id": stamp.created_by_id}, + ensure_ascii=False, + separators=(",", ":"), + ) + return { + "created_by": created_by, + "restored_from_revision": stamp.restored_from_revision, + "rollback_reason": stamp.reason, + } + + +def meta_from_row(row: dict[str, Any] | Any) -> RevisionMeta: + created_by_type = None + created_by_id = None + raw_actor = row.get("created_by") if hasattr(row, "get") else None + if isinstance(raw_actor, str) and raw_actor: + try: + actor = json.loads(raw_actor) + except json.JSONDecodeError: + actor = None + if isinstance(actor, dict) and actor.get("type") in {"user", "service"} and isinstance(actor.get("id"), str): + created_by_type = actor["type"] + created_by_id = actor["id"] + restored = row.get("restored_from_revision") if hasattr(row, "get") else None + reason = row.get("rollback_reason") if hasattr(row, "get") else None + return RevisionMeta( + created_at=_utc(row.get("created_at") if hasattr(row, "get") else None), + created_by_type=created_by_type, + created_by_id=created_by_id, + restored_from_revision=int(restored) if isinstance(restored, int) else None, + reason=reason if isinstance(reason, str) else None, + ) + + +def _utc(value: object) -> datetime | None: + if value is None: + return None + parsed = value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) diff --git a/src/powercontext/builtin/persistence/revision_metadata_schema.py b/src/powercontext/builtin/persistence/revision_metadata_schema.py new file mode 100644 index 0000000000..f56bde9274 --- /dev/null +++ b/src/powercontext/builtin/persistence/revision_metadata_schema.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Add the nullable Artifact revision metadata columns to databases created before them.""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection +from sqlalchemy.schema import CreateColumn + +from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE + +_REVISION_METADATA_COLUMNS = ("created_at", "created_by", "restored_from_revision", "rollback_reason") + + +async def ensure_artifact_revision_metadata_schema(connection: AsyncConnection, /) -> None: + """Add missing revision metadata columns. Existing rows keep NULL, which reads as unknown history.""" + + dialect = connection.dialect.name + if dialect not in {"sqlite", "mysql"}: + raise ValueError(f"unsupported Artifact revision metadata migration dialect: {dialect}") # noqa: TRY003 + existing = await _column_names(connection) + for name in _REVISION_METADATA_COLUMNS: + if name in existing: + continue + declaration = CreateColumn(ARTIFACTS_TABLE.c[name]).compile(dialect=connection.dialect) + await connection.exec_driver_sql(f"ALTER TABLE {ARTIFACTS_TABLE.name} ADD COLUMN {declaration}") + + +async def _column_names(connection: AsyncConnection) -> set[str]: + if connection.dialect.name == "sqlite": + rows = (await connection.exec_driver_sql(f"PRAGMA table_info('{ARTIFACTS_TABLE.name}')")).mappings() + return {str(row["name"]) for row in rows} + rows = ( + await connection.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = :table_name" + ), + {"table_name": ARTIFACTS_TABLE.name}, + ) + ).mappings() + return {str(row["column_name"]) for row in rows} diff --git a/src/powercontext/builtin/persistence/rollback.py b/src/powercontext/builtin/persistence/rollback.py new file mode 100644 index 0000000000..bd5aa7b87f --- /dev/null +++ b/src/powercontext/builtin/persistence/rollback.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validate a conditional replace that restores one historical revision.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.artifacts import Artifact, ArtifactRef +from powercontext.builtin.artifacts.profile.models import ProfileContent, ProfileWriteContent +from powercontext.builtin.artifacts.skill.models import SkillContent +from powercontext.builtin.artifacts.skill.package import build_instruction_skill_package +from powercontext.builtin.artifacts.topic_memory.models import TopicMemoryContent, TopicMemoryProjection +from powercontext.builtin.persistence.artifacts import ArtifactRepository +from powercontext.builtin.persistence.errors import RepositoryNotFoundError +from powercontext.builtin.persistence.family_management import SkillManagementWriter +from powercontext.builtin.records import ArtifactWrite, InvalidBaseAccessRequestError + +ROLLBACK_FAMILIES = frozenset({"profile", "prompt", "experience", "skill", "handoff", "topic-memory"}) +_REASON_MAX = 1024 + + +def merged_source_revision(family: str, write: ArtifactWrite, command: BaseModel) -> int | None: + """Resolve the rollback source from the request and, for Profile, its content field.""" + + requested = write.restored_from_revision + embedded = command.restored_from_revision if isinstance(command, ProfileWriteContent) else None + if requested is not None and embedded is not None and requested != embedded: + raise InvalidBaseAccessRequestError( + "restored_from_revision", + "does not match content.restored_from_revision", + ) + source = requested if requested is not None else embedded + if family not in ROLLBACK_FAMILIES and (source is not None or write.reason is not None): + raise InvalidBaseAccessRequestError("restored_from_revision", f"is not supported for {family}") + if source is None and write.reason is None: + return None + if source is None or write.reason is None: + raise InvalidBaseAccessRequestError("reason", "is required when restoring a revision") + normalize_reason(write.reason) + return source + + +def normalize_reason(value: str) -> str: + if len(value) > _REASON_MAX: + raise InvalidBaseAccessRequestError("reason", "must be at most 1024 characters") + stripped = value.strip() + if not stripped: + raise InvalidBaseAccessRequestError("reason", "must not be blank") + return stripped + + +def prepare_profile_command(command: BaseModel, source: int | None) -> BaseModel: + if not isinstance(command, ProfileWriteContent): + return command + if command.restored_from_revision == source: + return command + return command.model_copy(update={"restored_from_revision": source}) + + +async def require_matching_source( + artifacts: ArtifactRepository, + connection: AsyncConnection, + scope_id: str, + family: str, + artifact_id: str, + current: Artifact[Any], + command: BaseModel, + source_revision: int, + writer: object, +) -> None: + if source_revision == current.revision: + raise InvalidBaseAccessRequestError("restored_from_revision", "is the current head") + try: + historical = await artifacts.get( + connection, + scope_id, + ArtifactRef(family=family, artifact_id=artifact_id, revision=source_revision), + ) + except RepositoryNotFoundError: + raise InvalidBaseAccessRequestError("restored_from_revision", "does not exist") from None + submitted = await _submitted_body(writer, connection, scope_id, family, command) + if submitted != _stored_body(family, historical.content): + raise InvalidBaseAccessRequestError("content", "does not match the restored revision") + if submitted == _stored_body(family, current.content): + raise InvalidBaseAccessRequestError("content", "matches the current head") + + +async def _submitted_body( + writer: object, + connection: AsyncConnection, + scope_id: str, + family: str, + command: BaseModel, +) -> object: + if ( + family == "skill" + and isinstance(command, SkillContent) + and command.package is not None + and isinstance(writer, SkillManagementWriter) + ): + command = await writer.canonical_content(connection, scope_id, command) + return _stored_body(family, command) + + +def _stored_body(family: str, content: BaseModel) -> object: + if family == "profile": + if isinstance(content, ProfileContent | ProfileWriteContent): + return content.content + raise InvalidBaseAccessRequestError("content", "does not match the profile model") + if family == "topic-memory": + topic = content.content if isinstance(content, TopicMemoryProjection) else content + if not isinstance(topic, TopicMemoryContent): + raise InvalidBaseAccessRequestError("content", "does not match the topic memory model") + return (topic.title, topic.summary, topic.detail) + if family == "handoff": + return content.model_dump(mode="json", by_alias=True, exclude={"generation"}) + if family == "skill" and isinstance(content, SkillContent) and content.package is None: + # Legacy instruction-only revisions and new ones compare in their packaged form. + content = build_instruction_skill_package(content).as_skill_content() + return content.model_dump(mode="json", by_alias=True) diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index 5578edb9d6..ded0c1e128 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -38,6 +38,7 @@ MAX_ARTIFACT_FAMILY_LENGTH, MAX_ARTIFACT_HANDLE_KEY_LENGTH, MAX_ARTIFACT_ID_LENGTH, + MAX_ARTIFACT_REVISION_ACTOR_LENGTH, MAX_BINDING_NAME_LENGTH, MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH, MAX_EXTERNAL_SKILL_HOST_ID_LENGTH, @@ -196,6 +197,10 @@ def _entry_text_type(): Column("artifact_id", identity_string(MAX_ARTIFACT_ID_LENGTH), primary_key=True), Column("revision", Integer, primary_key=True), Column("content", _canonical_payload_type(), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=True), + Column("created_by", String(MAX_ARTIFACT_REVISION_ACTOR_LENGTH), nullable=True), + Column("restored_from_revision", Integer, nullable=True), + Column("rollback_reason", String(1024), nullable=True), ) ARTIFACT_HANDLES_TABLE = Table( diff --git a/src/powercontext/builtin/records.py b/src/powercontext/builtin/records.py index e6fa6713f1..b81f02822f 100644 --- a/src/powercontext/builtin/records.py +++ b/src/powercontext/builtin/records.py @@ -56,11 +56,20 @@ class SourceRecordPage(_RecordModel): next_cursor: str | None +class ArtifactRevisionActor(_RecordModel): + """Principal that created a rollback revision. Ordinary revisions omit it.""" + + type: Literal["user", "service"] + id: str + + class ArtifactWrite(_RecordModel): """Complete family-specific content for one Artifact write.""" content: dict[str, JsonValue] prompt_key: str | None = None + restored_from_revision: int | None = None + reason: str | None = None class ArtifactCreated(_RecordModel): @@ -85,6 +94,10 @@ class ArtifactRecord(_RecordModel): sources: tuple[SourceRef, ...] artifacts: tuple[ArtifactRef, ...] content_digest: str + created_at: datetime | None = None + created_by: ArtifactRevisionActor | None = None + restored_from_revision: int | None = None + reason: str | None = None class ArtifactCollectionItem(_RecordModel): @@ -101,6 +114,10 @@ class ArtifactCollectionItem(_RecordModel): summary: str | None = None published_at: datetime | None = None source_count: int | None = None + created_at: datetime | None = None + created_by: ArtifactRevisionActor | None = None + restored_from_revision: int | None = None + reason: str | None = None class LogicalArtifactRecord(_RecordModel): @@ -362,6 +379,8 @@ async def replace_artifact( write: ArtifactWrite, /, *, + actor_type: str | None = None, + actor_id: str | None = None, execution_context: Any = None, ) -> ArtifactRecord: ... diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index c2a48063c0..61b25e51eb 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -753,6 +753,8 @@ async def replace_artifact( write: ArtifactWrite, /, *, + actor_type: str | None = None, + actor_id: str | None = None, execution_context: Any = None, ) -> ArtifactRecord: async with self._runtime._scope_operation(self.scope_id), self._runtime._locked(self.scope_id): @@ -762,6 +764,8 @@ async def replace_artifact( artifact_id, expected_etag, write, + actor_type=actor_type, + actor_id=actor_id, execution_context=execution_context, ) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 6f6631ab45..4f923a00a1 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -115,6 +115,7 @@ assert_processing_schema_ready, bootstrap_processing_schema, ) +from powercontext.builtin.persistence.revision_metadata_schema import ensure_artifact_revision_metadata_schema from powercontext.builtin.persistence.scope_search_schema import ensure_scope_search_schema from powercontext.builtin.persistence.seekdb.profile import SeekDBConfig, SeekDBProfile from powercontext.builtin.persistence.skill_distribution_schema import ensure_skill_distribution_schema @@ -1107,6 +1108,7 @@ async def open_builtin_contexts( await ensure_skill_distribution_schema(connection) await ensure_topic_memory_tag_schema(connection) await ensure_scope_search_schema(connection) + await ensure_artifact_revision_metadata_schema(connection) # A Topic child reuses its parent's schema. It never reads or # writes Memory/Experience projections; rebuilding their FTS # indexes here would take the shared SQLite write lock once @@ -1190,6 +1192,7 @@ async def open_builtin_contexts( await ensure_skill_distribution_schema(connection) await ensure_topic_memory_tag_schema(connection) await ensure_scope_search_schema(connection) + await ensure_artifact_revision_metadata_schema(connection) if not _topic_memory_worker: await _initialize_atomic_memory_authority(connection) await atomic_index.initialize(connection) diff --git a/src/powercontext/http/_generated/models.py b/src/powercontext/http/_generated/models.py index 2b6b46713c..abbf0c3af8 100644 --- a/src/powercontext/http/_generated/models.py +++ b/src/powercontext/http/_generated/models.py @@ -260,6 +260,22 @@ class ReplaceProfileArtifactRequest(BaseModel): extra="forbid", ) content: ProfileWriteContent + restored_from_revision: Annotated[ + StrictInt | None, + Field( + description="Historical Profile revision restored by this replacement. Must match content.restored_from_revision when both are set.", + ge=1, + ), + ] = None + reason: Annotated[ + StrictStr | None, + Field( + description="Required when restored_from_revision or content.restored_from_revision is set.", + max_length=1024, + min_length=1, + pattern=".*\\S.*", + ), + ] = None class ArtifactReference(BaseModel): @@ -2344,6 +2360,8 @@ class ReplaceTopicMemoryArtifactRequest(BaseModel): extra="forbid", ) content: TopicMemoryWriteContent + restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None + reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None class Family3(StrEnum): @@ -3649,6 +3667,10 @@ class ArtifactCollectionItem(BaseModel): source_count: Annotated[ StrictInt | None, Field(description="Family-provided number of direct Source inputs when available.", ge=0) ] = None + created_at: AwareDatetime | None = None + created_by: AccessPrincipal | None = None + restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None + reason: Annotated[StrictStr | None, Field(max_length=1024)] = None class ArtifactCreated(BaseModel): @@ -3682,6 +3704,17 @@ class ArtifactRevision(BaseModel): sources: list[SourceTypeReference] artifacts: list[ArtifactReference] content_digest: Annotated[StrictStr, Field(pattern="^sha256:[0-9a-f]{64}$")] + created_at: Annotated[ + AwareDatetime | None, + Field(description="When this revision was committed. Absent for revisions stored before this field existed."), + ] = None + created_by: Annotated[ + AccessPrincipal | None, Field(description="Principal that committed a rollback. Ordinary revisions omit it.") + ] = None + restored_from_revision: Annotated[ + StrictInt | None, Field(description="Historical revision restored by this rollback.", ge=1) + ] = None + reason: Annotated[StrictStr | None, Field(description="Reason recorded for a rollback.", max_length=1024)] = None class HandoffReceiptIdentity(BaseModel): @@ -4505,6 +4538,8 @@ class ReplaceExperienceArtifactRequest(BaseModel): extra="forbid", ) content: ExperienceProposal + restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None + reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None class ReplaceSkillArtifactRequest(BaseModel): @@ -4512,6 +4547,8 @@ class ReplaceSkillArtifactRequest(BaseModel): extra="forbid", ) content: SkillProposal + restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None + reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None class SourceRecord(BaseModel): @@ -4728,6 +4765,8 @@ class ReplacePromptArtifactRequest(BaseModel): extra="forbid", ) content: PromptContent + restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None + reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None class ReplaceMemoryArtifactRequest(BaseModel): @@ -5007,6 +5046,8 @@ class ReplaceHandoffArtifactRequest(BaseModel): extra="forbid", ) content: HandoffContent + restored_from_revision: Annotated[StrictInt | None, Field(ge=1)] = None + reason: Annotated[StrictStr | None, Field(max_length=1024, min_length=1, pattern=".*\\S.*")] = None class Candidate(BaseModel): diff --git a/src/powercontext/http/_generated/schema.py b/src/powercontext/http/_generated/schema.py index 66ecb9e521..78709584c6 100644 --- a/src/powercontext/http/_generated/schema.py +++ b/src/powercontext/http/_generated/schema.py @@ -4340,7 +4340,64 @@ "head " "without " "semantic " - "generation.", + "generation. " + "Profile " + "replacement " + "uses " + "the " + "same " + "conditional " + "write. " + "To " + "roll " + "back " + "Profile, " + "Prompt, " + "Experience, " + "Skill, " + "Handoff, " + "or " + "Topic " + "Memory, " + "set " + "restored_from_revision " + "and " + "reason. " + "The " + "server " + "checks " + "that " + "the " + "submitted " + "canonical " + "content " + "matches " + "that " + "revision " + "and " + "differs " + "from " + "the " + "current " + "head, " + "then " + "commits " + "a " + "new " + "revision. " + "Atomic " + "Memory " + "replacement " + "rejects " + "both " + "fields; " + "restore " + "Atomic " + "Memory " + "through " + "its " + "restoration " + "flow.", "operationId": "replace_artifact", "x-powercontext-scope-mode": "current", "x-powercontext-access": {"resolver": "path_artifact_write_access"}, @@ -4365,6 +4422,7 @@ "handoff", "prompt", "topic-memory", + "profile", ], }, }, @@ -5801,7 +5859,36 @@ "required": ["family", "content"], }, "ReplaceProfileArtifactRequest": { - "properties": {"content": {"$ref": "#/components/schemas/ProfileWriteContent"}}, + "properties": { + "content": {"$ref": "#/components/schemas/ProfileWriteContent"}, + "restored_from_revision": { + "type": "integer", + "minimum": 1.0, + "description": "Historical " + "Profile " + "revision " + "restored " + "by " + "this " + "replacement. " + "Must " + "match " + "content.restored_from_revision " + "when " + "both " + "are " + "set.", + "nullable": True, + }, + "reason": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "pattern": ".*\\S.*", + "description": "Required when restored_from_revision or content.restored_from_revision is set.", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", "required": ["content"], @@ -5861,6 +5948,10 @@ "description": "Family-provided number of direct Source inputs when available.", "nullable": True, }, + "created_at": {"type": "string", "format": "date-time", "nullable": True}, + "created_by": {"allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], "nullable": True}, + "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "reason": {"type": "string", "maxLength": 1024, "nullable": True}, }, "type": "object", "required": ["scope_id", "family", "artifact_id", "revision", "sources", "artifacts", "content_digest"], @@ -5904,6 +5995,41 @@ "sources": {"items": {"$ref": "#/components/schemas/SourceTypeReference"}, "type": "array"}, "artifacts": {"items": {"$ref": "#/components/schemas/ArtifactReference"}, "type": "array"}, "content_digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When " + "this " + "revision " + "was " + "committed. " + "Absent " + "for " + "revisions " + "stored " + "before " + "this " + "field " + "existed.", + "nullable": True, + }, + "created_by": { + "allOf": [{"$ref": "#/components/schemas/AccessPrincipal"}], + "description": "Principal that committed a rollback. Ordinary revisions omit it.", + "nullable": True, + }, + "restored_from_revision": { + "type": "integer", + "minimum": 1.0, + "description": "Historical revision restored by this rollback.", + "nullable": True, + }, + "reason": { + "type": "string", + "maxLength": 1024, + "description": "Reason recorded for a rollback.", + "nullable": True, + }, }, "type": "object", "required": [ @@ -10548,7 +10674,17 @@ "required": ["family", "content"], }, "ReplaceTopicMemoryArtifactRequest": { - "properties": {"content": {"$ref": "#/components/schemas/TopicMemoryWriteContent"}}, + "properties": { + "content": {"$ref": "#/components/schemas/TopicMemoryWriteContent"}, + "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "reason": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", "required": ["content"], @@ -10921,7 +11057,17 @@ ] }, "ReplacePromptArtifactRequest": { - "properties": {"content": {"$ref": "#/components/schemas/PromptContent"}}, + "properties": { + "content": {"$ref": "#/components/schemas/PromptContent"}, + "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "reason": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", "required": ["content"], @@ -11193,19 +11339,49 @@ "required": ["kind", "text"], }, "ReplaceExperienceArtifactRequest": { - "properties": {"content": {"$ref": "#/components/schemas/ExperienceProposal"}}, + "properties": { + "content": {"$ref": "#/components/schemas/ExperienceProposal"}, + "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "reason": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", "required": ["content"], }, "ReplaceSkillArtifactRequest": { - "properties": {"content": {"$ref": "#/components/schemas/SkillProposal"}}, + "properties": { + "content": {"$ref": "#/components/schemas/SkillProposal"}, + "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "reason": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", "required": ["content"], }, "ReplaceHandoffArtifactRequest": { - "properties": {"content": {"$ref": "#/components/schemas/HandoffContent"}}, + "properties": { + "content": {"$ref": "#/components/schemas/HandoffContent"}, + "restored_from_revision": {"type": "integer", "minimum": 1.0, "nullable": True}, + "reason": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "pattern": ".*\\S.*", + "nullable": True, + }, + }, "additionalProperties": False, "type": "object", "required": ["content"], diff --git a/src/powercontext/limits.py b/src/powercontext/limits.py index 0feece8a39..1897a293b4 100644 --- a/src/powercontext/limits.py +++ b/src/powercontext/limits.py @@ -28,6 +28,7 @@ MAX_SOURCE_OBSERVATION_BYTES = 4 * 1024 * 1024 MAX_ARTIFACT_FAMILY_LENGTH = 128 MAX_ARTIFACT_ID_LENGTH = 128 +MAX_ARTIFACT_REVISION_ACTOR_LENGTH = 2_048 MAX_BINDING_NAME_LENGTH = 128 MAX_EXTERNAL_SKILL_NAME_LENGTH = 128 MAX_EXTERNAL_SKILL_DESCRIPTION_LENGTH = 2_000 diff --git a/src/powercontext/server/app.py b/src/powercontext/server/app.py index 2bf4e90044..62b23b1c24 100644 --- a/src/powercontext/server/app.py +++ b/src/powercontext/server/app.py @@ -172,6 +172,7 @@ ) from powercontext.builtin.records import ( ArtifactAlreadyExistsError, + ArtifactRevisionActor, ArtifactRevisionPreconditionError, BaseAccessError, BaseOperationNotSupportedError, @@ -942,6 +943,9 @@ async def replace_artifact( expected_etag: str, write: RuntimeArtifactWrite, /, + *, + actor_type: str | None = None, + actor_id: str | None = None, ) -> RuntimeArtifactRecord: ... @@ -2811,11 +2815,14 @@ async def replace_artifact( if family.value == "memory": raise BaseOperationNotSupportedError("artifact_family", "memory", "collection writes") expected_etag = _require_artifact_etag(if_match) + principal = current_principal() result = await application.records.for_scope(scope_id).replace_artifact( family.value, artifact_id, expected_etag, _artifact_write(request), + actor_type=None if principal is None else principal.type, + actor_id=None if principal is None else principal.id, **( {"execution_context": _atomic_execution_context(http_request, REPLACE_ARTIFACT.operation_id)} if family.value == "atomic-memory" @@ -2864,6 +2871,12 @@ def _source_record_response(value: RuntimeSourceRecord) -> SourceRecord: ) +def _transport_actor(actor: ArtifactRevisionActor | None) -> TransportAccessPrincipal | None: + if actor is None: + return None + return TransportAccessPrincipal(type=actor.type, id=actor.id) + + def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevision: return ArtifactRevision( scope_id=value.scope_id, @@ -2874,6 +2887,10 @@ def _artifact_revision_response(value: RuntimeArtifactRecord) -> ArtifactRevisio sources=[mapping.source_type_reference(ref) for ref in value.sources], artifacts=[mapping.artifact_reference(ref) for ref in value.artifacts], content_digest=value.content_digest, + created_at=value.created_at, + created_by=_transport_actor(value.created_by), + restored_from_revision=value.restored_from_revision, + reason=value.reason, ) @@ -2901,6 +2918,10 @@ def _artifact_collection_item_response(value: RuntimeArtifactCollectionItem) -> summary=value.summary, published_at=value.published_at, source_count=value.source_count, + created_at=value.created_at, + created_by=_transport_actor(value.created_by), + restored_from_revision=value.restored_from_revision, + reason=value.reason, ) @@ -2910,6 +2931,8 @@ def _artifact_write(value: CreateArtifactRequest | ReplaceArtifactRequest) -> Ru content = mapping.runtime_handoff_content(content) return RuntimeArtifactWrite( prompt_key=value.root.prompt_key.value if isinstance(value.root, CreatePromptArtifactRequest) else None, + restored_from_revision=getattr(value.root, "restored_from_revision", None), + reason=getattr(value.root, "reason", None), content=cast( dict[str, JsonValue], content.model_dump( diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py index 6a51f66283..588548f15b 100644 --- a/src/powercontext/server/dashboard/api.py +++ b/src/powercontext/server/dashboard/api.py @@ -67,11 +67,7 @@ def __init__(self, request: Request) -> None: ) async def read(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: - try: - async with asyncio.timeout(20): - result = await self.client.request("GET" if payload is None else "POST", path, json=payload) - except (TimeoutError, httpx.HTTPError) as error: - raise ReadError(503, "service_unavailable") from error + result = await self.send("GET" if payload is None else "POST", path, payload) if result.is_error: code = "service_unavailable" with suppress(ValueError, KeyError, TypeError): @@ -79,6 +75,19 @@ async def read(self, path: str, payload: dict[str, Any] | None = None) -> dict[s raise ReadError(result.status_code, code, result.headers.get("X-PowerContext-Request-ID")) return result.json() + async def send( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + try: + async with asyncio.timeout(20): + return await self.client.request(method, path, json=payload, headers=headers) + except (TimeoutError, httpx.HTTPError) as error: + raise ReadError(503, "service_unavailable") from error + async def record(self, scope: str, family: str, artifact: str, revision: int) -> dict[str, Any]: if family in {"experience", "skill"}: value = await self.read( diff --git a/src/powercontext/server/dashboard/content.py b/src/powercontext/server/dashboard/content.py index e72deb4257..9234e4b9ef 100644 --- a/src/powercontext/server/dashboard/content.py +++ b/src/powercontext/server/dashboard/content.py @@ -25,6 +25,7 @@ from powercontext.server.dashboard.navigation import positive_revision from powercontext.server.dashboard.pagination import PAGE_SIZE, cursor_links, list_links, list_page from powercontext.server.dashboard.presenters import memory_view, usage_view +from powercontext.server.dashboard.revisions import load_history RECORDS = {"handoff-detail": "handoff", "experience": "experience", "skill": "skill"} @@ -147,6 +148,15 @@ async def load_record(api: DashboardAPI, request: Request, ctx: dict[str, Any]) ctx["data"][family] = record ctx["source_record"] = record ctx["related_sources"] = [source for source in record["sources"] if source["source_type"] == "content"] + ctx["history"] = await load_history( + api, + request, + ctx, + family=family, + artifact_id=artifact, + selected=revision_number, + cursor_key=f"{family}_revision_cursor", + ) async def load_content(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None: @@ -160,6 +170,7 @@ async def load_content(api: DashboardAPI, request: Request, ctx: dict[str, Any]) elif page == "notes": await load_notes(api, request, ctx) await select_note(api, request, ctx) + await _load_atomic_memory_history(api, request, ctx) elif page == "handoff": await load_collection(api, request, ctx, "handoff") elif page == "methods": @@ -177,7 +188,7 @@ async def load_additional_page(api: DashboardAPI, request: Request, ctx: dict[st elif page == "profile": await load_profile(api, request, ctx) elif page == "prompts": - await load_prompts(api, ctx) + await load_prompts(api, request, ctx) elif page in RECORDS: await load_record(api, request, ctx) @@ -217,9 +228,19 @@ async def load_topics(api: DashboardAPI, request: Request, ctx: dict[str, Any]) ctx["errors"]["topic_memory_selected"] = ReadError(422, "invalid_request") except ReadError as error: ctx["errors"]["topic_memory_selected"] = error + if ctx["topic_artifact"] and ctx["topic_revision"] and ctx["topic_revision"].isdigit(): + ctx["history"] = await load_history( + api, + request, + ctx, + family="topic-memory", + artifact_id=ctx["topic_artifact"], + selected=int(ctx["topic_revision"]), + cursor_key="topic_revision_cursor", + ) -async def load_prompts(api: DashboardAPI, ctx: dict[str, Any]) -> None: +async def load_prompts(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None: """Load the scoped Prompt configurations exposed by the Prompt Dashboard.""" keys = ( "atomic_memory.extract", @@ -236,13 +257,59 @@ async def load_prompts(api: DashboardAPI, ctx: dict[str, Any]) -> None: ctx["data"]["prompts"].append(value) except ReadError as error: ctx["errors"].setdefault("prompts", error) + selected_key = request.query_params.get("prompt_key") + if selected_key: + try: + configuration = await api.prompt_configuration(ctx["scope"], selected_key) + except ReadError: + configuration = None + artifact = None if configuration is None else configuration.get("artifact") + if artifact and str(artifact.get("revision", "")).isdigit(): + selected = request.query_params.get("prompt_revision") or str(artifact["revision"]) + if str(selected).isdigit(): + ctx["prompt_key"] = selected_key + try: + ctx["prompt_opened"] = await api.artifact_revision( + ctx["scope"], "prompt", selected_key, int(selected) + ) + except ReadError as error: + # Showing the current configuration here would mislabel it as the requested revision. + ctx["errors"]["prompt_selected"] = error + return + ctx["history"] = await load_history( + api, + request, + ctx, + family="prompt", + artifact_id=selected_key, + selected=int(selected), + cursor_key="prompt_revision_cursor", + ) + + +async def _load_atomic_memory_history(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None: + """Show the selected Atomic Memory's own revisions. Restoration uses its own preview flow.""" + + note = ctx.get("selected_note") + ref = note.get("artifact") if isinstance(note, dict) else None + if not isinstance(ref, dict): + return + ctx["history"] = await load_history( + api, + request, + ctx, + family="atomic-memory", + artifact_id=ref["artifact_id"], + selected=ref["revision"], + cursor_key="notes_revision_cursor", + ) async def load_profile(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None: query = request.query_params revision = query.get("revision") view = query.get("view") - if view is not None and (view != "history" or revision is not None): + if view is not None and view != "history": raise ReadError(422, "invalid_request") scope = ctx["scope"] base = f"/v1/scopes/{segment(scope)}/artifacts/profile/profile" @@ -255,16 +322,24 @@ async def load_profile(api: DashboardAPI, request: Request, ctx: dict[str, Any]) if not heads["items"]: return selected_revision = str(heads["items"][0]["revision"]) + params = {"limit": str(PAGE_SIZE)} + if view == "history" and "profile_cursor" in query: + params["cursor"] = query["profile_cursor"] + result = await api.read(base + "/revisions?" + urlencode(params)) + ctx["profile_revisions"] = result["items"] if view == "history": - params = {"limit": str(PAGE_SIZE)} - if "profile_cursor" in query: - params["cursor"] = query["profile_cursor"] - result = await api.read(base + "/revisions?" + urlencode(params)) - ctx["profile_revisions"] = result["items"] ctx["profile_pager"] = cursor_links(request, ctx, "profile", result["next_cursor"]) - return record = await api.record(scope, "profile", "profile", positive_revision(selected_revision)) ctx["data"]["profile"] = record ctx["profile_html"] = profile_html(record["content"]) ctx["source_record"] = record ctx["related_sources"] = [source for source in record["sources"] if source["source_type"] == "content"] + ctx["history"] = await load_history( + api, + request, + ctx, + family="profile", + artifact_id="profile", + selected=positive_revision(selected_revision), + cursor_key="profile_cursor" if view == "history" else "profile_revision_cursor", + ) diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json index 8b045c4a12..b060ba0d79 100644 --- a/src/powercontext/server/dashboard/labels.en.json +++ b/src/powercontext/server/dashboard/labels.en.json @@ -248,6 +248,24 @@ "profile_current": "Return to current profile", "profile_historical": "Historical profile", "profile_history_empty": "No historical revisions", + "revision_history": "Revision history", + "revision_current": "Current", + "revision_rollback_badge": "Rollback", + "revision_unknown_time": "Time unknown", + "revision_compare": "Compare", + "revision_compare_missing": "That revision could not be compared.", + "revision_other": "Other revision", + "revision_rollback": "Roll back", + "revision_reason": "Reason", + "revision_confirm": "Confirm rollback", + "revision_actor": "Actor", + "revision_from": "Restored from", + "revision_conflict": "The current revision changed. Open it again before retrying.", + "revision_rejected": "This rollback was not accepted.", + "revision_forbidden": "You cannot roll back this artifact.", + "handoff_rollback_notice": "This creates a new revision. It does not mean the previous next action still applies.", + "revision_same": "The content matches the current revision, so rollback is unavailable.", + "revision_empty": "No revisions yet", "profile_generation_mode": "Generation mode", "profile_created_at": "Generated at", "profile_source_window": "Source window", diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json index 0eea5445dc..5e966a7598 100644 --- a/src/powercontext/server/dashboard/labels.json +++ b/src/powercontext/server/dashboard/labels.json @@ -248,6 +248,24 @@ "profile_current": "返回当前画像", "profile_historical": "历史画像", "profile_history_empty": "暂无历史版本", + "revision_history": "版本历史", + "revision_current": "当前版本", + "revision_rollback_badge": "回滚产生", + "revision_unknown_time": "时间未知", + "revision_compare": "对比", + "revision_compare_missing": "找不到要对比的版本。", + "revision_other": "另一版本", + "revision_rollback": "回滚", + "revision_reason": "原因", + "revision_confirm": "确认回滚", + "revision_actor": "操作者", + "revision_from": "来源版本", + "revision_conflict": "当前版本已经变化,请重新打开后再试。", + "revision_rejected": "这次回滚没有被接受。", + "revision_forbidden": "没有回滚权限。", + "handoff_rollback_notice": "这会成为一条新的版本,不表示当时的下一步仍然适用。", + "revision_same": "正文与当前版本相同,不能回滚。", + "revision_empty": "暂无历史版本", "profile_generation_mode": "生成方式", "profile_created_at": "生成时间", "profile_source_window": "来源窗口", diff --git a/src/powercontext/server/dashboard/revisions.py b/src/powercontext/server/dashboard/revisions.py new file mode 100644 index 0000000000..1d5591a603 --- /dev/null +++ b/src/powercontext/server/dashboard/revisions.py @@ -0,0 +1,258 @@ +# 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. + +"""Revision history, comparison, and rollback for the Dashboard.""" + +from __future__ import annotations + +import difflib +import json +from typing import Any +from urllib.parse import urlencode + +from fastapi import Request + +from powercontext.server.dashboard.api import DashboardAPI, ReadError, segment +from powercontext.server.dashboard.pagination import PAGE_SIZE + +ROLLBACK_FAMILIES = frozenset({"profile", "prompt", "experience", "skill", "handoff", "topic-memory"}) +_SKILL_FIELDS = ( + "name", + "description", + "instructions", + "validation", + "package", + "license", + "compatibility", + "metadata", + "allowed_tools", +) +_EXPERIENCE_FIELDS = ("situation", "action", "outcome", "lesson", "failure") +_HANDOFF_FIELDS = ("objective", "state", "disposition", "next_action", "omissions") +_TEXT_FIELDS = {"instructions", "detail", "objective", "situation", "action", "outcome", "lesson", "content"} + + +def canonical_view(family: str, content: dict[str, Any]) -> object: + """Return the family content used to decide whether a rollback would change the head.""" + + if family == "profile": + return content.get("content") + if family == "topic-memory": + return (content.get("title"), content.get("summary"), content.get("detail")) + if family == "prompt": + return (content.get("instructions"), content.get("demonstrations")) + if family == "experience": + return {name: content.get(name) for name in _EXPERIENCE_FIELDS} + if family == "skill": + return {name: content.get(name) for name in _SKILL_FIELDS} + if family == "handoff": + return {name: content.get(name) for name in _HANDOFF_FIELDS} + return content + + +def replace_body(family: str, content: dict[str, Any], source_revision: int, reason: str) -> dict[str, Any]: + """Build a conditional replace that restores one stored revision.""" + + if family == "profile": + payload: dict[str, Any] = {"content": content.get("content")} + elif family == "topic-memory": + payload = {name: content[name] for name in ("title", "summary", "detail")} + elif family == "handoff": + payload = _handoff_payload(content) + elif family == "experience": + payload = {name: content.get(name) for name in _EXPERIENCE_FIELDS if name in content} + elif family == "skill": + required = {"name", "description", "instructions", "validation"} + payload = {name: content.get(name) for name in _SKILL_FIELDS if name in content or name in required} + else: + payload = content + return {"content": payload, "restored_from_revision": source_revision, "reason": reason} + + +def _handoff_payload(content: dict[str, Any]) -> dict[str, Any]: + """Return handoff content in the replace contract. + + Stored revisions cite sources with ``source_type``. The replace contract names that field ``name``. + """ + + payload = { + "objective": content.get("objective"), + "state": [_handoff_statement(item) for item in content.get("state") or []], + "disposition": content.get("disposition"), + "next_action": None if content.get("next_action") is None else _handoff_statement(content["next_action"]), + "omissions": content.get("omissions") or [], + } + if "schema" in content: + payload["schema"] = content["schema"] + return payload + + +def _handoff_statement(statement: dict[str, Any]) -> dict[str, Any]: + return { + "text": statement.get("text"), + "citations": [_handoff_citation(item) for item in statement.get("citations") or []], + } + + +def _handoff_citation(citation: dict[str, Any]) -> dict[str, Any]: + kind = citation.get("kind") + if kind == "source": + ref = citation.get("source_ref") or {} + return { + "kind": "source", + "source_ref": {"name": ref.get("name") or ref.get("source_type"), "source_id": ref.get("source_id")}, + } + if kind == "artifact": + ref = citation.get("artifact_ref") or {} + return { + "kind": "artifact", + "artifact_ref": { + "family": ref.get("family"), + "artifact_id": ref.get("artifact_id"), + "revision": ref.get("revision"), + }, + } + return citation + + +def comparison(family: str, left: dict[str, Any], right: dict[str, Any]) -> list[dict[str, Any]]: + """Readable rows for two stored content objects. The left side is the opened revision.""" + + if family == "profile": + return [_text_row("content", left.get("content"), right.get("content"))] + if family == "topic-memory": + return [_text_row(name, left.get(name), right.get(name)) for name in ("title", "summary", "detail")] + if family == "prompt": + return [ + _text_row("instructions", left.get("instructions"), right.get("instructions")), + _text_row("demonstrations", left.get("demonstrations"), right.get("demonstrations")), + ] + if family == "experience": + return [_text_row(name, left.get(name), right.get(name)) for name in _EXPERIENCE_FIELDS] + if family == "skill": + return [_text_row(name, left.get(name), right.get(name)) for name in _SKILL_FIELDS] + if family == "handoff": + return [_text_row(name, left.get(name), right.get(name)) for name in _HANDOFF_FIELDS] + if family == "atomic-memory": + return [_text_row(name, left.get(name), right.get(name)) for name in ("kind", "text")] + return [_text_row("content", left, right)] + + +async def load_history( + api: DashboardAPI, + request: Request, + ctx: dict[str, Any], + *, + family: str, + artifact_id: str, + selected: int, + cursor_key: str, +) -> dict[str, Any] | None: + """Load one artifact's revision list and optional comparison.""" + + scope = ctx["scope"] + base = f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/{segment(artifact_id)}" + params = {"limit": str(PAGE_SIZE)} + if request.query_params.get(cursor_key): + params["cursor"] = request.query_params[cursor_key] + try: + page = await api.read(base + "/revisions?" + urlencode(params)) + head = await api.read(base) + except ReadError: + return None + items = page["items"] + head_revision = int(head["revision"]) + compare_raw = request.query_params.get("compare") + compare = _revision_param(compare_raw) + rows = None + compare_error = False + if compare is not None and compare != selected: + try: + left = await api.artifact_revision(scope, family, artifact_id, selected) + right = await api.artifact_revision(scope, family, artifact_id, compare) + rows = comparison(family, left["content"], right["content"]) + except ReadError: + rows = None + compare_error = True + same_content = False + if family in ROLLBACK_FAMILIES and selected != head_revision: + try: + opened = await api.artifact_revision(scope, family, artifact_id, selected) + same_content = canonical_view(family, opened["content"]) == canonical_view(family, head["content"]) + except ReadError: + same_content = False + return { + "family": family, + "artifact_id": artifact_id, + "head": head_revision, + "selected": selected, + "items": items, + "pager_key": cursor_key, + "next_cursor": page.get("next_cursor"), + "next_url": ( + ctx["link"](**{cursor_key: page["next_cursor"]}) + if page.get("next_cursor") and ctx.get("link") is not None + else None + ), + "can_rollback": family in ROLLBACK_FAMILIES and await _can_replace(request, api, scope, family, artifact_id), + "same_content": same_content, + "compare": compare, + "compare_error": compare_error, + "rows": rows, + "error": request.query_params.get("rollback_error"), + } + + +def _revision_param(value: str | None) -> int | None: + if value is None or not value.isdigit() or value.startswith("0"): + return None + number = int(value) + return number if number >= 1 else None + + +async def _can_replace(request: Request, api: DashboardAPI, scope: str, family: str, artifact_id: str) -> bool: + # Disabled mode has no access check. Treating that 503 as a denial hides rollback + # from the local deployment that is otherwise allowed to write. + if request.app.state.access_mode == "disabled": + return True + if family in {"prompt", "topic-memory"}: + requirement = {"action": "scope.admin", "resource": {"type": "scope", "scope_id": scope}} + else: + requirement = { + "action": "artifact.write", + "resource": { + "type": "artifact", + "scope_id": scope, + "identity": {"family": family, "artifact_id": artifact_id}, + "selector": None, + }, + } + try: + result = await api.read("/v1/access/check", {"match": "all", "requirements": [requirement]}) + except ReadError: + return False + return result.get("allowed") is True + + +def _text_row(name: str, left: object, right: object) -> dict[str, Any]: + left_text = left if isinstance(left, str) else json.dumps(left, ensure_ascii=False, indent=2, sort_keys=True) + right_text = right if isinstance(right, str) else json.dumps(right, ensure_ascii=False, indent=2, sort_keys=True) + diff = [] + if left_text != right_text: + for line in difflib.ndiff(left_text.splitlines(), right_text.splitlines()): + if line.startswith("? "): + continue + kind = {"+": "add", "-": "remove"}.get(line[:1], "same") + diff.append({"kind": kind, "text": line[2:] if line[:2] in {"+ ", "- ", " "} else line}) + return {"name": name, "diff": diff, "changed": left_text != right_text} diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py index 7c2aa94082..55e05d4b25 100644 --- a/src/powercontext/server/dashboard/routes.py +++ b/src/powercontext/server/dashboard/routes.py @@ -16,6 +16,7 @@ from __future__ import annotations +from collections.abc import Mapping from pathlib import Path from typing import Any from urllib.parse import urlencode @@ -38,7 +39,7 @@ from powercontext.server.dashboard.preferences import CATALOGS, presentation, remember_language from powercontext.server.dashboard.presenters import source_view from powercontext.server.dashboard.review import load_review, submit_review -from powercontext.server.dashboard.session import login_response +from powercontext.server.dashboard.session import login_response, same_origin ROOT = Path(__file__).parent LABELS = CATALOGS["zh"] @@ -95,6 +96,16 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s in { "artifact", "revision", + "compare", + "rollback_error", + "prompt_key", + "prompt_revision", + "prompt_revision_cursor", + "topic_revision_cursor", + "profile_revision_cursor", + "experience_revision_cursor", + "skill_revision_cursor", + "handoff_revision_cursor", "kind", "cursor", "experience_cursor", @@ -113,6 +124,7 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s "lang", "notes_page", "notes_cursor", + "notes_revision_cursor", "notes_history", "note_state", "skill_page", @@ -143,6 +155,7 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s if "scope" in params and params["scope"] != ctx["scope"]: query = {"scope": params["scope"], "period": ctx["period"]} else: + _drop_foreign_revision_cursors(query, params, request.query_params) query.update(params) if destination.startswith("evidence/"): destination = "evidence/" + segment(destination.removeprefix("evidence/")) @@ -154,6 +167,29 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s return link +_REVISION_CURSOR_IDENTITY = { + "topic_revision_cursor": "topic_artifact", + "prompt_revision_cursor": "prompt_key", + "experience_revision_cursor": "artifact", + "skill_revision_cursor": "artifact", + "handoff_revision_cursor": "artifact", + "notes_revision_cursor": "artifact", +} + + +def _drop_foreign_revision_cursors(query: dict[str, Any], params: dict[str, Any], current: Mapping[str, str]) -> None: + """Remove retained revision cursors that belong to an artifact the link no longer targets. + + A link that clears the selection drops the cursor too, because the page then opens a default artifact. + """ + + for cursor, identity in _REVISION_CURSOR_IDENTITY.items(): + if cursor in params or identity not in params: + continue + if params[identity] is None or params[identity] != current.get(identity): + query.pop(cursor, None) + + def reading_link_context( request: Request, ctx: dict[str, Any], destination: str, params: dict[str, Any], query: dict[str, Any] ) -> None: @@ -234,6 +270,9 @@ def initial_context(request: Request, page: str) -> dict[str, Any]: "profile_revisions": [], "profile_pager": None, "profile_html": "", + "history": None, + "prompt_key": None, + "prompt_opened": None, "return_to": collection_return( request.query_params.get("return_to"), request.query_params.get("scope", ""), @@ -393,6 +432,65 @@ async def download_handoff(request: Request) -> Response: return render(request, ctx) +@router.post("/rollback") +async def rollback_revision(request: Request) -> Response: + if not same_origin(request): + return HTMLResponse(status_code=403) + form = await request.form() + scope = str(form.get("scope") or "") + family = str(form.get("family") or "") + artifact_id = str(form.get("artifact_id") or "") + reason = str(form.get("reason") or "") + try: + source_revision = positive_revision(str(form.get("source_revision") or "")) + expected_revision = positive_revision(str(form.get("expected_revision") or "")) + except ReadError: + return RedirectResponse(f"/dashboard/home?scope={scope}", status_code=303) + api = DashboardAPI(request) + try: + stored = await api.artifact_revision(scope, family, artifact_id, source_revision) + from powercontext.server.dashboard.revisions import replace_body + + response = await api.send( + "PUT", + f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/{segment(artifact_id)}", + replace_body(family, stored["content"], source_revision, reason), + headers={"If-Match": f'"revision:{expected_revision}"'}, + ) + except ReadError as error: + code = "forbidden" if error.status == 403 else "rejected" + return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303) + finally: + await api.client.aclose() + if response.status_code == 412: + code = "revision_conflict" + elif response.status_code == 403: + code = "forbidden" + elif response.status_code != 200: + code = "rejected" + else: + created = response.json()["revision"] + return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None), status_code=303) + return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303) + + +def _rollback_return(scope: str, family: str, artifact_id: str, revision: int, error: str | None) -> str: + params: dict[str, Any] = {"scope": scope, "rollback_error": error} + if family == "profile": + page = "profile" + params["revision"] = revision + elif family == "topic-memory": + page = "topics" + params.update(topic_artifact=artifact_id, topic_revision=revision) + elif family == "prompt": + page = "prompts" + params.update(prompt_key=artifact_id, prompt_revision=revision) + else: + page = "handoff-detail" if family == "handoff" else family + params.update(artifact=artifact_id, revision=revision) + return f"/dashboard/{page}?{urlencode({key: value for key, value in params.items() if value is not None})}" + + @router.post("/review/decision") async def review_decision(request: Request) -> Response: ctx = initial_context(request, "review") diff --git a/src/powercontext/server/dashboard/session.py b/src/powercontext/server/dashboard/session.py index 71070ab5dc..1a87557b6b 100644 --- a/src/powercontext/server/dashboard/session.py +++ b/src/powercontext/server/dashboard/session.py @@ -26,6 +26,16 @@ COOKIE_NAME = "powercontext_dashboard_token" +def same_origin(request: Request) -> bool: + """Return whether the browser Origin matches this request's host and scheme.""" + + origin = request.headers.get("origin") + if not origin: + return False + parsed = urlsplit(origin) + return parsed.netloc == request.headers.get("host") and parsed.scheme == request.url.scheme + + def login_response( status: int = 401, *, rejected: bool = False, request: Request | None = None, next_url: str | None = None ) -> HTMLResponse: @@ -57,12 +67,7 @@ def authentication_headers(scope: Scope) -> dict[str, str]: async def save_session(request: Request) -> HTMLResponse | RedirectResponse: - origin = request.headers.get("origin") - if ( - not origin - or urlsplit(origin).netloc != request.headers.get("host") - or urlsplit(origin).scheme != request.url.scheme - ): + if not same_origin(request): return HTMLResponse(status_code=403) length = request.headers.get("content-length", "0") if not length.isdecimal() or len(length) > 6 or int(length) > 32768: diff --git a/src/powercontext/server/dashboard/templates/components/revision-history.html b/src/powercontext/server/dashboard/templates/components/revision-history.html new file mode 100644 index 0000000000..93318febf0 --- /dev/null +++ b/src/powercontext/server/dashboard/templates/components/revision-history.html @@ -0,0 +1,114 @@ + +{% if history %} +
+

{{ t.revision_history }}

+
+ {% for item in history["items"] %} +
+
+
+ {{ t.revision }} {{ item.revision }} + {% if item.revision == history.head %}{{ t.revision_current }}{% endif %} + {% if item.restored_from_revision %}{{ t.revision_rollback_badge }}{% endif %} +
+ {% if item.created_at %}{{ item.created_at }}{% else %}{{ t.revision_unknown_time }}{% endif %} + {% if item.restored_from_revision %} · {{ t.revision_from }} {{ item.restored_from_revision }}{% endif %} + {% if item.created_by %} · {{ t.revision_actor }} {{ item.created_by.type }}:{{ item.created_by.id }}{% endif %} +
+ {% if item.reason %}
{{ item.reason }}
{% endif %} +
+
+ {% if history.family == "profile" %} + {{ t.revision }} {{ item.revision }} + {% elif history.family == "topic-memory" %} + {{ t.revision }} {{ item.revision }} + {% elif history.family == "prompt" %} + {{ t.revision }} {{ item.revision }} + {% elif history.family == "atomic-memory" %} + {{ t.revision }} {{ item.revision }} + {% else %} + {{ t.revision }} {{ item.revision }} + {% endif %} + {% if item.revision != history.selected %} + {{ t.revision_compare }} + {% endif %} +
+
+
+ {% else %} +
{{ t.revision_empty }}
+ {% endfor %} +
+ {% if history.next_url %} + + {% endif %} + {% if history.error == "revision_conflict" %}
{{ t.revision_conflict }}
+ {% elif history.error == "forbidden" %}
{{ t.revision_forbidden }}
+ {% elif history.error %}
{{ t.revision_rejected }}
{% endif %} + {% if history.compare_error %}
{{ t.revision_compare_missing }}
{% endif %} + {% if history.rows %} +
+

{{ t.revision_compare }} · {{ history.selected }} / {{ history.compare }}

+ {% for row in history.rows %} +

{{ row.name }}

+ {% if row.changed %} +
{% for line in row.diff %}{% if line.kind == "add" %}+ {% elif line.kind == "remove" %}- {% else %}  {% endif %}{{ line.text }}
+{% endfor %}
+ {% else %} +

{{ t.revision_same }}

+ {% endif %} + {% endfor %} +
+ {% endif %} + {% if history.selected != history.head %} +
+ + {% if history.family == "profile" %}{% endif %} + {% if history.family not in ["profile", "prompt", "topic-memory"] %} + + + {% endif %} + {% if history.family == "topic-memory" %} + + + {% endif %} + {% if history.family == "prompt" %} + + + {% endif %} + + + +
+ {% endif %} + {% if history.can_rollback and history.selected != history.head and not history.same_content %} +
+ + + + + + {% if history.family == "handoff" %}

{{ t.handoff_rollback_notice }}

{% endif %} + + + +
+ {% elif history.same_content and history.selected != history.head %} +
{{ t.revision_same }}
+ {% endif %} +
+{% endif %} diff --git a/src/powercontext/server/dashboard/templates/experience.html b/src/powercontext/server/dashboard/templates/experience.html index b7b2da2867..ebafb1dacb 100644 --- a/src/powercontext/server/dashboard/templates/experience.html +++ b/src/powercontext/server/dashboard/templates/experience.html @@ -38,6 +38,7 @@

{{ data.experience.lesson }}

+{% include 'components/revision-history.html' %} {% include 'components/evidence-modal.html' %} {% endif %} diff --git a/src/powercontext/server/dashboard/templates/handoff-detail.html b/src/powercontext/server/dashboard/templates/handoff-detail.html index 83625afc71..d3a04705a5 100644 --- a/src/powercontext/server/dashboard/templates/handoff-detail.html +++ b/src/powercontext/server/dashboard/templates/handoff-detail.html @@ -40,6 +40,7 @@

{{ data.handoff.objective }}

+{% include 'components/revision-history.html' %} {% include 'components/evidence-modal.html' %} {% else %}

{{ t.handoff_empty }}

{{ t.handoff_empty_body }}

diff --git a/src/powercontext/server/dashboard/templates/notes.html b/src/powercontext/server/dashboard/templates/notes.html index b9296a2bac..6beeb5ca4e 100644 --- a/src/powercontext/server/dashboard/templates/notes.html +++ b/src/powercontext/server/dashboard/templates/notes.html @@ -61,5 +61,6 @@ +{% include 'components/revision-history.html' %} {% endif %} diff --git a/src/powercontext/server/dashboard/templates/profile.html b/src/powercontext/server/dashboard/templates/profile.html index 28e876698b..45eb3fcb44 100644 --- a/src/powercontext/server/dashboard/templates/profile.html +++ b/src/powercontext/server/dashboard/templates/profile.html @@ -36,7 +36,8 @@

{{ t.profile }} · {{ t.profile_history }}

{% endfor %} {{ pagination(profile_pager, t) }} -{% elif data.profile %} +{% endif %} +{% if data.profile %}
@@ -63,7 +64,8 @@

{{ t.profile_historical if request_revision else t.profile }}

{% include 'components/evidence-modal.html' %} -{% else %} + {% include 'components/revision-history.html' %} +{% elif not profile_history_view %}

{{ t.profile }}

{{ t.profile_empty }}

{{ t.profile_empty_body }}

{% endif %} diff --git a/src/powercontext/server/dashboard/templates/prompts.html b/src/powercontext/server/dashboard/templates/prompts.html index dd6beea1f7..cfbe2927d1 100644 --- a/src/powercontext/server/dashboard/templates/prompts.html +++ b/src/powercontext/server/dashboard/templates/prompts.html @@ -26,10 +26,17 @@

{{ t.get(prompt.prompt_key, prompt.prompt_key) }}

{{ prompt.prompt_key }}
{{ t.prompt_status }}: {{ prompt.status }}
{{ t.prompt_mode }}: {{ t.prompt_custom if prompt.mode == 'custom' else t.prompt_auto }}
-
{{ t.prompt_revision }}
{{ prompt.artifact.revision if prompt.artifact else t.prompt_auto }}
{{ t.prompt_demonstrations }}
{{ prompt.effective.demonstrations|length if prompt.effective else 0 }}
- {% if prompt.effective %}

{{ t.prompt_instructions }}

{{ prompt.effective.instructions }}
{% elif prompt.builtin %}

{{ t.prompt_instructions }}

{{ prompt.builtin.instructions }}
{% endif %} + {% set opened = prompt_opened if prompt_opened and prompt.prompt_key == prompt_key else none %} + {% if prompt.prompt_key == prompt_key and errors.get('prompt_selected') %} + {{ read_error(errors.prompt_selected, t.prompt_revision) }} + {% else %} +
{{ t.prompt_revision }}
{{ opened.revision if opened else (prompt.artifact.revision if prompt.artifact else t.prompt_auto) }}
{{ t.prompt_demonstrations }}
{{ opened.content.demonstrations|length if opened else (prompt.effective.demonstrations|length if prompt.effective else 0) }}
+ {% if prompt.artifact %}

{{ t.revision_history }}

{% endif %} + {% if opened %}

{{ t.prompt_instructions }}

{{ opened.content.instructions }}
{% elif prompt.effective %}

{{ t.prompt_instructions }}

{{ prompt.effective.instructions }}
{% elif prompt.builtin %}

{{ t.prompt_instructions }}

{{ prompt.builtin.instructions }}
{% endif %} + {% endif %}
{% endfor %} + {% include 'components/revision-history.html' %} {% endif %} diff --git a/src/powercontext/server/dashboard/templates/skill.html b/src/powercontext/server/dashboard/templates/skill.html index 67118cfcb1..83f81cbed4 100644 --- a/src/powercontext/server/dashboard/templates/skill.html +++ b/src/powercontext/server/dashboard/templates/skill.html @@ -32,6 +32,7 @@ +{% include 'components/revision-history.html' %} {% include 'components/evidence-modal.html' %} {% else %}

{{ t.methods_empty }}

{{ t.methods_empty_body }}

diff --git a/src/powercontext/server/dashboard/templates/topics.html b/src/powercontext/server/dashboard/templates/topics.html index ce766f916d..84b060cbe1 100644 --- a/src/powercontext/server/dashboard/templates/topics.html +++ b/src/powercontext/server/dashboard/templates/topics.html @@ -42,5 +42,6 @@

{{ topic.summary }}

{{ t.full_detail }}

{{ topic.detail }}

{{ t.source_references }}

{% if topic.source_refs %}
    {% for source in topic.source_refs %}
  • {{ source.name }}/{{ source.source_id }}
  • {% endfor %}
{% else %}

{{ t.no_sources }}

{% endif %} {% else %}

{{ t.select_topic }}

{{ t.select_topic_hint }}

{% endif %} + {% include 'components/revision-history.html' %} diff --git a/tests/builtin/persistence/test_atomic_memory_startup_legacy_schema.py b/tests/builtin/persistence/test_atomic_memory_startup_legacy_schema.py index 1a511f6378..b1d9cf1856 100644 --- a/tests/builtin/persistence/test_atomic_memory_startup_legacy_schema.py +++ b/tests/builtin/persistence/test_atomic_memory_startup_legacy_schema.py @@ -82,7 +82,9 @@ async def scenario() -> None: await add_legacy_citation_columns(connection) # Frozen legacy payloads retain the old collection and entry identities. await connection.execute( - text("INSERT INTO pc_artifacts VALUES ('project', 'memory', 'memory', 1, :content, NULL)"), + text( + "INSERT INTO pc_artifacts (scope_id, family, artifact_id, revision, content, memory_citations) VALUES ('project', 'memory', 'memory', 1, :content, NULL)" + ), {"content": content}, ) await connection.execute(text("INSERT INTO pc_artifact_heads VALUES ('project', 'memory', 'memory', 1)")) diff --git a/tests/builtin/persistence/test_prompt_records.py b/tests/builtin/persistence/test_prompt_records.py index 49f3f8de83..71a849ebb8 100644 --- a/tests/builtin/persistence/test_prompt_records.py +++ b/tests/builtin/persistence/test_prompt_records.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import json from dataclasses import replace import pytest @@ -25,16 +26,20 @@ from powercontext.builtin.persistence.artifacts import ArtifactRepository from powercontext.builtin.persistence.family_management import FamilyManagementWriterRegistry, PromptManagementWriter from powercontext.builtin.persistence.records import RelationalRecordService +from powercontext.builtin.persistence.rollback import merged_source_revision from powercontext.builtin.persistence.sources import SourceRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile -from powercontext.builtin.persistence.tables import BUILTIN_TABLES +from powercontext.builtin.persistence.tables import ARTIFACTS_TABLE, BUILTIN_TABLES from powercontext.builtin.records import ( + ArtifactRevisionActor, ArtifactRevisionPreconditionError, ArtifactWrite, BaseValueConflictError, + InvalidBaseAccessRequestError, InvalidCursorError, ) from powercontext.builtin.sources import CONTENT_SOURCE_ADAPTER +from powercontext.limits import MAX_ARTIFACT_REVISION_ACTOR_LENGTH def _records(profile: SQLiteProfile, registry: PromptRegistry | None = None) -> RelationalRecordService: @@ -198,3 +203,129 @@ async def scenario() -> None: assert recovered.content["mode"] == "auto" asyncio.run(scenario()) + + +def test_prompt_rollback_records_its_source_and_rejects_an_incomplete_restore() -> None: + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records = _records(profile) + await records.create_artifact( + "scope-a", + "prompt", + ArtifactWrite(content=_content("Keep the approved prompt."), prompt_key="memory.extract"), + ) + first = await records.get_artifact("scope-a", "prompt", "memory.extract") + updated = await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:1"', + ArtifactWrite(content=_content("Keep a later prompt.")), + ) + assert updated.created_at is not None + assert updated.created_by is None + assert updated.restored_from_revision is None + with pytest.raises(InvalidBaseAccessRequestError): + await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:2"', + ArtifactWrite(content=first.content, restored_from_revision=1), + ) + with pytest.raises(InvalidBaseAccessRequestError): + await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:2"', + ArtifactWrite(content=first.content, restored_from_revision=2, reason="Restore the current head"), + ) + with pytest.raises(InvalidBaseAccessRequestError): + await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:2"', + ArtifactWrite( + content=_content("Different text."), + restored_from_revision=1, + reason="Restore the approved prompt", + ), + ) + restored = await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:2"', + ArtifactWrite( + content=first.content, + restored_from_revision=1, + reason=" Restore the approved prompt ", + ), + actor_type="user", + actor_id="operator", + ) + assert restored.revision == 3 + assert restored.content_digest == first.content_digest + assert restored.restored_from_revision == 1 + assert restored.reason == "Restore the approved prompt" + assert restored.created_at is not None + assert restored.created_by == ArtifactRevisionActor(type="user", id="operator") + listed = await records.list_artifact_revisions("scope-a", "prompt", "memory.extract", limit=10, cursor=None) + assert listed.items[0].reason == "Restore the approved prompt" + assert listed.items[1].created_by is None + + asyncio.run(scenario()) + + +def test_rollback_actor_keeps_a_maximum_length_principal_id() -> None: + actor_id = "张" * 255 + quoted = "\\" * 255 + from sqlalchemy.dialects.mysql.base import MySQLDialect + from sqlalchemy.schema import CreateColumn + + ddl = str(CreateColumn(ARTIFACTS_TABLE.c.created_by).compile(dialect=MySQLDialect())) + assert "VARCHAR(2048)" in ddl + for value in (actor_id, quoted): + payload = json.dumps({"type": "user", "id": value}, ensure_ascii=False, separators=(",", ":")) + assert len(payload) <= MAX_ARTIFACT_REVISION_ACTOR_LENGTH + + async def scenario() -> None: + async with SQLiteProfile.open(SQLiteConfig(), tables=BUILTIN_TABLES) as profile: + records = _records(profile) + await records.create_artifact( + "scope-a", + "prompt", + ArtifactWrite(content=_content("Keep the approved prompt."), prompt_key="memory.extract"), + ) + first = await records.get_artifact("scope-a", "prompt", "memory.extract") + await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:1"', + ArtifactWrite(content=_content("Keep a later prompt.")), + ) + restored = await records.replace_artifact( + "scope-a", + "prompt", + "memory.extract", + '"revision:2"', + ArtifactWrite(content=first.content, restored_from_revision=1, reason="Restore the approved prompt"), + actor_type="user", + actor_id=actor_id, + ) + assert restored.created_by == ArtifactRevisionActor(type="user", id=actor_id) + + asyncio.run(scenario()) + + +class _EmptyCommand(BaseModel): + pass + + +def test_atomic_memory_replace_rejects_rollback_fields() -> None: + write = ArtifactWrite(content={"kind": "fact", "text": "x"}, restored_from_revision=1, reason="Restore memory") + with pytest.raises(InvalidBaseAccessRequestError, match="is not supported for atomic-memory"): + merged_source_revision("atomic-memory", write, _EmptyCommand()) diff --git a/tests/builtin/persistence/test_records.py b/tests/builtin/persistence/test_records.py index 5d1166cc83..72cfc3092f 100644 --- a/tests/builtin/persistence/test_records.py +++ b/tests/builtin/persistence/test_records.py @@ -808,7 +808,7 @@ async def scenario() -> None: def test_base_access_reuses_existing_tables_without_lifecycle_columns() -> None: assert "created_at" not in SOURCES_TABLE.c - assert "created_at" not in ARTIFACTS_TABLE.c + assert "deleted_at" not in ARTIFACTS_TABLE.c assert "deleted_at" not in ARTIFACT_HEADS_TABLE.c diff --git a/tests/e2e/test_profile_api.py b/tests/e2e/test_profile_api.py index 3b807d7b4b..4ed5ba448d 100644 --- a/tests/e2e/test_profile_api.py +++ b/tests/e2e/test_profile_api.py @@ -292,11 +292,15 @@ async def run(): headers={"If-Match": replaced.headers["ETag"]}, json={ "content": {"content": first.json()["content"]["content"], "restored_from_revision": 1}, + "reason": "Restore the approved profile", }, ) assert rollback.status_code == 200, rollback.text assert rollback.json()["revision"] == 3 assert rollback.json()["content"]["generation"]["mode"] == "rollback" + assert rollback.json()["restored_from_revision"] == 1 + assert rollback.json()["reason"] == "Restore the approved profile" + assert rollback.json()["created_at"] assert (await client.get(artifact_path + "/revisions/1")).json()["content"] == first.json()["content"] asyncio.run(run()) diff --git a/tests/test_dashboard_profile_handoff.py b/tests/test_dashboard_profile_handoff.py index 46b02e3ae1..f5e379cb1d 100644 --- a/tests/test_dashboard_profile_handoff.py +++ b/tests/test_dashboard_profile_handoff.py @@ -14,16 +14,29 @@ """Read and download exact artifacts through the authenticated Dashboard.""" +import json import re +import sqlite3 from html import unescape -from typing import Any +from pathlib import Path +from typing import Any, cast from urllib.parse import parse_qs, urlencode, urlsplit import pytest from fastapi.testclient import TestClient from markdown_it import MarkdownIt +from powercontext.builtin.artifacts.prompt import PROMPT_KEYS, PromptRegistry +from powercontext.builtin.artifacts.prompt.builtin import builtin_prompt_definitions +from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.server.dashboard.api import DashboardAPI +from powercontext.server.dashboard.pagination import PAGE_SIZE +from powercontext.server.factory import create_server_app +from powercontext.server.settings import ( + DashboardConfig, + McpConfig, + ServerSettings, +) from tests.test_dashboard import commit_handoff, create_scope from tests.test_dashboard import dashboard as dashboard @@ -92,8 +105,513 @@ def test_profile_safe_markdown_and_scope_isolation(dashboard): empty = dashboard.get("/dashboard/profile", params={"scope": other, "lang": "en"}) assert "No profile in this scope" in empty.text assert "Chinese 中文" not in empty.text - for params in ({"revision": "0"}, {"revision": "no"}, {"view": "history", "revision": "1"}): + for params in ({"revision": "0"}, {"revision": "no"}, {"view": "draft"}): assert dashboard.get("/dashboard/profile", params={"scope": scope, **params}).status_code == 422 + combined = dashboard.get("/dashboard/profile", params={"scope": scope, "view": "history", "revision": "1"}) + assert combined.status_code == 200 + assert "Chinese 中文" in combined.text + + +def test_profile_page_rolls_back_to_a_new_revision(dashboard): + scope = create_scope(dashboard, "Profile rollback")["scope_id"] + original = "# First profile\n\nOriginal preference." + _profile(dashboard, scope, original) + path = f"/v1/scopes/{scope}/artifacts/profile/profile" + current = dashboard.get(path) + changed = dashboard.put( + path, + json={"content": {"content": "# Second profile\n\nLater preference."}}, + headers={"If-Match": current.headers["etag"]}, + ) + assert changed.status_code == 200, changed.text + opened = dashboard.get("/dashboard/profile", params={"scope": scope, "revision": "1", "lang": "en"}) + assert opened.status_code == 200 + assert 'name="reason"' in opened.text + stale = dashboard.post( + "/dashboard/rollback", + data={ + "scope": scope, + "family": "profile", + "artifact_id": "profile", + "source_revision": "1", + "expected_revision": "1", + "reason": "Restore the approved profile", + }, + headers={"Origin": "http://testserver"}, + ) + assert stale.status_code == 200 + assert "The current revision changed" in stale.text + restored = dashboard.post( + "/dashboard/rollback", + data={ + "scope": scope, + "family": "profile", + "artifact_id": "profile", + "source_revision": "1", + "expected_revision": "2", + "reason": "Restore the approved profile", + }, + headers={"Origin": "http://testserver"}, + ) + assert restored.status_code == 200 + assert "Confirm rollback" not in restored.text + head = dashboard.get(path) + assert head.status_code == 200 + body = head.json() + assert body["revision"] == 3 + stored = dashboard.get(f"{path}/revisions/1").json() + assert body["content"]["content"] == stored["content"]["content"] + assert body["restored_from_revision"] == 1 + assert body["reason"] == "Restore the approved profile" + assert body["created_by"]["id"] + + +def test_rollback_is_offered_when_access_control_is_disabled(tmp_path: Path) -> None: + app = create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path}/open.db"), + dashboard=DashboardConfig(enabled=True), + mcp=McpConfig(enabled=False), + ), + scheduler_path=tmp_path / "scheduler.db", + ) + with TestClient(app) as client: + scope = create_scope(client, "Open rollback")["scope_id"] + _profile(client, scope, "# First profile\n\nOriginal preference.") + current = client.get(f"/v1/scopes/{scope}/artifacts/profile/profile") + changed = client.put( + f"/v1/scopes/{scope}/artifacts/profile/profile", + json={"content": {"content": "# Second profile\n\nLater preference."}}, + headers={"If-Match": current.headers["etag"]}, + ) + assert changed.status_code == 200, changed.text + opened = client.get("/dashboard/profile", params={"scope": scope, "revision": "1", "lang": "en"}) + assert opened.status_code == 200 + assert 'name="reason"' in opened.text + assert 'type="number"' in opened.text + + +def test_existing_database_without_revision_metadata_columns_keeps_working(tmp_path: Path) -> None: + def open_app(): + return create_server_app( + settings=ServerSettings( + database=SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path}/existing.db"), + dashboard=DashboardConfig(enabled=True), + mcp=McpConfig(enabled=False), + ), + scheduler_path=tmp_path / "scheduler.db", + ) + + with TestClient(open_app()) as client: + scope = create_scope(client, "Existing database")["scope_id"] + _profile(client, scope, "# First profile\n\nOriginal preference.") + # A database written before revision metadata existed has none of these columns. + with sqlite3.connect(tmp_path / "existing.db") as connection: + for column in ("created_at", "created_by", "restored_from_revision", "rollback_reason"): + connection.execute(f"ALTER TABLE pc_artifacts DROP COLUMN {column}") + with TestClient(open_app()) as client: + path = f"/v1/scopes/{scope}/artifacts/profile/profile" + current = client.get(path) + assert current.status_code == 200, current.text + assert current.json()["created_at"] is None + changed = client.put( + path, + json={"content": {"content": "# Second profile\n\nLater preference."}}, + headers={"If-Match": current.headers["etag"]}, + ) + assert changed.status_code == 200, changed.text + assert changed.json()["created_at"] is not None + + +def test_profile_compare_accepts_a_revision_outside_the_current_page(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Profile compare")["scope_id"] + _profile(dashboard, scope, "# First profile\n\nOriginal preference.") + path = f"/v1/scopes/{scope}/artifacts/profile/profile" + for revision in range(2, 9): + previous = dashboard.get(path) + changed = dashboard.put( + path, + json={"content": {"content": f"# Profile {revision}\n\nUpdated preference."}}, + headers={"If-Match": previous.headers["etag"]}, + ) + assert changed.status_code == 200, changed.text + compared = dashboard.get( + "/dashboard/profile", + params={"scope": scope, "revision": "1", "compare": "8", "lang": "en"}, + ) + assert compared.status_code == 200 + assert 'id="compare-revision"' in compared.text + assert 'type="number"' in compared.text + assert "First profile" in compared.text + assert "Profile 8" in compared.text + missing = dashboard.get( + "/dashboard/profile", + params={"scope": scope, "revision": "1", "compare": "99", "lang": "en"}, + ) + assert "That revision could not be compared." in missing.text + + +def test_prompt_page_shows_the_selected_revision(dashboard: TestClient) -> None: + # This fixture has no generation model, so custom prompts are otherwise rejected. + writer = cast(Any, dashboard.app).state.application._records()._family_writers.get("prompt") + writer._registry = PromptRegistry(builtin_prompt_definitions(), supported=frozenset(PROMPT_KEYS)) + scope = create_scope(dashboard, "Prompt history")["scope_id"] + created = dashboard.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "prompt", + "prompt_key": "experience.generate", + "content": { + "schema_version": "powercontext.prompt.v1", + "mode": "custom", + "instructions": "Keep the approved prompt.", + "demonstrations": [], + }, + }, + ) + assert created.status_code == 201, created.text + current = dashboard.get(f"/v1/scopes/{scope}/artifacts/prompt/experience.generate") + replaced = dashboard.put( + f"/v1/scopes/{scope}/artifacts/prompt/experience.generate", + json={ + "content": { + "schema_version": "powercontext.prompt.v1", + "mode": "custom", + "instructions": "Keep a later prompt.", + "demonstrations": [], + } + }, + headers={"If-Match": current.headers["etag"]}, + ) + assert replaced.status_code == 200, replaced.text + opened = dashboard.get( + "/dashboard/prompts", + params={"scope": scope, "prompt_key": "experience.generate", "prompt_revision": "1", "lang": "en"}, + ) + assert opened.status_code == 200 + assert "Keep the approved prompt." in opened.text + assert "Keep a later prompt." not in opened.text + missing = dashboard.get( + "/dashboard/prompts", + params={"scope": scope, "prompt_key": "experience.generate", "prompt_revision": "99", "lang": "en"}, + ) + assert missing.status_code == 200 + assert 'role="alert"' in missing.text + assert "Keep a later prompt." not in missing.text + assert "/dashboard/rollback" not in missing.text + assert 'id="compare-revision"' not in missing.text + + +def _put_content( + client: TestClient, scope: str, family: str, artifact_id: str, content: dict[str, Any] +) -> dict[str, Any]: + path = f"/v1/scopes/{scope}/artifacts/{family}/{artifact_id}" + current = client.get(path) + assert current.status_code == 200, (family, current.text) + replaced = client.put(path, json={"content": content}, headers={"If-Match": current.headers["etag"]}) + assert replaced.status_code == 200, (family, replaced.text) + return replaced.json() + + +def _rollback(client: TestClient, scope: str, family: str, artifact_id: str, expected: int) -> Any: + return client.post( + "/dashboard/rollback", + data={ + "scope": scope, + "family": family, + "artifact_id": artifact_id, + "source_revision": "1", + "expected_revision": str(expected), + "reason": "Restore the approved revision", + }, + headers={"Origin": "http://testserver"}, + ) + + +def _hrefs(html: str) -> list[str]: + return [unescape(href) for href in re.findall(r'href="([^"]+)"', html)] + + +def _revised_memory(client: TestClient, scope: str) -> str: + remembered = client.post("/v1/memory/remember", json={"scope_id": scope, "kind": "fact", "text": "Original note."}) + assert remembered.status_code == 200, remembered.text + artifact_id = remembered.json()["records"][0]["artifact"]["artifact_id"] + path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{artifact_id}" + current = client.get(path) + revised = client.put( + path, headers={"If-Match": current.headers["etag"]}, json={"content": {"kind": "fact", "text": "Revised note."}} + ) + assert revised.status_code == 200, revised.text + return artifact_id + + +def _reading(html: str) -> str: + return html.split('id="note-reading"', 1)[1] + + +def test_atomic_memory_history_compares_its_own_revisions_read_only(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Atomic memory history")["scope_id"] + artifact_id = _revised_memory(dashboard, scope) + page = dashboard.get( + "/dashboard/notes", + params={"scope": scope, "artifact": artifact_id, "revision": "1", "compare": "2", "lang": "en"}, + ) + assert page.status_code == 200, page.text + assert "Original note." in _reading(page.text).split("Revision history", 1)[0] + assert "Revision history" in page.text + assert "+ Revised note." in unescape(page.text) + assert "- Original note." in unescape(page.text) + assert "/dashboard/rollback" not in page.text + + +def test_atomic_memory_history_link_opens_that_exact_revision(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Atomic memory navigation")["scope_id"] + artifact_id = _revised_memory(dashboard, scope) + current = dashboard.get("/dashboard/notes", params={"scope": scope, "lang": "en"}) + assert current.status_code == 200, current.text + assert "Revised note." in _reading(current.text) + older = next( + href + for href in _hrefs(current.text) + if f"artifact={artifact_id}" in href and "revision=1" in href.split("#")[0] + ) + page = dashboard.get(older) + assert page.status_code == 200, page.text + reading = _reading(page.text).split("Revision history", 1)[0] + assert "Original note." in reading + assert "Revised note." not in reading + + +def test_notes_directory_link_drops_the_previous_notes_revision_cursor(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Atomic memory cursor")["scope_id"] + for index in range(PAGE_SIZE + 1): + remembered = dashboard.post( + "/v1/memory/remember", json={"scope_id": scope, "kind": "fact", "text": f"Note {index}."} + ) + assert remembered.status_code == 200, remembered.text + first = dashboard.get("/dashboard/notes", params={"scope": scope, "lang": "en"}) + selected = re.search(r'atomic-memory/([^@"]+)@', first.text) + assert selected is not None + path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{selected.group(1)}" + for revision in range(2, PAGE_SIZE + 2): + current = dashboard.get(path) + revised = dashboard.put( + path, + headers={"If-Match": current.headers["etag"]}, + json={"content": {"kind": "fact", "text": f"Revised {revision}."}}, + ) + assert revised.status_code == 200, revised.text + opened = dashboard.get("/dashboard/notes", params={"scope": scope, "lang": "en"}) + paged = dashboard.get(next(href for href in _hrefs(opened.text) if "notes_revision_cursor=" in href)) + assert paged.status_code == 200, paged.text + directory = next(href for href in _hrefs(paged.text) if "notes_cursor=" in href) + assert "notes_revision_cursor" not in directory + following = dashboard.get(directory) + assert following.status_code == 200, following.text + assert "Revision history" in following.text + + +def test_atomic_memory_replace_rejects_rollback_fields(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Atomic memory rollback")["scope_id"] + artifact_id = _revised_memory(dashboard, scope) + path = f"/v1/scopes/{scope}/artifacts/atomic-memory/{artifact_id}" + current = dashboard.get(path) + restored = dashboard.put( + path, + headers={"If-Match": current.headers["etag"]}, + json={"content": {"kind": "fact", "text": "Original note."}, "restored_from_revision": 1, "reason": "Undo"}, + ) + assert restored.status_code == 422, restored.text + assert dashboard.get(path).json()["revision"] == 2 + + +def test_rollback_rejects_a_cross_origin_request(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Rollback origin")["scope_id"] + _profile(dashboard, scope, "# First profile\n\nOriginal preference.") + path = f"/v1/scopes/{scope}/artifacts/profile/profile" + current = dashboard.get(path) + changed = dashboard.put( + path, + json={"content": {"content": "# Second profile\n\nLater preference."}}, + headers={"If-Match": current.headers["etag"]}, + ) + assert changed.status_code == 200, changed.text + payload = { + "scope": scope, + "family": "profile", + "artifact_id": "profile", + "source_revision": "1", + "expected_revision": "2", + "reason": "Restore the approved profile", + } + missing = dashboard.post("/dashboard/rollback", data=payload) + foreign = dashboard.post("/dashboard/rollback", data=payload, headers={"Origin": "http://sibling.test"}) + assert missing.status_code == 403 + assert foreign.status_code == 403 + assert dashboard.get(path).json()["revision"] == 2 + + +def test_topic_history_cursor_does_not_follow_a_link_to_another_topic(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Topic history cursor")["scope_id"] + ids = [] + for title in ("Topic A", "Topic B"): + created = dashboard.post( + f"/v1/scopes/{scope}/artifacts", + json={"family": "topic-memory", "content": {"title": title, "summary": "Summary", "detail": "Detail."}}, + ) + assert created.status_code == 201, created.text + ids.append(created.json()["artifact_id"]) + for revision in range(2, 10): + _put_content( + dashboard, + scope, + "topic-memory", + ids[0], + {"title": "Topic A", "summary": f"Summary {revision}", "detail": "Detail."}, + ) + opened = dashboard.get( + "/dashboard/topics", params={"scope": scope, "topic_artifact": ids[0], "topic_revision": "9", "lang": "en"} + ) + assert opened.status_code == 200, opened.text + paged = dashboard.get(next(href for href in _hrefs(opened.text) if "topic_revision_cursor=" in href)) + assert paged.status_code == 200, paged.text + other = next(href for href in _hrefs(paged.text) if f"topic_artifact={ids[1]}" in href) + assert "topic_revision_cursor" not in other + assert "Revision history" in dashboard.get(other).text + + +def test_dashboard_restores_a_legacy_instruction_only_skill_revision(dashboard: TestClient, tmp_path: Path) -> None: + scope = create_scope(dashboard, "Legacy skill rollback")["scope_id"] + legacy = { + "name": "compatibility-check", + "description": "Check compatibility before release", + "instructions": "Run the approved compatibility tests.", + "validation": ["Compatibility tests pass"], + } + created = dashboard.post(f"/v1/scopes/{scope}/artifacts", json={"family": "skill", "content": legacy}) + assert created.status_code == 201, created.text + skill_id = created.json()["artifact_id"] + _put_content(dashboard, scope, "skill", skill_id, {**legacy, "instructions": "Run a later set of tests."}) + # Revisions written before package-backed Skills stored the instruction core without a package. + with sqlite3.connect(tmp_path / "data.db") as connection: + connection.execute( + "UPDATE pc_artifacts SET content = ? WHERE scope_id = ? AND family = 'skill' AND artifact_id = ? " + "AND revision = 1", + (json.dumps(legacy).encode(), scope, skill_id), + ) + restored = _rollback(dashboard, scope, "skill", skill_id, 2) + assert restored.status_code == 200, restored.text + head = dashboard.get(f"/v1/scopes/{scope}/artifacts/skill/{skill_id}").json() + assert head["revision"] == 3 + assert head["content"]["instructions"].startswith("Run the approved compatibility tests.") + + +def test_dashboard_rolls_back_experience_skill_topic_and_handoff(dashboard: TestClient) -> None: + scope = create_scope(dashboard, "Family rollback")["scope_id"] + experience = dashboard.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "experience", + "content": { + "situation": "A compatibility issue was found before release", + "action": "Add cross-version tests", + "outcome": "Avoided a production regression", + "lesson": "Public API changes require compatibility coverage", + }, + }, + ) + assert experience.status_code == 201, experience.text + experience_id = experience.json()["artifact_id"] + _put_content( + dashboard, + scope, + "experience", + experience_id, + { + "situation": "A compatibility issue was found before release", + "action": "Add cross-version tests", + "outcome": "Avoided a production regression", + "lesson": "A later lesson replaced the approved one", + }, + ) + restored = _rollback(dashboard, scope, "experience", experience_id, 2) + assert restored.status_code == 200 + assert "Public API changes require compatibility coverage" in restored.text + assert dashboard.get(f"/v1/scopes/{scope}/artifacts/experience/{experience_id}").json()["revision"] == 3 + + skill = dashboard.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "skill", + "content": { + "name": "compatibility-check", + "description": "Check compatibility before release", + "instructions": "Run the approved compatibility tests.", + "validation": ["Compatibility tests pass"], + }, + }, + ) + assert skill.status_code == 201, skill.text + skill_id = skill.json()["artifact_id"] + _put_content( + dashboard, + scope, + "skill", + skill_id, + { + "name": "compatibility-check", + "description": "Check compatibility before release", + "instructions": "Run a later set of tests.", + "validation": ["Compatibility tests pass"], + }, + ) + restored_skill = _rollback(dashboard, scope, "skill", skill_id, 2) + assert restored_skill.status_code == 200, restored_skill.text + assert "Run the approved compatibility tests." in restored_skill.text + + topic = dashboard.post( + f"/v1/scopes/{scope}/artifacts", + json={ + "family": "topic-memory", + "content": {"title": "Approved topic", "summary": "Approved summary", "detail": "Approved detail."}, + }, + ) + assert topic.status_code == 201, topic.text + topic_id = topic.json()["artifact_id"] + _put_content( + dashboard, + scope, + "topic-memory", + topic_id, + {"title": "Later topic", "summary": "Later summary", "detail": "Later detail."}, + ) + restored_topic = _rollback(dashboard, scope, "topic-memory", topic_id, 2) + assert restored_topic.status_code == 200, restored_topic.text + assert "Approved detail." in restored_topic.text + + handoff = commit_handoff(dashboard, scope) + handoff_id = handoff["artifact_id"] + original = dashboard.get(f"/v1/scopes/{scope}/artifacts/handoff/{handoff_id}").json()["content"] + source = {"kind": "source", "source_ref": {"name": "content", "source_id": "review"}} + changed = { + "schema": original["schema"], + "objective": "A later objective replaced the approved handoff.", + "state": [{"text": "Review remains in progress.", "citations": [source]}], + "disposition": original["disposition"], + "next_action": {"text": "Continue the review.", "citations": [source]}, + "omissions": [], + } + _put_content(dashboard, scope, "handoff", handoff_id, changed) + confirmation = dashboard.get( + "/dashboard/handoff-detail", + params={"scope": scope, "artifact": handoff_id, "revision": "1", "lang": "en"}, + ) + assert confirmation.status_code == 200, confirmation.text + assert "This creates a new revision" in confirmation.text + restored_handoff = _rollback(dashboard, scope, "handoff", handoff_id, 2) + assert restored_handoff.status_code == 200, restored_handoff.text + assert "Review the HTTP contract" in restored_handoff.text + assert "A later objective replaced the approved handoff." not in restored_handoff.text def test_profile_evidence_is_bound_to_the_exact_revision(dashboard):