From 17b2aa72bfabc719b0534ec3835f23c18a4d1331 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 03:57:55 +0800 Subject: [PATCH 1/4] feat(skills): update in place and manage backups --- .../src-tauri/src/commands.rs | 157 +- apps/codex-plus-manager/src-tauri/src/lib.rs | 5 + .../src/ManagementExtra.tsx | 149 +- .../src/ManagementPages.css | 16 + .../src/SkillDuplicatesDrawer.tsx | 62 + .../codex-plus-manager/src/SkillFileTimes.tsx | 38 + .../src/SkillInPlaceUpdateDrawer.tsx | 56 + .../src/skill-duplicates-ui.test.ts | 79 + .../src/skill-file-times.test.ts | 99 + .../src/skill-location-association.test.ts | 5 +- crates/codex-plus-core/src/skills.rs | 2455 ++++++++++++++++- crates/codex-plus-core/src/skills/archive.rs | 33 + .../src/skills/in_place_update.rs | 1199 ++++++++ 13 files changed, 4238 insertions(+), 115 deletions(-) create mode 100644 apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx create mode 100644 apps/codex-plus-manager/src/SkillFileTimes.tsx create mode 100644 apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx create mode 100644 apps/codex-plus-manager/src/skill-duplicates-ui.test.ts create mode 100644 apps/codex-plus-manager/src/skill-file-times.test.ts create mode 100644 crates/codex-plus-core/src/skills/in_place_update.rs diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 63b8abd3ec..464bb5b698 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -918,6 +918,7 @@ pub struct SkillsPayload { pub repository_skills: Vec, pub repo_counts: std::collections::BTreeMap, pub backups: Vec, + pub backups_ok: bool, /// 单个仓库拉取失败不该让整块面板空掉,把错误单独带回前端提示。 pub repo_errors: Vec, pub skills_dir: String, @@ -7481,6 +7482,14 @@ pub async fn list_installed_skills() -> Result, Str }).await.map_err(|error| format!("读取本地 Skills 失败:{error}")) } +#[tauri::command] +pub async fn list_skill_file_times(id: String, root_kind: String, backup_id: Option) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + default_skills_manager().skill_file_times(&id, &root_kind, backup_id.as_deref()) + .map_err(|error| format!("读取 Skill 文件时间失败:{error:#}")) + }).await.map_err(|error| format!("读取 Skill 文件时间失败:{error}"))? +} + async fn resolve_skill_repo_for_operation( manager: &codex_plus_core::skills::SkillsManager, repo_key: &str, @@ -7606,6 +7615,102 @@ pub async fn discover_skill_source_evidence(id: String, local_path: Option Result, String> { + tauri::async_runtime::spawn_blocking(|| { + match default_skills_manager().preview_skill_duplicates() { + Ok(preview) => ok("重复副本已检查。", preview), + Err(error) => failed( + &format!("检查重复副本失败:{error:#}"), + codex_plus_core::skills::SkillDuplicatePreview { + preview_id: String::new(), + candidates: Vec::new(), + }, + ), + } + }).await.map_err(|error| format!("检查重复副本失败:{error}")) +} + +#[tauri::command] +pub async fn archive_skill_duplicate(preview_id: String, id: String, kind: Option) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_manager(); + let result = match kind.as_deref() { + None | Some("shared") => manager.archive_skill_duplicate(&preview_id, &id), + Some("source") => manager.archive_skill_managed_source(&preview_id, &id), + Some(_) => Err(anyhow::anyhow!("未知的重复副本类型")), + }; + match result { + Ok(_) => ok("重复副本已归档,可恢复至原位置。", current_skills_payload(&manager)), + Err(error) => failed(&format!("归档重复副本失败:{error:#}"), current_skills_payload(&manager)), + } + }).await.map_err(|error| format!("归档重复副本失败:{error}")) +} + +#[tauri::command] +pub async fn preview_skill_in_place_update( + id: String, + repo_key: String, + local_path: Option, +) -> CommandResult { + let manager = default_skills_manager(); + let result = async { + let inventory = manager.try_local_inventory()?; + let repo_path = if let Some(path) = local_path.as_deref() { + let path = std::path::Path::new(path); + inventory.iter().filter(|skill| skill.id == id).find_map(|skill| { + skill.locations.iter() + .find(|location| location.original_path.as_deref() == Some(path) && location.repo_key == repo_key) + .map(|location| location.repo_path.clone()) + .or_else(|| (skill.original_dir.as_deref() == Some(path) && skill.repo_key == repo_key) + .then(|| skill.repo_path.clone())) + }) + } else { + inventory.iter().find(|skill| skill.id == id && skill.repo_key == repo_key) + .map(|skill| skill.repo_path.clone()) + }.ok_or_else(|| anyhow::anyhow!("未找到指定位置已关联的 Skill"))?; + let remote = resolve_skill_repo_for_operation(&manager, &repo_key).await? + .into_iter().find(|skill| skill.repo_path == repo_path) + .ok_or_else(|| anyhow::anyhow!("仓库中已找不到该 Skill 路径"))?; + let repo = manager.list_repos().into_iter().find(|repo| repo.key() == repo_key) + .ok_or_else(|| anyhow::anyhow!("仓库已不存在"))?; + let zip = codex_plus_core::skills::download_repo_zip(&repo).await?; + match local_path.as_deref() { + Some(path) => manager.preview_skill_in_place_update_for_instance( + &id, std::path::Path::new(path), remote, zip, + ), + None => manager.preview_skill_in_place_update(&id, remote, zip), + } + }.await; + match result { + Ok(preview) => ok("请检查原目录及更新版本。", preview), + Err(error) => failed( + &format!("预览原位更新失败:{error:#}"), + codex_plus_core::skills::SkillInPlaceUpdatePreview::default(), + ), + } +} + +#[tauri::command] +pub async fn execute_skill_in_place_update(preview_id: String) -> CommandResult { + let manager = default_skills_manager(); + let result = async { + let expected = manager.in_place_update_preview_remote(&preview_id)?; + let observed = resolve_skill_repo_for_operation(&manager, &expected.repo_key).await? + .into_iter().find(|skill| skill.repo_path == expected.repo_path) + .ok_or_else(|| anyhow::anyhow!("预览后仓库中已找不到该 Skill,请重新预览"))?; + let worker = manager.clone(); + tauri::async_runtime::spawn_blocking(move || + worker.execute_skill_in_place_update(&preview_id, &observed) + ).await.map_err(|error| anyhow::anyhow!("原位更新任务失败:{error}"))??; + Ok::<_, anyhow::Error>(()) + }.await; + match result { + Ok(()) => ok("Skill 已原位更新,原版本已备份。", current_skills_payload(&manager)), + Err(error) => failed(&format!("原位更新失败:{error:#}"), current_skills_payload(&manager)), + } +} + /// 导入 cc-switch 已安装的 Skills。cc-switch 的 Skill 文件位于 /// `%USERPROFILE%/.cc-switch/skills//SKILL.md`,不在其 SQLite 数据库中。 #[tauri::command] @@ -7828,27 +7933,31 @@ pub fn uninstall_skill(id: String) -> CommandResult { } #[tauri::command] -pub fn restore_skill_backup(backup_id: String) -> CommandResult { - let manager = default_skills_manager(); - match manager.restore_backup(backup_id.trim()) { - Ok(_) => ok("Skill 已从备份恢复。", current_skills_payload(&manager)), - Err(error) => failed( - &format!("从备份恢复失败:{error}"), - current_skills_payload(&manager), - ), - } +pub async fn restore_skill_backup(backup_id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_manager(); + match manager.restore_backup(backup_id.trim()) { + Ok(_) => ok("Skill 已从备份恢复。", current_skills_payload(&manager)), + Err(error) => failed( + &format!("从备份恢复失败:{error}"), + current_skills_payload(&manager), + ), + } + }).await.map_err(|error| format!("从备份恢复失败:{error}")) } #[tauri::command] -pub fn delete_skill_backup(backup_id: String) -> CommandResult { - let manager = default_skills_manager(); - match manager.delete_backup(backup_id.trim()) { - Ok(_) => ok("备份已删除。", current_skills_payload(&manager)), - Err(error) => failed( - &format!("删除备份失败:{error}"), - current_skills_payload(&manager), - ), - } +pub async fn delete_skill_backup(backup_id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_manager(); + match manager.delete_backup(backup_id.trim()) { + Ok(_) => ok("备份已删除。", current_skills_payload(&manager)), + Err(error) => failed( + &format!("删除备份失败:{error}"), + current_skills_payload(&manager), + ), + } + }).await.map_err(|error| format!("删除备份失败:{error}")) } #[tauri::command] @@ -8080,13 +8189,23 @@ fn skills_payload( Vec::new() } }; + let backups = manager.try_list_backups(); + let backups_ok = backups.is_ok(); + let backups = match backups { + Ok(backups) => backups, + Err(error) => { + repo_errors.push(format!("读取 Skill 备份失败:{error:#}")); + Vec::new() + } + }; SkillsPayload { skills, inventory_ok, repos, repository_skills, repo_counts, - backups: manager.list_backups(), + backups, + backups_ok, repo_errors, skills_dir: manager.source_dir().to_string_lossy().to_string(), codex_skills_dir: manager.linked_dir().to_string_lossy().to_string(), diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index 711e3a86bd..ebd42be077 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -588,9 +588,14 @@ pub fn run() { commands::delete_user_script, commands::refresh_skill_catalog, commands::list_installed_skills, + commands::list_skill_file_times, commands::find_skill_repository_matches, commands::apply_skill_repository_matches, commands::discover_skill_source_evidence, + commands::preview_skill_duplicates, + commands::archive_skill_duplicate, + commands::preview_skill_in_place_update, + commands::execute_skill_in_place_update, commands::import_ccs_skills, commands::import_local_skills, commands::install_skill, diff --git a/apps/codex-plus-manager/src/ManagementExtra.tsx b/apps/codex-plus-manager/src/ManagementExtra.tsx index 601ac674b1..e40ec4c4c3 100644 --- a/apps/codex-plus-manager/src/ManagementExtra.tsx +++ b/apps/codex-plus-manager/src/ManagementExtra.tsx @@ -20,6 +20,9 @@ import { SkillListViewport } from "./SkillListViewport"; import { aggregateSkillGroupItems, customSkillGroupKey, groupSkills, legacySkillCustomGroupName, migrateSkillGroupAssignments, migrateSkillInstanceAssignments, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillGroupIdentityKey, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName, type SkillGroup, type SkillGroupSortDirection } from "./skill-groups"; import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchState, skillBatchTargets, skillCodexStatusError, SkillBatchRegistry, type PendingSkillBatchOperation } from "./skill-agent-batch"; import { SkillSourceMatchDrawer, type SourceSelection } from "./SkillSourceMatchDrawer"; +import { SkillDuplicatesDrawer } from "./SkillDuplicatesDrawer"; +import { SkillFileTimes } from "./SkillFileTimes"; +import { SkillInPlaceUpdateDrawer } from "./SkillInPlaceUpdateDrawer"; import { TopbarActions } from "./TopbarActions"; type Result = { status: string; message: string }; @@ -33,10 +36,12 @@ type Skill = { id: string; name: string; description: string; repoKey: string; r /** Codex++ 的受控源里有它;只在某个 Agent 自己目录里的 Skill 为 false。 */ managed?: boolean; locations?: SkillLocation[]; conflict?: string | null; instanceKey?: string; conflictKind?: "independent" | "equal-independent" | "managed-modified" | null; safeToggleAgents?: string[]; - originalDir?: string | null; managedSourceDir?: string | null; updateOwner?: string; installChannel?: string; + originalDir?: string | null; managedSourceDir?: string | null; managedSourceStatus?: string | null; + managedSourceError?: string | null; sourceVerified?: boolean; updateOwner?: string; installChannel?: string; displayInstances?: Skill[] }; type Repo = { owner: string; name: string; branch: string; subdir: string; enabled: boolean }; -type SkillPayload = Result & { skills: Skill[]; inventoryOk: boolean; repos: Repo[]; repositorySkills: Skill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string; agentSkillsDirs?: Record }; +type SkillBackup = { id: string; skillId: string; name: string; backedUpAt: string; backupPath?: string | null; restorePath?: string | null; purpose?: string | null; restoreBlockedReason?: string | null; deleteWarning?: string | null }; +type SkillPayload = Result & { skills: Skill[]; inventoryOk: boolean; repos: Repo[]; repositorySkills: Skill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string; agentSkillsDirs?: Record; backups?: SkillBackup[]; backupsOk: boolean }; type RepoInspection = Result & { repo: Repo; skills: Skill[]; skillCount: number }; type SourceApplyResult = SkillPayload & { applied: Array<{ localId: string; fromRepoKey: string; toRepoKey: string; localPath?: string | null }> }; const SKILL_GROUP_STORAGE_KEY = "codex-plus-plus.skill-groups.v1"; @@ -68,6 +73,10 @@ function parseRepoInput(value: string, current: Repo): Repo | null { /** 后端 `LOCAL_IMPORT_REPO_KEY`:从本地目录导入的 Skill(#511)。 */ const LOCAL_IMPORT_REPO_KEY = "local"; function sourceLabel(skill: Skill) { if (skill.bundled) return t("内置"); if (skill.repoKey === "cc-switch" || !skill.repoKey) return t("本地"); if (skill.repoKey === LOCAL_IMPORT_REPO_KEY) return t("本地导入"); return skill.repoKey.split("@", 1)[0]; } +function skillChannelLabel(skill: Skill) { + return skill.installChannel === "cc-switch" || (skill.repoKey === "cc-switch" && skill.sourceVerified) + ? "cc-switch" : skill.installChannel === "github" ? "GitHub" : skill.installChannel === "system" ? t("内置") : skill.installChannel === "local" || skill.repoKey === "cc-switch" ? t("本地安装") : t("未记录"); +} function repoUrl(skill: Skill) { if (!skill.repoKey || skill.repoKey === "cc-switch" || skill.repoKey === LOCAL_IMPORT_REPO_KEY) return ""; const parts = skill.repoKey.split("@")[0].split("/"); @@ -202,40 +211,98 @@ function SkillRepoDrawer({ repos, catalog, counts, initialKey, busy, updates, on ; } -function SkillDetailDrawer({ skill, count, busy, onClose, onOpenUrl, onOpenRepo, onMatch }: { - skill: Skill; count?: number; busy: boolean; onClose: () => void; onOpenUrl: (url: string) => void; - onOpenRepo: () => void; onMatch: (path?: string, context?: SkillMatchContext) => void; +function SkillBackupList({ backups, busy, onRestore, onDelete }: { + backups: SkillBackup[]; busy: boolean; onRestore: (backup: SkillBackup) => void; onDelete: (backup: SkillBackup) => void; +}) { + return <>{backups.map((backup) =>
+
{backup.name || backup.skillId}{backup.backedUpAt}
+
{backup.purpose === "duplicate" ? t("重复副本归档") : backup.purpose === "managed-source" ? t("受控源归档") : backup.purpose === "disabled" ? t("停用副本备份") : backup.purpose === "update" ? t("更新前备份") : t("技能备份")} + {t("备份位置")}:{backup.backupPath || t("未知")} + {t("恢复位置")}:{backup.restorePath || t("未记录")} + + {backup.restoreBlockedReason && {backup.restoreBlockedReason}} +
+
+
)}; +} +function SkillBackupsDrawer({ backups, busy, onClose, onRestore, onDelete }: { + backups: SkillBackup[]; busy: boolean; onClose: () => void; onRestore: (backup: SkillBackup) => void; onDelete: (backup: SkillBackup) => void; +}) { + return + {backups.length ? :

{t("暂无 Skill 备份。")}

} +
; +} +function SkillDetailDrawer({ skill, count, backups, busy, onClose, onOpenUrl, onOpenRepo, onMatch, onRestore, onDeleteBackup, onUpdate, onManageSource }: { + skill: Skill; count?: number; backups: SkillBackup[]; busy: boolean; onClose: () => void; onOpenUrl: (url: string) => void; + onOpenRepo: () => void; onMatch: (localPath?: string, context?: SkillMatchContext) => void; onRestore: (backup: SkillBackup) => void; onDeleteBackup: (backup: SkillBackup) => void; onUpdate: (repoKey: string, localPath?: string) => void; onManageSource: () => void; }) { const url = repoUrl(skill); - const labels: Record = { shared: t("共享"), codex: "Codex", claude: "Claude Code", grok: "Grok", system: "Codex" }; - const targets = new Set(skill.locations?.flatMap((location) => + const channel = skillChannelLabel(skill); + const owner = skill.updateOwner === "in_place" ? t("Codex++ 原位维护") : skill.updateOwner === "manager" ? t("Codex++ 管理") : t("未接管"); + const selectableOriginal = skill.conflictKind === "independent" && !!skill.originalDir + && skill.locations?.some((location) => !location.managed); + const associatedOriginal = skill.conflictKind === "equal-independent" && !!skill.originalDir && !!url; + const managedSourcePath = skill.managedSourceDir; + const matchTargets = new Set(skill.locations?.flatMap((location) => location.selectable && location.originalPath ? [location.originalPath] : location.associationPath ? [location.associationPath] : []) || []); const matchAtRepoPath = skill.installed && !skill.bundled && !skill.conflict && !skill.displayInstances - && (targets.size === 1 || (targets.size === 0 && !!skill.managedSourceDir)); + && (matchTargets.size === 1 || (matchTargets.size === 0 && !!skill.managedSourceDir)); const matchLabel = t(url ? "修改 GitHub 仓库关联" : "关联 GitHub 仓库"); + const previewUpdate = skill.installed && !skill.bundled && (!skill.conflict || selectableOriginal || associatedOriginal) + && !!url && !!skill.originalDir && skill.updateOwner !== "manager"; + const rootLabel: Record = { shared: t("共享"), codex: "Codex", claude: "Claude Code", grok: "Grok" }; return - {skill.conflict &&

{skill.conflict}

} -
{t("来源")}
{skill.displayInstances ? t("按安装位置查看") : url ? : sourceLabel(skill)}
-
{t("Skill ID")}
{skill.id}
{t("仓库路径")}
{skill.repoPath || "—"}{matchAtRepoPath && }
-
{t("介绍")}
{skill.description || t("暂无描述")}
{url && <>
GitHub
} +
+ {skill.conflict &&

{skill.displayInstances ? t("各 Agent 的安装内容或来源可能不同,请分别查看。") : skill.conflict}

} +
+
{t(!skill.displayInstances && !url && !skill.bundled ? "来源" : "上游来源")}
{skill.displayInstances ? t("按安装位置查看") : url ? : skill.bundled ? t("内置") : t("本地")}{associatedOriginal && · {t("仅关联所选安装位置")}}
+
{t("安装渠道")}
{skill.displayInstances ? t("按安装位置查看") : channel}
+
{t("更新方式")}
{skill.displayInstances ? t("按安装位置查看") : owner}
+
{t("Skill ID")}
{skill.id}
+
{t("仓库路径")}
{skill.repoPath || "—"}{matchAtRepoPath && }
+
{t("介绍")}
{skill.description || t("暂无描述")}
+ {url && <>
GitHub
} + {skill.originalDir && <>
{t(associatedOriginal ? "关联来源的安装目录" : "原安装目录")}
{skill.originalDir}
} {skill.locations?.flatMap((location) => { const instance = skill.displayInstances?.find((item) => item.locations?.some((itemLocation) => itemLocation.manifestPath === location.manifestPath)); const locationRepo = location.repoKey || instance?.repoKey || (!skill.displayInstances ? skill.repoKey : ""); const locationRepoPath = location.repoPath || instance?.repoPath || (!skill.displayInstances ? skill.repoPath : "") || ""; + const locationOwner = location.updateOwner || instance?.updateOwner || (!skill.displayInstances ? skill.updateOwner : ""); + const hasRepo = !!repoFromKey(locationRepo); const associationPath = location.selectable && location.originalPath ? location.originalPath : location.associationPath; - return [ -
{labels[location.rootKind] || location.rootKind}
, -
{location.manifestPath}
{location.managed ? t("Codex++ 受控") : t("本地安装")} · {location.statusError ? t("状态未知") : location.enabled ? t("已启用") : t("未启用")} - {location.statusError && <>
{location.statusError}} - {(instance || associationPath) && <>
{t("来源")}:{repoFromKey(locationRepo) ? `${locationRepo}${locationRepoPath ? ` / ${locationRepoPath}` : ""}` : t("本地")}} + const sharedAssociation = associationPath && (skill.locations || []).filter((item) => + (item.selectable && item.originalPath ? item.originalPath : item.associationPath) === associationPath).length > 1; + return [
{rootLabel[location.rootKind] || location.rootKind}
,
{location.manifestPath}
{location.managed ? t("Codex++ 受控") : t("本地安装")} · {location.statusError ? t("状态未知") : location.enabled ? t("已启用") : t("未启用")} + {(skill.conflict || skill.displayInstances) && } + {location.statusError && <>
{location.statusError}} + {instance && <>
{t("安装渠道")}:{skillChannelLabel(instance)}} + {(instance || associationPath) && <>
{t(hasRepo ? "上游来源" : "来源")}:{hasRepo ? `${locationRepo}${locationRepoPath ? ` / ${locationRepoPath}` : ""}` : t("本地")} + {hasRepo && <>
{t("更新方式")}:{locationOwner === "in_place" ? t("Codex++ 原位维护") : locationOwner === "manager" ? t("Codex++ 管理") : t("未接管")}} + {sharedAssociation && <>
{t("此来源由多个安装位置共用")}} {associationPath && !matchAtRepoPath &&
}
, - ]; + agentLabel: rootLabel[location.rootKind] || location.rootKind, currentRepoKey: locationRepo, currentRepoPath: locationRepoPath, + })}>{t(hasRepo ? "重新关联此安装位置" : "关联此安装位置")} + {location.selectable && location.originalPath && (skill.displayInstances || skill.conflictKind === "equal-independent") && hasRepo && locationOwner !== "manager" && }
}}]; })} + {managedSourcePath && <> +
{t("受控源目录")}
{managedSourcePath} + + {skill.managedSourceStatus === "referenced" && {t("正在被安装位置引用")}} + {skill.managedSourceStatus === "matching" && {t("未被目录链接引用,内容与安装位置一致")}} + {skill.managedSourceStatus === "stored" && {t("保留的工作源,可用于重新启用")}} + {skill.managedSourceStatus === "different" && (skill.displayInstances && !skill.conflict + ? {t("受控源与部分 Agent 的独立安装内容不同")} + : {t("未被目录链接引用,内容与安装位置不同;启用到其他 Agent 前需处理")})} + {skill.managedSourceStatus === "unknown" && {t("无法比较受控源")}:{skill.managedSourceError}}
+ }
+ {previewUpdate &&
+ +
} + {backups.length > 0 &&

{t("备份")}

} +
; } function SortableSkillGroup({ group, disabled, children }: { group: SkillGroup; disabled: boolean; children: (handle: ReactNode) => ReactNode }) { @@ -289,6 +356,9 @@ export function SkillsPage({ notify }: { notify: Notify }) { const [repoOpen, setRepoOpen] = useState(false); const [detail, setDetail] = useState(null); const [matchTarget, setMatchTarget] = useState<({ id?: string; localPath?: string } & SkillMatchContext) | null>(null); + const [duplicatesOpen, setDuplicatesOpen] = useState(false); + const [backupsOpen, setBackupsOpen] = useState(false); + const [updateTarget, setUpdateTarget] = useState<{ id: string; repoKey: string; localPath?: string } | null>(null); const [repoSelection, setRepoSelection] = useState(null); const [loading, setLoading] = useState(false); const [refreshingCatalog, setRefreshingCatalog] = useState(() => !!skillPageCache.catalogPending); @@ -368,10 +438,12 @@ export function SkillsPage({ notify }: { notify: Notify }) { setDataState((current) => { if (!mounted.current || request !== dataRequest.current || request < current.version) return current; const previous = current.value; - const value = previous && !payload.inventoryOk - ? { ...payload, skills: previous.skills, repos: previous.repos, - repositorySkills: previous.repositorySkills, repoCounts: previous.repoCounts } - : payload; + const value = previous ? { + ...payload, + ...(!payload.inventoryOk && { skills: previous.skills, repos: previous.repos, + repositorySkills: previous.repositorySkills, repoCounts: previous.repoCounts }), + ...(!payload.backupsOk && { backups: previous.backups }), + } : payload; return { version: request, value }; }); }; @@ -642,6 +714,17 @@ export function SkillsPage({ notify }: { notify: Notify }) { return { status: "failed", message }; } finally { setBusy(false); } }; + const restoreBackup = async (backup: SkillBackup) => { + const destination = backup.restorePath ? `\n${backup.restorePath}` : ""; + if (!(await appConfirm(`${t("恢复此备份?")}${destination}`, { title: t("恢复 Skill 备份"), confirmText: t("恢复") }))) return; + await run("restore_skill_backup", { backupId: backup.id }); + }; + const deleteBackup = async (backup: SkillBackup) => { + const path = backup.backupPath ? `\n${backup.backupPath}` : ""; + const warning = backup.deleteWarning ? `\n\n${backup.deleteWarning}` : ""; + if (!(await appConfirm(`${tf("删除 Skill 备份“{0}”?", [backup.name || backup.skillId])}${path}${warning}`, { title: t("删除 Skill 备份"), confirmText: t("删除"), danger: true }))) return; + await run("delete_skill_backup", { backupId: backup.id }); + }; const openRepo = (key: string) => { setRepoSelection(key); setDetail(null); setRepoOpen(true); }; const deferredQuery = useDeferredValue(query); const knownSkills = useMemo(() => data?.skills || [], [data]); @@ -798,6 +881,8 @@ export function SkillsPage({ notify }: { notify: Notify }) { void importLocalSkills()} disabled={busy}>{t("从本地目录导入")} void run("import_ccs_skills", {})} disabled={busy}>{t("从 cc-switch 导入")} + setDuplicatesOpen(true)} disabled={busy}>{t("预览重复副本")} + setBackupsOpen(true)} disabled={busy}>{t("查看 Skill 备份")} } /> @@ -828,7 +913,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { detail={
    {updateFailures.details.map((detail) =>
  • {detail.repoKey} · {detail.id}:{detail.message}
  • )}
}> } - {data?.repoErrors?.length ? } title={t("部分仓库读取失败")} detail={{data.repoErrors.map((e) => {e})}} /> : null} + {data?.repoErrors?.length ? } title={t(data.inventoryOk && data.backupsOk ? "部分仓库读取失败" : "Skill 数据读取失败")} detail={{data.repoErrors.map((e) => {e})}} /> : null}
{!groups.length ? : @@ -869,14 +954,22 @@ export function SkillsPage({ notify }: { notify: Notify }) { })}}
{repoOpen && setRepoOpen(false)} onSave={saveRepo} onDelete={(r) => void deleteRepo(r)} onInspect={inspectRepo} onOpenUrl={(url) => void openUrl(url)} onOpenSkill={setDetail} onRun={(command, args) => { queueUpdate(command, args); }} />} - {currentDetail && backup.skillId === currentDetail.id)} busy={busy} onClose={() => setDetail(null)} onOpenUrl={(url) => void openUrl(url)} onOpenRepo={() => repoOpen ? setDetail(null) : openRepo(currentDetail.repoKey)} - onMatch={(localPath, context) => { setDetail(null); setMatchTarget({ id: currentDetail.id, localPath, ...context }); }}/>} + onManageSource={() => { setDetail(null); setDuplicatesOpen(true); }} + onMatch={(localPath, context) => { setDetail(null); setMatchTarget({ id: currentDetail.id, localPath, ...context }); }} + onRestore={(backup) => void restoreBackup(backup)} + onDeleteBackup={(backup) => void deleteBackup(backup)} + onUpdate={(repoKey, localPath) => { setUpdateTarget({ id: currentDetail.id, repoKey, localPath }); setDetail(null); }}/>} {matchTarget && skill.installed && !skill.bundled && (!skill.repoPath || !skill.repoKey.includes("/"))).map((skill) => skill.id)} onClose={() => setMatchTarget(null)} onAddRepo={addMatchingRepo} onApply={applySourceMatches}/>} + {duplicatesOpen && setDuplicatesOpen(false)} onArchive={(previewId, id, kind) => run("archive_skill_duplicate", { previewId, id, kind })}/>} + {backupsOpen && setBackupsOpen(false)} onRestore={(backup) => void restoreBackup(backup)} onDelete={(backup) => void deleteBackup(backup)}/>} + {updateTarget && setUpdateTarget(null)} onExecute={(previewId) => run("execute_skill_in_place_update", { previewId })}/>} ; } export function SessionPage({ sessions, onRefresh, onDelete, onDeleteMany, notify, repairPanel }: { sessions: { sessions: ManagedSession[]; totalCount: number; hasMore: boolean; offset: number; limit: number } | null; onRefresh: (offset?: number) => Promise; onDelete: (s: ManagedSession) => Promise; onDeleteMany: (s: ManagedSession[]) => Promise; notify: Notify; repairPanel?: ReactNode }) { const [query, setQuery] = useState(""); const [mode, setMode] = useState<"project" | "time">("project"); const [expanded, setExpanded] = useState>(new Set()); const [selection, setSelection] = useState(false); const [selected, setSelected] = useState>(new Set()); const [detail, setDetail] = useState(null); const all = sessions?.sessions || []; const visible = all.filter((s) => matchesManagementQuery([s.title, s.cwd, s.id, s.modelProvider], query)); const groups = sessionGroups(visible, mode); const selectionScope = visible.map((s) => s.id).join("\0"); useEffect(() => { const ids = new Set(selectionScope.split("\0")); setSelected((old) => { const next = new Set([...old].filter((id) => ids.has(id))); return next.size === old.size ? old : next; }); }, [selectionScope]); const selectedRows = visible.filter((s) => selected.has(s.id)); const toggle = (id: string) => setSelected((old) => { const next = new Set(old); next.has(id) ? next.delete(id) : next.add(id); return next; }); return
} title={t("会话")} actions={<>} />{repairPanel}
{selection && <>}
{!visible.length ?

{t("还没有匹配的会话")}

: groups.map((g) => { const open = query.trim() || expanded.has(g.key) || groups.length === 1; return
{open && g.sessions.map((s) =>
{selection && toggle(s.id)}/>}
{s.modelProvider || "Codex"}
)}
; })}
{sessions ? tf("第 {0} 页 · 共 {1} 个会话", [Math.floor(sessions.offset / sessions.limit) + 1, sessions.totalCount]) : "—"}
{detail && setDetail(null)} footer={<>}>
{t("项目目录")}
{detail.cwd || "—"}
{t("模型供应商")}
{detail.modelProvider || "Codex"}
{t("数据库")}
{detail.dbPath || "—"}
}
; } diff --git a/apps/codex-plus-manager/src/ManagementPages.css b/apps/codex-plus-manager/src/ManagementPages.css index 974fc23ac7..8ba77b432a 100644 --- a/apps/codex-plus-manager/src/ManagementPages.css +++ b/apps/codex-plus-manager/src/ManagementPages.css @@ -45,6 +45,16 @@ .mg-skills-page .mg-drawer h2{min-width:0;overflow-wrap:anywhere} .mg-skills-page .mg-drawer>header{height:auto;min-height:64px;gap:12px;padding-block:12px} .mg-skills-page .mg-drawer>header>.mg-icon-button{flex-shrink:0} +.mg-skill-detail{display:flex;flex-direction:column;gap:10px;min-width:0} +.mg-skill-detail>.mg-notice{margin:0;padding:8px 10px} +.mg-skill-detail .mg-detail{grid-template-columns:100px minmax(0,1fr);gap:6px 12px;line-height:1.45} +.mg-skill-detail .mg-link-button{max-width:100%;min-width:0} +.mg-skill-detail .mg-row-actions{justify-content:flex-start;flex-wrap:wrap;gap:6px;margin-top:4px} +.mg-skill-detail>section h3{margin:0 0 6px;font-size:13px} +.mg-skill-detail .mg-source-row{grid-template-columns:minmax(0,1fr);gap:4px;padding:7px 0} +.mg-skill-detail .mg-source-row>div:first-child{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px;min-width:0} +.mg-skill-detail .mg-source-row strong{overflow-wrap:anywhere} +.mg-skill-detail .mg-source-row small{margin-top:2px} @media (max-width:900px){.mg-skill-head,.mg-skill-row{grid-template-columns:minmax(0,1fr) 110px 124px 116px;gap:8px}} @media (max-width:680px){.mg-skill-head,.mg-skill-row{grid-template-columns:minmax(0,1fr) 80px 104px 105px;gap:6px;padding-inline:8px}.mg-form-grid{grid-template-columns:1fr}.mg-skill-row .mg-button{padding-inline:6px}.mg-status-toggle{width:36px}.mg-skill-status{gap:5px}} /* Skills 页的颜色、按钮和表格与 MCP 页共用;这里只保留仓库抽屉自己的分隔线和强调色。 */ @@ -337,3 +347,9 @@ /* 多 Agent 表头(MCP / Skills):Agent 列是图标 + 名字 + 数量三行,比普通表头高,上下留出边距 */ .shell .mg-agent-matrix .mg-matrix-head { padding-top: 8px; padding-bottom: 8px; } .shell .mg-agent-matrix .mg-agent-column { gap: 3px; } +.mg-skill-file-times { position:relative; margin-top:6px; font-size:12px; } +.mg-skill-file-times summary { min-height:26px; padding-right:32px; line-height:26px; cursor:pointer; color:hsl(var(--muted-foreground)); } +.mg-skill-file-times>button.mg-icon-button { position:absolute; top:0; right:0; width:26px; height:26px; } +.mg-skill-file-times ul { display:grid; gap:4px; max-height:220px; margin:6px 0; padding:0; overflow:auto; list-style:none; } +.mg-skill-file-times li { display:flex; flex-wrap:wrap; justify-content:space-between; gap:2px 12px; overflow-wrap:anywhere; } +.mg-skill-file-times time { white-space:nowrap; color:hsl(var(--muted-foreground)); } diff --git a/apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx b/apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx new file mode 100644 index 0000000000..5e3c27138c --- /dev/null +++ b/apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { RefreshCw } from "lucide-react"; +import { ManagementDrawer } from "./ManagementPages"; +import { appConfirm } from "./app-dialog"; +import { t } from "./i18n"; +import { SkillFileTimes } from "./SkillFileTimes"; +import "./SkillSourceMatchDrawer.css"; + +type Duplicate = { id: string; kind?: "shared" | "source"; sharedPath: string; codexPath: string; originalRootKind?: string; status: "identical-managed" | "identical-source" | "different" | "unmanaged" | "unsupported" | "enabled-only-copy"; detail?: string | null }; +type Preview = { status: string; message: string; previewId: string; candidates: Duplicate[] }; +const statusLabel = (status: Duplicate["status"]) => ({ + "identical-managed": t("内容一致,可归档受控副本"), + "identical-source": t("内容一致,可将闲置受控源归档"), + different: t("内容不同,保留两份"), + unmanaged: t("归属未确认,保留两份"), + unsupported: t("无法比较,保留两份"), + "enabled-only-copy": t("共享位置已停用,保留当前启用的副本"), +})[status]; + +export function SkillDuplicatesDrawer({ onClose, onArchive }: { + onClose: () => void; + onArchive: (previewId: string, id: string, kind: "shared" | "source") => Promise; +}) { + const [preview, setPreview] = useState(null); + const [revision, setRevision] = useState(0); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + let active = true; + setLoading(true); setPreview(null); setError(""); + void invoke("preview_skill_duplicates").then((result) => { + if (!active) return; + if (result.status === "ok") setPreview(result); + else setError(result.message); + }, (reason) => { if (active) setError(String(reason)); }).finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [revision]); + const archive = async (item: Duplicate) => { + if (!preview || (item.status !== "identical-managed" && item.status !== "identical-source")) return; + const source = item.kind === "source"; + const message = source ? `${t("将闲置受控源移入备份,并保留此安装位置作为原件?")}\n${item.codexPath}\n${t("保留的安装位置")}:${item.sharedPath}` + : `${t("将受控副本移入备份?")}\n${item.codexPath}`; + if (!(await appConfirm(message, { title: t("归档重复副本"), confirmText: t("归档") }))) return; + setBusy(true); + try { if (await onArchive(preview.previewId, item.id, item.kind || "shared")) setRevision((old) => old + 1); } + finally { setBusy(false); } + }; + return {} : onClose} + footer={<>}> + {loading &&

{t("正在检查…")}

} + {error &&

{error}

} + {preview && !preview.candidates.length &&

{t("没有发现可比较的重复副本。")}

} + {preview?.candidates.map((item) =>
+
{item.id}{statusLabel(item.status)}{item.detail && {item.detail}}
+
{t(item.kind === "source" ? "保留的安装位置" : "共享位置")}
{item.sharedPath}
{item.kind === "source" ? t("受控源目录") : "Codex"}
{item.codexPath}
+ {(item.status === "identical-managed" || item.status === "identical-source") && } +
+
)} +
; +} diff --git a/apps/codex-plus-manager/src/SkillFileTimes.tsx b/apps/codex-plus-manager/src/SkillFileTimes.tsx new file mode 100644 index 0000000000..841b709c8c --- /dev/null +++ b/apps/codex-plus-manager/src/SkillFileTimes.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; +import { t } from "./i18n"; + +type SkillFileTime = { path: string; modifiedAtMs: number; isLink: boolean }; + +export function SkillFileTimes({ id, rootKind = "", backupId }: { id: string; rootKind?: string; backupId?: string }) { + const [files, setFiles] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(0); + const pending = useRef(false); + const mounted = useRef(false); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + const load = () => { + if (pending.current) return; + pending.current = true; + setLoading(true); + setError(""); + void invoke("list_skill_file_times", { id, rootKind, backupId: backupId || null }) + .then((result) => { if (mounted.current) { setFiles(result); setPage(0); } }, + (reason) => { if (mounted.current) setError(String(reason)); }) + .finally(() => { pending.current = false; if (mounted.current) setLoading(false); }); + }; + return
{ if (event.currentTarget.open && !files) load(); }}> + {t("文件修改时间")} + {loading && {t("正在读取…")}} + {error && {error}} + {(files || error) && } + {files && <>
    {files.slice(page * 100, (page + 1) * 100).map((file) =>
  • {file.path}{file.isLink && <> · {t("链接(未展开)")}}
  • )}
+ {files.length > 100 &&
+ + {page + 1} / {Math.ceil(files.length / 100)} + +
}} +
; +} diff --git a/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx b/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx new file mode 100644 index 0000000000..6d8ef799c6 --- /dev/null +++ b/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { RefreshCw } from "lucide-react"; +import { ManagementDrawer } from "./ManagementPages"; +import { t } from "./i18n"; + +type Preview = { + status: string; message: string; previewId: string; id: string; repoKey: string; repoPath: string; + targetPath: string; currentHash: string; nextHash: string; conflicts: string[]; + managedPaths?: string[]; followedPaths?: string[]; skippedPaths?: string[]; +}; + +export function SkillInPlaceUpdateDrawer({ id, repoKey, localPath, onClose, onExecute }: { + id: string; repoKey: string; localPath?: string; onClose: () => void; onExecute: (previewId: string) => Promise; +}) { + const [revision, setRevision] = useState(0); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + useEffect(() => { + let active = true; + setLoading(true); setPreview(null); setError(""); + void invoke("preview_skill_in_place_update", { id, repoKey, localPath: localPath || null }).then((result) => { + if (!active) return; + if (result.status === "ok") setPreview(result); + else setError(result.message); + }, (reason) => { if (active) setError(String(reason)); }).finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [id, repoKey, localPath, revision]); + const execute = async () => { + if (!preview || preview.conflicts.length) return; + setBusy(true); + try { + if (await onExecute(preview.previewId)) setDone(true); + else setRevision((old) => old + 1); + } finally { setBusy(false); } + }; + return {} : onClose} + footer={<> + {!done && } + + {!done && } + }> + {loading &&

{t("正在检查更新…")}

} + {error &&

{error}

} + {done &&

{t("原位更新已完成,原版本已保存到备份。")}

} + {preview && <>
{t("仓库")}
{preview.repoKey}
{t("仓库路径")}
{preview.repoPath || "/"}
{t("安装位置")}
{preview.targetPath}
{t("当前版本")}
{preview.currentHash || "—"}
{t("待更新版本")}
{preview.nextHash || "—"}
+ {!!preview.managedPaths?.length &&
{t("将同步的受控副本")}
    {preview.managedPaths.map((path) =>
  • {path}
  • )}
} + {!!preview.followedPaths?.length &&
{t("将随原件或受控副本更新的路径")}
    {preview.followedPaths.map((path) =>
  • {path}
  • )}
} + {!!preview.skippedPaths?.length &&
{t("独立或归属未确认的安装,保持不变")}
    {preview.skippedPaths.map((path) =>
  • {path}
  • )}
} + {preview.conflicts.length > 0 &&
{t("存在冲突,无法更新")}
    {preview.conflicts.map((conflict) =>
  • {conflict}
  • )}
} + } +
; +} diff --git a/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts b/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts new file mode 100644 index 0000000000..381ed55163 --- /dev/null +++ b/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +type Element = { type: any; props: Record; children: any[] }; +const source = readFileSync(new URL("./SkillDuplicatesDrawer.tsx", import.meta.url), "utf8"); +const file = ts.createSourceFile("SkillDuplicatesDrawer.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const nodes = file.statements.filter((node) => + ts.isFunctionDeclaration(node) && node.name?.text === "SkillDuplicatesDrawer" + || ts.isVariableStatement(node) && node.declarationList.declarations.some((item) => + ts.isIdentifier(item.name) && item.name.text === "statusLabel")); +const code = ts.transpileModule(nodes.map((node) => node.getText(file)).join("\n"), { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test("a verified managed duplicate can be archived despite other independent same-name skills", async () => { + const slots: any[] = []; + const effects: Array<() => void> = []; + const archives: Array<[string, string, string]> = []; + const confirmations: string[] = []; + let cursor = 0; + let elements: Element[] = []; + const context: Record = { + exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + fragment: "fragment", + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = typeof initial === "function" ? initial() : initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useEffect: (run: () => void, deps: any[]) => { + const index = cursor++; + const previous = slots[index] as any[] | undefined; + if (!previous || deps.some((value, position) => !Object.is(value, previous[position]))) effects.push(run); + slots[index] = deps; + }, + t: (value: string) => value, appConfirm: async (message: string) => { confirmations.push(message); return true; }, + invoke: async () => ({ status: "ok", previewId: "verified-preview", candidates: [ + { id: "rea", sharedPath: "C:/agents/rea", codexPath: "C:/codex/rea", status: "identical-managed" }, + { id: "rea", kind: "source", sharedPath: "C:/codex/rea", codexPath: "C:/managed/rea", originalRootKind: "codex", status: "identical-source" }, + { id: "other", sharedPath: "C:/agents/other", codexPath: "C:/codex/other", status: "different" }, + { id: "unknown", sharedPath: "C:/agents/unknown", codexPath: "C:/codex/unknown", status: "unsupported", detail: "无法确定名称规则的启用状态" }, + ] }), + ManagementDrawer: "ManagementDrawer", RefreshCw: "RefreshCw", SkillFileTimes: "SkillFileTimes", + }; + vm.createContext(context); + vm.runInContext(code, context); + const collect = (node: any) => { + if (!node || typeof node !== "object") return; + if (Array.isArray(node)) { node.forEach(collect); return; } + elements.push(node); + node.children?.forEach(collect); + if (node.props?.footer) collect(node.props.footer); + }; + const props = { + onClose: () => {}, + onArchive: async (previewId: string, id: string, kind: string) => { archives.push([previewId, id, kind]); return true; }, + }; + const render = () => { + cursor = 0; elements = []; effects.length = 0; + collect(context.exports.SkillDuplicatesDrawer(props)); + effects.splice(0).forEach((run) => run()); + }; + render(); await tick(); render(); + const buttons = elements.filter((node) => node.type === "button" && node.children.includes("归档副本")); + assert.equal(buttons.length, 2); + assert.ok(elements.some((node) => node.type === "small" && node.children.includes("无法确定名称规则的启用状态"))); + assert.equal(elements.filter((node) => node.type === "SkillFileTimes").length, 8); + assert.equal(buttons[0].props.disabled, false); + await buttons[0].props.onClick(); + await buttons[1].props.onClick(); + await tick(); + assert.deepEqual(archives, [["verified-preview", "rea", "shared"], ["verified-preview", "rea", "source"]]); + assert.ok(confirmations[1].includes("C:/managed/rea")); + assert.ok(confirmations[1].includes("C:/codex/rea")); +}); diff --git a/apps/codex-plus-manager/src/skill-file-times.test.ts b/apps/codex-plus-manager/src/skill-file-times.test.ts new file mode 100644 index 0000000000..73c43db185 --- /dev/null +++ b/apps/codex-plus-manager/src/skill-file-times.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +const source = readFileSync(new URL("./SkillFileTimes.tsx", import.meta.url), "utf8"); +const file = ts.createSourceFile("SkillFileTimes.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const component = file.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "SkillFileTimes"); +assert.ok(component); +const code = ts.transpileModule(component.getText(file), { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test("file dates are read only on expansion and identify every returned file", async () => { + const slots: any[] = []; + const effects: Array<() => void> = []; + const calls: any[] = []; + let cursor = 0; + let resolveFiles!: (value: any[]) => void; + let rejectFiles!: (error: Error) => void; + const context: Record = { + exports: {}, fragment: "fragment", jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + ChevronLeft: "ChevronLeft", ChevronRight: "ChevronRight", RefreshCw: "RefreshCw", + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useRef: (value: any) => { const index = cursor++; return slots[index] ??= { current: value }; }, + useEffect: (run: () => void) => { const index = cursor++; if (!(index in slots)) { slots[index] = true; effects.push(run); } }, + t: (value: string) => value, + invoke: (command: string, args: any) => { + calls.push({ command, args }); + return new Promise((resolve, reject) => { resolveFiles = resolve; rejectFiles = reject; }); + }, + }; + vm.createContext(context); + vm.runInContext(code, context); + const render = () => { + cursor = 0; + const root = context.exports.SkillFileTimes({ id: "alpha", backupId: "alpha-123" }); + effects.splice(0).forEach((run) => run()); + return root; + }; + let root = render(); + assert.equal(calls.length, 0); + root.props.onToggle({ currentTarget: { open: true } }); + root.props.onToggle({ currentTarget: { open: true } }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "list_skill_file_times"); + assert.equal(calls[0].args.backupId, "alpha-123"); + rejectFiles(new Error("cannot read folder")); + await tick(); + root = render(); + assert.ok(root.children.some((child: any) => child?.props?.role === "alert")); + root.children.find((child: any) => child?.props?.["aria-label"] === "刷新").props.onClick(); + assert.equal(calls.length, 2); + resolveFiles([{ path: "SKILL.md", modifiedAtMs: Date.parse("2026-11-10T00:00:00Z") }, + { path: "scripts/run.js", modifiedAtMs: Date.parse("2026-11-09T00:00:00Z"), isLink: true }, + ...Array.from({ length: 248 }, (_, index) => ({ path: `file-${index}`, modifiedAtMs: 0 }))]); + await tick(); + root = render(); + let content = root.children.find((child: any) => child?.type === "fragment"); + let list = content.children.find((child: any) => child?.type === "ul"); + assert.equal(list.children[0].length, 100); + assert.deepEqual(Array.from(list.children[0].slice(0, 2), (item: any) => item.children[0].children[0]), ["SKILL.md", "scripts/run.js"]); + assert.deepEqual(Array.from(list.children[0].slice(0, 2), (item: any) => item.children[1].props.dateTime), + ["2026-11-10T00:00:00.000Z", "2026-11-09T00:00:00.000Z"]); + assert.equal(list.children[0][1].children[0].children[1].children[1], "链接(未展开)"); + let controls = content.children.find((child: any) => child?.type === "div"); + assert.equal(controls.children[0].props.disabled, true); + controls.children[2].props.onClick(); + root = render(); + content = root.children.find((child: any) => child?.type === "fragment"); + list = content.children.find((child: any) => child?.type === "ul"); + assert.equal(list.children[0].length, 100); + controls = content.children.find((child: any) => child?.type === "div"); + controls.children[2].props.onClick(); + root = render(); + content = root.children.find((child: any) => child?.type === "fragment"); + list = content.children.find((child: any) => child?.type === "ul"); + assert.equal(list.children[0].length, 50); + controls = content.children.find((child: any) => child?.type === "div"); + assert.equal(controls.children[2].props.disabled, true); + controls.children[0].props.onClick(); + root = render(); + root.props.onToggle({ currentTarget: { open: true } }); + assert.equal(calls.length, 2); + root.children.find((child: any) => child?.props?.["aria-label"] === "刷新").props.onClick(); + assert.equal(calls.length, 3); + resolveFiles([{ path: "updated.md", modifiedAtMs: 0 }]); + await tick(); + root = render(); + content = root.children.find((child: any) => child?.type === "fragment"); + list = content.children.find((child: any) => child?.type === "ul"); + assert.equal(list.children[0][0].children[0].children[0], "updated.md"); +}); diff --git a/apps/codex-plus-manager/src/skill-location-association.test.ts b/apps/codex-plus-manager/src/skill-location-association.test.ts index 872e10d81e..1d7ef9d6f7 100644 --- a/apps/codex-plus-manager/src/skill-location-association.test.ts +++ b/apps/codex-plus-manager/src/skill-location-association.test.ts @@ -18,6 +18,7 @@ test("each Agent installation associates its own repository path", () => { const context: Record = { exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), fragment: "fragment", t: (value: string) => value, repoUrl: () => "", sourceLabel: () => "本地", + skillChannelLabel: () => "本地安装", SkillFileTimes: "SkillFileTimes", CloudDownload: "CloudDownload", ManagementDrawer: "ManagementDrawer", Button: "Button", Edit3: "Edit3", ExternalLink: "ExternalLink", ArrowRight: "ArrowRight", }; vm.createContext(context); @@ -31,7 +32,7 @@ test("each Agent installation associates its own repository path", () => { const element = context.SkillDetailDrawer({ skill: { id: "alpha", name: "alpha", description: "", repoKey: "", installed: true, bundled: false, locations, displayInstances: locations.map((location) => ({ repoKey: location.repoKey, repoPath: location.repoPath, locations: [location] })) }, - busy: false, onClose: () => {}, onOpenUrl: () => {}, onOpenRepo: () => {}, + backups: [], busy: false, onClose: () => {}, onOpenUrl: () => {}, onOpenRepo: () => {}, onUpdate: () => {}, onMatch: (path: string, details: Record) => selected.push([path, details]), }); const collect = (node: any, result: Element[] = []): Element[] => { @@ -41,7 +42,7 @@ test("each Agent installation associates its own repository path", () => { node.children?.forEach((child: any) => collect(child, result)); return result; }; - const buttons = collect(element).filter((item) => item.type === "Button" && item.children.includes("关联此安装位置")); + const buttons = collect(element).filter((item) => item.type === "Button" && item.children.includes("重新关联此安装位置")); assert.equal(buttons.length, 2); buttons.forEach((button) => button.props.onClick()); assert.deepEqual(selected.map(([path, detail]) => [path, detail.currentRepoPath]), [ diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 891a24b54c..cf41ed1937 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -24,8 +24,10 @@ use serde_json::Value; use sha2::{Digest, Sha256}; mod source_match; +mod in_place_update; pub use source_match::{SkillRepositoryMatchApplied, SkillRepositoryMatchApply, SkillRepositoryMatchPreview, SkillRepositoryMatchSelection, SkillSourceEvidence}; +pub use in_place_update::SkillInPlaceUpdatePreview; mod archive; @@ -119,6 +121,10 @@ pub struct SkillsState { #[serde(default)] pub installed: BTreeMap, #[serde(default)] + pub backup_origins: BTreeMap, + #[serde(default)] + pub update_backup_groups: BTreeMap, + #[serde(default)] instance_targets: BTreeMap, #[serde(default)] instance_records: BTreeMap>, @@ -140,11 +146,40 @@ struct SkillInstanceTarget { identity: SkillPathIdentity, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillUpdateBackupGroup { + pub installed: InstalledSkill, + pub backup_ids: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + partially_deleted: bool, + #[serde(default)] + published: Vec, + #[serde(default)] + instance_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SkillPublishedSnapshot { + path: PathBuf, + identity: SkillPathIdentity, + digest: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillBackupOrigin { + pub path: PathBuf, + pub purpose: String, +} + impl Default for SkillsState { fn default() -> Self { Self { repos: default_skill_repos(), installed: BTreeMap::new(), + backup_origins: BTreeMap::new(), + update_backup_groups: BTreeMap::new(), instance_targets: BTreeMap::new(), instance_records: BTreeMap::new(), shared_agent_paths: BTreeMap::new(), @@ -289,6 +324,72 @@ pub struct SkillBackup { pub skill_id: String, pub name: String, pub backed_up_at: String, + pub backup_path: String, + pub restore_path: Option, + pub purpose: Option, + pub restore_blocked_reason: Option, + pub delete_warning: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillFileTime { + pub path: String, + pub modified_at_ms: i64, + pub is_link: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillDuplicate { + pub id: String, + pub kind: String, + pub shared_path: String, + pub codex_path: String, + pub original_root_kind: String, + pub status: String, + pub detail: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillDuplicatePreview { + pub preview_id: String, + pub candidates: Vec, +} + +#[derive(Clone)] +struct DuplicateSnapshot { + shared_path: PathBuf, + shared_target: PathBuf, + codex_path: PathBuf, + shared_identity: SkillPathIdentity, + shared_target_identity: SkillPathIdentity, + codex_identity: SkillPathIdentity, + digest: [u8; 32], +} + +#[derive(Clone)] +struct DuplicatePreviewSnapshot { + scope: CoordinationScope, + candidates: BTreeMap, + managed_sources: BTreeMap, +} + +#[derive(Clone)] +struct ManagedSourceSnapshot { + source_path: PathBuf, + source_identity: SkillPathIdentity, + original_path: PathBuf, + original_identity: SkillPathIdentity, + managed_copies: Vec, + digest: [u8; 32], + installed: InstalledSkill, +} + +fn duplicate_previews() -> MutexGuard<'static, BTreeMap> { + static PREVIEWS: OnceLock>> = OnceLock::new(); + PREVIEWS.get_or_init(|| Mutex::new(BTreeMap::new())).lock().expect("Skill 重复预览锁中毒") } /// 下载前的状态快照。公开字段供调用方定位仓库和原安装路径,代次只在本进程有效。 @@ -791,6 +892,17 @@ impl SkillsManager { skip_unknown: bool, ) -> anyhow::Result>, Vec)>> { let config = self.read_native_skill_config()?; + let updated = self.set_native_skill_paths_from_config(manifests, enabled, skip_unknown, &config)?; + Ok(updated.map(|updated| (config.original, updated))) + } + + fn set_native_skill_paths_from_config( + &self, + manifests: &[(PathBuf, String)], + enabled: bool, + skip_unknown: bool, + config: &NativeSkillConfig, + ) -> anyhow::Result>> { let mut changes = Vec::new(); for (path, name) in manifests { let name = match config.name_for_matching(path, name) { @@ -868,7 +980,7 @@ impl SkillsManager { let updated = document.to_string().into_bytes(); crate::settings::atomic_write(&self.codex_home.join("config.toml"), &updated) .context("无法保存 Codex 技能配置")?; - Ok(Some((config.original, updated))) + Ok(Some(updated)) } /// 路径不完整时返回全局范围;任意受管路径相交都不能互相隔离。 @@ -972,18 +1084,28 @@ impl SkillsManager { Ok(metadata) if metadata_is_link(&metadata) && is_managed_link(&link, &self.source_dir.join(id)) => remove_link(&link), Ok(_) if is_managed_copy(&link) => { + self.ensure_duplicate_has_no_discovered_references(&link)?; let identity = skill_path_identity(&link)?; let backup = self.backup_directory_checked(id, &link, identity)?; - let key = link.to_string_lossy().into_owned(); - if initial_state.shared_agent_paths.get(&key) == Some(&[identity.device, identity.file]) { - let mut state = initial_state.clone(); - state.shared_agent_paths.remove(&key); - if let Err(error) = self.save_state_unlocked(&state) { - return match restore_skill_backup(&backup, &link, identity) { - Ok(()) => Err(error), - Err(rollback) => Err(error.context(format!("恢复 Skill 副本失败:{rollback:#}"))), - }; + let result = (|| -> anyhow::Result<()> { + let mut state = self.load_state_checked_unlocked()?; + let key = link.to_string_lossy().into_owned(); + if state.shared_agent_paths.get(&key) == Some(&[identity.device, identity.file]) { + state.shared_agent_paths.remove(&key); } + let backup_id = self.backup_id_for_path(&backup)?; + state.backup_origins.insert(backup_id, SkillBackupOrigin { + path: link.clone(), purpose: "disabled".into(), + }); + self.save_state_unlocked(&state) + })(); + if let Err(error) = result { + return match restore_skill_backup(&backup, &link, identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!( + "停用副本保留在 {}:{rollback:#}", backup.display() + ))), + }; } Ok(()) } @@ -1003,17 +1125,51 @@ impl SkillsManager { anyhow::bail!("skill 源目录不存在:{}", source.display()); } std::fs::create_dir_all(&dir).with_context(|| format!("创建 {} 失败", dir.display()))?; - let identity = link_or_copy(&source, &link)?; - if shared.is_some() { - let mut state = initial_state.clone(); - state.shared_agent_paths.insert(link.to_string_lossy().into_owned(), [identity.device, identity.file]); - if let Err(error) = self.save_state_unlocked(&state) { + let state = self.load_state_checked_unlocked()?; + for (backup_id, origin) in &state.backup_origins { + if origin.purpose != "disabled" || origin.path != link { continue; } + let (backup, backup_name) = self.resolve_backup(backup_id)?; + if split_backup_id(&backup_name).0 != id { continue; } + let metadata = std::fs::symlink_metadata(&backup) + .with_context(|| format!("读取停用备份失败:{}", backup.display()))?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata) && is_managed_copy(&backup), + "停用备份不是受控实体目录:{}", backup.display()); + let source_identity = skill_path_identity(&source)?; + let backup_identity = skill_path_identity(&backup)?; + let source_digest = skill_directory_digest(&source)?.context("Skill 受控源已不存在")?; + if skill_directory_digest(&backup)? != Some(source_digest) { continue; } + self.ensure_duplicate_has_no_discovered_references(&backup)?; + restore_skill_backup(&backup, &link, backup_identity)?; + let result = (|| -> anyhow::Result<()> { + anyhow::ensure!(skill_path_identity(&source)? == source_identity + && skill_directory_digest(&source)? == Some(source_digest) + && skill_directory_digest(&link)? == Some(source_digest), + "重新启用期间 Skill 内容已改变,请重试"); + self.register_published_skill_copy(id, &source, &link, backup_identity, Some(backup_id)) + })(); + if let Err(error) = result { + return match restore_skill_backup(&link, &backup, backup_identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!( + "停用副本保留在 {}:{rollback:#}", link.display() + ))), + }; + } + return Ok(()); + } + let mut published = None; + let result = link_or_copy_tracked(&source, &link, &mut published).and_then(|identity| { + self.register_published_skill_copy(id, &source, &link, identity, None) + }); + if let Err(error) = result { + if let Some(identity) = published { return match recover_published_path(&link, identity) { Ok(Some(path)) => Err(error.context(format!("新建的 Skill 内容保留在 {}", path.display()))), Ok(None) => Err(error), - Err(rollback) => Err(error.context(format!("回滚 Skill 内容失败:{rollback:#}"))), + Err(rollback) => Err(error.context(format!("回滚新建 Skill 副本失败:{rollback:#}"))), }; } + return Err(error); } Ok(()) })(); @@ -1021,6 +1177,58 @@ impl SkillsManager { result } + fn register_published_skill_copy( + &self, + id: &str, + source: &Path, + path: &Path, + identity: SkillPathIdentity, + restored_backup: Option<&str>, + ) -> anyhow::Result<()> { + let shared_source = self.shared_skills_dir().is_some_and(|root| + root.join(id).canonicalize().ok().zip(source.canonicalize().ok()) + .is_some_and(|(shared, source)| shared == source)); + let managed_copy = is_managed_copy(path); + if !managed_copy && !shared_source { + return Ok(()); + } + let mut state = self.load_state_checked_unlocked()?; + let mut changed = false; + if shared_source { + anyhow::ensure!(skill_path_identity(path)? == identity, "启用期间 Skill 位置已改变"); + state.shared_agent_paths.insert(path.to_string_lossy().into_owned(), [identity.device, identity.file]); + changed = true; + } + if let Some(backup_id) = restored_backup { + let origin = state.backup_origins.remove(backup_id).context("停用备份记录已改变")?; + anyhow::ensure!(origin.purpose == "disabled" && origin.path == path, "停用备份记录已改变"); + changed = true; + } + if managed_copy { + if let Some(records) = state.instance_records.get_mut(id) { + for record in records.values_mut() { + let tracked_source = record.target.path.canonicalize().ok() == source.canonicalize().ok() + && skill_path_identity(&record.target.path).ok() == Some(record.target.identity) + || record.managed_copies.iter().any(|copy| + copy.path == source && skill_path_identity(source).ok() == Some(copy.identity)); + if !tracked_source { continue; } + anyhow::ensure!(skill_path_identity(&record.target.path)? == record.target.identity + && skill_path_identity(path)? == identity + && skill_directory_digest(&record.target.path)? == skill_directory_digest(source)? + && skill_directory_digest(source)? == skill_directory_digest(path)? + && skill_path_identity(&record.target.path)? == record.target.identity + && skill_path_identity(path)? == identity, + "启用期间 Skill 原件或受控副本已改变"); + record.managed_copies.retain(|copy| copy.path != path); + record.managed_copies.push(SkillInstanceTarget { path: path.to_path_buf(), identity }); + changed = true; + break; + } + } + } + if changed { self.save_state_unlocked(&state) } else { Ok(()) } + } + /// 没有受控源时,从 Codex 或其它 Agent 目录里找到这个 Skill 的真实目录,纳入受控源。 /// /// 只在各安装位置内容一致时复制受控源,原安装位置保持不动。 @@ -1042,6 +1250,56 @@ impl SkillsManager { originals.insert(candidate.canonicalize()?); } } + if !source.exists() { + let mut state = self.load_state_checked_unlocked()?; + for (backup_id, origin) in state.backup_origins.clone() { + if origin.purpose != "managed-source" || origin.path != source { continue; } + let (backup, name) = self.resolve_backup(&backup_id)?; + if split_backup_id(&name).0 != id { continue; } + let identity = skill_path_identity(&backup)?; + let digest = skill_directory_digest(&backup)?.context("受控源备份已不存在")?; + let mut matches = !originals.is_empty(); + for original in &originals { + if skill_directory_digest(original)? != Some(digest) { + matches = false; + } + } + if !matches { continue; } + self.ensure_duplicate_has_no_discovered_references(&backup)?; + std::fs::create_dir_all(&self.source_dir)?; + restore_skill_backup(&backup, &source, identity)?; + let result = (|| -> anyhow::Result<()> { + anyhow::ensure!(skill_directory_digest(&source)? == Some(digest), + "恢复受控源期间内容已改变"); + for original in &originals { + anyhow::ensure!(skill_directory_digest(original)? == Some(digest), + "恢复受控源期间 Skill 安装内容已改变"); + } + if let Some(records) = state.instance_records.get_mut(id) { + for record in records.values_mut() { + if originals.contains(&record.target.path.canonicalize()?) + && skill_path_identity(&record.target.path)? == record.target.identity + { + record.managed_copies.retain(|copy| copy.path != source); + record.managed_copies.push(SkillInstanceTarget { path: source.clone(), identity }); + break; + } + } + } + state.backup_origins.remove(&backup_id); + self.save_state_unlocked(&state) + })(); + if let Err(error) = result { + return match restore_skill_backup(&source, &backup, identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!( + "受控源保留在 {}:{rollback:#}", source.display() + ))), + }; + } + return Ok(()); + } + } if source.is_dir() { let source_target = source.canonicalize()?; let digest = skill_directory_digest(&source_target)?.context("Skill 受控源已不存在")?; @@ -1074,7 +1332,7 @@ impl SkillsManager { if let Some(existing) = self.load_state_checked_unlocked()?.installed.get(id) { installed = existing.clone(); } - self.commit_staged_skill_unlocked(&staging, installed, false, true, None, true, coordination)?; + self.commit_staged_skill_unlocked(&staging, installed, false, true, None, true, Some(candidate), coordination)?; Ok(()) })(); result.map_err(|error| retain_staging_context(error, &staging)) @@ -1131,6 +1389,10 @@ impl SkillsManager { } fn save_state_unlocked(&self, state: &SkillsState) -> anyhow::Result<()> { + #[cfg(test)] + if FAIL_SKILL_STATE_SAVE.with(|fail| fail.replace(false)) { + anyhow::bail!("模拟 Skill 状态写入失败"); + } crate::settings::atomic_write( &self.state_path, serde_json::to_string_pretty(state)?.as_bytes(), @@ -2092,7 +2354,7 @@ impl SkillsManager { { anyhow::bail!("导入期间 Skill 已改变,请重新导入"); } - self.commit_staged_skill_unlocked(staging, installed, false, true, None, false, &mut coordination) + self.commit_staged_skill_unlocked(staging, installed, false, true, None, false, None, &mut coordination) } fn stage_directory_import( @@ -2177,6 +2439,7 @@ impl SkillsManager { replace_identity: bool, agent_snapshot: Option<&AgentCopiesSnapshot>, require_empty: bool, + adopted_from: Option<&Path>, coordination: &mut SkillsCoordination, ) -> anyhow::Result { let id = installed.id.clone(); @@ -2233,6 +2496,23 @@ impl SkillsManager { if skill_path_identity(&destination)? != identity { anyhow::bail!("Skill 发布期间源目录发生变化:{}", destination.display()); } + if let Some(original) = adopted_from { + let canonical = original.canonicalize()?; + let record_path = state.instance_records.get(&id).and_then(|records| records.values() + .find(|record| record.target.path.canonicalize().ok().as_ref() == Some(&canonical)) + .map(|record| record.target.path.clone())); + if let Some(record_path) = record_path { + let record = &state.instance_records[&id][&record_path]; + anyhow::ensure!(skill_path_identity(&record.target.path)? == record.target.identity + && skill_directory_digest(original)? == skill_directory_digest(&destination)? + && skill_path_identity(&record.target.path)? == record.target.identity + && skill_path_identity(&destination)? == identity, + "创建受控源期间 Skill 原件或路径已改变"); + let record = state.instance_records.get_mut(&id).unwrap().get_mut(&record_path).unwrap(); + record.managed_copies.retain(|copy| copy.path != destination); + record.managed_copies.push(SkillInstanceTarget { path: destination.clone(), identity }); + } + } if enabled { std::fs::create_dir_all(self.linked_dir())?; let mut published_link = None; @@ -2559,7 +2839,7 @@ impl SkillsManager { install_channel: "github".into(), update_owner: "manager".into(), ..Default::default() - }, enabled, true, Some(&agent_snapshot), guard.install_only, &mut coordination)?; + }, enabled, true, Some(&agent_snapshot), guard.install_only, None, &mut coordination)?; // 安装记录保留 legacy ID,返回给目录缓存的必须是可用于新安装的稳定 ID。 let mut catalog_skill = skill; let manifest = skill_manifest_path(&catalog_skill.repo_path); @@ -2608,7 +2888,7 @@ impl SkillsManager { install_channel: "github".into(), update_owner: "manager".into(), ..Default::default() - }, enable_after_install, true, Some(&agent_snapshot), false, &mut coordination) + }, enable_after_install, true, Some(&agent_snapshot), false, None, &mut coordination) })(); result.map_err(|error| retain_staging_context(error, &staging)) } @@ -2695,7 +2975,7 @@ impl SkillsManager { ..Default::default() }); self.commit_staged_skill_unlocked( - &staging, installed, false, contents_changed, None, false, coordination, + &staging, installed, false, contents_changed, None, false, None, coordination, ).map_err(|error| retain_staging_context(error, &staging))?; return Ok(()); } @@ -2713,10 +2993,43 @@ impl SkillsManager { } std::fs::create_dir_all(self.linked_dir()) .with_context(|| format!("创建 {} 失败", self.linked_dir().display()))?; - let identity = link_or_copy(&source, &link)?; + let config = self.read_native_skill_config()?; + let mut published = None; + let identity = match link_or_copy_tracked(&source, &link, &mut published) { + Ok(identity) => identity, + Err(error) => { + return match published { + Some(identity) => match recover_published_path(&link, identity) { + Ok(Some(path)) => Err(error.context(format!("新建的 Skill 内容保留在 {}", path.display()))), + Ok(None) => Err(error), + Err(rollback) => Err(error.context(format!("回滚新建 Skill 副本失败:{rollback:#}"))), + }, + None => Err(error), + }; + } + }; let manifest = link.join(SKILL_MANIFEST_FILE); let (name, _) = read_skill_manifest(&manifest, id); - if let Err(error) = self.set_native_skill_paths(&[(manifest, name)], true, false) { + let mut updated_config = None; + let result = (|| -> anyhow::Result<()> { + updated_config = self.set_native_skill_paths_from_config(&[(manifest, name)], true, false, &config)?; + self.register_published_skill_copy(id, &source, &link, identity, None) + })(); + if let Err(mut error) = result { + if let Some(updated) = updated_config { + let config_path = self.codex_home.join("config.toml"); + let rollback = match std::fs::read(&config_path) { + Ok(actual) if actual == updated => match &config.original { + Some(original) => crate::settings::atomic_write(&config_path, original), + None => std::fs::remove_file(&config_path).map_err(Into::into), + }, + Ok(_) => Err(anyhow::anyhow!("Codex 配置已被其他窗口更新,未覆盖新配置")), + Err(error) => Err(error.into()), + }; + if let Err(rollback) = rollback { + error = error.context(format!("Codex 配置回滚失败:{rollback:#}")); + } + } return match recover_published_path(&link, identity) { Ok(Some(path)) => Err(error.context(format!("新建的 Skill 内容保留在 {}", path.display()))), Ok(None) => Err(error), @@ -2838,10 +3151,428 @@ impl SkillsManager { for (agent, dir) in &self.agent_dirs { roots.push((format!("@local/agent-{agent}/"), dir.join(".codex-plus-backups"))); } + if let Some(shared) = &self.shared_skills_dir { + roots.push(("@local/shared/".into(), shared.join(".codex-plus-backups"))); + } roots } + fn backup_id_for_path(&self, backup: &Path) -> anyhow::Result { + if let Some(id) = self.backup_roots().into_iter().find_map(|(prefix, root)| { + backup.strip_prefix(root).ok().and_then(|name| { + name.to_str().filter(|name| !name.contains(['/', '\\'])) + .map(|name| format!("{prefix}{name}")) + }) + }) { + return Ok(id); + } + let parent = backup.parent().context("Skill 备份没有父目录")?; + anyhow::ensure!(parent.file_name().is_some_and(|name| name == ".codex-plus-backups"), + "无法识别 Skill 备份位置"); + let name = backup.file_name().and_then(|name| name.to_str()).context("Skill 备份名称无效")?; + validate_skill_id(name)?; + Ok(format!("@external/{}/{name}", uuid::Uuid::new_v4())) + } + + fn ensure_duplicate_has_no_discovered_references( + &self, + codex: &Path, + ) -> anyhow::Result<()> { + let target = codex.canonicalize()?; + let identity = skill_path_identity(codex)?; + let linked_dir = self.linked_dir(); + let roots = std::iter::once(self.source_dir.as_path()) + .chain(std::iter::once(linked_dir.as_path())) + .chain(self.shared_skills_dir()) + .chain(self.agent_dirs.iter().map(|(_, root)| root.as_path())); + for root in roots { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error).with_context(|| format!("无法检查 {}", root.display())), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if !file_type.is_dir() && !file_type.is_symlink() { + continue; + } + if skill_path_identity(&path)? == identity { + continue; + } + let resolved = match path.canonicalize() { + Ok(resolved) => resolved, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error).with_context(|| format!("无法检查 {}", path.display())), + }; + if resolved.starts_with(&target) { + anyhow::bail!("此副本仍被其他技能目录引用,归档会使其失效:{}", path.display()); + } + } + } + Ok(()) + } + + /// 预览受控副本和闲置受控源的重复关系,不修改任何路径。 + pub fn preview_skill_duplicates(&self) -> anyhow::Result { + let shared_root = self.shared_skills_dir(); + let shared_skills = match shared_root { + Some(root) => scan_shared_skill_dirs(root)?, + None => Vec::new(), + }; + let native_config = self.read_native_skill_config()?; + let state = { + let _guard = skills_coordination(); + self.load_state_checked_unlocked()? + }; + let mut candidates = Vec::new(); + let mut snapshots = BTreeMap::new(); + let mut managed_sources = BTreeMap::new(); + for (id, _) in shared_skills { + let shared = shared_root.unwrap().join(&id); + let codex = self.linked_dir().join(&id); + if !codex.join(SKILL_MANIFEST_FILE).is_file() { continue; } + let mut candidate = SkillDuplicate { + id: id.clone(), + kind: "shared".into(), + shared_path: shared.to_string_lossy().into_owned(), + codex_path: codex.to_string_lossy().into_owned(), + original_root_kind: "shared".into(), + status: String::new(), + detail: None, + }; + let comparison = (|| -> anyhow::Result> { + let shared_target = shared.canonicalize().context("无法解析共享 Skill 路径")?; + if codex.canonicalize()? == shared_target { + candidate.status = "same-location".into(); + return Ok(None); + } + if !is_managed_copy(&codex) || !state.installed.contains_key(&id) + || !self.source_dir.join(&id).is_dir() + { + candidate.status = "unmanaged".into(); + return Ok(None); + } + self.ensure_duplicate_has_no_discovered_references(&codex)?; + if shared_target.join(MANAGED_COPY_MARKER).exists() { + anyhow::bail!("共享目录含有管理器标记,无法确认完整内容"); + } + let shared_identity = skill_path_identity(&shared)?; + let shared_target_identity = skill_path_identity(&shared_target)?; + let codex_identity = skill_path_identity(&codex)?; + let shared_digest = skill_directory_digest(&shared_target)? + .context("共享 Skill 目录已不存在")?; + let codex_digest = skill_directory_digest(&codex)? + .context("Codex Skill 目录已不存在")?; + if shared_digest != codex_digest { + candidate.status = "different".into(); + return Ok(None); + } + let (shared_name, _) = read_skill_manifest(&shared.join(SKILL_MANIFEST_FILE), &id); + let (codex_name, _) = read_skill_manifest(&codex.join(SKILL_MANIFEST_FILE), &id); + let shared_manifest = shared.join(SKILL_MANIFEST_FILE); + let codex_manifest = codex.join(SKILL_MANIFEST_FILE); + let shared_name = native_config.name_for_matching(&shared_manifest, &shared_name)?; + let codex_name = native_config.name_for_matching(&codex_manifest, &codex_name)?; + if !native_config.enabled(&shared_manifest, &shared_name)? + && native_config.enabled(&codex_manifest, &codex_name)? + { + candidate.status = "enabled-only-copy".into(); + candidate.detail = Some("共享路径已停用,归档会使 Codex 无法使用此 Skill".into()); + return Ok(None); + } + if skill_path_identity(&shared)? != shared_identity + || skill_path_identity(&shared_target)? != shared_target_identity + || skill_path_identity(&codex)? != codex_identity + { + anyhow::bail!("比较期间 Skill 路径已改变,请重试"); + } + candidate.status = "identical-managed".into(); + Ok(Some(DuplicateSnapshot { + shared_path: shared.clone(), shared_target, codex_path: codex.clone(), + shared_identity, shared_target_identity, codex_identity, digest: shared_digest, + })) + })(); + match comparison { + Ok(Some(snapshot)) => { snapshots.insert(id, snapshot); } + Ok(None) => {} + Err(error) => { + candidate.status = "unsupported".into(); + candidate.detail = Some(format!("{error:#}")); + } + } + if candidate.status != "same-location" { candidates.push(candidate); } + } + for (id, installed) in &state.installed { + let source = self.source_dir.join(id); + if !source.join(SKILL_MANIFEST_FILE).is_file() { continue; } + let mut roots = vec![("codex".to_string(), self.linked_dir())]; + roots.extend(self.shared_skills_dir().map(|root| ("shared".to_string(), root.to_path_buf()))); + roots.extend(self.agent_dirs.iter().cloned()); + let mut originals = BTreeMap::new(); + for (kind, root) in roots { + let path = root.join(id); + if path.join(SKILL_MANIFEST_FILE).is_file() { + originals.entry(path.canonicalize()?).or_insert((kind, path)); + } + } + let recorded = installed.original_dir.as_ref().and_then(|path| path.canonicalize().ok()); + let original = originals.iter() + .find(|(path, _)| Some(*path) == recorded.as_ref()) + .or_else(|| originals.iter().find(|(_, (kind, path))| + kind == "shared" && !is_managed_copy(path))) + .or_else(|| originals.iter().find(|(_, (kind, _))| kind == "codex")) + .or_else(|| originals.iter().find(|(_, (kind, _))| kind == "shared")); + let Some((original, (kind, displayed))) = original else { continue }; + let mut candidate = SkillDuplicate { + id: id.clone(), kind: "source".into(), + shared_path: displayed.to_string_lossy().into_owned(), + codex_path: source.to_string_lossy().into_owned(), + original_root_kind: kind.clone(), status: String::new(), detail: None, + }; + let comparison = (|| -> anyhow::Result> { + let metadata = std::fs::symlink_metadata(&source)?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata) + && !source.join(MANAGED_COPY_MARKER).exists(), "受控源不是可归档的实体目录"); + self.ensure_duplicate_has_no_discovered_references(&source)?; + anyhow::ensure!(matches!(kind.as_str(), "codex" | "shared"), + "缺少 Codex 安装原件,请分别处理"); + anyhow::ensure!(installed.update_owner != "manager", + "此受控源负责当前更新,不能归档"); + if let Some(recorded) = &installed.original_dir { + anyhow::ensure!(recorded.canonicalize().ok().as_ref() == Some(original), + "当前安装记录指向其他原件,不能归档"); + } + anyhow::ensure!(!state.instance_targets.get(id).is_some_and(|target| + target.path.canonicalize().ok().as_ref() != Some(original)), + "当前关联指向其他原件,不能归档"); + anyhow::ensure!(state.instance_records.get(id).is_none_or(|records| records.is_empty()), + "受控源属于现有更新记录,不能归档"); + anyhow::ensure!(!state.update_backup_groups.values().any(|group| + group.published.iter().any(|published| published.path == source)), + "现有更新备份需要此受控源,不能归档"); + let source_identity = skill_path_identity(&source)?; + let original_identity = skill_path_identity(original)?; + let digest = skill_directory_digest(&source)?.context("受控源已不存在")?; + if skill_directory_digest(original)? != Some(digest) { + candidate.status = "different".into(); + return Ok(None); + } + let mut managed_copies = Vec::new(); + for (path, (_, displayed)) in &originals { + if path == original { continue; } + anyhow::ensure!(is_managed_copy(displayed), + "其他安装位置的归属不明确,不能归档:{}", displayed.display()); + anyhow::ensure!(skill_directory_digest(path)? == Some(digest), + "其他受控副本内容不同,不能归档:{}", displayed.display()); + managed_copies.push(SkillInstanceTarget { + path: displayed.clone(), identity: skill_path_identity(displayed)?, + }); + } + anyhow::ensure!(skill_path_identity(&source)? == source_identity + && skill_path_identity(original)? == original_identity, + "比较期间 Skill 路径已改变,请重试"); + candidate.status = "identical-source".into(); + Ok(Some(ManagedSourceSnapshot { + source_path: source.clone(), source_identity, + original_path: original.clone(), original_identity, managed_copies, digest, + installed: installed.clone(), + })) + })(); + match comparison { + Ok(Some(snapshot)) => { managed_sources.insert(id.clone(), snapshot); } + Ok(None) => {} + Err(error) => { + candidate.status = "unsupported".into(); + candidate.detail = Some(format!("{error:#}")); + } + } + candidates.push(candidate); + } + let preview_id = uuid::Uuid::new_v4().to_string(); + let mut previews = duplicate_previews(); + if previews.len() >= 32 { + let oldest = previews.keys().next().cloned().unwrap(); + previews.remove(&oldest); + } + previews.insert(preview_id.clone(), DuplicatePreviewSnapshot { + scope: self.coordination_scope(), candidates: snapshots, managed_sources, + }); + Ok(SkillDuplicatePreview { preview_id, candidates }) + } + + /// 将未被引用的受控源归档,并把唯一的实际安装位置登记为原件。 + pub fn archive_skill_managed_source(&self, preview_id: &str, id: &str) -> anyhow::Result { + validate_skill_id(id)?; + let preview = duplicate_previews().get(preview_id).cloned() + .context("重复 Skill 预览已失效,请重新预览")?; + let snapshot = preview.managed_sources.get(id).context("此受控源不在可归档的预览中")?; + let mut coordination = skills_coordination(); + anyhow::ensure!(coordination_scope_is_current(&preview.scope, &self.coordination_scope()) + && snapshot.source_path == self.source_dir.join(id), "Skill 管理范围已改变,请重新预览"); + let mut state = self.load_state_checked_unlocked()?; + anyhow::ensure!(state.installed.get(id) == Some(&snapshot.installed) + && state.instance_records.get(id).is_none_or(|records| records.is_empty()) + && !state.update_backup_groups.values().any(|group| + group.published.iter().any(|published| published.path == snapshot.source_path)) + && state.instance_targets.get(id).is_none_or(|target| + target.path.canonicalize().ok().as_ref() == Some(&snapshot.original_path) + && skill_path_identity(&target.path).ok() == Some(target.identity)), + "Skill 安装记录已改变,请重新预览"); + self.ensure_duplicate_has_no_discovered_references(&snapshot.source_path)?; + let mut roots = vec![self.linked_dir()]; + roots.extend(self.shared_skills_dir().map(Path::to_path_buf)); + roots.extend(self.agent_dirs.iter().map(|(_, root)| root.clone())); + let root_enabled = roots.iter().any(|root| + root.join(id).canonicalize().ok().as_ref() == Some(&snapshot.original_path)); + let originals = roots.into_iter().map(|root| root.join(id)) + .filter(|path| path.join(SKILL_MANIFEST_FILE).is_file()) + .map(|path| path.canonicalize()) + .collect::>>()?; + let mut expected = BTreeSet::from([snapshot.original_path.clone()]); + for copy in &snapshot.managed_copies { + anyhow::ensure!(is_managed_copy(©.path) + && skill_path_identity(©.path)? == copy.identity + && skill_directory_digest(©.path)? == Some(snapshot.digest), + "受控副本在预览后发生改变,请重新预览:{}", copy.path.display()); + expected.insert(copy.path.canonicalize()?); + } + let source_metadata = std::fs::symlink_metadata(&snapshot.source_path)?; + anyhow::ensure!(root_enabled && originals == expected + && source_metadata.is_dir() && !metadata_is_link(&source_metadata) + && !snapshot.source_path.join(MANAGED_COPY_MARKER).exists() + && skill_path_identity(&snapshot.source_path)? == snapshot.source_identity + && skill_path_identity(&snapshot.original_path)? == snapshot.original_identity + && skill_directory_digest(&snapshot.source_path)? == Some(snapshot.digest) + && skill_directory_digest(&snapshot.original_path)? == Some(snapshot.digest), + "Skill 内容或路径在预览后发生改变,请重新预览"); + let backup = self.backup_directory_checked(id, &snapshot.source_path, snapshot.source_identity)?; + let result = (|| -> anyhow::Result<()> { + #[cfg(test)] + AFTER_DUPLICATE_BACKUP.with(|hook| { + if let Some(hook) = hook.borrow_mut().take() { hook(); } + }); + anyhow::ensure!(skill_path_identity(&snapshot.original_path)? == snapshot.original_identity + && skill_directory_digest(&snapshot.original_path)? == Some(snapshot.digest) + && skill_directory_digest(&backup)? == Some(snapshot.digest), + "归档期间 Skill 内容或路径已改变,请重新预览"); + for copy in &snapshot.managed_copies { + anyhow::ensure!(is_managed_copy(©.path) + && skill_path_identity(©.path)? == copy.identity + && skill_directory_digest(©.path)? == Some(snapshot.digest), + "归档期间受控副本已改变:{}", copy.path.display()); + } + let mut installed = snapshot.installed.clone(); + installed.original_dir = Some(snapshot.original_path.clone()); + let target = SkillInstanceTarget { + path: snapshot.original_path.clone(), identity: snapshot.original_identity, + }; + state.installed.insert(id.to_string(), installed.clone()); + state.instance_targets.insert(id.to_string(), target.clone()); + state.instance_records.entry(id.to_string()).or_default().insert( + target.path.clone(), SkillInstanceRecord { + installed, target, managed_copies: snapshot.managed_copies.clone(), + }, + ); + state.backup_origins.insert(self.backup_id_for_path(&backup)?, SkillBackupOrigin { + path: snapshot.source_path.clone(), purpose: "managed-source".into(), + }); + self.save_state_unlocked(&state) + })(); + if let Err(error) = result { + return match restore_skill_backup(&backup, &snapshot.source_path, snapshot.source_identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!("受控源保留在 {}:{rollback:#}", backup.display()))), + }; + } + coordination.changed_nested_skill(self.coordination_scope(), id); + Ok(state) + } + + /// 仅归档预览后路径及全部内容仍未改变的受控副本。 + pub fn archive_skill_duplicate(&self, preview_id: &str, id: &str) -> anyhow::Result { + validate_skill_id(id)?; + let preview = duplicate_previews().get(preview_id).cloned() + .context("重复 Skill 预览已失效,请重新预览")?; + let snapshot = preview.candidates.get(id).context("此 Skill 不在可归档的预览中")?; + let mut coordination = skills_coordination(); + if !coordination_scope_is_current(&preview.scope, &self.coordination_scope()) + || snapshot.codex_path != self.linked_dir().join(id) + || self.shared_skills_dir().map(|root| root.join(id)).as_ref() != Some(&snapshot.shared_path) + { + anyhow::bail!("Skill 路径或管理范围已改变,请重新预览"); + } + let mut state = self.load_state_checked_unlocked()?; + self.ensure_duplicate_has_no_discovered_references(&snapshot.codex_path)?; + let native_config = self.read_native_skill_config()?; + let (shared_name, _) = read_skill_manifest(&snapshot.shared_path.join(SKILL_MANIFEST_FILE), id); + let (codex_name, _) = read_skill_manifest(&snapshot.codex_path.join(SKILL_MANIFEST_FILE), id); + let shared_name = native_config.name_for_matching(&snapshot.shared_path.join(SKILL_MANIFEST_FILE), &shared_name)?; + let codex_name = native_config.name_for_matching(&snapshot.codex_path.join(SKILL_MANIFEST_FILE), &codex_name)?; + anyhow::ensure!( + native_config.enabled(&snapshot.shared_path.join(SKILL_MANIFEST_FILE), &shared_name)? + || !native_config.enabled(&snapshot.codex_path.join(SKILL_MANIFEST_FILE), &codex_name)?, + "共享路径已停用,归档会改变 Codex 启用状态,请重新预览" + ); + if !state.installed.contains_key(id) || !self.source_dir.join(id).is_dir() + || !is_managed_copy(&snapshot.codex_path) + || skill_path_identity(&snapshot.codex_path)? != snapshot.codex_identity + || skill_path_identity(&snapshot.shared_path)? != snapshot.shared_identity + || snapshot.shared_path.canonicalize()? != snapshot.shared_target + || skill_path_identity(&snapshot.shared_target)? != snapshot.shared_target_identity + || snapshot.shared_target.join(MANAGED_COPY_MARKER).exists() + || skill_directory_digest(&snapshot.shared_target)? != Some(snapshot.digest) + || skill_directory_digest(&snapshot.codex_path)? != Some(snapshot.digest) + { + anyhow::bail!("Skill 内容或路径在预览后发生改变,请重新预览"); + } + let backup = self.backup_directory_checked(id, &snapshot.codex_path, snapshot.codex_identity)?; + let result = (|| -> anyhow::Result<()> { + #[cfg(test)] + AFTER_DUPLICATE_BACKUP.with(|hook| { + if let Some(hook) = hook.borrow_mut().take() { hook(); } + }); + anyhow::ensure!(skill_path_identity(&snapshot.shared_path)? == snapshot.shared_identity + && snapshot.shared_path.canonicalize()? == snapshot.shared_target + && skill_path_identity(&snapshot.shared_target)? == snapshot.shared_target_identity + && skill_directory_digest(&snapshot.shared_target)? == Some(snapshot.digest) + && skill_directory_digest(&backup)? == Some(snapshot.digest), + "归档期间 Skill 内容或路径已改变,请重新预览"); + let backup_id = self.backup_id_for_path(&backup)?; + state.backup_origins.insert(backup_id, SkillBackupOrigin { + path: snapshot.codex_path.clone(), purpose: "duplicate".into(), + }); + self.save_state_unlocked(&state) + })(); + if let Err(error) = result { + return match restore_skill_backup(&backup, &snapshot.codex_path, snapshot.codex_identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!("重复副本保留在 {}:{rollback:#}", backup.display()))), + }; + } + coordination.changed_nested_skill(self.coordination_scope(), id); + Ok(state) + } + fn resolve_backup(&self, id: &str) -> anyhow::Result<(PathBuf, String)> { + if let Some(rest) = id.strip_prefix("@external/") { + let (token, name) = rest.split_once('/').context("外部 Skill 备份标识无效")?; + uuid::Uuid::parse_str(token).context("外部 Skill 备份标识无效")?; + validate_skill_id(name)?; + let state = self.load_state_checked_unlocked()?; + let origin = state.backup_origins.get(id).context("外部 Skill 备份未登记")?; + let (skill_id, _) = split_backup_id(name); + let group = state.update_backup_groups.values() + .find(|group| group.backup_ids.iter().any(|member| member == id)) + .context("外部 Skill 备份不属于更新备份组")?; + anyhow::ensure!(origin.purpose == "update" && group.installed.id == skill_id + && origin.path.is_absolute() + && group.published.iter().any(|item| item.path == origin.path), + "外部 Skill 备份与当前原目录不一致"); + let parent = origin.path.parent().context("Skill 原目录没有父目录")?; + return Ok((parent.join(".codex-plus-backups").join(name), name.to_string())); + } // 带 '/' 的命名空间不可能与合法旧 Skill ID 混淆;只接受已配置根的前缀。 for (prefix, root) in self.backup_roots().into_iter().filter(|(prefix, _)| !prefix.is_empty()) { if let Some(name) = id.strip_prefix(&prefix) { @@ -2853,56 +3584,431 @@ impl SkillsManager { Ok((self.backups_dir.join(id), id.to_string())) } + pub fn skill_file_times(&self, id: &str, root_kind: &str, backup_id: Option<&str>) -> anyhow::Result> { + validate_skill_id(id)?; + let root = if let Some(backup_id) = backup_id { + let (path, name) = self.resolve_backup(backup_id)?; + anyhow::ensure!(split_backup_id(&name).0 == id, "备份与 Skill 不匹配"); + let metadata = std::fs::symlink_metadata(&path)?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), "Skill 备份不是实体目录"); + path + } else { + let dir = match root_kind { + "codex" => self.linked_dir(), + "system" => self.linked_dir().join(BUNDLED_SKILLS_DIR), + "shared" => self.shared_skills_dir().context("未配置共享 Skill 目录")?.to_path_buf(), + "source" => self.source_dir.clone(), + agent => self.agent_dir(agent)?, + }; + let path = dir.join(id); + anyhow::ensure!(path.join(SKILL_MANIFEST_FILE).is_file(), "Skill 安装位置不存在"); + path + }; + fn collect(root: &Path, dir: &Path, files: &mut Vec) -> anyhow::Result<()> { + for entry in std::fs::read_dir(dir).with_context(|| format!("读取目录失败:{}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path) + .with_context(|| format!("读取文件信息失败:{}", path.display()))?; + if metadata.is_dir() && !metadata_is_link(&metadata) { + collect(root, &path, files)?; + } else if metadata.is_file() || metadata_is_link(&metadata) { + let modified = metadata.modified()?; + let modified_at_ms = match modified.duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => i64::try_from(duration.as_millis())?, + Err(error) => -i64::try_from(error.duration().as_millis())?, + }; + files.push(SkillFileTime { + path: path.strip_prefix(root)?.to_string_lossy().into_owned(), + modified_at_ms, + is_link: metadata_is_link(&metadata), + }); + } + } + Ok(()) + } + let mut files = Vec::new(); + collect(&root, &root, &mut files)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) + } + pub fn list_backups(&self) -> Vec { + self.try_list_backups().expect("读取 Skill 备份记录失败") + } + + pub fn try_list_backups(&self) -> anyhow::Result> { + let state = self.load_state_checked_unlocked()?; let mut backups = Vec::new(); let mut seen = BTreeSet::new(); + let group_messages = |backup_id: &str| { + let group = state.update_backup_groups.values() + .find(|group| group.backup_ids.iter().any(|id| id == backup_id)); + let restore_blocked = group.filter(|group| group.partially_deleted) + .map(|_| "更新备份组已有成员被删除,无法整体恢复".into()); + let delete_warning = group.filter(|group| !group.partially_deleted && group.backup_ids.len() > 1) + .map(|_| "删除后,此更新备份组将无法整体恢复;其他备份文件会保留".into()); + (restore_blocked, delete_warning) + }; for (prefix, root) in self.backup_roots() { - let Ok(resolved) = root.canonicalize() else { continue }; - let Ok(identity) = skill_path_identity(&resolved) else { continue }; + let resolved = match root.canonicalize() { + Ok(resolved) => resolved, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error).with_context(|| format!("无法读取 Skill 备份目录:{}", root.display())), + }; + let identity = skill_path_identity(&resolved) + .with_context(|| format!("无法检查 Skill 备份目录:{}", root.display()))?; if !seen.insert(identity) { continue; } - let Ok(dir) = std::fs::read_dir(root) else { continue }; - for entry in dir.flatten() { + let dir = std::fs::read_dir(&root) + .with_context(|| format!("无法读取 Skill 备份目录:{}", root.display()))?; + for entry in dir { + let entry = entry.with_context(|| format!("无法读取 Skill 备份目录:{}", root.display()))?; let path = entry.path(); - if !std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_dir()) { continue; } + let metadata = std::fs::symlink_metadata(&path) + .with_context(|| format!("无法读取 Skill 备份:{}", path.display()))?; + if !metadata.is_dir() || metadata_is_link(&metadata) { continue; } let Some(id) = path.file_name().and_then(|name| name.to_str()) else { continue }; + if id.starts_with('.') { continue; } let (skill_id, backed_up_at) = split_backup_id(id); let (name, _) = read_skill_manifest(&path.join(SKILL_MANIFEST_FILE), &skill_id); - backups.push(SkillBackup { id: format!("{prefix}{id}"), skill_id, name, backed_up_at }); + let backup_id = format!("{prefix}{id}"); + let origin = state.backup_origins.get(&backup_id); + let (restore_blocked_reason, delete_warning) = group_messages(&backup_id); + backups.push(SkillBackup { + id: backup_id, + skill_id, + name, + backed_up_at, + backup_path: path.to_string_lossy().into_owned(), + restore_path: origin.map(|value| value.path.to_string_lossy().into_owned()), + purpose: origin.map(|value| value.purpose.clone()), + restore_blocked_reason, + delete_warning, + }); + } + } + for backup_id in state.backup_origins.keys().filter(|id| id.starts_with("@external/")) { + let (path, name) = self.resolve_backup(backup_id)?; + let metadata = std::fs::symlink_metadata(&path)?; + if !metadata.is_dir() || metadata_is_link(&metadata) { + anyhow::bail!("Skill 备份不是实体目录:{}", path.display()); } + let (skill_id, backed_up_at) = split_backup_id(&name); + let (name, _) = read_skill_manifest(&path.join(SKILL_MANIFEST_FILE), &skill_id); + let origin = &state.backup_origins[backup_id]; + let (restore_blocked_reason, delete_warning) = group_messages(backup_id); + backups.push(SkillBackup { + id: backup_id.clone(), skill_id, name, backed_up_at, + backup_path: path.to_string_lossy().into_owned(), + restore_path: Some(origin.path.to_string_lossy().into_owned()), + purpose: Some(origin.purpose.clone()), + restore_blocked_reason, delete_warning, + }); } backups.sort_by(|a, b| b.id.cmp(&a.id)); - backups + Ok(backups) } /// 从备份恢复:目录移回 SSOT 并重新建链。 pub fn restore_backup(&self, backup_id: &str) -> anyhow::Result { let mut coordination = skills_coordination(); let (backup, backup_name) = self.resolve_backup(backup_id)?; - if !backup.is_dir() { - anyhow::bail!("备份不存在:{}", backup.display()); - } + let metadata = std::fs::symlink_metadata(&backup) + .with_context(|| format!("读取备份失败:{}", backup.display()))?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), + "备份不是实体目录:{}", backup.display()); let (skill_id, _) = split_backup_id(&backup_name); validate_skill_id(&skill_id)?; - let current = self.load_state_checked_unlocked()?; - self.ensure_skill_name_not_aliased(&skill_id, ¤t)?; - let destination = self.source_dir.join(&skill_id); - if current.installed.contains_key(&skill_id) - || std::fs::symlink_metadata(&destination).is_ok() - || std::fs::symlink_metadata(self.linked_dir().join(&skill_id)).is_ok() + let mut current = self.load_state_checked_unlocked()?; + if let Some((group_id, group)) = current.update_backup_groups.iter() + .find(|(_, group)| group.backup_ids.iter().any(|id| id == backup_id)) + .map(|(id, group)| (id.clone(), group.clone())) { - anyhow::bail!("{skill_id} 已经装着了,先卸载再从备份恢复"); - } - std::fs::create_dir_all(&self.source_dir) - .with_context(|| format!("创建目录失败:{}", self.source_dir.display()))?; - let staging = self.staging_dir(&skill_id); - std::fs::create_dir(&staging) - .with_context(|| format!("创建 Skill 暂存目录失败:{}", staging.display()))?; - if let Err(error) = copy_dir_all(&backup, &staging) { - return Err(retain_staging_context(error, &staging)); - } - let (name, description) = - read_skill_manifest(&staging.join(SKILL_MANIFEST_FILE), &skill_id); - let result = self.commit_staged_skill_unlocked( + anyhow::ensure!(!group.partially_deleted, "更新备份组已有成员被删除,无法整体恢复"); + let instance = group.instance_path.as_ref().map(|path| + current.instance_records.get(&skill_id).and_then(|records| records.get(path)).cloned() + .context("Skill 原件记录已消失,不能恢复更新")).transpose()?; + let installed = instance.as_ref().map(|record| record.installed.clone()) + .or_else(|| current.installed.get(&skill_id).cloned()).context("Skill 已卸载,不能恢复更新")?; + anyhow::ensure!(group.installed.id == skill_id + && group.installed.repo_key == installed.repo_key + && group.installed.repo_path == installed.repo_path + && !group.backup_ids.is_empty(), "Skill 来源已改变,不能恢复此更新"); + anyhow::ensure!(group.published.len() == group.backup_ids.len(), + "此更新备份缺少发布时的路径身份,无法安全自动恢复"); + let mut paths = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current_installed = installed.clone(); + for member in &group.backup_ids { + let origin = current.backup_origins.get(member).context("更新备份组不完整")?; + let (old_backup, name) = self.resolve_backup(member)?; + let metadata = std::fs::symlink_metadata(&old_backup) + .with_context(|| format!("读取备份失败:{}", old_backup.display()))?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), + "备份不是实体目录:{}", old_backup.display()); + anyhow::ensure!(split_backup_id(&name).0 == skill_id && origin.purpose == "update" + && seen.insert(origin.path.clone()), "更新备份组包含无效或重复的路径"); + let original = installed.original_dir.as_ref().and_then(|path| path.canonicalize().ok()) + .zip(origin.path.canonicalize().ok()) + .is_some_and(|(original, target)| original == target); + let source = origin.path == self.source_dir.join(&skill_id); + let agent = origin.path == self.linked_dir().join(&skill_id) + || self.agent_dirs.iter().any(|(_, root)| origin.path == root.join(&skill_id)); + anyhow::ensure!(original || source || agent, "更新备份的原路径与当前 Skill 不一致"); + let metadata = std::fs::symlink_metadata(&origin.path) + .with_context(|| format!("当前 Skill 目录不存在:{}", origin.path.display()))?; + anyhow::ensure!(metadata.file_type().is_dir(), "当前 Skill 路径不是实体目录"); + anyhow::ensure!(!agent || original || is_managed_copy(&origin.path), + "当前 Skill 副本不再由管理器管理"); + let published = group.published.iter().find(|item| item.path == origin.path) + .context("更新备份缺少对应的路径身份")?; + anyhow::ensure!(skill_path_identity(&origin.path)? == published.identity, + "当前 Skill 目录已被替换,不能自动恢复:{}", origin.path.display()); + let current_digest = skill_directory_digest(&origin.path)?.context("当前 Skill 目录已不存在")?; + if current_digest != published.digest { + current_installed.content_hash.clear(); + } + paths.push((member.clone(), origin.clone(), old_backup.clone(), + skill_path_identity(&old_backup)?, skill_path_identity(&origin.path)?)); + } + let target = instance.as_ref().map(|record| &record.target) + .or_else(|| if group.instance_path.is_none() { current.instance_targets.get(&skill_id) } else { None }); + if let Some(target) = target { + anyhow::ensure!(!paths.is_empty() && paths[0].1.path == target.path + && paths[0].4 == target.identity + && group.installed.original_dir.as_ref() == Some(&target.path), + "更新备份与当前关联的 Skill 原件不一致,不能自动恢复"); + if let Some(instance) = &instance { + for (_, origin, _, _, identity) in paths.iter().skip(1) { + anyhow::ensure!(instance.managed_copies.iter().any(|copy| + copy.path == origin.path && copy.identity == *identity), + "更新副本的身份已改变,不能自动恢复"); + } + } + } + let mut displaced = Vec::new(); + let mut restored = Vec::new(); + let result = (|| -> anyhow::Result<()> { + let mut next_ids = Vec::new(); + for (_, origin, _, _, identity) in &paths { + let next = self.backup_directory_checked(&skill_id, &origin.path, *identity)?; + displaced.push((origin.path.clone(), next.clone(), *identity)); + let next_id = self.backup_id_for_path(&next)?; + current.backup_origins.insert(next_id.clone(), origin.clone()); + next_ids.push(next_id); + } + for (member, origin, old_backup, identity, _) in &paths { + restore_skill_backup(old_backup, &origin.path, *identity)?; + restored.push((origin.path.clone(), old_backup.clone(), *identity)); + current.backup_origins.remove(member); + } + current.update_backup_groups.remove(&group_id); + let mut published = Vec::new(); + for (_, origin, _, identity, _) in &paths { + published.push(SkillPublishedSnapshot { + path: origin.path.clone(), + identity: *identity, + digest: skill_directory_digest(&origin.path)?.context("已恢复 Skill 目录不存在")?, + }); + } + current.update_backup_groups.insert(uuid::Uuid::new_v4().to_string(), + SkillUpdateBackupGroup { installed: current_installed, backup_ids: next_ids, published, + instance_path: group.instance_path.clone(), partially_deleted: false }); + if let Some(instance) = &instance { + let mirror = current.instance_targets.get(&skill_id) == Some(&instance.target); + let record = current.instance_records.get_mut(&skill_id).unwrap() + .get_mut(&instance.target.path).unwrap(); + record.installed = group.installed.clone(); + record.target.identity = skill_path_identity(&record.target.path)?; + for copy in &mut record.managed_copies { + if paths.iter().any(|(_, origin, _, _, _)| origin.path == copy.path) { + copy.identity = skill_path_identity(©.path)?; + } + } + if mirror { + current.installed.insert(skill_id.clone(), group.installed); + current.instance_targets.insert(skill_id.clone(), record.target.clone()); + } + } else { + current.installed.insert(skill_id.clone(), group.installed); + if let Some(target) = current.instance_targets.get_mut(&skill_id) { + target.identity = skill_path_identity(&target.path)?; + } + } + self.save_state_unlocked(¤t) + })(); + if let Err(error) = result { + let mut failures = Vec::new(); + for (original, backup, identity) in restored.iter().rev() { + if let Err(rollback) = restore_skill_backup(original, backup, *identity) { + failures.push(format!("旧版本回滚失败:{rollback:#}")); + } + } + for (original, backup, identity) in displaced.iter().rev() { + if let Err(rollback) = restore_skill_backup(backup, original, *identity) { + failures.push(format!("当前版本保留在 {}:{rollback:#}", backup.display())); + } + } + return if failures.is_empty() { Err(error) } else { Err(error.context(failures.join(";"))) }; + } + coordination.changed_nested_skill(self.coordination_scope(), &skill_id); + return Ok(current); + } + if let Some(origin) = current.backup_origins.get(backup_id).cloned() { + anyhow::ensure!(origin.purpose != "update", "此旧版更新备份缺少完整的备份组记录,不能自动恢复"); + if matches!(origin.purpose.as_str(), "disabled" | "managed-source") { + self.ensure_duplicate_has_no_discovered_references(&backup)?; + } + let installed = current.installed.get(&skill_id); + let agent_copy = origin.path == self.linked_dir().join(&skill_id) + || self.agent_dirs.iter().any(|(_, dir)| origin.path == dir.join(&skill_id)); + let allowed = match origin.purpose.as_str() { + "duplicate" => origin.path == self.linked_dir().join(&skill_id), + "disabled" => agent_copy, + "managed-source" => origin.path == self.source_dir.join(&skill_id), + "update" => installed.is_some() && ( + installed.and_then(|item| item.original_dir.as_ref()) == Some(&origin.path) + || origin.path == self.source_dir.join(&skill_id) + || agent_copy + ), + _ => false, + }; + if !allowed { + anyhow::bail!("备份的原路径与当前 Skill 记录不一致,未恢复"); + } + let backup_identity = skill_path_identity(&backup)?; + let mut displaced: Option<(PathBuf, SkillPathIdentity)> = None; + if std::fs::symlink_metadata(&origin.path).is_ok() { + if origin.purpose != "update" { + anyhow::bail!("原位置已有内容,未覆盖:{}", origin.path.display()); + } + if !std::fs::symlink_metadata(&origin.path)?.file_type().is_dir() { + anyhow::bail!("原位置不是实体目录,未覆盖:{}", origin.path.display()); + } + if agent_copy + && installed.and_then(|item| item.original_dir.as_ref()) != Some(&origin.path) + && !is_managed_copy(&origin.path) + { + anyhow::bail!("原位置不是受控 Skill 副本,未覆盖:{}", origin.path.display()); + } + let identity = skill_path_identity(&origin.path)?; + let new_backup = self.backup_directory_checked(&skill_id, &origin.path, identity)?; + displaced = Some((new_backup, identity)); + } + let native_change = if origin.purpose == "duplicate" { + let shared = self.shared_skills_dir() + .context("共享 Skill 目录已不可用,无法确认恢复后的启用状态")? + .join(&skill_id).join(SKILL_MANIFEST_FILE); + anyhow::ensure!(shared.is_file(), "共享 Skill 已不存在,无法确认恢复后的启用状态"); + let config = self.read_native_skill_config()?; + let (shared_name, _) = read_skill_manifest(&shared, &skill_id); + let backup_manifest = backup.join(SKILL_MANIFEST_FILE); + let (mut copy_name, _) = read_skill_manifest(&backup_manifest, &skill_id); + let shared_name = config.name_for_matching(&shared, &shared_name)?; + if config.rules.iter().any(|rule| rule.name.is_some()) { + copy_name = read_native_skill_name(&backup_manifest)?; + } + let enabled = config.enabled(&shared, &shared_name)?; + let updated = self.set_native_skill_paths_from_config( + &[(origin.path.join(SKILL_MANIFEST_FILE), copy_name)], enabled, false, &config, + )?; + Some((config.original, updated)) + } else { + None + }; + let result = (|| -> anyhow::Result<()> { + if let Some((original, updated)) = &native_change { + let actual = match std::fs::read(self.codex_home.join("config.toml")) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + anyhow::ensure!(actual.as_ref() == updated.as_ref().or(original.as_ref()), + "Codex 配置已被其他窗口更新,请重试;未覆盖新配置"); + } + if let Some((new_backup, _)) = &displaced { + current.backup_origins.insert(self.backup_id_for_path(new_backup)?, origin.clone()); + } + restore_skill_backup(&backup, &origin.path, backup_identity)?; + current.backup_origins.remove(backup_id); + if origin.purpose == "managed-source" { + if let Some(records) = current.instance_records.get_mut(&skill_id) { + for record in records.values_mut() { + if skill_path_identity(&record.target.path)? == record.target.identity + && skill_directory_digest(&record.target.path)? == skill_directory_digest(&origin.path)? + { + record.managed_copies.retain(|copy| copy.path != origin.path); + record.managed_copies.push(SkillInstanceTarget { + path: origin.path.clone(), identity: backup_identity, + }); + break; + } + } + } + } + if let Some((original, updated)) = &native_change { + let actual = match std::fs::read(self.codex_home.join("config.toml")) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + anyhow::ensure!(actual.as_ref() == updated.as_ref().or(original.as_ref()), + "Codex 配置已被其他窗口更新,请重试;未覆盖新配置"); + } + self.save_state_unlocked(¤t) + })(); + if let Err(error) = result { + let mut failures = Vec::new(); + if skill_path_identity(&origin.path).ok() == Some(backup_identity) { + if let Err(rollback) = restore_skill_backup(&origin.path, &backup, backup_identity) { + failures.push(format!("旧版本回滚失败:{rollback:#}")); + } + } + if let Some((new_backup, identity)) = &displaced { + if let Err(rollback) = restore_skill_backup(new_backup, &origin.path, *identity) { + failures.push(format!("当前版本保留在 {}:{rollback:#}", new_backup.display())); + } + } + if let Some((original, Some(updated))) = &native_change { + let config_path = self.codex_home.join("config.toml"); + match std::fs::read(&config_path) { + Ok(actual) if actual == *updated => { + let rollback = match original { + Some(bytes) => crate::settings::atomic_write(&config_path, bytes), + None => std::fs::remove_file(&config_path).map_err(Into::into), + }; + if let Err(rollback) = rollback { + failures.push(format!("Codex 配置回滚失败:{rollback:#}")); + } + } + Ok(_) => failures.push("Codex 配置已被其他窗口更新,未覆盖新配置".into()), + Err(rollback) => failures.push(format!("无法检查 Codex 配置,未覆盖:{rollback}")), + } + } + return if failures.is_empty() { Err(error) } else { Err(error.context(failures.join(";"))) }; + } + coordination.changed_nested_skill(self.coordination_scope(), &skill_id); + return Ok(current); + } + self.ensure_skill_name_not_aliased(&skill_id, ¤t)?; + let destination = self.source_dir.join(&skill_id); + if current.installed.contains_key(&skill_id) + || std::fs::symlink_metadata(&destination).is_ok() + || std::fs::symlink_metadata(self.linked_dir().join(&skill_id)).is_ok() + { + anyhow::bail!("{skill_id} 已经装着了,先卸载再从备份恢复"); + } + std::fs::create_dir_all(&self.source_dir) + .with_context(|| format!("创建目录失败:{}", self.source_dir.display()))?; + let staging = self.staging_dir(&skill_id); + std::fs::create_dir(&staging) + .with_context(|| format!("创建 Skill 暂存目录失败:{}", staging.display()))?; + if let Err(error) = copy_dir_all_inner(&backup, &staging, true, true) { + return Err(retain_staging_context(error, &staging)); + } + let (name, description) = + read_skill_manifest(&staging.join(SKILL_MANIFEST_FILE), &skill_id); + let result = self.commit_staged_skill_unlocked( &staging, InstalledSkill { id: skill_id.clone(), @@ -2912,12 +4018,16 @@ impl SkillsManager { repo_path: String::new(), content_hash: String::new(), installed_at: current_unix_timestamp_string(), - ..Default::default() + verified_cc_switch: false, + install_channel: "local".into(), + original_dir: None, + update_owner: "manager".into(), }, true, true, None, false, + None, &mut coordination, ); let state = result.map_err(|error| retain_staging_context(error, &staging))?; @@ -2931,12 +4041,48 @@ impl SkillsManager { /// 显式删除单个备份。AGENTS.md 禁止批量删除,所以只删指定的这一个。 pub fn delete_backup(&self, backup_id: &str) -> anyhow::Result> { let _guard = skills_coordination(); - let (backup, _) = self.resolve_backup(backup_id)?; - if backup.is_dir() { - std::fs::remove_dir_all(&backup) - .with_context(|| format!("删除备份失败:{}", backup.display()))?; + let mut state = self.load_state_checked_unlocked()?; + let group = state.update_backup_groups.iter() + .find(|(_, group)| group.backup_ids.iter().any(|id| id == backup_id)) + .map(|(id, _)| id.clone()); + let (backup, name) = self.resolve_backup(backup_id)?; + anyhow::ensure!(!name.starts_with('.'), "临时目录不是可删除的 Skill 备份"); + let metadata = std::fs::symlink_metadata(&backup) + .with_context(|| format!("读取备份失败:{}", backup.display()))?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), + "备份不是实体目录,未删除:{}", backup.display()); + self.ensure_duplicate_has_no_discovered_references(&backup)?; + let identity = skill_path_identity(&backup)?; + let staged = recover_published_path(&backup, identity)? + .context("备份已不存在,未删除")?; + let mut state_changed = state.backup_origins.remove(backup_id).is_some(); + if let Some(group_id) = group { + let group = state.update_backup_groups.get_mut(&group_id).unwrap(); + group.backup_ids.retain(|id| id != backup_id); + group.partially_deleted = true; + if group.backup_ids.is_empty() { + state.update_backup_groups.remove(&group_id); + } + state_changed = true; } - Ok(self.list_backups()) + if state_changed { + if let Err(error) = self.save_state_unlocked(&state) { + return match restore_skill_backup(&staged, &backup, identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!("备份保留在 {}:{rollback:#}", staged.display()))), + }; + } + } + drop(_guard); + #[cfg(test)] + let removal = DELETE_SKILL_BACKUP_HOOK.with(|hook| hook.borrow_mut().take().map(|hook| hook(&staged))) + .unwrap_or_else(|| std::fs::remove_dir_all(&staged)); + #[cfg(not(test))] + let removal = std::fs::remove_dir_all(&staged); + if let Err(error) = removal { + anyhow::bail!("删除备份失败,残余文件可能不完整,保留在 {}:{error}", staged.display()); + } + self.try_list_backups() } fn staging_dir(&self, id: &str) -> PathBuf { @@ -4179,6 +5325,8 @@ const MANAGED_COPY_MARKER: &str = ".codex-plus-managed"; #[cfg(test)] thread_local! { static DIGEST_HOOK: std::cell::RefCell>> = std::cell::RefCell::new(None); + static AFTER_DUPLICATE_BACKUP: std::cell::RefCell>> = + std::cell::RefCell::new(None); static FORCE_SKILL_COPY: std::cell::Cell = const { std::cell::Cell::new(false) }; } @@ -4368,6 +5516,9 @@ fn is_cross_device_error(error: &anyhow::Error) -> bool { #[cfg(test)] thread_local! { static FORCE_CROSS_DEVICE_BACKUP: std::cell::Cell = const { std::cell::Cell::new(false) }; + static FAIL_SKILL_STATE_SAVE: std::cell::Cell = const { std::cell::Cell::new(false) }; + static DELETE_SKILL_BACKUP_HOOK: std::cell::RefCell std::io::Result<()>>>> = + const { std::cell::RefCell::new(None) }; } fn move_skill_to_backup(source: &Path, target: &Path) -> anyhow::Result<()> { @@ -4530,6 +5681,10 @@ fn metadata_is_link(metadata: &std::fs::Metadata) -> bool { } fn copy_dir_all(source: &Path, destination: &Path) -> anyhow::Result<()> { + copy_dir_all_inner(source, destination, false, true) +} + +fn copy_dir_all_inner(source: &Path, destination: &Path, reject_links: bool, root: bool) -> anyhow::Result<()> { std::fs::create_dir_all(destination) .with_context(|| format!("创建目录失败:{}", destination.display()))?; for entry in @@ -4537,13 +5692,16 @@ fn copy_dir_all(source: &Path, destination: &Path) -> anyhow::Result<()> { { let entry = entry?; // 副本标记只属于那一份副本,不跟着复制(例如停用 Codex 的副本时复制回受控源)。 - if entry.file_name() == MANAGED_COPY_MARKER { + if root && entry.file_name() == MANAGED_COPY_MARKER { continue; } let from = entry.path(); let to = destination.join(entry.file_name()); + if reject_links && metadata_is_link(&std::fs::symlink_metadata(&from)?) { + anyhow::bail!("备份包含无法自动恢复的链接:{}", from.display()); + } if from.is_dir() { - copy_dir_all(&from, &to)?; + copy_dir_all_inner(&from, &to, reject_links, false)?; } else { std::fs::copy(&from, &to).with_context(|| format!("复制 {} 失败", from.display()))?; } @@ -4667,6 +5825,1166 @@ mod tests { use std::io::Write; use std::sync::Arc; + #[test] + fn skill_file_times_list_each_file_for_locations_and_backups() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp).with_shared_skills_dir(temp.path().join("shared")); + let shared = temp.path().join("shared").join("alpha"); + let backup = temp.path().join("skill-backups").join("alpha-123"); + let second_backup = temp.path().join("skill-backups").join("alpha-456"); + let source = temp.path().join("skills").join("alpha"); + write_skill(&shared, "shared"); + write_skill(&backup, "backup"); + write_skill(&second_backup, "second backup"); + write_skill(&source, "source"); + let first_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + let second_time = first_time + std::time::Duration::from_secs(60); + for (path, time) in [(&backup, first_time), (&second_backup, second_time)] { + std::fs::OpenOptions::new().write(true).open(path.join("SKILL.md")).unwrap() + .set_times(std::fs::FileTimes::new().set_modified(time)).unwrap(); + } + std::fs::create_dir_all(shared.join("scripts")).unwrap(); + std::fs::write(shared.join("scripts").join("run.js"), "run").unwrap(); + std::fs::write(backup.join("notes.txt"), "old").unwrap(); + + let shared_files = manager.skill_file_times("alpha", "shared", None).unwrap(); + assert_eq!(shared_files.iter().map(|file| file.path.as_str()).collect::>(), + vec!["SKILL.md", if cfg!(windows) { "scripts\\run.js" } else { "scripts/run.js" }]); + let actual_modified = std::fs::metadata(shared.join("SKILL.md")).unwrap().modified().unwrap() + .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64; + assert_eq!(shared_files[0].modified_at_ms, actual_modified); + let backup_files = manager.skill_file_times("alpha", "", Some("alpha-123")).unwrap(); + assert_eq!(backup_files.iter().map(|file| file.path.as_str()).collect::>(), vec!["SKILL.md", "notes.txt"]); + let second_files = manager.skill_file_times("alpha", "", Some("alpha-456")).unwrap(); + assert_eq!(second_files.iter().map(|file| file.path.as_str()).collect::>(), vec!["SKILL.md"]); + assert_eq!(backup_files[0].modified_at_ms, 1_700_000_000_000); + assert_eq!(second_files[0].modified_at_ms, 1_700_000_060_000); + assert_eq!(manager.skill_file_times("alpha", "source", None).unwrap()[0].path, "SKILL.md"); + assert!(shared_files.iter().all(|file| !file.is_link)); + assert!(manager.skill_file_times("other", "", Some("alpha-123")).is_err()); + assert!(manager.skill_file_times("../alpha", "shared", None).is_err()); + assert!(manager.skill_file_times("alpha", "unknown", None).is_err()); + } + + #[test] + fn skill_file_times_follow_root_links_but_mark_nested_links() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let target = temp.path().join("target"); + let nested_target = temp.path().join("nested-target"); + write_skill(&target, "shared"); + write_skill(&nested_target, "not traversed"); + std::fs::create_dir_all(&shared).unwrap(); + #[cfg(windows)] + for (link, destination) in [(shared.join("alpha"), &target), (target.join("nested"), &nested_target)] { + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]).arg(link).arg(destination).output().unwrap(); + assert!(output.status.success(), "could not create directory junction: {output:?}"); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(&target, shared.join("alpha")).unwrap(); + std::os::unix::fs::symlink(&nested_target, target.join("nested")).unwrap(); + } + let files = manager(&temp).with_shared_skills_dir(&shared) + .skill_file_times("alpha", "shared", None).unwrap(); + assert_eq!(files.iter().map(|file| file.path.as_str()).collect::>(), vec!["SKILL.md", "nested"]); + assert!(!files[0].is_link); + assert!(files[1].is_link); + } + + #[test] + fn disabled_managed_copy_is_reused_without_accumulating_backups() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let original = temp.path().join("external/alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let copy = temp.path().join("claude-skills").join("alpha"); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let enabled = manager.set_enabled_for("claude", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + enabled.unwrap(); + let identity = skill_path_identity(©).unwrap(); + for _ in 0..3 { + manager.set_enabled_for("claude", "alpha", false).unwrap(); + let backups = manager.try_list_backups().unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].purpose.as_deref(), Some("disabled")); + assert_eq!(backups[0].restore_path.as_deref(), copy.to_str()); + assert!(!copy.exists()); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + assert_eq!(skill_path_identity(©).unwrap(), identity); + assert!(manager.try_list_backups().unwrap().is_empty()); + } + manager.set_enabled_for("claude", "alpha", false).unwrap(); + let backup = manager.try_list_backups().unwrap().pop().unwrap(); + manager.restore_backup(&backup.id).unwrap(); + assert_eq!(skill_path_identity(©).unwrap(), identity); + assert!(manager.try_list_backups().unwrap().is_empty()); + } + + #[test] + fn disabled_modified_copy_stays_backed_up_when_source_differs() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let original = temp.path().join("external/alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let copy = temp.path().join("claude-skills/alpha"); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + std::fs::write(copy.join(SKILL_MANIFEST_FILE), "modified").unwrap(); + manager.set_enabled_for("claude", "alpha", false).unwrap(); + let modified = manager.try_list_backups().unwrap().pop().unwrap(); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + manager.set_enabled_for("claude", "alpha", false).unwrap(); + assert_eq!(manager.try_list_backups().unwrap().len(), 2); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + let backups = manager.try_list_backups().unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].id, modified.id); + assert_eq!(std::fs::read_to_string(Path::new(&modified.backup_path).join(SKILL_MANIFEST_FILE)).unwrap(), "modified"); + assert!(std::fs::read_to_string(copy.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("original")); + assert!(manager.restore_backup(&modified.id).is_err()); + } + + #[test] + fn disabled_copy_move_rolls_back_if_state_save_fails() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let original = temp.path().join("external/alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let copy = temp.path().join("claude-skills/alpha"); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + let identity = skill_path_identity(©).unwrap(); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + assert!(manager.set_enabled_for("claude", "alpha", false).is_err()); + assert_eq!(skill_path_identity(©).unwrap(), identity); + assert!(manager.try_list_backups().unwrap().is_empty()); + + manager.set_enabled_for("claude", "alpha", false).unwrap(); + let backup = manager.try_list_backups().unwrap().pop().unwrap(); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + assert!(manager.set_enabled_for("claude", "alpha", true).is_err()); + assert!(!copy.exists()); + assert_eq!(manager.try_list_backups().unwrap()[0].id, backup.id); + assert_eq!(skill_path_identity(Path::new(&backup.backup_path)).unwrap(), identity); + } + + #[test] + fn copying_skill_preserves_nested_files_named_like_the_manager_marker() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let copy = temp.path().join("copy"); + write_skill(&source, "original"); + std::fs::write(source.join(MANAGED_COPY_MARKER), "").unwrap(); + std::fs::create_dir(source.join("nested")).unwrap(); + std::fs::write(source.join("nested").join(MANAGED_COPY_MARKER), "user data").unwrap(); + copy_dir_all(&source, ©).unwrap(); + assert!(!copy.join(MANAGED_COPY_MARKER).exists()); + assert_eq!(std::fs::read_to_string(copy.join("nested").join(MANAGED_COPY_MARKER)).unwrap(), "user data"); + assert_eq!(skill_directory_digest(&source).unwrap(), skill_directory_digest(©).unwrap()); + } + + fn legacy_managed_source_fixture() -> (tempfile::TempDir, SkillsManager, PathBuf, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let source = manager.source_dir.join("alpha"); + let original = manager.linked_dir().join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + copy_dir_all(&original, &source).unwrap(); + std::fs::write(original.join(MANAGED_COPY_MARKER), "").unwrap(); + let mut state = manager.load_state(); + state.installed.insert("alpha".into(), InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: LOCAL_IMPORT_REPO_KEY.into(), repo_path: String::new(), content_hash: String::new(), + installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), + original_dir: None, update_owner: String::new(), + }); + manager.save_state_unlocked(&state).unwrap(); + (temp, manager, source, original) + } + + #[test] + fn idle_managed_source_is_archived_and_reused_for_enabling() { + let (temp, manager, source, original) = legacy_managed_source_fixture(); + let original_identity = skill_path_identity(&original).unwrap(); + let source_identity = skill_path_identity(&source).unwrap(); + let existing_copy = temp.path().join("claude-skills").join("alpha"); + copy_dir_all(&original, &existing_copy).unwrap(); + std::fs::write(existing_copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let copy_identity = skill_path_identity(&existing_copy).unwrap(); + let preview = manager.preview_skill_duplicates().unwrap(); + assert_eq!(preview.candidates.iter().find(|item| item.kind == "source").unwrap().status, "identical-source"); + manager.archive_skill_managed_source(&preview.preview_id, "alpha").unwrap(); + assert!(!source.exists()); + assert_eq!(skill_path_identity(&original).unwrap(), original_identity); + let backup = manager.try_list_backups().unwrap().pop().unwrap(); + assert_eq!(backup.purpose.as_deref(), Some("managed-source")); + assert_eq!(Path::new(backup.restore_path.as_deref().unwrap()), source); + let before = manager.load_state(); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + assert!(manager.restore_backup(&backup.id).is_err()); + assert!(!source.exists()); + assert!(Path::new(&backup.backup_path).join(SKILL_MANIFEST_FILE).is_file()); + assert_eq!(manager.load_state(), before); + let inventory = manager.try_local_inventory().unwrap(); + let item = entry(&inventory, "alpha"); + assert!(item.locations.iter().any(|location| location.root_kind == "codex" + && location.selectable && location.original_path.as_ref() == Some(&original.canonicalize().unwrap()))); + + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let enabled = manager.set_enabled_for("grok", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + enabled.unwrap(); + assert_eq!(skill_path_identity(&source).unwrap(), source_identity); + assert!(temp.path().join("grok-skills/alpha/SKILL.md").is_file()); + assert_eq!(skill_path_identity(&existing_copy).unwrap(), copy_identity); + assert!(manager.try_list_backups().unwrap().is_empty()); + let state = manager.load_state(); + let record = state.instance_records["alpha"].values().next().unwrap(); + assert!(record.managed_copies.iter().any(|copy| copy.path == source && copy.identity == source_identity)); + assert!(record.managed_copies.iter().any(|copy| copy.path == existing_copy && copy.identity == copy_identity)); + } + + #[test] + fn idle_managed_source_restore_rejects_occupied_path_and_rolls_back_state_errors() { + let (_temp, manager, source, original) = legacy_managed_source_fixture(); + let source_identity = skill_path_identity(&source).unwrap(); + let before = manager.load_state(); + let preview = manager.preview_skill_duplicates().unwrap(); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + assert!(manager.archive_skill_managed_source(&preview.preview_id, "alpha").is_err()); + assert_eq!(skill_path_identity(&source).unwrap(), source_identity); + assert_eq!(manager.load_state(), before); + assert!(manager.try_list_backups().unwrap().is_empty()); + + let preview = manager.preview_skill_duplicates().unwrap(); + manager.archive_skill_managed_source(&preview.preview_id, "alpha").unwrap(); + let backup = manager.try_list_backups().unwrap().pop().unwrap(); + write_skill(&source, "new independent content"); + assert!(manager.restore_backup(&backup.id).is_err()); + assert_eq!(std::fs::read_to_string(source.join(SKILL_MANIFEST_FILE)).unwrap(), "new independent content"); + assert!(original.join(SKILL_MANIFEST_FILE).is_file()); + assert!(Path::new(&backup.backup_path).join(SKILL_MANIFEST_FILE).is_file()); + } + + #[test] + fn managed_source_archive_rechecks_complete_contents_after_preview() { + let (_temp, manager, source, original) = legacy_managed_source_fixture(); + let preview = manager.preview_skill_duplicates().unwrap(); + std::fs::write(original.join("new-file"), "independent edit").unwrap(); + assert!(manager.archive_skill_managed_source(&preview.preview_id, "alpha").is_err()); + assert!(source.join(SKILL_MANIFEST_FILE).is_file()); + let preview = manager.preview_skill_duplicates().unwrap(); + assert_eq!(preview.candidates.iter().find(|item| item.kind == "source").unwrap().status, "different"); + assert!(manager.try_list_backups().unwrap().is_empty()); + } + + #[cfg(any(windows, unix))] + #[test] + fn managed_source_archive_rejects_new_or_existing_agent_links() { + let (temp, manager, source, _original) = legacy_managed_source_fixture(); + let preview = manager.preview_skill_duplicates().unwrap(); + let root = temp.path().join("grok-skills"); + std::fs::create_dir_all(&root).unwrap(); + let link = root.join("alpha"); + #[cfg(windows)] + { + let output = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) + .arg(&link).arg(&source).output().unwrap(); + assert!(output.status.success()); + } + #[cfg(unix)] + std::os::unix::fs::symlink(&source, &link).unwrap(); + assert!(manager.archive_skill_managed_source(&preview.preview_id, "alpha").is_err()); + let preview = manager.preview_skill_duplicates().unwrap(); + let candidate = preview.candidates.iter().find(|item| item.kind == "source").unwrap(); + assert_eq!(candidate.status, "unsupported"); + assert!(candidate.detail.as_deref().unwrap().contains("引用")); + assert!(link.join(SKILL_MANIFEST_FILE).is_file()); + assert!(source.join(SKILL_MANIFEST_FILE).is_file()); + } + + fn published_snapshot(path: &Path) -> SkillPublishedSnapshot { + SkillPublishedSnapshot { + path: path.to_path_buf(), + identity: skill_path_identity(path).unwrap(), + digest: skill_directory_digest(path).unwrap().unwrap(), + } + } + + fn associated_original(manager: &SkillsManager, original: &Path) { + let installed = InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: "example/repo@main".into(), repo_path: "skills/alpha".into(), + content_hash: String::new(), installed_at: String::new(), verified_cc_switch: false, + install_channel: "local".into(), original_dir: Some(original.to_path_buf()), + update_owner: "in_place".into(), + }; + let target = SkillInstanceTarget { path: original.to_path_buf(), identity: skill_path_identity(original).unwrap() }; + let mut state = manager.load_state(); + state.repos.push(SkillRepo { + owner: "example".into(), name: "repo".into(), branch: "main".into(), + subdir: String::new(), enabled: true, + }); + state.installed.insert("alpha".into(), installed.clone()); + state.instance_targets.insert("alpha".into(), target.clone()); + state.instance_records.entry("alpha".into()).or_default().insert(original.to_path_buf(), + SkillInstanceRecord { installed, target, managed_copies: Vec::new() }); + manager.save_state_unlocked(&state).unwrap(); + } + + #[test] + fn enabling_agent_registers_copy_for_associated_shared_original() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + associated_original(&manager, &original); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("claude", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + result.unwrap(); + let source = manager.source_dir.join("alpha"); + let copy = temp.path().join("claude-skills").join("alpha"); + let state = manager.load_state(); + let record = &state.instance_records["alpha"][&original]; + assert!(!source.exists()); + assert!(record.managed_copies.iter().any(|tracked| + tracked.path == copy && tracked.identity == skill_path_identity(©).unwrap())); + assert!(is_managed_copy(©)); + assert_eq!(state.installed["alpha"].repo_key, "example/repo@main"); + let remote = RemoteSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: "example/repo@main".into(), repo_path: "skills/alpha".into(), content_hash: "new".into(), + }; + let preview = manager.preview_skill_in_place_update_for_instance("alpha", &original, remote, Vec::new()).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + assert!(!preview.managed_paths.contains(&source)); + assert!(preview.managed_paths.contains(©)); + + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + assert!(manager.set_enabled_for("claude", "alpha", false).is_err()); + assert_eq!(manager.load_state(), state); + assert!(copy.join(SKILL_MANIFEST_FILE).is_file()); + assert!(manager.try_list_backups().unwrap().is_empty()); + + manager.set_enabled_for("claude", "alpha", false).unwrap(); + assert!(!copy.exists()); + let disabled = manager.load_state(); + assert!(!disabled.shared_agent_paths.contains_key(©.to_string_lossy().into_owned())); + let backups = manager.try_list_backups().unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].purpose.as_deref(), Some("disabled")); + + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("claude", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + result.unwrap(); + assert!(manager.try_list_backups().unwrap().is_empty()); + let restored = manager.load_state(); + let identity = skill_path_identity(©).unwrap(); + assert_eq!(restored.shared_agent_paths[©.to_string_lossy().into_owned()], [identity.device, identity.file]); + assert!(restored.instance_records["alpha"][&original].managed_copies.iter() + .any(|tracked| tracked.path == copy && tracked.identity == identity)); + } + + #[test] + fn enabling_codex_registers_copy_only_for_confirmed_existing_source() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let original = temp.path().join("claude-skills/alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + associated_original(&manager, &original); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let first = manager.set_enabled_for("grok", "alpha", true); + let second = first.and_then(|_| manager.set_enabled_for("codex", "alpha", true)); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + second.unwrap(); + let state = manager.load_state(); + let record = &state.instance_records["alpha"][&original]; + for path in [manager.source_dir.join("alpha"), temp.path().join("grok-skills/alpha"), manager.linked_dir().join("alpha")] { + assert!(record.managed_copies.iter().any(|tracked| + tracked.path == path && tracked.identity == skill_path_identity(&path).unwrap())); + } + } + + #[test] + fn enabling_skill_rolls_back_published_copy_when_record_save_fails() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + associated_original(&manager, &original); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + assert!(manager.set_enabled_for("claude", "alpha", true).is_err()); + assert!(!manager.source_dir.join("alpha").exists()); + assert!(original.join(SKILL_MANIFEST_FILE).is_file()); + assert!(std::fs::read_dir(temp.path().join("claude-skills")).unwrap().any(|entry| + entry.unwrap().file_name().to_string_lossy().starts_with(".codex-plus-recovery-"))); + + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let initial = manager.set_enabled_for("claude", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + initial.unwrap(); + let before = manager.load_state(); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("grok", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + assert!(result.is_err()); + assert!(!temp.path().join("grok-skills/alpha").exists()); + assert_eq!(manager.load_state(), before); + assert!(std::fs::read_dir(temp.path().join("grok-skills")).unwrap().any(|entry| + entry.unwrap().file_name().to_string_lossy().starts_with(".codex-plus-recovery-"))); + } + + #[test] + fn codex_copy_record_save_failure_restores_native_config_and_copy() { + let temp = tempfile::tempdir().unwrap(); + let manager = multi_agent_manager(&temp); + let original = temp.path().join("claude-skills/alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + associated_original(&manager, &original); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("grok", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + result.unwrap(); + std::fs::create_dir_all(&manager.codex_home).unwrap(); + let config = manager.codex_home.join("config.toml"); + let before_config = b"[[skills.config]]\nname = 'alpha'\nenabled = false\n"; + std::fs::write(&config, before_config).unwrap(); + let before = manager.load_state(); + FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("codex", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + assert!(result.is_err()); + assert_eq!(std::fs::read(&config).unwrap(), before_config); + assert_eq!(manager.load_state(), before); + assert!(!manager.linked_dir().join("alpha").exists()); + assert!(std::fs::read_dir(manager.linked_dir()).unwrap().any(|entry| { + let path = entry.unwrap().path(); + path.file_name().unwrap().to_string_lossy().starts_with(".codex-plus-recovery-") + && path.join(SKILL_MANIFEST_FILE).is_file() + })); + } + + #[test] + fn enabling_skill_does_not_claim_unconfirmed_existing_source_or_independent_copy() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + let original = shared.join("alpha"); + let source = manager.source_dir.join("alpha"); + let independent = temp.path().join("claude-skills/alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + copy_dir_all(&original, &source).unwrap(); + copy_dir_all(&original, &independent).unwrap(); + associated_original(&manager, &original); + let before = manager.load_state(); + manager.set_enabled_for("claude", "alpha", true).unwrap(); + assert_eq!(manager.load_state(), before); + assert!(!is_managed_copy(&independent)); + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + copy_dir_all(&original, &manager.source_dir.join("alpha")).unwrap(); + associated_original(&manager, &original); + FORCE_SKILL_COPY.with(|flag| flag.set(true)); + let result = manager.set_enabled_for("grok", "alpha", true); + FORCE_SKILL_COPY.with(|flag| flag.set(false)); + result.unwrap(); + let state = manager.load_state(); + let copies = &state.instance_records["alpha"][&original].managed_copies; + assert_eq!(copies.len(), 1); + assert_eq!(copies[0].path, temp.path().join("grok-skills/alpha")); + assert_ne!(copies[0].path, manager.source_dir.join("alpha")); + } + + #[test] + fn shared_duplicate_preview_archives_and_restores_only_the_managed_copy() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let external = shared.join("alpha"); + write_skill(&external, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &external).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + let copy = manager.linked_dir().join("alpha"); + copy_dir_all(&external, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let preview = manager.preview_skill_duplicates().unwrap(); + assert_eq!(preview.candidates[0].status, "identical-managed"); + manager.archive_skill_duplicate(&preview.preview_id, "alpha").unwrap(); + assert!(!copy.exists()); + assert!(external.join("SKILL.md").is_file()); + let backup = manager.try_list_backups().unwrap().into_iter() + .find(|item| item.purpose.as_deref() == Some("duplicate")).unwrap(); + assert_eq!(backup.restore_path.as_deref(), Some(copy.to_str().unwrap())); + assert!(manager.load_state().installed.contains_key("alpha")); + manager.restore_backup(&backup.id).unwrap(); + assert!(copy.join(MANAGED_COPY_MARKER).is_file()); + assert!(external.join("SKILL.md").is_file()); + } + + #[test] + fn restoring_duplicate_preserves_shared_enabled_state_and_rolls_back_on_save_failure() { + for enabled in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + manager.set_enabled("alpha", !enabled).unwrap(); + let preview = manager.preview_skill_duplicates().unwrap(); + manager.archive_skill_duplicate(&preview.preview_id, "alpha").unwrap(); + manager.set_enabled("alpha", enabled).unwrap(); + let backup = manager.try_list_backups().unwrap().into_iter() + .find(|item| item.purpose.as_deref() == Some("duplicate")).unwrap(); + let before = std::fs::read(manager.codex_home.join("config.toml")).unwrap(); + + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.restore_backup(&backup.id).is_err()); + assert!(!copy.exists()); + assert_eq!(std::fs::read(manager.codex_home.join("config.toml")).unwrap(), before); + assert!(manager.try_list_backups().unwrap().iter().any(|item| item.id == backup.id)); + + manager.restore_backup(&backup.id).unwrap(); + let config = manager.read_native_skill_config().unwrap(); + assert_eq!(config.enabled(&original.join(SKILL_MANIFEST_FILE), "alpha").unwrap(), enabled); + assert_eq!(config.enabled(©.join(SKILL_MANIFEST_FILE), "alpha").unwrap(), enabled); + } + } + + #[test] + fn duplicate_archive_rejects_changes_after_preview_and_preserves_differences() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let external = shared.join("alpha"); + write_skill(&external, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &external).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + let copy = manager.linked_dir().join("alpha"); + copy_dir_all(&external, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let preview = manager.preview_skill_duplicates().unwrap(); + std::fs::write(copy.join(SKILL_MANIFEST_FILE), "modified").unwrap(); + assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); + assert_eq!(manager.preview_skill_duplicates().unwrap().candidates[0].status, "different"); + assert!(external.join(SKILL_MANIFEST_FILE).is_file()); + assert_eq!(std::fs::read_to_string(copy.join(SKILL_MANIFEST_FILE)).unwrap(), "modified"); + } + + #[cfg(any(unix, windows))] + #[test] + fn duplicate_archive_rejects_agent_link_created_before_or_after_preview() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp); + let original = shared.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + let agent_root = temp.path().join("claude-skills"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + write_skill(&original.join("nested"), "---\nname: nested\n---\n"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + std::fs::create_dir_all(&agent_root).unwrap(); + for (name, target) in [ + ("alpha", copy.clone()), + ("other", copy.clone()), + ("alpha", copy.join("nested")), + ("other", copy.join("nested")), + ] { + let agent = agent_root.join(name); + let before_link = manager.preview_skill_duplicates().unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &agent).unwrap(); + #[cfg(windows)] + { + let result = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) + .arg(&agent).arg(&target).output().unwrap(); + assert!(result.status.success(), "could not create directory junction: {result:?}"); + } + assert!(manager.archive_skill_duplicate(&before_link.preview_id, "alpha").is_err()); + let after_link = manager.preview_skill_duplicates().unwrap(); + let candidate = after_link.candidates.iter().find(|candidate| candidate.id == "alpha").unwrap(); + assert_eq!(candidate.status, "unsupported"); + assert!(candidate.detail.as_deref().unwrap().contains("引用")); + assert!(agent.join(SKILL_MANIFEST_FILE).is_file()); + assert!(copy.join(SKILL_MANIFEST_FILE).is_file()); + #[cfg(windows)] + std::fs::remove_dir(&agent).unwrap(); + #[cfg(unix)] + std::fs::remove_file(&agent).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn duplicate_archive_preserves_different_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + std::fs::write(original.join("run.sh"), "#!/bin/sh\n").unwrap(); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + std::fs::set_permissions(copy.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(original.join("run.sh"), std::fs::Permissions::from_mode(0o644)).unwrap(); + + let preview = manager.preview_skill_duplicates().unwrap(); + + assert_eq!(preview.candidates[0].status, "different"); + assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); + } + + #[test] + fn duplicate_archive_rolls_back_content_changes_during_move() { + for mutate_shared in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let preview = manager.preview_skill_duplicates().unwrap(); + let before = manager.load_state(); + let backups = manager.backups_dir.clone(); + let shared_manifest = original.join(SKILL_MANIFEST_FILE); + AFTER_DUPLICATE_BACKUP.with(|hook| *hook.borrow_mut() = Some(Box::new(move || { + let path = if mutate_shared { + shared_manifest + } else { + std::fs::read_dir(backups).unwrap().map(|entry| entry.unwrap().path()) + .find(|path| path.is_dir()).unwrap().join(SKILL_MANIFEST_FILE) + }; + std::fs::write(path, "changed during archive").unwrap(); + }))); + assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); + assert_eq!(manager.load_state(), before); + assert!(copy.join(MANAGED_COPY_MARKER).is_file()); + let changed = if mutate_shared { &original } else { © }; + assert_eq!(std::fs::read_to_string(changed.join(SKILL_MANIFEST_FILE)).unwrap(), "changed during archive"); + } + } + + #[test] + fn duplicate_archive_preserves_native_enabled_state() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let preview = manager.preview_skill_duplicates().unwrap(); + manager.set_native_skill_paths(&[(original.join(SKILL_MANIFEST_FILE), "alpha".into())], false, false).unwrap(); + assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); + assert_eq!(manager.preview_skill_duplicates().unwrap().candidates[0].status, "enabled-only-copy"); + assert!(copy.join(SKILL_MANIFEST_FILE).is_file()); + } + + #[test] + fn restoring_an_in_place_update_keeps_the_current_version_as_a_backup() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\nold"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + let identity = skill_path_identity(&original).unwrap(); + let backup = manager.backup_directory_checked("alpha", &original, identity).unwrap(); + let backup_id = manager.backup_id_for_path(&backup).unwrap(); + let mut state = manager.load_state(); + state.backup_origins.insert(backup_id.clone(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + write_skill(&original, "---\nname: alpha\n---\nnew"); + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), backup_ids: vec![backup_id.clone()], + published: vec![published_snapshot(&original)], + instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + + manager.restore_backup(&backup_id).unwrap(); + assert!(std::fs::read_to_string(original.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("old")); + let retained = manager.try_list_backups().unwrap().into_iter() + .find(|item| item.purpose.as_deref() == Some("update")).unwrap(); + let (path, _) = manager.resolve_backup(&retained.id).unwrap(); + assert!(std::fs::read_to_string(path.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("new")); + } + + #[test] + fn update_backup_group_restores_all_versions_and_rolls_back_on_save_failure() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\nold"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + let source = manager.source_dir.join("alpha"); + let copy = manager.linked_dir().join("alpha"); + copy_dir_all(&original, ©).unwrap(); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let mut state = manager.load_state(); + state.installed.get_mut("alpha").unwrap().content_hash = "old".into(); + let previous = state.installed["alpha"].clone(); + let mut ids = Vec::new(); + for path in [&original, &source, ©] { + let backup = manager.backup_directory_checked("alpha", path, skill_path_identity(path).unwrap()).unwrap(); + let id = manager.backup_id_for_path(&backup).unwrap(); + state.backup_origins.insert(id.clone(), SkillBackupOrigin { + path: path.clone(), purpose: "update".into(), + }); + ids.push(id); + write_skill(path, "---\nname: alpha\n---\nnew"); + } + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + state.installed.get_mut("alpha").unwrap().content_hash = "new".into(); + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: previous, backup_ids: ids.clone(), + published: [&original, &source, ©].iter().map(|path| published_snapshot(path)).collect(), + instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.restore_backup(&ids[1]).is_err()); + assert_eq!(manager.load_state(), state); + for path in [&original, &source, ©] { + assert!(std::fs::read_to_string(path.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("new")); + } + let restored = manager.restore_backup(&ids[1]).unwrap(); + assert_eq!(restored.installed["alpha"].content_hash, "old"); + for path in [&original, &source, ©] { + assert!(std::fs::read_to_string(path.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("old")); + } + let reverse = restored.update_backup_groups.values().next().unwrap().backup_ids[0].clone(); + let restored = manager.restore_backup(&reverse).unwrap(); + assert_eq!(restored.installed["alpha"].content_hash, "new"); + for path in [&original, &source, ©] { + assert!(std::fs::read_to_string(path.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("new")); + } + } + + #[test] + fn update_restore_rejects_replaced_directory_and_preserves_local_edits() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\nold"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + let backup = manager.backup_directory_checked("alpha", &original, skill_path_identity(&original).unwrap()).unwrap(); + let id = manager.backup_id_for_path(&backup).unwrap(); + write_skill(&original, "---\nname: alpha\n---\nnew"); + let mut state = manager.load_state(); + let previous = state.installed["alpha"].clone(); + state.installed.get_mut("alpha").unwrap().content_hash = "new".into(); + state.backup_origins.insert(id.clone(), SkillBackupOrigin { path: original.clone(), purpose: "update".into() }); + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: previous, backup_ids: vec![id.clone()], published: vec![published_snapshot(&original)], + instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + + let moved = shared.join("moved"); + std::fs::rename(&original, &moved).unwrap(); + write_skill(&original, "---\nname: alpha\n---\nreplacement"); + assert!(manager.restore_backup(&id).is_err()); + assert!(std::fs::read_to_string(original.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("replacement")); + std::fs::rename(&original, shared.join("replacement")).unwrap(); + std::fs::rename(&moved, &original).unwrap(); + std::fs::write(original.join(SKILL_MANIFEST_FILE), "---\nname: alpha\n---\nuser edit").unwrap(); + let restored = manager.restore_backup(&id).unwrap(); + let reverse = restored.update_backup_groups.values().next().unwrap(); + assert!(reverse.installed.content_hash.is_empty()); + let reverse_id = reverse.backup_ids[0].clone(); + let restored = manager.restore_backup(&reverse_id).unwrap(); + assert!(restored.installed["alpha"].content_hash.is_empty()); + assert!(std::fs::read_to_string(original.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("user edit")); + } + + #[test] + fn external_update_backups_are_listed_and_restored_only_from_registered_origin() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp).with_shared_skills_dir(temp.path().join("shared")); + let original = temp.path().join("other-volume/alpha"); + write_skill(&original, "---\nname: alpha\n---\nold"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let mut state = manager.load_state(); + let old = state.installed["alpha"].clone(); + FORCE_CROSS_DEVICE_BACKUP.with(|flag| flag.set(true)); + let backup = manager.backup_directory_checked("alpha", &original, skill_path_identity(&original).unwrap()).unwrap(); + let backup_id = manager.backup_id_for_path(&backup).unwrap(); + assert!(backup_id.starts_with("@external/")); + assert!(manager.resolve_backup(&backup_id).is_err()); + write_skill(&original, "---\nname: alpha\n---\nnew"); + state.backup_origins.insert(backup_id.clone(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: old, backup_ids: vec![backup_id.clone()], published: vec![published_snapshot(&original)], + instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + assert!(manager.try_list_backups().unwrap().iter().any(|item| item.id == backup_id)); + assert!(manager.resolve_backup("@external/00000000-0000-0000-0000-000000000000/alpha-1").is_err()); + let restored = manager.restore_backup(&backup_id).unwrap(); + assert!(std::fs::read_to_string(original.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("old")); + let reverse = restored.update_backup_groups.values().next().unwrap().backup_ids[0].clone(); + manager.restore_backup(&reverse).unwrap(); + assert!(std::fs::read_to_string(original.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("new")); + } + + #[test] + fn update_restore_rejects_an_unmarked_codex_replacement() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = manager(&temp); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + let copy = manager.linked_dir().join("alpha"); + write_skill(©, "---\nname: alpha\n---\nold copy"); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + let identity = skill_path_identity(©).unwrap(); + let backup = manager.backup_directory_checked("alpha", ©, identity).unwrap(); + let backup_id = manager.backup_id_for_path(&backup).unwrap(); + let mut state = manager.load_state(); + state.backup_origins.insert(backup_id.clone(), SkillBackupOrigin { + path: copy.clone(), purpose: "update".into(), + }); + write_skill(©, "---\nname: alpha\n---\nuser replacement"); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), backup_ids: vec![backup_id.clone()], + published: vec![published_snapshot(©)], + instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + std::fs::remove_file(copy.join(MANAGED_COPY_MARKER)).unwrap(); + + assert!(manager.restore_backup(&backup_id).is_err()); + assert!(std::fs::read_to_string(copy.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("user replacement")); + assert!(backup.join(SKILL_MANIFEST_FILE).is_file()); + std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); + manager.restore_backup(&backup_id).unwrap(); + assert!(std::fs::read_to_string(copy.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("old copy")); + } + + #[test] + fn backup_listing_reports_path_and_delete_rolls_back_on_state_failure() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let backup = manager.backups_dir.join("alpha-123"); + let other = manager.backups_dir.join("beta-456"); + let hidden = manager.backups_dir.join(".codex-plus-recovery-pending"); + write_skill(&backup, "alpha"); + write_skill(&other, "beta"); + write_skill(&hidden, "pending"); + let mut state = manager.load_state(); + state.backup_origins.insert("alpha-123".into(), SkillBackupOrigin { + path: manager.linked_dir().join("alpha"), purpose: "duplicate".into(), + }); + manager.save_state_unlocked(&state).unwrap(); + + let backups = manager.try_list_backups().unwrap(); + assert_eq!(backups.len(), 2); + let item = backups.iter().find(|item| item.id == "alpha-123").unwrap(); + assert_eq!(item.backup_path, backup.to_string_lossy()); + assert!(item.restore_blocked_reason.is_none()); + assert!(item.delete_warning.is_none()); + assert!(manager.delete_backup(".codex-plus-recovery-pending").is_err()); + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.delete_backup("alpha-123").is_err()); + assert_eq!(std::fs::read_to_string(backup.join(SKILL_MANIFEST_FILE)).unwrap(), "alpha"); + assert_eq!(manager.load_state(), state); + + let remaining = manager.delete_backup("alpha-123").unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "beta-456"); + assert!(!backup.exists()); + assert!(other.exists() && hidden.exists()); + assert!(!manager.load_state().backup_origins.contains_key("alpha-123")); + } + + #[test] + fn backup_listing_reports_unreadable_root() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + std::fs::write(&manager.backups_dir, "not a directory").unwrap(); + let error = manager.try_list_backups().unwrap_err(); + assert!(format!("{error:#}").contains("无法读取 Skill 备份目录")); + } + + #[test] + fn partially_deleted_backup_remains_out_of_restore_and_releases_the_lock() { + for grouped in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let first = manager.backups_dir.join("alpha-123"); + let second = manager.backups_dir.join("alpha-124"); + write_skill(&first, "first"); + std::fs::write(first.join("remaining.txt"), "remaining").unwrap(); + if grouped { + write_skill(&second, "second"); + let original = temp.path().join("original/alpha"); + write_skill(&original, "current"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let mut state = manager.load_state(); + for id in ["alpha-123", "alpha-124"] { + state.backup_origins.insert(id.into(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + } + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), + backup_ids: vec!["alpha-123".into(), "alpha-124".into()], + published: Vec::new(), instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + } + DELETE_SKILL_BACKUP_HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(|staged| { + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _guard = skills_coordination(); + sender.send(()).unwrap(); + }); + receiver.recv_timeout(std::time::Duration::from_secs(5)) + .expect("删除备份时仍持有 Skill 协调锁"); + std::fs::remove_file(staged.join(SKILL_MANIFEST_FILE))?; + Err(std::io::Error::other("模拟部分删除失败")) + }))); + + let error = manager.delete_backup("alpha-123").unwrap_err().to_string(); + assert!(!first.exists()); + let staged = std::fs::read_dir(&manager.backups_dir).unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.file_name().unwrap().to_string_lossy().starts_with(".codex-plus-recovery-")) + .unwrap(); + assert!(error.contains(&staged.to_string_lossy().to_string())); + assert!(staged.join("remaining.txt").is_file()); + assert!(!staged.join(SKILL_MANIFEST_FILE).exists()); + let backups = manager.try_list_backups().unwrap(); + assert!(backups.iter().all(|item| item.id != "alpha-123")); + if grouped { + assert!(manager.load_state().update_backup_groups["group"].partially_deleted); + assert!(backups.iter().find(|item| item.id == "alpha-124").unwrap().restore_blocked_reason.is_some()); + assert!(manager.restore_backup("alpha-124").is_err()); + } + } + } + + #[test] + fn legacy_backup_restore_rejects_root_links_and_nested_cycles() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + std::fs::create_dir_all(&manager.backups_dir).unwrap(); + let target = temp.path().join("target"); + write_skill(&target, "target"); + let linked_backup = manager.backups_dir.join("alpha-123"); + let cyclic_backup = manager.backups_dir.join("beta-123"); + write_skill(&cyclic_backup, "beta"); + let nested_link = cyclic_backup.join("loop"); + #[cfg(windows)] + for (link, destination) in [(&linked_backup, &target), (&nested_link, &cyclic_backup)] { + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]).arg(link).arg(destination).output().unwrap(); + assert!(output.status.success(), "could not create directory junction: {output:?}"); + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(&target, &linked_backup).unwrap(); + std::os::unix::fs::symlink(&cyclic_backup, &nested_link).unwrap(); + } + let root_error = manager.restore_backup("alpha-123").unwrap_err().to_string(); + assert!(root_error.contains("备份不是实体目录")); + let nested_error = format!("{:#}", manager.restore_backup("beta-123").unwrap_err()); + assert!(nested_error.contains("备份包含无法自动恢复的链接")); + assert_eq!(std::fs::read_to_string(target.join(SKILL_MANIFEST_FILE)).unwrap(), "target"); + assert!(cyclic_backup.join(SKILL_MANIFEST_FILE).is_file()); + assert!(!manager.source_dir.join("beta").exists()); + } + + #[test] + fn deleting_update_backup_members_preserves_remaining_files_and_marks_group_incomplete() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let first = manager.backups_dir.join("alpha-123"); + let second = manager.backups_dir.join("alpha-124"); + write_skill(&first, "first"); + write_skill(&second, "second"); + let original = temp.path().join("original/alpha"); + write_skill(&original, "current"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let mut state = manager.load_state(); + for id in ["alpha-123", "alpha-124"] { + state.backup_origins.insert(id.into(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + } + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), + backup_ids: vec!["alpha-123".into(), "alpha-124".into()], + published: Vec::new(), instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + + let serialized = serde_json::to_value(&state).unwrap(); + assert!(serialized["updateBackupGroups"]["group"].get("partiallyDeleted").is_none()); + assert_eq!(serde_json::from_value::(serialized).unwrap(), state); + let backups = manager.try_list_backups().unwrap(); + assert!(backups.iter().all(|item| item.delete_warning.is_some() && item.restore_blocked_reason.is_none())); + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.delete_backup("alpha-123").is_err()); + assert!(first.exists() && second.exists()); + assert_eq!(manager.load_state(), state); + + manager.delete_backup("alpha-123").unwrap(); + let state = manager.load_state(); + assert!(!first.exists()); + assert!(second.exists()); + assert!(!state.backup_origins.contains_key("alpha-123")); + assert!(state.update_backup_groups["group"].partially_deleted); + assert_eq!(state.update_backup_groups["group"].backup_ids, vec!["alpha-124"]); + let second_item = manager.try_list_backups().unwrap().pop().unwrap(); + assert!(second_item.restore_blocked_reason.is_some()); + assert!(second_item.delete_warning.is_none()); + assert!(manager.restore_backup("alpha-124").unwrap_err().to_string().contains("无法整体恢复")); + assert_eq!(std::fs::read_to_string(second.join(SKILL_MANIFEST_FILE)).unwrap(), "second"); + + // 后续备份可以复用已删除的名称,不应被归入旧备份组。 + write_skill(&first, "new backup"); + let replacement = manager.try_list_backups().unwrap().into_iter() + .find(|item| item.id == "alpha-123").unwrap(); + assert!(replacement.restore_blocked_reason.is_none()); + manager.delete_backup("alpha-124").unwrap(); + let result = manager.load_state(); + assert!(!result.update_backup_groups.contains_key("group")); + assert!(!result.backup_origins.contains_key("alpha-124")); + assert!(first.exists() && !second.exists()); + } + + #[test] + fn deleting_external_update_backup_keeps_other_registered_backup_accessible() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let originals = [temp.path().join("external-one/alpha"), temp.path().join("external-two/alpha")]; + for original in &originals { + write_skill(original, "old"); + } + manager.import_from_local_directory("alpha", &originals[0]).unwrap(); + let mut state = manager.load_state(); + let mut ids = Vec::new(); + let mut paths = Vec::new(); + for original in &originals { + FORCE_CROSS_DEVICE_BACKUP.with(|flag| flag.set(true)); + let backup = manager.backup_directory_checked("alpha", original, skill_path_identity(original).unwrap()).unwrap(); + let id = manager.backup_id_for_path(&backup).unwrap(); + assert!(id.starts_with("@external/")); + state.backup_origins.insert(id.clone(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + ids.push(id); + paths.push(backup); + write_skill(original, "current"); + } + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), + backup_ids: ids.clone(), + published: originals.iter().map(|path| published_snapshot(path)).collect(), + instance_path: None, + partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + + let backups = manager.try_list_backups().unwrap(); + assert_eq!(backups.len(), 2); + assert!(backups.iter().any(|item| item.id == ids[0] && item.backup_path == paths[0].to_string_lossy())); + manager.delete_backup(&ids[0]).unwrap(); + let remaining = manager.try_list_backups().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, ids[1]); + assert!(remaining[0].restore_blocked_reason.is_some()); + assert!(manager.restore_backup(&ids[1]).is_err()); + manager.delete_backup(&ids[1]).unwrap(); + assert!(manager.try_list_backups().unwrap().is_empty()); + assert!(!paths[0].exists() && !paths[1].exists()); + for original in &originals { + assert_eq!(std::fs::read_to_string(original.join(SKILL_MANIFEST_FILE)).unwrap(), "current"); + } + } + + #[cfg(windows)] + #[test] + fn backup_deletion_rejects_directory_junction() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let target = temp.path().join("original"); + write_skill(&target, "original"); + std::fs::create_dir_all(&manager.backups_dir).unwrap(); + let link = manager.backups_dir.join("alpha-123"); + let output = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) + .arg(&link).arg(&target).output().unwrap(); + assert!(output.status.success(), "could not create junction: {:?}", output); + assert!(manager.try_list_backups().unwrap().is_empty()); + assert!(manager.delete_backup("alpha-123").is_err()); + assert_eq!(std::fs::read_to_string(target.join(SKILL_MANIFEST_FILE)).unwrap(), "original"); + std::fs::remove_dir(link).unwrap(); + } + + #[cfg(unix)] + #[test] + fn backup_deletion_rejects_directory_symlink() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let target = temp.path().join("original"); + write_skill(&target, "original"); + std::fs::create_dir_all(&manager.backups_dir).unwrap(); + let link = manager.backups_dir.join("alpha-123"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(manager.try_list_backups().unwrap().is_empty()); + assert!(manager.delete_backup("alpha-123").is_err()); + assert_eq!(std::fs::read_to_string(target.join(SKILL_MANIFEST_FILE)).unwrap(), "original"); + } + fn manager(temp: &tempfile::TempDir) -> SkillsManager { SkillsManager::new( temp.path().join("skills"), @@ -4910,6 +7228,8 @@ mod tests { let updated = repo_zip(&[("kit-main/alpha/SKILL.md", "---\nname: alpha\n---\nupdated")]); manager.install_from_zip(&skill, &updated).unwrap(); assert!(std::fs::read_to_string(linked.join("alpha/SKILL.md")).unwrap().ends_with("updated")); + assert!(manager.preview_skill_duplicates().unwrap().candidates.iter() + .all(|candidate| candidate.kind != "shared")); manager.uninstall("alpha").unwrap(); assert!(!manager.source_dir.join("alpha").exists()); assert!(!linked.join("alpha").exists()); @@ -4992,7 +7312,7 @@ mod tests { }; let mut coordination = skills_coordination(); assert!(manager.commit_staged_skill_unlocked( - &staging, installed, true, false, None, true, &mut coordination, + &staging, installed, true, false, None, true, None, &mut coordination, ).is_err()); assert_eq!(std::fs::read_to_string(config).unwrap(), original); assert!(!manager.linked_dir().join("alpha").exists()); @@ -5247,7 +7567,8 @@ mod tests { assert_eq!(backup.skill_id, "alpha"); manager.uninstall("alpha").unwrap(); manager.restore_backup(&backup.id).unwrap(); - assert_eq!(std::fs::read_to_string(manager.source_dir.join("alpha/SKILL.md")).unwrap(), "agent edit"); + assert_eq!(std::fs::read_to_string(copy.join("SKILL.md")).unwrap(), "agent edit"); + assert!(!manager.source_dir.join("alpha").exists()); assert!(!manager.list_backups().iter().any(|item| item.id == backup.id)); } @@ -5361,7 +7682,7 @@ mod tests { manager.set_enabled_for("claude", "alpha", true).unwrap(); let (staging, installed) = manager.stage_directory_import("alpha", &external, "local").unwrap(); let mut coordination = skills_coordination(); - let result = manager.commit_staged_skill_unlocked(&staging, installed, false, true, Some(&snapshot), false, &mut coordination); + let result = manager.commit_staged_skill_unlocked(&staging, installed, false, true, Some(&snapshot), false, None, &mut coordination); assert!(format!("{:#}", result.unwrap_err()).contains("检查副本期间")); } @@ -5454,7 +7775,9 @@ mod tests { assert_eq!(manager.load_state(), before); assert_eq!(std::fs::read_to_string(manager.source_dir.join("alpha/SKILL.md")).unwrap(), "old source"); assert_eq!(std::fs::read_to_string(manager.linked_dir().join("alpha/SKILL.md")).unwrap(), "user local"); - assert!(manager.list_backups().is_empty()); + assert!(manager.try_list_backups().is_err()); + assert!(!manager.backups_dir.exists() + || std::fs::read_dir(&manager.backups_dir).unwrap().next().is_none()); } #[test] @@ -6470,7 +8793,7 @@ mod tests { let mut coordination = skills_coordination(); assert!(manager.commit_staged_skill_unlocked( - &staging, installed, true, true, None, true, &mut coordination, + &staging, installed, true, true, None, true, None, &mut coordination, ).is_err()); assert_eq!(std::fs::read_to_string(source.join(SKILL_MANIFEST_FILE)).unwrap(), "external"); assert!(!manager.state_path.exists()); @@ -6498,7 +8821,7 @@ mod tests { let mut coordination = skills_coordination(); assert!(manager.commit_staged_skill_unlocked( - &staging, installed, true, true, None, true, &mut coordination, + &staging, installed, true, true, None, true, None, &mut coordination, ).is_err()); assert_eq!(std::fs::read_to_string(copy.join(SKILL_MANIFEST_FILE)).unwrap(), "existing agent copy"); assert!(!manager.source_dir.join("alpha").exists()); diff --git a/crates/codex-plus-core/src/skills/archive.rs b/crates/codex-plus-core/src/skills/archive.rs index 367c75e554..24e5c19bd1 100644 --- a/crates/codex-plus-core/src/skills/archive.rs +++ b/crates/codex-plus-core/src/skills/archive.rs @@ -104,6 +104,12 @@ pub(super) fn stage_downloaded_zip( } std::fs::write(&output_path, &contents) .with_context(|| format!("写入 {} 失败", output_path.display()))?; + #[cfg(unix)] + if let Some(mode) = file.unix_mode() { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&output_path, std::fs::Permissions::from_mode(mode & 0o777)) + .with_context(|| format!("设置文件权限失败:{}", output_path.display()))?; + } // 与 Git trees API / parse_skills_from_zip 使用相同的 blob ID。 let mut blob = sha1::Sha1::new(); @@ -176,6 +182,33 @@ mod tests { .id } + #[cfg(unix)] + #[test] + fn stages_executable_files_with_their_archive_permissions() { + use std::os::unix::fs::PermissionsExt; + + let repo = parse_repo_key("acme/kit@main").unwrap(); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer.start_file( + "kit-main/alpha/SKILL.md", + zip::write::SimpleFileOptions::default().unix_permissions(0o644), + ).unwrap(); + writer.write_all(b"---\nname: Alpha\n---\n").unwrap(); + writer.start_file( + "kit-main/alpha/run.sh", + zip::write::SimpleFileOptions::default().unix_permissions(0o755), + ).unwrap(); + writer.write_all(b"#!/bin/sh\n").unwrap(); + let zip = writer.finish().unwrap().into_inner(); + let staging = tempfile::tempdir().unwrap(); + let id = skill_id(&repo, "alpha/SKILL.md"); + + stage_downloaded_zip(&repo, &zip, &id, None, staging.path()).unwrap(); + + assert_eq!(std::fs::metadata(staging.path().join("SKILL.md")).unwrap().permissions().mode() & 0o777, 0o644); + assert_eq!(std::fs::metadata(staging.path().join("run.sh")).unwrap().permissions().mode() & 0o777, 0o755); + } + fn set_declared_size(zip: &mut [u8], name: &str, size: u32) { // 只修改中央目录的展开大小,不分配数百 MiB。若实现读取这个条目, // 大小上限或实际/声明大小不符必然报错,成功便证明它完全未被解压。 diff --git a/crates/codex-plus-core/src/skills/in_place_update.rs b/crates/codex-plus-core/src/skills/in_place_update.rs new file mode 100644 index 0000000000..544a106bb3 --- /dev/null +++ b/crates/codex-plus-core/src/skills/in_place_update.rs @@ -0,0 +1,1199 @@ +use super::*; + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillInPlaceUpdatePreview { + pub preview_id: String, + pub id: String, + pub repo_key: String, + pub repo_path: String, + pub target_path: PathBuf, + pub current_hash: String, + pub next_hash: String, + pub conflicts: Vec, + pub managed_paths: Vec, + pub followed_paths: Vec, + pub skipped_paths: Vec, +} + +#[derive(Clone)] +struct UpdatePath { + path: PathBuf, + identity: SkillPathIdentity, + digest: [u8; 32], + marked: bool, +} + +#[derive(Clone, PartialEq, Eq)] +struct FollowingPath { + path: PathBuf, + identity: SkillPathIdentity, + target: PathBuf, +} + +struct InPlaceUpdate { + scope: CoordinationScope, + generation: u64, + repo_generation: u64, + installed: InstalledSkill, + repo: SkillRepo, + remote: RemoteSkill, + paths: Vec, + zip: Vec, + conflicts: Vec, + instance_target: Option, + instance_record: Option, + followed_paths: Vec, + skipped_paths: Vec, +} + +fn classify_following_paths( + paths: &[UpdatePath], + candidates: Vec, + skipped: &mut Vec, +) -> anyhow::Result> { + let targets = paths.iter().map(|path| path.path.canonicalize()) + .collect::>>()?; + let mut followed = Vec::new(); + for candidate in candidates { + if targets.contains(&candidate.target) { + followed.push(candidate); + } else { + skipped.push(candidate.path); + } + } + Ok(followed) +} + +fn update_previews() -> MutexGuard<'static, BTreeMap> { + static PREVIEWS: OnceLock>> = OnceLock::new(); + PREVIEWS.get_or_init(Mutex::default).lock() + .expect("Skill 更新预览锁中毒,拒绝继续修改") +} + +#[cfg(test)] +thread_local! { + static BEFORE_UPDATE_BACKUP: std::cell::RefCell>> = + std::cell::RefCell::new(None); +} + +impl SkillsManager { + fn in_place_update_paths( + &self, + id: &str, + original: &Path, + instance: Option<&SkillInstanceTarget>, + record: Option<&SkillInstanceRecord>, + ) -> anyhow::Result<(Vec, Vec, Vec, Vec)> { + let target = original.canonicalize().context("无法解析 Skill 原目录")?; + let mut roots = vec![self.linked_dir()]; + roots.extend(self.shared_skills_dir().map(Path::to_path_buf)); + roots.extend(self.agent_dirs.iter().map(|(_, path)| path.clone())); + if let Some(instance) = instance { + anyhow::ensure!(instance.path == original + && roots.iter().any(|root| root.join(id).canonicalize().is_ok_and(|path| path == target)), + "关联的原目录不属于当前发现范围,请重新关联"); + anyhow::ensure!(std::fs::symlink_metadata(original)?.file_type().is_dir() + && skill_path_identity(original)? == instance.identity, + "关联的原目录已被替换,请重新关联"); + let digest = skill_directory_digest(original)?.context("Skill 原目录不存在")?; + let mut skipped = Vec::new(); + let mut conflicts = Vec::new(); + let mut following = Vec::new(); + let mut seen_paths = BTreeSet::new(); + let mut paths = vec![UpdatePath { + path: original.to_path_buf(), identity: instance.identity, digest, + marked: is_managed_copy(original), + }]; + for path in std::iter::once(self.source_dir.join(id)) + .chain(roots.iter().map(|root| root.join(id))) + { + if seen_paths.insert(path.clone()) && path != original && path.join(SKILL_MANIFEST_FILE).is_file() { + let resolved = path.canonicalize()?; + if std::fs::symlink_metadata(&path)?.file_type().is_symlink() || resolved == target { + following.push(FollowingPath { + identity: skill_path_identity(&path)?, path, target: resolved, + }); + continue; + } + let tracked = record.and_then(|record| record.managed_copies.iter().find(|item| item.path == path)); + let marked = is_managed_copy(&path); + if tracked.is_some_and(|item| skill_path_identity(&path).ok() == Some(item.identity)) + && (path == self.source_dir.join(id) || marked) { + if skill_directory_digest(&path)? == Some(digest) { + paths.push(UpdatePath { identity: skill_path_identity(&path)?, digest, path, marked }); + } else { + conflicts.push(format!("受控副本内容已修改:{}", path.display())); + } + } else { + following.push(FollowingPath { + identity: skill_path_identity(&path)?, path, target: resolved, + }); + } + } + } + if std::fs::symlink_metadata(original.join(".git")).is_ok() { + conflicts.push("Skill 原目录是 Git 工作树,不能直接替换其仓库信息".into()); + } + let followed = classify_following_paths(&paths, following, &mut skipped)?; + return Ok((paths, conflicts, followed, skipped)); + } + let discovered = roots.iter().map(|root| root.join(id)) + .find(|path| path.canonicalize().is_ok_and(|path| path == target)) + .context("原目录不属于当前发现范围,请重新关联来源")?; + let target_path = if std::fs::symlink_metadata(&discovered)?.file_type().is_dir() { + discovered + } else { + target.clone() + }; + let target_digest = skill_directory_digest(&target_path)? + .context("Skill 原目录不存在")?; + let mut paths = vec![UpdatePath { + identity: skill_path_identity(&target_path)?, + digest: target_digest, + path: target_path.clone(), + marked: false, + }]; + let mut seen = BTreeSet::from([target]); + let mut conflicts = Vec::new(); + let mut following = Vec::new(); + let mut skipped = Vec::new(); + let mut seen_paths = BTreeSet::new(); + if std::fs::symlink_metadata(paths[0].path.join(".git")).is_ok() { + conflicts.push("Skill 原目录是 Git 工作树,不能直接替换其仓库信息".into()); + } + let mut candidates = vec![(self.source_dir.join(id), true)]; + candidates.extend(roots.into_iter().map(|root| (root.join(id), false))); + for (path, source) in candidates { + if !seen_paths.insert(path.clone()) || !path.join(SKILL_MANIFEST_FILE).is_file() { + continue; + } + let resolved = path.canonicalize().with_context(|| format!("无法解析 {}", path.display()))?; + if path == target_path { + continue; + } + if std::fs::symlink_metadata(&path)?.file_type().is_symlink() || seen.contains(&resolved) { + following.push(FollowingPath { + identity: skill_path_identity(&path)?, path, target: resolved, + }); + continue; + } + if !seen.insert(resolved.clone()) { + continue; + } + let marked = is_managed_copy(&path); + if source && !std::fs::symlink_metadata(&path)?.file_type().is_dir() { + conflicts.push(format!("受控源是指向其他位置的链接,不能自动替换:{}", path.display())); + continue; + } + if !source && !marked { + conflicts.push(format!("独立 Skill 目录需要单独确认:{}", path.display())); + continue; + } + let digest = skill_directory_digest(&path)? + .context("受控 Skill 副本不存在")?; + if digest != target_digest { + conflicts.push(format!("受控副本内容已修改:{}", path.display())); + } + paths.push(UpdatePath { + identity: skill_path_identity(&path)?, + digest, + path, + marked, + }); + } + let followed = classify_following_paths(&paths, following, &mut skipped)?; + Ok((paths, conflicts, followed, skipped)) + } + + /// 下载完成后固定目录和远端版本;关联来源不改变更新所有权。 + pub fn preview_skill_in_place_update( + &self, + id: &str, + remote: RemoteSkill, + zip: Vec, + ) -> anyhow::Result { + self.preview_skill_in_place_update_inner(id, None, remote, zip) + } + + pub fn preview_skill_in_place_update_for_instance( + &self, + id: &str, + local_path: &Path, + remote: RemoteSkill, + zip: Vec, + ) -> anyhow::Result { + self.preview_skill_in_place_update_inner(id, Some(local_path), remote, zip) + } + + fn preview_skill_in_place_update_inner( + &self, + id: &str, + local_path: Option<&Path>, + remote: RemoteSkill, + zip: Vec, + ) -> anyhow::Result { + validate_skill_id(id)?; + anyhow::ensure!(zip.len() <= REPO_ZIP_DOWNLOAD_LIMIT_BYTES, "仓库压缩包超过 64 MiB"); + let (scope, generation, repo_generation, installed, repo, instance_target, instance_record) = { + let coordination = skills_coordination(); + let state = self.load_state_checked_unlocked()?; + let instance_record = if let Some(path) = local_path { + let path = path.canonicalize().context("所选 Skill 原件不存在")?; + let record = state.instance_record(id, &path).cloned(); + if record.is_none() { + let legacy = state.installed.get(id).and_then(|item| item.original_dir.as_ref()) + .and_then(|original| original.canonicalize().ok()).as_ref() == Some(&path); + let target_valid = state.instance_targets.get(id).is_none_or(|target| + target.path.canonicalize().ok().as_ref() == Some(&path) + && skill_path_identity(&target.path).ok() == Some(target.identity)); + anyhow::ensure!(legacy && target_valid, "所选 Skill 尚未关联来源或原件已被替换"); + } + record + } else { + state.instance_targets.get(id).and_then(|target| + target.path.canonicalize().ok().and_then(|path| state.instance_record(id, &path).cloned())) + }; + let installed = instance_record.as_ref().map(|record| record.installed.clone()) + .or_else(|| state.installed.get(id).cloned()).context("Skill 尚未关联来源")?; + anyhow::ensure!(installed.repo_key == remote.repo_key + && installed.repo_path == remote.repo_path + && !remote.content_hash.is_empty(), "仓库路径或版本与来源记录不匹配"); + anyhow::ensure!(installed.update_owner.is_empty() || installed.update_owner == "in_place", + "此 Skill 由其他更新方式管理,请使用原有更新入口"); + let repo = state.repos.iter().find(|repo| repo.key() == installed.repo_key) + .cloned().context("Skill 仓库已不存在")?; + let scope = self.coordination_scope(); + let generation = coordination.mutation_generation(&scope, id); + let repo_generation = coordination.repo_generation(&scope, &repo.key()); + let instance_target = instance_record.as_ref().map(|record| record.target.clone()) + .or_else(|| state.instance_targets.get(id).cloned()); + (scope, generation, repo_generation, installed, repo, instance_target, instance_record) + }; + let original = installed.original_dir.as_ref() + .context("无法确定唯一原目录,请先处理独立副本")?; + let (paths, mut conflicts, followed_paths, skipped_paths) = self.in_place_update_paths(id, original, instance_target.as_ref(), instance_record.as_ref())?; + if !installed.content_hash.is_empty() + && source_match::local_repository_hash(&paths[0].path)? != installed.content_hash + { + conflicts.push("Skill 原件自上次关联或更新后已修改,请先处理本地更改".into()); + } + let update = InPlaceUpdate { + scope, + generation, + repo_generation, + installed, + repo, + remote, + paths, + zip, + conflicts, + instance_target, + instance_record, + followed_paths, + skipped_paths, + }; + { + let coordination = skills_coordination(); + self.check_in_place_state(&update, &coordination)?; + } + let preview_id = uuid::Uuid::new_v4().to_string(); + let preview = SkillInPlaceUpdatePreview { + preview_id: preview_id.clone(), + id: id.to_string(), + repo_key: update.remote.repo_key.clone(), + repo_path: update.remote.repo_path.clone(), + target_path: update.paths[0].path.clone(), + current_hash: update.installed.content_hash.clone(), + next_hash: update.remote.content_hash.clone(), + conflicts: update.conflicts.clone(), + managed_paths: update.paths.iter().skip(1).map(|path| path.path.clone()).collect(), + followed_paths: update.followed_paths.iter().map(|path| path.path.clone()).collect(), + skipped_paths: update.skipped_paths.clone(), + }; + let mut previews = update_previews(); + previews.retain(|_, item| { + item.installed.id != id || !coordination_scopes_overlap(&item.scope, &update.scope) + }); + while previews.len() >= 4 { + previews.pop_first(); + } + previews.insert(preview_id, update); + Ok(preview) + } + + fn check_in_place_state( + &self, + update: &InPlaceUpdate, + coordination: &SkillsCoordination, + ) -> anyhow::Result<()> { + let state = self.load_state_checked_unlocked()?; + self.ensure_skill_name_not_aliased(&update.installed.id, &state)?; + anyhow::ensure!(coordination_scope_is_current(&update.scope, &self.coordination_scope()) + && coordination.mutation_generation(&update.scope, &update.installed.id) == update.generation + && coordination.repo_generation(&update.scope, &update.repo.key()) == update.repo_generation + && if let Some(record) = &update.instance_record { + state.instance_records.get(&update.installed.id) + .and_then(|records| records.get(&record.target.path)) == Some(record) + } else { + state.installed.get(&update.installed.id) == Some(&update.installed) + && state.instance_targets.get(&update.installed.id) == update.instance_target.as_ref() + } + && state.repos.contains(&update.repo), + "预览后 Skill 或仓库已改变,请重新预览"); + for path in &update.paths { + anyhow::ensure!(skill_path_identity(&path.path)? == path.identity, + "预览后 Skill 路径已改变,请重新预览"); + } + for path in &update.followed_paths { + anyhow::ensure!(skill_path_identity(&path.path)? == path.identity + && path.path.canonicalize()? == path.target, + "预览后 Skill 链接已改变,请重新预览"); + } + Ok(()) + } + + fn check_in_place_contents(&self, update: &InPlaceUpdate) -> anyhow::Result<()> { + let original = update.installed.original_dir.as_ref().context("Skill 原目录缺失")?; + let (paths, conflicts, followed_paths, skipped_paths) = self.in_place_update_paths(&update.installed.id, original, update.instance_target.as_ref(), update.instance_record.as_ref())?; + let expected_conflicts: Vec<_> = update.conflicts.iter() + .filter(|item| !item.starts_with("Skill 原件自上次关联或更新后已修改")) + .cloned().collect(); + anyhow::ensure!(paths.len() == update.paths.len() + && paths.iter().zip(&update.paths).all(|(current, previous)| + current.path == previous.path && current.identity == previous.identity + && current.digest == previous.digest && current.marked == previous.marked) + && conflicts == expected_conflicts && followed_paths == update.followed_paths + && skipped_paths == update.skipped_paths, + "预览后 Skill 文件或副本已改变,请重新预览"); + Ok(()) + } + + /// 获取后端保存的远端身份,供确认时重新检查仓库版本。 + pub fn in_place_update_preview_remote(&self, preview_id: &str) -> anyhow::Result { + let previews = update_previews(); + let update = previews.get(preview_id).context("Skill 更新预览已失效,请重新预览")?; + anyhow::ensure!(coordination_scope_is_current(&update.scope, &self.coordination_scope()), + "Skill 管理范围已改变,请重新预览"); + Ok(update.remote.clone()) + } + + /// 只执行后端保存的预览,仓库 ZIP 和目标路径均不由调用方在确认时提供。 + pub fn execute_skill_in_place_update( + &self, + preview_id: &str, + observed_remote: &RemoteSkill, + ) -> anyhow::Result { + let update = update_previews().remove(preview_id) + .context("Skill 更新预览已失效,请重新预览")?; + anyhow::ensure!(observed_remote.repo_key == update.remote.repo_key + && observed_remote.repo_path == update.remote.repo_path + && observed_remote.content_hash == update.remote.content_hash, + "预览后仓库版本已改变,请重新预览"); + anyhow::ensure!(update.conflicts.is_empty(), "存在 Skill 副本冲突,请先处理"); + self.check_in_place_contents(&update)?; + { + let coordination = skills_coordination(); + self.check_in_place_state(&update, &coordination)?; + } + let id = &update.installed.id; + let mut staged: Vec = Vec::new(); + for path in &update.paths { + let parent = path.path.parent().context("Skill 原目录没有父目录")?; + let staging = parent.join(format!(".codex-plus-update-{id}-{}", uuid::Uuid::new_v4())); + if let Err(error) = std::fs::create_dir(&staging) { + let error = anyhow::Error::new(error) + .context(format!("创建 Skill 暂存目录失败:{}", staging.display())); + return Err(staged.iter().fold(error, |error, path| retain_staging_context(error, path))); + } + staged.push(staging); + } + let result = (|| -> anyhow::Result { + let remote = archive::stage_downloaded_zip( + &update.repo, &update.zip, id, Some(&update.installed.repo_path), &staged[0], + )?; + anyhow::ensure!(remote.repo_path == update.remote.repo_path + && remote.content_hash == update.remote.content_hash, + "仓库压缩包与预览版本不一致,请重新预览"); + if update.paths[0].marked { + std::fs::write(staged[0].join(MANAGED_COPY_MARKER), "")?; + } + for (index, path) in update.paths.iter().enumerate().skip(1) { + copy_dir_all(&staged[0], &staged[index])?; + if path.marked { + std::fs::write(staged[index].join(MANAGED_COPY_MARKER), "")?; + } + } + self.check_in_place_contents(&update)?; + let mut coordination = skills_coordination(); + let mut state = self.load_state_checked_unlocked()?; + let mut displaced = Vec::new(); + let mut published = Vec::new(); + let commit = (|| -> anyhow::Result<()> { + #[cfg(test)] + BEFORE_UPDATE_BACKUP.with(|hook| { + if let Some(hook) = hook.borrow_mut().take() { hook(); } + }); + self.check_in_place_state(&update, &coordination)?; + for path in &update.paths { + let backup = self.backup_directory_checked(id, &path.path, path.identity)?; + displaced.push((path.path.clone(), backup.clone(), path.identity)); + anyhow::ensure!(skill_directory_digest(&backup)? == Some(path.digest), + "更新期间 Skill 内容已改变,请重新预览:{}", path.path.display()); + } + for (path, staging) in update.paths.iter().zip(&staged) { + let identity = skill_path_identity(staging)?; + move_path_no_replace(staging, &path.path)?; + published.push((path.path.clone(), identity)); + anyhow::ensure!(skill_path_identity(&path.path)? == identity, + "Skill 更新期间目标路径发生变化"); + } + let mut backup_ids = Vec::new(); + for (original, backup, _) in &displaced { + let backup_id = self.backup_id_for_path(backup)?; + state.backup_origins.insert(backup_id.clone(), SkillBackupOrigin { + path: original.clone(), + purpose: "update".into(), + }); + backup_ids.push(backup_id); + } + state.update_backup_groups.insert(uuid::Uuid::new_v4().to_string(), + SkillUpdateBackupGroup { + installed: update.installed.clone(), + backup_ids, + partially_deleted: false, + instance_path: update.instance_record.as_ref().map(|record| record.target.path.clone()), + published: update.paths.iter().map(|path| { + Ok(SkillPublishedSnapshot { + path: path.path.clone(), + identity: skill_path_identity(&path.path)?, + digest: skill_directory_digest(&path.path)?.context("更新后的 Skill 目录不存在")?, + }) + }).collect::>()?, + }); + let mirror = update.instance_record.as_ref().is_none_or(|record| + state.instance_targets.get(id) == Some(&record.target)); + let installed = if let Some(record) = &update.instance_record { + &mut state.instance_records.get_mut(id).context("Skill 原件记录已消失")? + .get_mut(&record.target.path).context("Skill 原件记录已消失")?.installed + } else { + state.installed.get_mut(id).context("Skill 安装记录已消失")? + }; + installed.name = remote.name; + installed.description = remote.description; + installed.content_hash = remote.content_hash; + installed.original_dir = Some(update.paths[0].path.clone()); + installed.update_owner = "in_place".into(); + let updated_installed = installed.clone(); + if let Some(record) = &update.instance_record { + let record = state.instance_records.get_mut(id).unwrap().get_mut(&record.target.path).unwrap(); + record.target.identity = skill_path_identity(&update.paths[0].path)?; + for tracked in &mut record.managed_copies { + if let Some(path) = update.paths.iter().skip(1).find(|path| path.path == tracked.path) { + tracked.identity = skill_path_identity(&path.path)?; + } + } + } + if mirror { + state.installed.insert(id.clone(), updated_installed); + } + if mirror && update.instance_target.is_some() { + state.instance_targets.insert(id.clone(), SkillInstanceTarget { + path: update.paths[0].path.clone(), + identity: skill_path_identity(&update.paths[0].path)?, + }); + } + self.save_state_unlocked(&state) + })(); + if let Err(error) = commit { + let mut failures = Vec::new(); + let mut recoveries = Vec::new(); + for (path, identity) in published.iter().rev() { + match recover_published_path(path, *identity) { + Ok(Some(path)) => recoveries.push(path.display().to_string()), + Ok(None) => {} + Err(error) => failures.push(format!("{error:#}")), + } + } + for (path, backup, identity) in displaced.iter().rev() { + if let Err(error) = restore_skill_backup(backup, path, *identity) { + failures.push(format!("原内容保留在 {}:{error:#}", backup.display())); + } + } + if !failures.is_empty() || !recoveries.is_empty() { + return Err(error.context(format!("回滚:{};新内容保留在 {}", + failures.join(";"), recoveries.join("、")))); + } + return Err(error); + } + coordination.changed_skill(self.coordination_scope(), id); + Ok(state) + })(); + result.map_err(|error| { + staged.iter().fold(error, |error, path| retain_staging_context(error, path)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn fixture() -> (tempfile::TempDir, SkillsManager, SkillRepo, RemoteSkill, Vec) { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let agent = temp.path().join("agent"); + let manager = SkillsManager::new( + temp.path().join("library"), + temp.path().join("backups"), + temp.path().join("state.json"), + temp.path().join("codex"), + ).with_shared_skills_dir(&shared) + .with_agent_dirs(vec![("claude".into(), agent.clone())]); + let original = shared.join("alpha"); + std::fs::create_dir_all(&original).unwrap(); + std::fs::write(original.join(SKILL_MANIFEST_FILE), "---\nname: alpha\n---\nold").unwrap(); + std::fs::write(original.join("details.txt"), "old details").unwrap(); + copy_dir_all(&original, &manager.source_dir.join("alpha")).unwrap(); + copy_dir_all(&original, &agent.join("alpha")).unwrap(); + std::fs::write(agent.join("alpha").join(MANAGED_COPY_MARKER), "").unwrap(); + let repo = SkillRepo { + owner: "example".into(), name: "skills".into(), branch: "main".into(), + subdir: String::new(), enabled: true, + }; + let mut state = manager.load_state(); + state.repos.push(repo.clone()); + state.installed.insert("alpha".into(), InstalledSkill { + id: "alpha".into(), name: "alpha".into(), description: String::new(), + repo_key: repo.key(), repo_path: "skills/alpha".into(), + content_hash: source_match::local_repository_hash(&original).unwrap(), + installed_at: String::new(), verified_cc_switch: false, + install_channel: "local".into(), original_dir: Some(original), update_owner: String::new(), + }); + manager.save_state_unlocked(&state).unwrap(); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + writer.start_file("skills-main/skills/alpha/SKILL.md", options).unwrap(); + writer.write_all(b"---\nname: alpha\n---\nnew").unwrap(); + writer.start_file("skills-main/skills/alpha/details.txt", options).unwrap(); + writer.write_all(b"new details").unwrap(); + let zip = writer.finish().unwrap().into_inner(); + let remote = parse_skills_from_zip(&repo, &zip, &BTreeMap::new()).unwrap() + .into_iter().find(|item| item.repo_path == "skills/alpha").unwrap(); + (temp, manager, repo, remote, zip) + } + + #[cfg(unix)] + fn test_directory_link(source: &Path, target: &Path) { + std::os::unix::fs::symlink(source, target).unwrap(); + } + + #[cfg(windows)] + fn test_directory_link(source: &Path, target: &Path) { + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(target.to_string_lossy().replace('/', "\\")) + .arg(source.to_string_lossy().replace('/', "\\")).output().unwrap(); + assert!(output.status.success(), "无法创建测试 junction:{}", + String::from_utf8_lossy(&output.stderr)); + } + + #[cfg(any(unix, windows))] + #[test] + fn following_links_are_reported_and_rechecked_without_replacement() { + let (temp, manager, repo, remote, zip) = fixture(); + let manager = manager.with_agent_dirs(vec![ + ("claude".into(), temp.path().join("agent")), + ("independent".into(), temp.path().join("independent")), + ("unknown".into(), temp.path().join("unknown")), + ]); + let original = temp.path().join("shared/alpha"); + let source = manager.source_dir.join("alpha"); + let copy = temp.path().join("agent/alpha"); + let linked = manager.linked_dir().join("alpha"); + let independent = temp.path().join("independent/alpha"); + let unknown = temp.path().join("unknown/alpha"); + copy_dir_all(&original, &independent).unwrap(); + std::fs::create_dir_all(manager.linked_dir()).unwrap(); + std::fs::create_dir_all(unknown.parent().unwrap()).unwrap(); + test_directory_link(&source, &linked); + test_directory_link(&independent, &unknown); + let matches = manager.preview_repository_matches_for_instance( + &repo, &[remote.clone()], "alpha", &original, + ).unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: matches.preview_id, local_id: "alpha".into(), + repo_key: repo.key(), repo_path: remote.repo_path.clone(), + local_path: Some(original.clone()), manual: false, + }; + let result = manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &original, remote.clone(), zip.clone(), + ).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + assert!(preview.managed_paths.contains(&source)); + assert!(preview.managed_paths.contains(©)); + assert!(preview.followed_paths.contains(&linked)); + assert!(!preview.managed_paths.contains(&linked)); + assert!(preview.skipped_paths.contains(&independent)); + assert!(preview.skipped_paths.contains(&unknown)); + + let held_link = temp.path().join("original-link"); + std::fs::rename(&linked, &held_link).unwrap(); + test_directory_link(&source, &linked); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(original.join("details.txt")).unwrap(), "old details"); + let held_replacement = temp.path().join("replacement-link"); + std::fs::rename(&linked, &held_replacement).unwrap(); + std::fs::rename(&held_link, &linked).unwrap(); + + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &original, remote.clone(), zip.clone(), + ).unwrap(); + let linked_during_commit = linked.clone(); + let source_during_commit = source.clone(); + let held_during_commit = temp.path().join("link-during-commit"); + let held_for_hook = held_during_commit.clone(); + BEFORE_UPDATE_BACKUP.with(|hook| *hook.borrow_mut() = Some(Box::new(move || { + std::fs::rename(&linked_during_commit, &held_for_hook).unwrap(); + test_directory_link(&source_during_commit, &linked_during_commit); + }))); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(original.join("details.txt")).unwrap(), "old details"); + let replaced_during_commit = temp.path().join("replacement-during-commit"); + std::fs::rename(&linked, &replaced_during_commit).unwrap(); + std::fs::rename(&held_during_commit, &linked).unwrap(); + let link_identity = skill_path_identity(&linked).unwrap(); + + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &original, remote.clone(), zip, + ).unwrap(); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + assert_eq!(skill_path_identity(&linked).unwrap(), link_identity); + assert_eq!(std::fs::read_to_string(linked.join("details.txt")).unwrap(), "new details"); + assert_eq!(std::fs::read_to_string(unknown.join("details.txt")).unwrap(), "old details"); + assert!(!state.backup_origins.values().any(|origin| origin.path == linked || origin.path == unknown)); + } + + #[test] + fn independently_associated_instances_keep_sources_updates_and_backups_separate() { + let (temp, manager, repo, remote, zip) = fixture(); + let first = temp.path().join("shared/alpha").canonicalize().unwrap(); + let second = temp.path().join("agent/alpha").canonicalize().unwrap(); + std::fs::remove_file(second.join(MANAGED_COPY_MARKER)).unwrap(); + let mut other_repo = repo.clone(); + other_repo.owner = "another".into(); + let mut state = manager.load_state(); + state.repos.push(other_repo.clone()); + manager.save_state_unlocked(&state).unwrap(); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + writer.start_file("skills-main/SKILL.md", options).unwrap(); + writer.write_all(b"---\nname: alpha\n---\nnew").unwrap(); + writer.start_file("skills-main/details.txt", options).unwrap(); + writer.write_all(b"new details").unwrap(); + let other_zip = writer.finish().unwrap().into_inner(); + let other_remote = parse_skills_from_zip(&other_repo, &other_zip, &BTreeMap::new()).unwrap() + .into_iter().find(|item| item.repo_path == ".").unwrap(); + for (path, source, candidate) in [(&first, &repo, &remote), (&second, &other_repo, &other_remote)] { + let preview = manager.preview_repository_matches_for_instance( + source, &[candidate.clone()], "alpha", path, + ).unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: preview.preview_id, local_id: "alpha".into(), repo_key: source.key(), + repo_path: candidate.repo_path.clone(), local_path: Some(path.clone()), manual: false, + }; + let result = manager.apply_repository_matches(&[selection], &[candidate.clone()]).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert_eq!(result.applied[0].local_path.as_ref(), Some(path)); + } + let inventory = manager.try_merge_entries(&[remote.clone(), other_remote.clone()]).unwrap(); + let first_row = inventory.iter().find(|item| item.id == "alpha" + && item.original_dir.as_ref() == Some(&first)).unwrap(); + let second_row = inventory.iter().find(|item| item.id == "alpha" + && item.original_dir.as_ref() == Some(&second)).unwrap(); + assert_eq!(first_row.repo_key, repo.key()); + assert_eq!(second_row.repo_key, other_repo.key()); + assert_eq!(second_row.repo_path, "."); + assert_eq!(first_row.locations[0].repo_key, repo.key()); + assert_eq!(second_row.locations[0].repo_key, other_repo.key()); + + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &first, remote.clone(), zip.clone(), + ).unwrap(); + assert!(preview.skipped_paths.iter().any(|path| path.canonicalize().ok().as_ref() == Some(&second))); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + let first_backup = state.update_backup_groups.values().next().unwrap().backup_ids[0].clone(); + assert_eq!(std::fs::read_to_string(second.join("details.txt")).unwrap(), "old details"); + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &second, other_remote.clone(), other_zip, + ).unwrap(); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &other_remote).unwrap(); + let second_backup = state.update_backup_groups.values().find(|group| + group.instance_path.as_ref() == Some(&second)).unwrap().backup_ids[0].clone(); + manager.restore_backup(&second_backup).unwrap(); + assert_eq!(std::fs::read_to_string(first.join("details.txt")).unwrap(), "new details"); + assert_eq!(std::fs::read_to_string(second.join("details.txt")).unwrap(), "old details"); + manager.restore_backup(&first_backup).unwrap(); + let inventory = manager.try_local_inventory().unwrap(); + assert_eq!(inventory.iter().find(|item| item.original_dir.as_ref() == Some(&first)).unwrap().repo_key, repo.key()); + assert_eq!(inventory.iter().find(|item| item.original_dir.as_ref() == Some(&second)).unwrap().repo_key, other_repo.key()); + } + + #[test] + fn same_repository_agent_paths_update_only_the_selected_installation() { + let (temp, manager, repo, _, _) = fixture(); + let first = temp.path().join("shared/alpha").canonicalize().unwrap(); + let second = temp.path().join("agent/alpha").canonicalize().unwrap(); + std::fs::remove_file(second.join(MANAGED_COPY_MARKER)).unwrap(); + std::fs::write(second.join("details.txt"), "claude old").unwrap(); + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + for (path, details) in [(".agents/skills/alpha", "codex new"), (".codex/skills/alpha", "claude new")] { + writer.start_file(format!("skills-main/{path}/SKILL.md"), options).unwrap(); + writer.write_all(b"---\nname: alpha\n---\nnew").unwrap(); + writer.start_file(format!("skills-main/{path}/details.txt"), options).unwrap(); + writer.write_all(details.as_bytes()).unwrap(); + } + let zip = writer.finish().unwrap().into_inner(); + let candidates = parse_skills_from_zip(&repo, &zip, &BTreeMap::new()).unwrap(); + let codex_remote = candidates.iter().find(|item| item.repo_path == ".agents/skills/alpha").unwrap(); + let claude_remote = candidates.iter().find(|item| item.repo_path == ".codex/skills/alpha").unwrap(); + for (path, remote) in [(&first, codex_remote), (&second, claude_remote)] { + let preview = manager.preview_repository_matches_for_instance( + &repo, &[remote.clone()], "alpha", path, + ).unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: preview.preview_id, local_id: "alpha".into(), repo_key: repo.key(), + repo_path: remote.repo_path.clone(), local_path: Some(path.clone()), manual: false, + }; + let result = manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + + let inventory = manager.try_merge_entries(&candidates).unwrap(); + let instances = inventory.iter().filter(|item| item.id == "alpha" && item.installed).collect::>(); + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|item| item.conflict.is_none())); + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &first, codex_remote.clone(), zip.clone(), + ).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + assert!(preview.skipped_paths.iter().any(|path| path.canonicalize().ok().as_ref() == Some(&second))); + manager.execute_skill_in_place_update(&preview.preview_id, codex_remote).unwrap(); + assert_eq!(std::fs::read_to_string(first.join("details.txt")).unwrap(), "codex new"); + assert_eq!(std::fs::read_to_string(second.join("details.txt")).unwrap(), "claude old"); + + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &second, claude_remote.clone(), zip, + ).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + assert!(preview.skipped_paths.iter().any(|path| path.canonicalize().ok().as_ref() == Some(&first))); + manager.execute_skill_in_place_update(&preview.preview_id, claude_remote).unwrap(); + assert_eq!(std::fs::read_to_string(first.join("details.txt")).unwrap(), "codex new"); + assert_eq!(std::fs::read_to_string(second.join("details.txt")).unwrap(), "claude new"); + } + + #[test] + fn equal_agent_installations_with_the_same_repository_are_not_conflicts() { + let (temp, manager, repo, remote, _) = fixture(); + let first = temp.path().join("shared/alpha").canonicalize().unwrap(); + let second = temp.path().join("agent/alpha").canonicalize().unwrap(); + std::fs::remove_file(second.join(MANAGED_COPY_MARKER)).unwrap(); + for path in [&first, &second] { + let preview = manager.preview_repository_matches_for_instance( + &repo, &[remote.clone()], "alpha", path, + ).unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: preview.preview_id, local_id: "alpha".into(), repo_key: repo.key(), + repo_path: remote.repo_path.clone(), local_path: Some(path.clone()), manual: false, + }; + let result = manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + let inventory = manager.try_local_inventory().unwrap(); + let instances = inventory.iter().filter(|item| item.id == "alpha").collect::>(); + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|item| item.conflict.is_none())); + assert_eq!(instances.iter().filter_map(|item| item.original_dir.clone()).collect::>(), + BTreeSet::from([first, second])); + } + + #[test] + fn confirmed_managed_copies_update_and_restore_with_original() { + let (temp, manager, repo, remote, zip) = fixture(); + let original = temp.path().join("shared/alpha"); + let source = manager.source_dir.join("alpha"); + let copy = temp.path().join("agent/alpha"); + let mut state = manager.load_state(); + state.installed.get_mut("alpha").unwrap().update_owner = "manager".into(); + manager.save_state_unlocked(&state).unwrap(); + let preview = manager.preview_repository_matches_for_instance(&repo, &[remote.clone()], "alpha", &original).unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: preview.preview_id, local_id: "alpha".into(), repo_key: repo.key(), + repo_path: remote.repo_path.clone(), local_path: Some(original.clone()), manual: false, + }; + assert!(manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap().errors.is_empty()); + std::fs::write(copy.join("details.txt"), "modified").unwrap(); + let preview = manager.preview_skill_in_place_update_for_instance("alpha", &original, remote.clone(), zip.clone()).unwrap(); + assert!(preview.conflicts.iter().any(|item| item.contains("受控副本内容已修改"))); + std::fs::write(copy.join("details.txt"), "old details").unwrap(); + let preview = manager.preview_skill_in_place_update_for_instance("alpha", &original, remote.clone(), zip).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + assert!(preview.managed_paths.contains(&source)); + assert!(preview.managed_paths.contains(©)); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + for path in [&original, &source, ©] { + assert_eq!(std::fs::read_to_string(path.join("details.txt")).unwrap(), "new details"); + } + let backup = state.update_backup_groups.values().next().unwrap().backup_ids[0].clone(); + manager.restore_backup(&backup).unwrap(); + for path in [&original, &source, ©] { + assert_eq!(std::fs::read_to_string(path.join("details.txt")).unwrap(), "old details"); + } + } + + #[test] + fn archived_source_preserves_repository_association_and_updates_the_codex_original() { + let (temp, manager, repo, remote, zip) = fixture(); + let shared = temp.path().join("shared/alpha"); + let codex = manager.linked_dir().join("alpha"); + std::fs::create_dir_all(manager.linked_dir()).unwrap(); + std::fs::rename(&shared, &codex).unwrap(); + std::fs::write(codex.join(MANAGED_COPY_MARKER), "").unwrap(); + std::fs::rename(temp.path().join("agent/alpha"), temp.path().join("held-agent")).unwrap(); + let mut state = manager.load_state(); + state.installed.get_mut("alpha").unwrap().original_dir = None; + manager.save_state_unlocked(&state).unwrap(); + + let duplicates = manager.preview_skill_duplicates().unwrap(); + assert_eq!(duplicates.candidates.iter().find(|item| item.kind == "source").unwrap().status, "identical-source"); + manager.archive_skill_managed_source(&duplicates.preview_id, "alpha").unwrap(); + let original = codex.canonicalize().unwrap(); + let unselected = manager.preview_repository_matches(&repo, &[remote.clone()], Some("alpha")).unwrap(); + assert!(unselected.matches.iter().any(|item| item.conflict.is_none())); + let matches = manager.preview_repository_matches_for_instance( + &repo, &[remote.clone()], "alpha", &original, + ).unwrap(); + let selected = matches.matches.iter().find(|item| item.repo_path == remote.repo_path).unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: matches.preview_id.clone(), local_id: "alpha".into(), + repo_key: repo.key(), repo_path: selected.repo_path.clone(), + local_path: Some(original.clone()), manual: false, + }; + let result = manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &original, remote.clone(), zip, + ).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + assert!(codex.join(MANAGED_COPY_MARKER).is_file()); + assert_eq!(std::fs::read_to_string(codex.join("details.txt")).unwrap(), "new details"); + let group = state.update_backup_groups.values().next().unwrap(); + manager.restore_backup(&group.backup_ids[0]).unwrap(); + assert!(codex.join(MANAGED_COPY_MARKER).is_file()); + assert_eq!(std::fs::read_to_string(codex.join("details.txt")).unwrap(), "old details"); + } + + #[test] + fn legacy_single_instance_source_remains_available_at_its_location() { + let (temp, manager, repo, remote, zip) = fixture(); + let original = temp.path().join("shared/alpha"); + let inventory = manager.try_local_inventory().unwrap(); + let location = inventory.iter().find(|item| item.id == "alpha").unwrap().locations.iter() + .find(|location| location.root_kind == "shared").unwrap(); + assert_eq!(location.repo_key, repo.key()); + assert_eq!(location.repo_path, remote.repo_path); + let preview = manager.preview_skill_in_place_update_for_instance( + "alpha", &original, remote, zip, + ).unwrap(); + assert_eq!(preview.target_path, original); + } + + #[test] + fn selected_independent_original_updates_and_restores_without_touching_other_installs() { + let (temp, manager, repo, remote, zip) = fixture(); + let original = temp.path().join("shared/alpha"); + let agent = temp.path().join("agent/alpha"); + std::fs::remove_file(agent.join(MANAGED_COPY_MARKER)).unwrap(); + std::fs::write(agent.join(SKILL_MANIFEST_FILE), "---\nname: alpha\n---\nindependent").unwrap(); + let agent_identity = skill_path_identity(&agent).unwrap(); + let source = manager.source_dir.join("alpha"); + let source_identity = skill_path_identity(&source).unwrap(); + let matches = manager.preview_repository_matches_for_instance( + &repo, &[remote.clone()], "alpha", &original, + ).unwrap(); + let found = matches.matches.iter().find(|item| item.repo_path == "skills/alpha").unwrap(); + let mut selected = SkillRepositoryMatchSelection { + preview_id: matches.preview_id.clone(), local_id: "alpha".into(), + repo_key: repo.key(), repo_path: found.repo_path.clone(), + local_path: Some(agent.clone()), manual: false, + }; + assert!(!manager.apply_repository_matches(&[selected.clone()], &[remote.clone()]).unwrap().errors.is_empty()); + selected.local_path = Some(original.clone()); + assert!(manager.apply_repository_matches(&[selected], &[remote.clone()]).unwrap().errors.is_empty()); + let inventory = manager.try_local_inventory().unwrap(); + let associated = inventory.iter().find(|item| item.original_dir.as_ref() + .is_some_and(|path| path == &original.canonicalize().unwrap())).unwrap(); + assert_eq!(associated.repo_key, repo.key()); + let other = inventory.iter().find(|item| item.original_dir.as_ref() + .is_some_and(|path| path == &agent.canonicalize().unwrap())).unwrap(); + assert!(other.repo_key.is_empty()); + + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip.clone()).unwrap(); + assert!(preview.conflicts.is_empty()); + assert!(preview.skipped_paths.contains(&agent)); + assert!(preview.managed_paths.contains(&source)); + std::fs::write(original.join("details.txt"), "edited after preview").unwrap(); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + std::fs::write(original.join("details.txt"), "old details").unwrap(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip.clone()).unwrap(); + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(original.join("details.txt")).unwrap(), "old details"); + assert_eq!(skill_path_identity(&agent).unwrap(), agent_identity); + assert_eq!(skill_path_identity(&source).unwrap(), source_identity); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip.clone()).unwrap(); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + assert_eq!(std::fs::read_to_string(original.join("details.txt")).unwrap(), "new details"); + assert_eq!(std::fs::read_to_string(agent.join(SKILL_MANIFEST_FILE)).unwrap(), "---\nname: alpha\n---\nindependent"); + assert_eq!(skill_path_identity(&agent).unwrap(), agent_identity); + assert_eq!(std::fs::read_to_string(source.join("details.txt")).unwrap(), "new details"); + assert_ne!(skill_path_identity(&source).unwrap(), source_identity); + let backup_id = state.update_backup_groups.values().next().unwrap().backup_ids[0].clone(); + manager.restore_backup(&backup_id).unwrap(); + assert_eq!(std::fs::read_to_string(original.join("details.txt")).unwrap(), "old details"); + assert_eq!(skill_path_identity(&agent).unwrap(), agent_identity); + assert_eq!(skill_path_identity(&source).unwrap(), source_identity); + let state = manager.load_state(); + assert_eq!(state.instance_targets["alpha"].identity, skill_path_identity(&original).unwrap()); + let replaced = temp.path().join("preserved-original"); + std::fs::rename(&original, &replaced).unwrap(); + copy_dir_all(&replaced, &original).unwrap(); + assert!(manager.preview_skill_in_place_update("alpha", remote, zip).is_err()); + } + + #[test] + fn equal_independent_installs_can_associate_and_update_one_original() { + let (temp, manager, repo, remote, zip) = fixture(); + let original = temp.path().join("shared/alpha"); + let agent = temp.path().join("agent/alpha"); + std::fs::remove_file(agent.join(MANAGED_COPY_MARKER)).unwrap(); + let agent_identity = skill_path_identity(&agent).unwrap(); + let source_identity = skill_path_identity(&manager.source_dir.join("alpha")).unwrap(); + let entries = manager.try_local_inventory().unwrap(); + let instances = entries.iter().filter(|item| item.id == "alpha").collect::>(); + assert_eq!(instances.len(), 2); + assert!(instances.iter().all(|item| item.conflict.is_none())); + let shared = instances.iter().flat_map(|item| &item.locations) + .find(|location| location.root_kind == "shared").unwrap(); + let claude = instances.iter().flat_map(|item| &item.locations) + .find(|location| location.root_kind == "claude").unwrap(); + assert!(shared.selectable && claude.selectable); + assert_eq!(shared.original_path, Some(original.canonicalize().unwrap())); + assert_eq!(claude.original_path, Some(agent.canonicalize().unwrap())); + let selected_path = shared.original_path.clone().unwrap(); + let matches = manager.preview_repository_matches_for_instance( + &repo, &[remote.clone()], "alpha", &selected_path, + ).unwrap(); + let found = matches.matches.iter().find(|item| item.repo_path == "skills/alpha").unwrap(); + let selection = SkillRepositoryMatchSelection { + preview_id: matches.preview_id.clone(), local_id: "alpha".into(), + repo_key: repo.key(), repo_path: found.repo_path.clone(), + local_path: Some(selected_path.clone()), manual: false, + }; + assert!(manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap().errors.is_empty()); + let entries = manager.try_local_inventory().unwrap(); + let selected = entries.iter().find(|item| item.id == "alpha" + && item.locations.iter().any(|location| location.original_path.as_ref() == Some(&selected_path))).unwrap(); + assert!(selected.conflict.is_none()); + assert!(selected.locations.iter().any(|location| location.original_path.as_ref() == Some(&selected_path) + && location.repo_key == repo.key() && location.repo_path == "skills/alpha")); + + let saved = temp.path().join("original-before-replacement"); + let replacement = temp.path().join("unconfirmed-replacement"); + std::fs::rename(&original, &saved).unwrap(); + copy_dir_all(&saved, &original).unwrap(); + let entries = manager.try_local_inventory().unwrap(); + assert!(entries.iter().find(|item| item.id == "alpha" + && item.locations.iter().any(|location| location.root_kind == "shared")).unwrap().repo_key.is_empty()); + std::fs::rename(&original, &replacement).unwrap(); + std::fs::rename(&saved, &original).unwrap(); + + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + assert_eq!(preview.target_path, selected_path); + assert!(preview.skipped_paths.contains(&agent)); + assert!(preview.managed_paths.contains(&manager.source_dir.join("alpha"))); + manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + assert_eq!(std::fs::read_to_string(original.join("details.txt")).unwrap(), "new details"); + assert_eq!(std::fs::read_to_string(agent.join("details.txt")).unwrap(), "old details"); + assert_eq!(skill_path_identity(&agent).unwrap(), agent_identity); + assert_eq!(std::fs::read_to_string(manager.source_dir.join("alpha/details.txt")).unwrap(), "new details"); + assert_ne!(skill_path_identity(&manager.source_dir.join("alpha")).unwrap(), source_identity); + } + + #[test] + fn update_synchronizes_managed_copies_and_restores_the_whole_group() { + let (temp, manager, _, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + assert!(preview.conflicts.is_empty()); + let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + let paths = [temp.path().join("shared/alpha"), manager.source_dir.join("alpha"), + temp.path().join("agent/alpha")]; + for path in &paths { + assert_eq!(std::fs::read_to_string(path.join("details.txt")).unwrap(), "new details"); + } + assert!(is_managed_copy(&paths[2])); + let group = state.update_backup_groups.values().next().unwrap(); + assert_eq!(group.backup_ids.len(), 3); + assert_eq!(group.published.len(), 3); + manager.restore_backup(&group.backup_ids[0]).unwrap(); + for path in &paths { + assert_eq!(std::fs::read_to_string(path.join("details.txt")).unwrap(), "old details"); + } + } + + #[test] + fn adopted_external_skill_updates_its_original_after_source_association() { + for shared_original in [true, false] { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let agent = temp.path().join("agent"); + let other = temp.path().join("other"); + let manager = SkillsManager::new( + temp.path().join("library"), temp.path().join("backups"), + temp.path().join("state.json"), temp.path().join("codex"), + ).with_agent_dirs(vec![("claude".into(), agent.clone()), ("grok".into(), other.clone())]); + let original = if shared_original { shared.join("alpha") } else { agent.join("alpha") }; + std::fs::create_dir_all(&original).unwrap(); + std::fs::write(original.join(SKILL_MANIFEST_FILE), "---\nname: alpha\n---\nold").unwrap(); + let repo = SkillRepo { + owner: "example".into(), name: "skills".into(), branch: "main".into(), + subdir: String::new(), enabled: true, + }; + manager.save_repo(repo.clone(), None).unwrap(); + if shared_original { + manager.import_from_local_directory("alpha", &original).unwrap(); + let mut state = manager.load_state(); + state.installed.get_mut("alpha").unwrap().update_owner.clear(); + manager.save_state_unlocked(&state).unwrap(); + } + manager.set_enabled_for("grok", "alpha", true).unwrap(); + let manager = manager.with_shared_skills_dir(&shared); + assert!(manager.load_state().installed["alpha"].update_owner.is_empty()); + // 模拟旧版本采纳时错误写入的管理归属。 + let mut state = manager.load_state(); + state.installed.get_mut("alpha").unwrap().update_owner = "manager".into(); + state.installed.get_mut("alpha").unwrap().repo_key = repo.key(); + state.installed.get_mut("alpha").unwrap().repo_path = "skills/alpha".into(); + manager.save_state_unlocked(&state).unwrap(); + assert!(manager.capture_download_guard("alpha", &repo.key(), false).is_err()); + + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer.start_file("skills-main/skills/alpha/SKILL.md", + zip::write::SimpleFileOptions::default()).unwrap(); + writer.write_all(b"---\nname: alpha\n---\nnew").unwrap(); + let zip = writer.finish().unwrap().into_inner(); + let remote = parse_skills_from_zip(&repo, &zip, &BTreeMap::new()).unwrap() + .into_iter().find(|item| item.repo_path == "skills/alpha").unwrap(); + let matches = manager.preview_repository_matches(&repo, &[remote.clone()], Some("alpha")).unwrap(); + let found = matches.matches.iter().find(|item| item.repo_path == "skills/alpha").unwrap(); + let result = manager.apply_repository_matches(&[SkillRepositoryMatchSelection { + preview_id: matches.preview_id, local_id: "alpha".into(), + repo_key: repo.key(), repo_path: found.repo_path.clone(), manual: false, local_path: None, + }], &[remote.clone()]).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert!(manager.load_state().installed["alpha"].update_owner.is_empty()); + assert!(manager.capture_download_guard("alpha", &repo.key(), false).is_err()); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); + manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); + for path in [&original, &manager.source_dir.join("alpha"), &other.join("alpha")] { + assert!(std::fs::read_to_string(path.join(SKILL_MANIFEST_FILE)).unwrap().ends_with("new")); + } + } + } + + #[test] + fn changed_contents_after_preview_are_not_overwritten() { + let (temp, manager, _, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + std::fs::write(temp.path().join("agent/alpha/details.txt"), "user edit").unwrap(); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(temp.path().join("agent/alpha/details.txt")).unwrap(), "user edit"); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "old details"); + } + + #[test] + fn nested_managed_marker_change_after_association_blocks_update() { + let (temp, manager, _, remote, zip) = fixture(); + for path in [temp.path().join("shared/alpha"), manager.source_dir.join("alpha"), + temp.path().join("agent/alpha")] { + let nested = path.join("nested"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(nested.join(MANAGED_COPY_MARKER), "user content").unwrap(); + } + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + assert!(preview.conflicts.iter().any(|item| item.contains("原件自上次关联或更新后已修改"))); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/nested") + .join(MANAGED_COPY_MARKER)).unwrap(), "user content"); + } + + #[test] + fn changed_contents_during_commit_are_restored_without_publication() { + let (temp, manager, _, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + let edited = manager.source_dir.join("alpha/details.txt"); + BEFORE_UPDATE_BACKUP.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move || { + std::fs::write(edited, "late user edit").unwrap(); + })); + }); + let error = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap_err(); + assert!(format!("{error:#}").contains("更新期间 Skill 内容已改变")); + assert_eq!(std::fs::read_to_string(manager.source_dir.join("alpha/details.txt")).unwrap(), "late user edit"); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "old details"); + assert_eq!(std::fs::read_to_string(temp.path().join("agent/alpha/details.txt")).unwrap(), "old details"); + assert!(manager.load_state().update_backup_groups.is_empty()); + } + + #[test] + fn state_save_failure_restores_original_and_all_managed_copies() { + let (temp, manager, _, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(false)); + for path in [temp.path().join("shared/alpha"), manager.source_dir.join("alpha"), + temp.path().join("agent/alpha")] { + assert_eq!(std::fs::read_to_string(path.join("details.txt")).unwrap(), "old details"); + } + assert!(manager.load_state().update_backup_groups.is_empty()); + } + + #[test] + fn changed_remote_after_preview_is_not_installed() { + let (temp, manager, _, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + let mut changed = remote; + changed.content_hash = "different".into(); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &changed).is_err()); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "old details"); + } + + #[test] + fn git_worktree_at_original_path_cannot_be_replaced() { + let (temp, manager, _, remote, zip) = fixture(); + let git = temp.path().join("shared/alpha/.git"); + std::fs::write(&git, "gitdir: ../gitdir").unwrap(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + assert!(preview.conflicts.iter().any(|message| message.contains("Git"))); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(git).unwrap(), "gitdir: ../gitdir"); + } +} From 23697cfa652af0891d3daa3e6e9113262ce2a3c2 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 22:19:35 +0800 Subject: [PATCH 2/4] fix(skills): verify source evidence and update baselines --- .../src/skills/source_match.rs | 113 +++++++++++++++++- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/codex-plus-core/src/skills/source_match.rs b/crates/codex-plus-core/src/skills/source_match.rs index f2a7f129a2..d9d1897729 100644 --- a/crates/codex-plus-core/src/skills/source_match.rs +++ b/crates/codex-plus-core/src/skills/source_match.rs @@ -325,7 +325,7 @@ impl SkillsManager { } } let mut command = std::process::Command::new("git"); - command.args(["-C"]).arg(&target).args(["remote", "get-url", "origin"]); + command.args(["-C"]).arg(&target).args(["rev-parse", "--show-toplevel"]); #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -334,8 +334,21 @@ impl SkillsManager { let git = command.output(); match git { Ok(output) if output.status.success() => { - for repo in github_repositories(&String::from_utf8_lossy(&output.stdout)) { - evidence.insert((repo, "git-remote".into(), target.clone())); + let root = String::from_utf8(output.stdout)?; + if Path::new(root.trim_end_matches(['\r', '\n'])).canonicalize()? == target { + let mut command = std::process::Command::new("git"); + command.args(["-C"]).arg(&target).args(["remote", "get-url", "origin"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(crate::windows_create_no_window()); + } + let output = command.output()?; + if output.status.success() { + for repo in github_repositories(&String::from_utf8_lossy(&output.stdout)) { + evidence.insert((repo, "git-remote".into(), target.clone())); + } + } } } Ok(_) => {} @@ -721,11 +734,13 @@ impl SkillsManager { item.repo_key = selection.repo_key.clone(); item.repo_path = selection.repo_path.clone(); if !same_source { - item.content_hash = snapshot.local_hash; + item.content_hash = if confirmed.exact { snapshot.local_hash } else { String::new() }; item.update_owner.clear(); if item.install_channel.is_empty() { item.install_channel = "local".into(); } + } else if item.content_hash.is_empty() && confirmed.exact { + item.content_hash = snapshot.local_hash; } if snapshot.original_dir.is_some() && item.update_owner == "manager" { item.update_owner.clear(); @@ -1120,6 +1135,67 @@ mod tests { assert_eq!(std::fs::read_to_string(shared.join("alpha/details.txt")).unwrap(), "whole tree"); } + #[test] + fn differing_associations_do_not_report_a_repository_update() { + for manual in [false, true] { + for selected_instance in [false, true] { + let (temp, manager, repo) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "local"); + let original = shared.join("alpha"); + let local_hash = local_repository_hash(&original).unwrap(); + let mut remote = vec![candidate( + &repo, + if manual { "skills/unrelated" } else { "skills/alpha" }, + "different-remote-content".into(), + )]; + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap() + }; + let found = if manual { &preview.manual_candidates[0] } else { &preview.matches[0] }; + assert!(!found.exact); + let result = manager.apply_repository_matches(&[selection(&preview, found, manual)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let installed = if selected_instance { + &state.instance_records["alpha"].values().next().unwrap().installed + } else { + &state.installed["alpha"] + }; + assert!(installed.content_hash.is_empty()); + assert!(installed.update_owner.is_empty()); + assert_eq!(local_repository_hash(&original).unwrap(), local_hash); + let entries = manager.merge_entries(&remote); + assert!(entries.iter().any(|entry| entry.id == "alpha" && entry.installed)); + assert!(entries.iter().filter(|entry| entry.id == "alpha").all(|entry| !entry.update_available)); + assert!(entries.iter().flat_map(|entry| &entry.locations).all(|location| !location.update_available)); + + remote[0].content_hash = local_hash.clone(); + let preview = if selected_instance { + manager.preview_repository_matches_for_instance(&repo, &remote, "alpha", &original).unwrap() + } else { + manager.preview_repository_matches(&repo, &remote, Some("alpha")).unwrap() + }; + assert!(preview.matches[0].exact); + let result = manager.apply_repository_matches(&[selection(&preview, &preview.matches[0], false)], &remote).unwrap(); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let state = manager.load_state(); + let installed = if selected_instance { + &state.instance_records["alpha"].values().next().unwrap().installed + } else { + &state.installed["alpha"] + }; + assert_eq!(installed.content_hash, local_hash); + remote[0].content_hash = "later-remote-version".into(); + let entries = manager.merge_entries(&remote); + assert!(entries.iter().filter(|entry| entry.id == "alpha").any(|entry| + entry.update_available || entry.locations.iter().any(|location| location.update_available))); + } + } + } + #[test] fn adopted_shared_skill_tracks_matching_managed_copies_without_taking_update_ownership() { let (temp, manager, repo) = fixture(); @@ -1379,6 +1455,35 @@ mod tests { assert!(!manager.load_state().installed.contains_key("alpha")); } + #[test] + fn source_evidence_ignores_parent_git_remote_but_accepts_skill_repository_root() { + let (temp, manager, _) = fixture(); + let shared = temp.path().join("shared"); + write_skill(&shared, "alpha", "source"); + let original = shared.join("alpha"); + let git = |directory: &Path, arguments: &[&str]| { + let mut command = std::process::Command::new("git"); + command.arg("-C").arg(directory).args(arguments); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(crate::windows_create_no_window()); + } + let output = command.output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + }; + git(&shared, &["init", "-q"]); + git(&shared, &["remote", "add", "origin", "https://github.com/example/dotfiles.git"]); + let evidence = manager.discover_skill_source_evidence("alpha").unwrap(); + assert!(!evidence.iter().any(|item| item.kind == "git-remote")); + + git(&original, &["init", "-q"]); + git(&original, &["remote", "add", "origin", "https://github.com/example/alpha.git"]); + let evidence = manager.discover_skill_source_evidence("alpha").unwrap(); + assert!(evidence.iter().any(|item| item.kind == "git-remote" && item.repo == "example/alpha")); + assert!(!evidence.iter().any(|item| item.repo == "example/dotfiles")); + } + #[test] fn changed_local_or_remote_contents_reject_stale_confirmation() { let (temp, manager, repo) = fixture(); From e1e155bfdede350c613496b7e4dafc56636aab25 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 22:31:31 +0800 Subject: [PATCH 3/4] fix(skills): address update and backup review --- .../src-tauri/src/commands.rs | 23 +- apps/codex-plus-manager/src-tauri/src/lib.rs | 3 + .../src-tauri/src/skill_backup_usage.rs | 9 + .../src/ManagementExtra.tsx | 17 +- .../src/SkillBackupUsage.tsx | 64 ++++ .../src/SkillInPlaceUpdateDrawer.tsx | 16 +- apps/codex-plus-manager/src/i18n-en.ts | 13 + .../src/skill-backup-usage.test.ts | 189 +++++++++++ .../src/skill-duplicates-ui.test.ts | 10 +- .../src/skill-in-place-update.test.ts | 79 +++++ crates/codex-plus-core/src/skills.rs | 312 ++++++++++++++---- .../src/skills/backup_usage.rs | 126 +++++++ .../src/skills/in_place_update.rs | 137 +++++++- .../tests/all/skills_lifecycle.rs | 14 +- 14 files changed, 901 insertions(+), 111 deletions(-) create mode 100644 apps/codex-plus-manager/src-tauri/src/skill_backup_usage.rs create mode 100644 apps/codex-plus-manager/src/SkillBackupUsage.tsx create mode 100644 apps/codex-plus-manager/src/skill-backup-usage.test.ts create mode 100644 apps/codex-plus-manager/src/skill-in-place-update.test.ts create mode 100644 crates/codex-plus-core/src/skills/backup_usage.rs diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 464bb5b698..50f0c2128d 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -7691,6 +7691,11 @@ pub async fn preview_skill_in_place_update( } } +#[tauri::command] +pub fn discard_skill_in_place_update_preview(preview_id: String) { + codex_plus_core::skills::SkillsManager::discard_in_place_update_preview(&preview_id); +} + #[tauri::command] pub async fn execute_skill_in_place_update(preview_id: String) -> CommandResult { let manager = default_skills_manager(); @@ -7947,11 +7952,23 @@ pub async fn restore_skill_backup(backup_id: String) -> Result Result, String> { +pub async fn delete_skill_backup( + backup_id: String, + missing_only: Option, +) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let manager = default_skills_manager(); - match manager.delete_backup(backup_id.trim()) { - Ok(_) => ok("备份已删除。", current_skills_payload(&manager)), + let missing_only = missing_only.unwrap_or(false); + let result = if missing_only { + manager.delete_missing_backup(backup_id.trim()) + } else { + manager.delete_backup(backup_id.trim()) + }; + match result { + Ok(_) => ok( + if missing_only { "已清除丢失的备份记录。" } else { "备份已删除。" }, + current_skills_payload(&manager), + ), Err(error) => failed( &format!("删除备份失败:{error}"), current_skills_payload(&manager), diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index ebd42be077..cd6ac96513 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ pub mod install; mod lightweight; mod provider_lookup_commands; mod runtime_health; +mod skill_backup_usage; mod tray_panel; mod shutdown_watchdog; @@ -595,6 +596,7 @@ pub fn run() { commands::preview_skill_duplicates, commands::archive_skill_duplicate, commands::preview_skill_in_place_update, + commands::discard_skill_in_place_update_preview, commands::execute_skill_in_place_update, commands::import_ccs_skills, commands::import_local_skills, @@ -605,6 +607,7 @@ pub fn run() { commands::uninstall_skill, commands::restore_skill_backup, commands::delete_skill_backup, + skill_backup_usage::skill_backup_usage, commands::upsert_skill_repo, commands::inspect_skill_repo, commands::delete_skill_repo, diff --git a/apps/codex-plus-manager/src-tauri/src/skill_backup_usage.rs b/apps/codex-plus-manager/src-tauri/src/skill_backup_usage.rs new file mode 100644 index 0000000000..32c996373f --- /dev/null +++ b/apps/codex-plus-manager/src-tauri/src/skill_backup_usage.rs @@ -0,0 +1,9 @@ +#[tauri::command] +pub async fn skill_backup_usage() -> Result { + tauri::async_runtime::spawn_blocking(|| { + crate::commands::default_skills_manager().backup_usage() + .map_err(|error| format!("统计 Skill 备份失败:{error:#}")) + }) + .await + .map_err(|error| format!("统计 Skill 备份失败:{error}"))? +} diff --git a/apps/codex-plus-manager/src/ManagementExtra.tsx b/apps/codex-plus-manager/src/ManagementExtra.tsx index e40ec4c4c3..1b3ca9bbfd 100644 --- a/apps/codex-plus-manager/src/ManagementExtra.tsx +++ b/apps/codex-plus-manager/src/ManagementExtra.tsx @@ -23,6 +23,7 @@ import { SkillSourceMatchDrawer, type SourceSelection } from "./SkillSourceMatch import { SkillDuplicatesDrawer } from "./SkillDuplicatesDrawer"; import { SkillFileTimes } from "./SkillFileTimes"; import { SkillInPlaceUpdateDrawer } from "./SkillInPlaceUpdateDrawer"; +import { SkillBackupUsage } from "./SkillBackupUsage"; import { TopbarActions } from "./TopbarActions"; type Result = { status: string; message: string }; @@ -40,7 +41,7 @@ type Skill = { id: string; name: string; description: string; repoKey: string; r managedSourceError?: string | null; sourceVerified?: boolean; updateOwner?: string; installChannel?: string; displayInstances?: Skill[] }; type Repo = { owner: string; name: string; branch: string; subdir: string; enabled: boolean }; -type SkillBackup = { id: string; skillId: string; name: string; backedUpAt: string; backupPath?: string | null; restorePath?: string | null; purpose?: string | null; restoreBlockedReason?: string | null; deleteWarning?: string | null }; +type SkillBackup = { id: string; skillId: string; name: string; backedUpAt: string; backupPath?: string | null; restorePath?: string | null; purpose?: string | null; missing?: boolean; restoreBlockedReason?: string | null; deleteWarning?: string | null }; type SkillPayload = Result & { skills: Skill[]; inventoryOk: boolean; repos: Repo[]; repositorySkills: Skill[]; repoCounts: Record; repoErrors: string[]; skillsDir: string; codexSkillsDir: string; agentSkillsDirs?: Record; backups?: SkillBackup[]; backupsOk: boolean }; type RepoInspection = Result & { repo: Repo; skills: Skill[]; skillCount: number }; type SourceApplyResult = SkillPayload & { applied: Array<{ localId: string; fromRepoKey: string; toRepoKey: string; localPath?: string | null }> }; @@ -219,16 +220,17 @@ function SkillBackupList({ backups, busy, onRestore, onDelete }: {
{backup.purpose === "duplicate" ? t("重复副本归档") : backup.purpose === "managed-source" ? t("受控源归档") : backup.purpose === "disabled" ? t("停用副本备份") : backup.purpose === "update" ? t("更新前备份") : t("技能备份")} {t("备份位置")}:{backup.backupPath || t("未知")} {t("恢复位置")}:{backup.restorePath || t("未记录")} - + {backup.missing ? {t("备份文件已丢失")} : } {backup.restoreBlockedReason && {backup.restoreBlockedReason}} -
-
+
+
)}; } function SkillBackupsDrawer({ backups, busy, onClose, onRestore, onDelete }: { backups: SkillBackup[]; busy: boolean; onClose: () => void; onRestore: (backup: SkillBackup) => void; onDelete: (backup: SkillBackup) => void; }) { return + [backup.id, !!backup.missing]))}/> {backups.length ? :

{t("暂无 Skill 备份。")}

}
; } @@ -722,8 +724,11 @@ export function SkillsPage({ notify }: { notify: Notify }) { const deleteBackup = async (backup: SkillBackup) => { const path = backup.backupPath ? `\n${backup.backupPath}` : ""; const warning = backup.deleteWarning ? `\n\n${backup.deleteWarning}` : ""; - if (!(await appConfirm(`${tf("删除 Skill 备份“{0}”?", [backup.name || backup.skillId])}${path}${warning}`, { title: t("删除 Skill 备份"), confirmText: t("删除"), danger: true }))) return; - await run("delete_skill_backup", { backupId: backup.id }); + const message = backup.missing + ? `${tf("清除已丢失备份“{0}”的记录?", [backup.name || backup.skillId])}${path}\n\n${t("此操作不会删除其他文件。")}${warning}` + : `${tf("删除 Skill 备份“{0}”?", [backup.name || backup.skillId])}${path}${warning}`; + if (!(await appConfirm(message, { title: t(backup.missing ? "清除备份记录" : "删除 Skill 备份"), confirmText: t(backup.missing ? "清除记录" : "删除"), danger: true }))) return; + await run("delete_skill_backup", { backupId: backup.id, missingOnly: !!backup.missing }); }; const openRepo = (key: string) => { setRepoSelection(key); setDetail(null); setRepoOpen(true); }; const deferredQuery = useDeferredValue(query); diff --git a/apps/codex-plus-manager/src/SkillBackupUsage.tsx b/apps/codex-plus-manager/src/SkillBackupUsage.tsx new file mode 100644 index 0000000000..0d64384a6d --- /dev/null +++ b/apps/codex-plus-manager/src/SkillBackupUsage.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { RefreshCw } from "lucide-react"; +import { t, tf } from "./i18n"; + +type BackupUsage = { backupCount: number; measuredCount: number; bytes: number; errors: string[] }; +type CachedUsage = { usage: BackupUsage; revision: string; scannedAt: number }; + +let cachedUsage: CachedUsage | null = null; +let pendingUsage: Promise | null = null; +function requestBackupUsage(revision: string) { + if (!pendingUsage) { + pendingUsage = invoke("skill_backup_usage").then((usage) => { + cachedUsage = { usage, revision, scannedAt: Date.now() }; + return cachedUsage; + }, (error) => { + if (cachedUsage) cachedUsage.scannedAt = 0; + throw error; + }).finally(() => { pendingUsage = null; }); + } + return pendingUsage; +} + +export function SkillBackupUsage({ backupRevision }: { backupRevision: string }) { + const [result, setResult] = useState(cachedUsage); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const mounted = useRef(false); + const pending = useRef(false); + + const load = () => { + if (pending.current) return; + pending.current = true; + setLoading(true); + setError(""); + void requestBackupUsage(backupRevision).then( + (value) => { if (mounted.current) setResult(value); }, + (reason) => { if (mounted.current) setError(String(reason)); }, + ).finally(() => { pending.current = false; if (mounted.current) setLoading(false); }); + }; + useEffect(() => { + mounted.current = true; + if (pendingUsage || !cachedUsage || Date.now() - cachedUsage.scannedAt >= 60_000) load(); + return () => { mounted.current = false; }; + }, []); + + const usage = result?.usage; + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + const unit = usage?.bytes ? Math.min(Math.floor(Math.log(usage.bytes) / Math.log(1024)), units.length - 1) : 0; + const size = usage ? `${(usage.bytes / (1024 ** unit)).toLocaleString(undefined, { maximumFractionDigits: unit ? 1 : 0 })} ${units[unit]}` : ""; + return
+
+ {loading && {t("正在统计备份…")}} + {usage && (loading || error) && {t("上次统计")}:} + {usage && {tf("备份数量:{0}", [usage.backupCount])} · {usage.errors.length + ? tf("已统计 {0}/{1} 份,文件大小:{2}", [usage.measuredCount, usage.backupCount, size]) + : tf("文件大小:{0}", [size])}} + +
+ {result && result.revision !== backupRevision && {t("备份列表已变化,请刷新统计")}} + {error && {error}} + {usage?.errors.map((item) => {item})} +
; +} diff --git a/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx b/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx index 6d8ef799c6..d10ebcf38d 100644 --- a/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx +++ b/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx @@ -21,13 +21,22 @@ export function SkillInPlaceUpdateDrawer({ id, repoKey, localPath, onClose, onEx const [done, setDone] = useState(false); useEffect(() => { let active = true; + let previewId = ""; + const discard = (id: string) => { + void invoke("discard_skill_in_place_update_preview", { previewId: id }) + .catch((reason) => console.error("释放 Skill 更新预览失败", reason)); + }; setLoading(true); setPreview(null); setError(""); void invoke("preview_skill_in_place_update", { id, repoKey, localPath: localPath || null }).then((result) => { - if (!active) return; - if (result.status === "ok") setPreview(result); + if (result.status === "ok") { + previewId = result.previewId; + if (active) setPreview(result); + else discard(previewId); + } + else if (!active) return; else setError(result.message); }, (reason) => { if (active) setError(String(reason)); }).finally(() => { if (active) setLoading(false); }); - return () => { active = false; }; + return () => { active = false; if (previewId) discard(previewId); }; }, [id, repoKey, localPath, revision]); const execute = async () => { if (!preview || preview.conflicts.length) return; @@ -47,6 +56,7 @@ export function SkillInPlaceUpdateDrawer({ id, repoKey, localPath, onClose, onEx {error &&

{error}

} {done &&

{t("原位更新已完成,原版本已保存到备份。")}

} {preview && <>
{t("仓库")}
{preview.repoKey}
{t("仓库路径")}
{preview.repoPath || "/"}
{t("安装位置")}
{preview.targetPath}
{t("当前版本")}
{preview.currentHash || "—"}
{t("待更新版本")}
{preview.nextHash || "—"}
+ {!preview.currentHash &&

{t("尚无一致的更新基线,本地内容可能与仓库不同;确认后会先备份再替换。")}

} {!!preview.managedPaths?.length &&
{t("将同步的受控副本")}
    {preview.managedPaths.map((path) =>
  • {path}
  • )}
} {!!preview.followedPaths?.length &&
{t("将随原件或受控副本更新的路径")}
    {preview.followedPaths.map((path) =>
  • {path}
  • )}
} {!!preview.skippedPaths?.length &&
{t("独立或归属未确认的安装,保持不变")}
    {preview.skippedPaths.map((path) =>
  • {path}
  • )}
} diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index e6917e0553..40c2467711 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -5,6 +5,7 @@ // Plain strings: t("中文") -> EN_PLAIN["中文"]. export const EN_PLAIN: Record = { + "尚无一致的更新基线,本地内容可能与仓库不同;确认后会先备份再替换。": "There is no matching update baseline. Local content may differ from the repository; confirming will back it up before replacement.", "说明中的链接": "Link in documentation", "脚本中的链接": "Link in a script", "包元数据": "Package metadata", @@ -3047,6 +3048,14 @@ export const EN_PLAIN: Record = { "管理 Grok CLI 的模型与 API 端点": "Manage Grok CLI models and API endpoints", // Skills 面板 + "正在统计备份…": "Calculating backup sizes…", + "上次统计": "Previous calculation", + "刷新备份统计": "Refresh backup statistics", + "备份列表已变化,请刷新统计": "The backup list has changed. Refresh the statistics.", + "备份文件已丢失": "Backup files are missing", + "清除记录": "Clear record", + "清除备份记录": "Clear backup record", + "此操作不会删除其他文件。": "This will not delete any other files.", "Skills": "Skills", "共享": "Shared", "本地安装": "Local installation", @@ -3567,6 +3576,10 @@ export const EN_PLAIN: Record = { // Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled. export const EN_TEMPLATE: Record = { + "备份数量:{0}": "Backups: {0}", + "已统计 {0}/{1} 份,文件大小:{2}": "Calculated {0}/{1} backups; file size: {2}", + "文件大小:{0}": "File size: {0}", + "清除已丢失备份“{0}”的记录?": "Clear the record for missing backup “{0}”?", "关联所选 {0} 项": "Associate {0} selected", "正在检查仓库 {0}/{1}": "Checking repositories {0}/{1}", "已暂停,已检查 {0}/{1} 个仓库": "Paused after checking {0}/{1} repositories", diff --git a/apps/codex-plus-manager/src/skill-backup-usage.test.ts b/apps/codex-plus-manager/src/skill-backup-usage.test.ts new file mode 100644 index 0000000000..fcba43114d --- /dev/null +++ b/apps/codex-plus-manager/src/skill-backup-usage.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +const source = readFileSync(new URL("./SkillBackupUsage.tsx", import.meta.url), "utf8"); +const file = ts.createSourceFile("SkillBackupUsage.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const statements = file.statements.filter((node) => + ts.isFunctionDeclaration(node) && ["requestBackupUsage", "SkillBackupUsage"].includes(node.name?.text || "") + || ts.isVariableStatement(node) && node.declarationList.declarations.some((item) => ["cachedUsage", "pendingUsage"].includes(item.name.getText(file)))); +const code = ts.transpileModule(statements.map((node) => node.getText(file)).join("\n"), { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function content(node: any): string { + if (Array.isArray(node)) return node.map(content).join(""); + if (!node || typeof node === "boolean") return ""; + return typeof node === "object" ? content(node.children) : String(node); +} + +function find(node: any, predicate: (item: any) => boolean): any { + if (Array.isArray(node)) return node.map((item) => find(item, predicate)).find(Boolean); + if (!node || typeof node !== "object") return undefined; + return predicate(node) ? node : find(node.children, predicate); +} + +test("backup usage scans on opening and manual refresh, and labels incomplete results", async () => { + let slots: any[] = []; + let effects: Array<() => void> = []; + let cleanups: Array<() => void> = []; + let cursor = 0; + const calls: string[] = []; + let now = 1_000; + let resolveUsage!: (value: any) => void; + let rejectUsage!: (error: Error) => void; + const context: Record = { + exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + RefreshCw: "RefreshCw", + Date: class extends Date { static now() { return now; } }, + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useRef: (value: any) => { const index = cursor++; return slots[index] ??= { current: value }; }, + useEffect: (run: () => () => void) => { + const index = cursor++; + if (!(index in slots)) { + slots[index] = true; + effects.push(() => { cleanups.push(run()); }); + } + }, + t: (value: string) => value, + tf: (value: string, args: any[]) => value.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), + invoke: (command: string) => { + calls.push(command); + return new Promise((resolve, reject) => { resolveUsage = resolve; rejectUsage = reject; }); + }, + }; + vm.createContext(context); + vm.runInContext(code, context); + const render = (backupRevision = "first") => { + cursor = 0; + const root = context.exports.SkillBackupUsage({ backupRevision }); + effects.splice(0).forEach((run) => run()); + return root; + }; + render(); + assert.deepEqual(calls, ["skill_backup_usage"]); + let root = render(); + assert.ok(content(root).includes("正在统计备份")); + assert.equal(find(root, (item) => item.props["aria-label"] === "刷新备份统计").props.disabled, true); + + // Reopening while the first scan is still running reuses it. + cleanups.splice(0).forEach((cleanup) => cleanup()); + slots = []; + effects = []; + render(); + assert.equal(calls.length, 1); + resolveUsage({ backupCount: 2, measuredCount: 1, bytes: 2048, errors: ["C:/missing: not found"] }); + await tick(); + root = render(); + assert.ok(content(root).includes("备份数量:2")); + assert.ok(content(root).includes("已统计 1/2 份,文件大小:2 KiB")); + assert.ok(content(root).includes("C:/missing: not found")); + + // A fresh cached result is displayed when reopening, without another walk. + cleanups.splice(0).forEach((cleanup) => cleanup()); + slots = []; + effects = []; + root = render(); + assert.equal(calls.length, 1); + assert.ok(content(root).includes("备份数量:2")); + + root = render("second"); + assert.ok(content(root).includes("备份列表已变化,请刷新统计")); + assert.equal(calls.length, 1); + find(root, (item) => item.props["aria-label"] === "刷新备份统计").props.onClick(); + assert.equal(calls.length, 2); + cleanups.splice(0).forEach((cleanup) => cleanup()); + slots = []; + effects = []; + render("second"); + assert.equal(calls.length, 2); + assert.ok(content(render("second")).includes("正在统计备份")); + rejectUsage(new Error("cannot inspect backups")); + await tick(); + root = render("second"); + assert.ok(content(root).includes("cannot inspect backups")); + assert.ok(content(root).includes("上次统计")); + find(root, (item) => item.props["aria-label"] === "刷新备份统计").props.onClick(); + resolveUsage({ backupCount: 1, measuredCount: 1, bytes: 3, errors: [] }); + await tick(); + root = render("second"); + assert.ok(content(root).includes("备份数量:1")); + assert.ok(content(root).includes("文件大小:3 B")); + assert.ok(!content(root).includes("备份列表已变化")); + + now += 60_000; + cleanups.splice(0).forEach((cleanup) => cleanup()); + slots = []; + effects = []; + root = render("second"); + assert.equal(calls.length, 4); + assert.ok(content(root).includes("文件大小:3 B")); + root = render("second"); + assert.ok(content(root).includes("正在统计备份")); + resolveUsage({ backupCount: 1, measuredCount: 1, bytes: 4, errors: [] }); + await tick(); + assert.ok(content(render("second")).includes("文件大小:4 B")); +}); + +test("clearing a missing backup sends missing-only intent after confirmation", async () => { + const managementSource = readFileSync(new URL("./ManagementExtra.tsx", import.meta.url), "utf8"); + const managementFile = ts.createSourceFile("ManagementExtra.tsx", managementSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + let initializer: ts.Expression | undefined; + const visit = (node: ts.Node) => { + if (ts.isVariableDeclaration(node) && node.name.getText(managementFile) === "deleteBackup") initializer = node.initializer; + ts.forEachChild(node, visit); + }; + visit(managementFile); + assert.ok(initializer); + const compiled = ts.transpileModule(`const deleteBackup = ${initializer.getText(managementFile)};\nexports.deleteBackup = deleteBackup;`, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const confirmations: any[] = []; + const calls: any[] = []; + const context: Record = { + exports: {}, t: (value: string) => value, + tf: (value: string, args: any[]) => value.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])), + appConfirm: async (message: string, options: any) => { confirmations.push({ message, options }); return true; }, + run: async (command: string, args: any) => { calls.push({ command, args }); }, + }; + vm.createContext(context); + vm.runInContext(compiled, context); + await context.exports.deleteBackup({ id: "alpha-1", name: "alpha", missing: true }); + assert.ok(confirmations[0].message.includes("此操作不会删除其他文件")); + assert.equal(confirmations[0].options.confirmText, "清除记录"); + assert.equal(calls[0].command, "delete_skill_backup"); + assert.equal(calls[0].args.missingOnly, true); + await context.exports.deleteBackup({ id: "alpha-2", name: "alpha" }); + assert.equal(calls[1].args.missingOnly, false); +}); + +test("a missing backup can be cleared but cannot be restored", () => { + const managementSource = readFileSync(new URL("./ManagementExtra.tsx", import.meta.url), "utf8"); + const managementFile = ts.createSourceFile("ManagementExtra.tsx", managementSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const component = managementFile.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "SkillBackupList"); + assert.ok(component); + const compiled = ts.transpileModule(`${component.getText(managementFile)}\nexports.render = SkillBackupList;`, { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const context: Record = { + exports: {}, fragment: "fragment", jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + Button: "Button", SkillFileTimes: "SkillFileTimes", Trash2: "Trash2", t: (value: string) => value, + }; + vm.createContext(context); + vm.runInContext(compiled, context); + const root = context.exports.render({ + backups: [{ id: "alpha-1", skillId: "alpha", name: "alpha", backedUpAt: "1", missing: true }], + busy: false, onRestore: () => {}, onDelete: () => {}, + }); + assert.ok(content(root).includes("备份文件已丢失")); + assert.equal(find(root, (item) => item.type === "Button").props.disabled, true); + assert.ok(find(root, (item) => item.props["aria-label"] === "清除记录 alpha")); + assert.equal(find(root, (item) => item.type === "SkillFileTimes"), undefined); +}); diff --git a/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts b/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts index 381ed55163..4df3ec566e 100644 --- a/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts +++ b/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts @@ -39,8 +39,8 @@ test("a verified managed duplicate can be archived despite other independent sam }, t: (value: string) => value, appConfirm: async (message: string) => { confirmations.push(message); return true; }, invoke: async () => ({ status: "ok", previewId: "verified-preview", candidates: [ - { id: "rea", sharedPath: "C:/agents/rea", codexPath: "C:/codex/rea", status: "identical-managed" }, - { id: "rea", kind: "source", sharedPath: "C:/codex/rea", codexPath: "C:/managed/rea", originalRootKind: "codex", status: "identical-source" }, + { id: "alpha", sharedPath: "C:/agents/alpha", codexPath: "C:/codex/alpha", status: "identical-managed" }, + { id: "alpha", kind: "source", sharedPath: "C:/codex/alpha", codexPath: "C:/managed/alpha", originalRootKind: "codex", status: "identical-source" }, { id: "other", sharedPath: "C:/agents/other", codexPath: "C:/codex/other", status: "different" }, { id: "unknown", sharedPath: "C:/agents/unknown", codexPath: "C:/codex/unknown", status: "unsupported", detail: "无法确定名称规则的启用状态" }, ] }), @@ -73,7 +73,7 @@ test("a verified managed duplicate can be archived despite other independent sam await buttons[0].props.onClick(); await buttons[1].props.onClick(); await tick(); - assert.deepEqual(archives, [["verified-preview", "rea", "shared"], ["verified-preview", "rea", "source"]]); - assert.ok(confirmations[1].includes("C:/managed/rea")); - assert.ok(confirmations[1].includes("C:/codex/rea")); + assert.deepEqual(archives, [["verified-preview", "alpha", "shared"], ["verified-preview", "alpha", "source"]]); + assert.ok(confirmations[1].includes("C:/managed/alpha")); + assert.ok(confirmations[1].includes("C:/codex/alpha")); }); diff --git a/apps/codex-plus-manager/src/skill-in-place-update.test.ts b/apps/codex-plus-manager/src/skill-in-place-update.test.ts new file mode 100644 index 0000000000..cbb1ffab2a --- /dev/null +++ b/apps/codex-plus-manager/src/skill-in-place-update.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +const source = readFileSync(new URL("./SkillInPlaceUpdateDrawer.tsx", import.meta.url), "utf8"); +const file = ts.createSourceFile("SkillInPlaceUpdateDrawer.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const component = file.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "SkillInPlaceUpdateDrawer"); +assert.ok(component); +const code = ts.transpileModule(component.getText(file), { + compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture() { + const slots: any[] = []; + const effects: Array<() => () => void> = []; + const calls: Array<{ command: string; args: any }> = []; + let cursor = 0; + let resolvePreview!: (result: any) => void; + const context: Record = { + exports: {}, fragment: "fragment", jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), + useState: (initial: any) => { + const index = cursor++; + if (!(index in slots)) slots[index] = initial; + return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; + }, + useEffect: (effect: () => () => void) => { const index = cursor++; if (!(index in slots)) { slots[index] = true; effects.push(effect); } }, + invoke: (command: string, args: any) => { + calls.push({ command, args }); + return command === "preview_skill_in_place_update" + ? new Promise((resolve) => { resolvePreview = resolve; }) + : Promise.resolve(); + }, + t: (value: string) => value, ManagementDrawer: "ManagementDrawer", RefreshCw: "RefreshCw", console, + }; + vm.createContext(context); + vm.runInContext(code, context); + const render = () => { + cursor = 0; + return context.exports.SkillInPlaceUpdateDrawer({ id: "alpha", repoKey: "owner/repo@main", onClose: () => {}, onExecute: async () => true }); + }; + render(); + const cleanup = effects[0](); + return { calls, cleanup, render, resolve: (currentHash: string) => resolvePreview({ + status: "ok", previewId: "preview-1", repoKey: "owner/repo@main", repoPath: "alpha", + targetPath: "/skills/alpha", currentHash, nextHash: "new", conflicts: [], + }) }; +} + +test("closing a loaded update preview releases it", async () => { + const drawer = fixture(); + drawer.resolve("old"); + await tick(); + drawer.cleanup(); + assert.equal(drawer.calls.at(-1)?.command, "discard_skill_in_place_update_preview"); + assert.equal(drawer.calls.at(-1)?.args.previewId, "preview-1"); +}); + +test("closing while a preview is loading releases its late result", async () => { + const drawer = fixture(); + drawer.cleanup(); + drawer.resolve("old"); + await tick(); + assert.equal(drawer.calls.at(-1)?.command, "discard_skill_in_place_update_preview"); + assert.equal(drawer.calls.at(-1)?.args.previewId, "preview-1"); +}); + +test("a preview without a matching baseline warns before update", async () => { + const drawer = fixture(); + drawer.resolve(""); + await tick(); + const root = drawer.render(); + const serialized = JSON.stringify(root); + assert.match(serialized, /尚无一致的更新基线/); + assert.match(serialized, /确认后会先备份再替换/); + drawer.cleanup(); +}); diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index bca2f1b1f8..5e27752a23 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -25,9 +25,11 @@ use sha2::{Digest, Sha256}; mod source_match; mod in_place_update; +mod backup_usage; pub use source_match::{SkillRepositoryMatchApplied, SkillRepositoryMatchApply, SkillRepositoryMatchPreview, SkillRepositoryMatchSelection, SkillSourceEvidence}; pub use in_place_update::SkillInPlaceUpdatePreview; +pub use backup_usage::SkillBackupUsage; mod archive; @@ -325,6 +327,7 @@ pub struct SkillBackup { pub name: String, pub backed_up_at: String, pub backup_path: String, + pub missing: bool, pub restore_path: Option, pub purpose: Option, pub restore_blocked_reason: Option, @@ -3552,23 +3555,27 @@ impl SkillsManager { Ok(state) } + fn resolve_external_backup(id: &str, state: &SkillsState) -> anyhow::Result<(PathBuf, String)> { + let rest = id.strip_prefix("@external/").context("外部 Skill 备份标识无效")?; + let (token, name) = rest.split_once('/').context("外部 Skill 备份标识无效")?; + uuid::Uuid::parse_str(token).context("外部 Skill 备份标识无效")?; + validate_skill_id(name)?; + let origin = state.backup_origins.get(id).context("外部 Skill 备份未登记")?; + let (skill_id, _) = split_backup_id(name); + let group = state.update_backup_groups.values() + .find(|group| group.backup_ids.iter().any(|member| member == id)) + .context("外部 Skill 备份不属于更新备份组")?; + anyhow::ensure!(origin.purpose == "update" && group.installed.id == skill_id + && origin.path.is_absolute() + && group.published.iter().any(|item| item.path == origin.path), + "外部 Skill 备份与当前原目录不一致"); + let parent = origin.path.parent().context("Skill 原目录没有父目录")?; + Ok((parent.join(".codex-plus-backups").join(name), name.to_string())) + } + fn resolve_backup(&self, id: &str) -> anyhow::Result<(PathBuf, String)> { - if let Some(rest) = id.strip_prefix("@external/") { - let (token, name) = rest.split_once('/').context("外部 Skill 备份标识无效")?; - uuid::Uuid::parse_str(token).context("外部 Skill 备份标识无效")?; - validate_skill_id(name)?; - let state = self.load_state_checked_unlocked()?; - let origin = state.backup_origins.get(id).context("外部 Skill 备份未登记")?; - let (skill_id, _) = split_backup_id(name); - let group = state.update_backup_groups.values() - .find(|group| group.backup_ids.iter().any(|member| member == id)) - .context("外部 Skill 备份不属于更新备份组")?; - anyhow::ensure!(origin.purpose == "update" && group.installed.id == skill_id - && origin.path.is_absolute() - && group.published.iter().any(|item| item.path == origin.path), - "外部 Skill 备份与当前原目录不一致"); - let parent = origin.path.parent().context("Skill 原目录没有父目录")?; - return Ok((parent.join(".codex-plus-backups").join(name), name.to_string())); + if id.starts_with("@external/") { + return Self::resolve_external_backup(id, &self.load_state_checked_unlocked()?); } // 带 '/' 的命名空间不可能与合法旧 Skill ID 混淆;只接受已配置根的前缀。 for (prefix, root) in self.backup_roots().into_iter().filter(|(prefix, _)| !prefix.is_empty()) { @@ -3630,19 +3637,57 @@ impl SkillsManager { Ok(files) } - pub fn list_backups(&self) -> Vec { - self.try_list_backups().expect("读取 Skill 备份记录失败") + pub fn list_backups(&self) -> anyhow::Result> { + self.try_list_backups() } pub fn try_list_backups(&self) -> anyhow::Result> { let state = self.load_state_checked_unlocked()?; + #[cfg(test)] + BACKUP_LIST_SNAPSHOT_HOOK.with(|hook| { + if let Some(hook) = hook.borrow_mut().take() { hook(); } + }); let mut backups = Vec::new(); let mut seen = BTreeSet::new(); + let mut external = Vec::new(); + let mut missing_ids = BTreeSet::new(); + for backup_id in state.backup_origins.keys().filter(|id| id.starts_with("@external/")) { + let (path, name) = Self::resolve_external_backup(backup_id, &state)?; + let missing = match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_dir() && !metadata_is_link(&metadata) => false, + Ok(_) => anyhow::bail!("Skill 备份不是实体目录:{}", path.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => return Err(error).with_context(|| format!("无法读取 Skill 备份:{}", path.display())), + }; + if missing { + missing_ids.insert(backup_id.clone()); + } + external.push((backup_id, path, name, missing)); + } + for backup_id in state.update_backup_groups.values().flat_map(|group| &group.backup_ids) + .filter(|id| !id.starts_with("@external/")) + { + let (path, _) = self.resolve_backup(backup_id)?; + match std::fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + missing_ids.insert(backup_id.clone()); + } + Err(error) => return Err(error).with_context(|| format!("无法读取 Skill 备份:{}", path.display())), + } + } let group_messages = |backup_id: &str| { let group = state.update_backup_groups.values() .find(|group| group.backup_ids.iter().any(|id| id == backup_id)); - let restore_blocked = group.filter(|group| group.partially_deleted) - .map(|_| "更新备份组已有成员被删除,无法整体恢复".into()); + let restore_blocked = group.and_then(|group| { + if group.partially_deleted { + Some("更新备份组已有成员被删除,无法整体恢复".into()) + } else if group.backup_ids.iter().any(|id| missing_ids.contains(id)) { + Some("更新备份组有成员文件已丢失,无法整体恢复".into()) + } else { + None + } + }); let delete_warning = group.filter(|group| !group.partially_deleted && group.backup_ids.len() > 1) .map(|_| "删除后,此更新备份组将无法整体恢复;其他备份文件会保留".into()); (restore_blocked, delete_warning) @@ -3677,6 +3722,7 @@ impl SkillsManager { name, backed_up_at, backup_path: path.to_string_lossy().into_owned(), + missing: false, restore_path: origin.map(|value| value.path.to_string_lossy().into_owned()), purpose: origin.map(|value| value.purpose.clone()), restore_blocked_reason, @@ -3684,19 +3730,19 @@ impl SkillsManager { }); } } - for backup_id in state.backup_origins.keys().filter(|id| id.starts_with("@external/")) { - let (path, name) = self.resolve_backup(backup_id)?; - let metadata = std::fs::symlink_metadata(&path)?; - if !metadata.is_dir() || metadata_is_link(&metadata) { - anyhow::bail!("Skill 备份不是实体目录:{}", path.display()); - } + for (backup_id, path, name, missing) in external { let (skill_id, backed_up_at) = split_backup_id(&name); - let (name, _) = read_skill_manifest(&path.join(SKILL_MANIFEST_FILE), &skill_id); + let name = if missing { + skill_id.clone() + } else { + read_skill_manifest(&path.join(SKILL_MANIFEST_FILE), &skill_id).0 + }; let origin = &state.backup_origins[backup_id]; let (restore_blocked_reason, delete_warning) = group_messages(backup_id); backups.push(SkillBackup { id: backup_id.clone(), skill_id, name, backed_up_at, backup_path: path.to_string_lossy().into_owned(), + missing, restore_path: Some(origin.path.to_string_lossy().into_owned()), purpose: Some(origin.purpose.clone()), restore_blocked_reason, delete_warning, @@ -4037,6 +4083,15 @@ impl SkillsManager { /// 显式删除单个备份。AGENTS.md 禁止批量删除,所以只删指定的这一个。 pub fn delete_backup(&self, backup_id: &str) -> anyhow::Result> { + self.delete_backup_checked(backup_id, false) + } + + /// 只清除确认不存在的外部备份登记。 + pub fn delete_missing_backup(&self, backup_id: &str) -> anyhow::Result> { + self.delete_backup_checked(backup_id, true) + } + + fn delete_backup_checked(&self, backup_id: &str, missing_only: bool) -> anyhow::Result> { let _guard = skills_coordination(); let mut state = self.load_state_checked_unlocked()?; let group = state.update_backup_groups.iter() @@ -4044,14 +4099,21 @@ impl SkillsManager { .map(|(id, _)| id.clone()); let (backup, name) = self.resolve_backup(backup_id)?; anyhow::ensure!(!name.starts_with('.'), "临时目录不是可删除的 Skill 备份"); - let metadata = std::fs::symlink_metadata(&backup) - .with_context(|| format!("读取备份失败:{}", backup.display()))?; - anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), - "备份不是实体目录,未删除:{}", backup.display()); - self.ensure_duplicate_has_no_discovered_references(&backup)?; - let identity = skill_path_identity(&backup)?; - let staged = recover_published_path(&backup, identity)? - .context("备份已不存在,未删除")?; + let staged = match std::fs::symlink_metadata(&backup) { + Ok(metadata) => { + anyhow::ensure!(!missing_only, "备份文件已重新出现,请刷新后重新确认:{}", backup.display()); + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), + "备份不是实体目录,未删除:{}", backup.display()); + self.ensure_duplicate_has_no_discovered_references(&backup)?; + let identity = skill_path_identity(&backup)?; + let staged = recover_published_path(&backup, identity)? + .context("备份已不存在,未删除")?; + Some((staged, identity)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && missing_only + && backup_id.starts_with("@external/") => None, + Err(error) => return Err(error).with_context(|| format!("读取备份失败:{}", backup.display())), + }; let mut state_changed = state.backup_origins.remove(backup_id).is_some(); if let Some(group_id) = group { let group = state.update_backup_groups.get_mut(&group_id).unwrap(); @@ -4064,20 +4126,26 @@ impl SkillsManager { } if state_changed { if let Err(error) = self.save_state_unlocked(&state) { - return match restore_skill_backup(&staged, &backup, identity) { - Ok(()) => Err(error), - Err(rollback) => Err(error.context(format!("备份保留在 {}:{rollback:#}", staged.display()))), + return if let Some((staged, identity)) = &staged { + match restore_skill_backup(staged, &backup, *identity) { + Ok(()) => Err(error), + Err(rollback) => Err(error.context(format!("备份保留在 {}:{rollback:#}", staged.display()))), + } + } else { + Err(error) }; } } drop(_guard); - #[cfg(test)] - let removal = DELETE_SKILL_BACKUP_HOOK.with(|hook| hook.borrow_mut().take().map(|hook| hook(&staged))) - .unwrap_or_else(|| std::fs::remove_dir_all(&staged)); - #[cfg(not(test))] - let removal = std::fs::remove_dir_all(&staged); - if let Err(error) = removal { - anyhow::bail!("删除备份失败,残余文件可能不完整,保留在 {}:{error}", staged.display()); + if let Some((staged, _)) = staged { + #[cfg(test)] + let removal = DELETE_SKILL_BACKUP_HOOK.with(|hook| hook.borrow_mut().take().map(|hook| hook(&staged))) + .unwrap_or_else(|| std::fs::remove_dir_all(&staged)); + #[cfg(not(test))] + let removal = std::fs::remove_dir_all(&staged); + if let Err(error) = removal { + anyhow::bail!("删除备份失败,残余文件可能不完整,保留在 {}:{error}", staged.display()); + } } self.try_list_backups() } @@ -5529,6 +5597,8 @@ thread_local! { static FAIL_SKILL_STATE_SAVE: std::cell::Cell = const { std::cell::Cell::new(false) }; static DELETE_SKILL_BACKUP_HOOK: std::cell::RefCell std::io::Result<()>>>> = const { std::cell::RefCell::new(None) }; + static BACKUP_LIST_SNAPSHOT_HOOK: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; } fn move_skill_to_backup(source: &Path, target: &Path) -> anyhow::Result<()> { @@ -6765,7 +6835,7 @@ mod tests { } #[test] - fn partially_deleted_backup_remains_out_of_restore_and_releases_the_lock() { + fn partially_deleted_backup_remains_out_of_restore() { for grouped in [false, true] { let temp = tempfile::tempdir().unwrap(); let manager = manager(&temp); @@ -6792,13 +6862,6 @@ mod tests { manager.save_state_unlocked(&state).unwrap(); } DELETE_SKILL_BACKUP_HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(|staged| { - let (sender, receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _guard = skills_coordination(); - sender.send(()).unwrap(); - }); - receiver.recv_timeout(std::time::Duration::from_secs(5)) - .expect("删除备份时仍持有 Skill 协调锁"); std::fs::remove_file(staged.join(SKILL_MANIFEST_FILE))?; Err(std::io::Error::other("模拟部分删除失败")) }))); @@ -6962,6 +7025,115 @@ mod tests { } } + #[test] + fn missing_external_backup_can_be_listed_and_its_record_deleted() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let originals = [temp.path().join("external-one/alpha"), temp.path().join("external-two/alpha")]; + for original in &originals { + write_skill(original, "old"); + } + manager.import_from_local_directory("alpha", &originals[0]).unwrap(); + let mut state = manager.load_state(); + let mut ids = Vec::new(); + let mut paths = Vec::new(); + for original in &originals { + FORCE_CROSS_DEVICE_BACKUP.with(|flag| flag.set(true)); + let backup = manager.backup_directory_checked("alpha", original, skill_path_identity(original).unwrap()).unwrap(); + let id = manager.backup_id_for_path(&backup).unwrap(); + state.backup_origins.insert(id.clone(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + ids.push(id); + paths.push(backup); + write_skill(original, "current"); + } + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), + backup_ids: ids.clone(), + published: originals.iter().map(|path| published_snapshot(path)).collect(), + instance_path: None, + partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + std::fs::remove_file(paths[0].join(SKILL_MANIFEST_FILE)).unwrap(); + std::fs::remove_dir(&paths[0]).unwrap(); + + let backups = manager.list_backups().unwrap(); + assert_eq!(backups.len(), 2); + let missing = backups.iter().find(|item| item.id == ids[0]).unwrap(); + assert!(missing.missing); + assert_eq!(missing.backup_path, paths[0].to_string_lossy()); + assert!(missing.restore_blocked_reason.as_ref().unwrap().contains("丢失")); + let remaining = backups.iter().find(|item| item.id == ids[1]).unwrap(); + assert!(!remaining.missing && remaining.restore_blocked_reason.is_some()); + assert!(manager.restore_backup(&ids[1]).is_err()); + + write_skill(&paths[0], "reappeared"); + let error = manager.delete_missing_backup(&ids[0]).unwrap_err(); + assert!(error.to_string().contains("重新出现")); + assert_eq!(manager.load_state(), state); + assert_eq!(std::fs::read_to_string(paths[0].join(SKILL_MANIFEST_FILE)).unwrap(), "reappeared"); + std::fs::remove_file(paths[0].join(SKILL_MANIFEST_FILE)).unwrap(); + std::fs::remove_dir(&paths[0]).unwrap(); + assert!(manager.delete_backup(&ids[0]).is_err()); + assert_eq!(manager.load_state(), state); + + FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); + assert!(manager.delete_missing_backup(&ids[0]).is_err()); + assert_eq!(manager.load_state(), state); + let concurrent_manager = manager.clone(); + let concurrent_id = ids[0].clone(); + BACKUP_LIST_SNAPSHOT_HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(move || { + std::thread::spawn(move || concurrent_manager.delete_missing_backup(&concurrent_id).unwrap()) + .join().unwrap(); + }))); + let previous_snapshot = manager.try_list_backups().unwrap(); + assert_eq!(previous_snapshot.len(), 2); + assert!(previous_snapshot.iter().find(|item| item.id == ids[0]).unwrap().missing); + let remaining = manager.try_list_backups().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, ids[1]); + assert!(remaining[0].restore_blocked_reason.is_some()); + assert!(paths[1].join(SKILL_MANIFEST_FILE).is_file()); + assert!(!manager.load_state().backup_origins.contains_key(&ids[0])); + } + + #[test] + fn missing_local_backup_blocks_restore_for_the_remaining_group() { + let temp = tempfile::tempdir().unwrap(); + let manager = manager(&temp); + let original = temp.path().join("original/alpha"); + write_skill(&original, "current"); + manager.import_from_local_directory("alpha", &original).unwrap(); + let first = manager.backups_dir.join("alpha-123"); + let second = manager.backups_dir.join("alpha-124"); + write_skill(&first, "first"); + write_skill(&second, "second"); + let mut state = manager.load_state(); + for id in ["alpha-123", "alpha-124"] { + state.backup_origins.insert(id.into(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + } + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: state.installed["alpha"].clone(), + backup_ids: vec!["alpha-123".into(), "alpha-124".into()], + published: Vec::new(), instance_path: None, partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + std::fs::remove_file(first.join(SKILL_MANIFEST_FILE)).unwrap(); + std::fs::remove_dir(&first).unwrap(); + + let backups = manager.try_list_backups().unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].id, "alpha-124"); + assert!(backups[0].restore_blocked_reason.as_ref().unwrap().contains("丢失")); + assert!(manager.restore_backup("alpha-124").is_err()); + assert!(second.join(SKILL_MANIFEST_FILE).is_file()); + assert_eq!(manager.load_state(), state); + } + #[cfg(windows)] #[test] fn backup_deletion_rejects_directory_junction() { @@ -7442,7 +7614,7 @@ mod tests { manager.set_enabled_for("claude", "alpha", false).unwrap(); assert!(!claude.exists()); assert!(source.join(SKILL_MANIFEST_FILE).is_file()); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); } #[test] @@ -7595,13 +7767,13 @@ mod tests { FORCE_CROSS_DEVICE_BACKUP.with(|flag| flag.set(true)); manager.set_enabled_for("claude", "alpha", false).unwrap(); assert!(!copy.exists()); - let backup = manager.list_backups().into_iter().find(|backup| backup.id.starts_with("@local/agent-claude/")).unwrap(); + let backup = manager.list_backups().unwrap().into_iter().find(|backup| backup.id.starts_with("@local/agent-claude/")).unwrap(); assert_eq!(backup.skill_id, "alpha"); manager.uninstall("alpha").unwrap(); manager.restore_backup(&backup.id).unwrap(); assert_eq!(std::fs::read_to_string(copy.join("SKILL.md")).unwrap(), "agent edit"); assert!(!manager.source_dir.join("alpha").exists()); - assert!(!manager.list_backups().iter().any(|item| item.id == backup.id)); + assert!(!manager.list_backups().unwrap().iter().any(|item| item.id == backup.id)); } #[test] @@ -7614,7 +7786,7 @@ mod tests { FORCE_CROSS_DEVICE_BACKUP.with(|flag| flag.set(true)); manager.uninstall("alpha").unwrap(); assert!(!copy.exists()); - let backup = manager.list_backups().pop().unwrap(); + let backup = manager.list_backups().unwrap().pop().unwrap(); assert!(backup.id.starts_with("@local/agent-claude/")); assert!(manager.delete_backup(&backup.id).unwrap().is_empty()); } @@ -7674,7 +7846,7 @@ mod tests { let agent = temp.path().join("claude-skills"); std::fs::create_dir_all(&agent).unwrap(); std::os::unix::fs::symlink(&manager.backups_dir, agent.join(".codex-plus-backups")).unwrap(); - let backups = manager.list_backups(); + let backups = manager.list_backups().unwrap(); assert_eq!(backups.len(), 1); assert_eq!(backups[0].id, "alpha-123"); } @@ -7750,7 +7922,7 @@ mod tests { manager.uninstall("alpha").unwrap(); assert!(!copy.exists()); - let contents: BTreeSet<_> = manager.list_backups().iter() + let contents: BTreeSet<_> = manager.list_backups().unwrap().iter() .map(|backup| std::fs::read_to_string(manager.backups_dir.join(&backup.id).join(SKILL_MANIFEST_FILE)).unwrap()) .collect(); assert!(contents.contains("original")); @@ -7787,7 +7959,7 @@ mod tests { manager.import_from_directory("alpha", &external).unwrap(); assert!(!manager.linked_dir().join("alpha").exists()); assert_eq!(std::fs::read_to_string(manager.source_dir.join("alpha/SKILL.md")).unwrap(), "imported"); - let contents: std::collections::BTreeSet<_> = manager.list_backups().iter() + let contents: std::collections::BTreeSet<_> = manager.list_backups().unwrap().iter() .map(|backup| std::fs::read_to_string(manager.backups_dir.join(&backup.id).join("SKILL.md")).unwrap()).collect(); assert_eq!(contents, ["old source".to_string(), "user local".to_string()].into_iter().collect()); } @@ -7920,7 +8092,7 @@ mod tests { std::fs::write(local.join("SKILL.md"), "local edits").unwrap(); manager.install_from_zip(&sample_skill("alpha", "alpha", "two"), &repo_zip(&[("kit-main/alpha/SKILL.md", "replacement")])).unwrap(); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "replacement"); - let contents: std::collections::BTreeSet<_> = manager.list_backups().iter() + let contents: std::collections::BTreeSet<_> = manager.list_backups().unwrap().iter() .map(|backup| std::fs::read_to_string(manager.backups_dir.join(&backup.id).join("SKILL.md")).unwrap()).collect(); assert_eq!(contents, ["original".to_string(), "local edits".to_string()].into_iter().collect()); } @@ -8573,7 +8745,7 @@ mod tests { assert_eq!(std::fs::read_to_string(link.join(SKILL_MANIFEST_FILE)).unwrap(), "copy edit"); assert!(!entry(&first.try_local_inventory().unwrap(), &skill.id).enabled); assert!(first.install_downloaded_zip_if_unchanged(&skill, &zip, &guard).is_err()); - assert!(first.list_backups().is_empty()); + assert!(first.list_backups().unwrap().is_empty()); } #[test] @@ -8618,7 +8790,7 @@ mod tests { let updated_zip = repo_zip(&[("kit-main/alpha/SKILL.md", "updated")]); let updated = zip_skill(&repo, &updated_zip, "alpha"); manager.install_downloaded_zip_if_unchanged(&updated, &updated_zip, &guard).unwrap(); - let backups: BTreeSet<_> = manager.list_backups().iter() + let backups: BTreeSet<_> = manager.list_backups().unwrap().iter() .map(|backup| std::fs::read_to_string(manager.backups_dir.join(&backup.id).join(SKILL_MANIFEST_FILE)).unwrap()) .collect(); assert!(backups.contains("source edit")); @@ -8668,7 +8840,7 @@ mod tests { assert_eq!(manager.load_state().installed[&old.id].content_hash, old.content_hash); manager.set_enabled_for("claude", &old.id, false).unwrap(); assert!(!copy.exists()); - let backups: BTreeSet<_> = manager.list_backups().iter() + let backups: BTreeSet<_> = manager.list_backups().unwrap().iter() .map(|backup| std::fs::read_to_string(manager.backups_dir.join(&backup.id).join(SKILL_MANIFEST_FILE)).unwrap()) .collect(); assert!(backups.contains("agent edit")); @@ -11003,7 +11175,7 @@ mod tests { std::fs::write(local.join("SKILL.md"), "user original").unwrap(); assert!(manager.local_inventory().iter().any(|skill| skill.id == "custom" && skill.installed)); manager.uninstall("custom").unwrap(); - let backups = manager.list_backups(); + let backups = manager.list_backups().unwrap(); assert_eq!(backups.len(), 1); assert!(!local.exists()); manager.restore_backup(&backups[0].id).unwrap(); @@ -11019,7 +11191,7 @@ mod tests { std::fs::write(directory.join("SKILL.md"), content).unwrap(); } manager.uninstall("custom").unwrap(); - let contents: std::collections::BTreeSet<_> = manager.list_backups().iter() + let contents: std::collections::BTreeSet<_> = manager.list_backups().unwrap().iter() .map(|backup| std::fs::read_to_string(manager.backups_dir.join(&backup.id).join("SKILL.md")).unwrap()).collect(); assert_eq!(contents, ["source".to_string(), "edited local".to_string()].into_iter().collect()); } @@ -11056,7 +11228,7 @@ mod tests { assert!(!manager.source_dir().join("alpha").exists()); assert!(!manager.linked_dir().join("alpha").exists()); - let backups = manager.list_backups(); + let backups = manager.list_backups().unwrap(); assert_eq!(backups.len(), if physical_copy { 2 } else { 1 }); assert_eq!(backups[0].skill_id, "alpha"); @@ -11069,7 +11241,7 @@ mod tests { .join("SKILL.md") .is_file() ); - assert_eq!(manager.list_backups().len(), usize::from(physical_copy)); + assert_eq!(manager.list_backups().unwrap().len(), usize::from(physical_copy)); } #[test] @@ -11194,13 +11366,13 @@ mod tests { assert_eq!(skill_path_identity(&local).unwrap(), identity); assert!(!manager.source_dir().join("my-own-skill").exists()); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); assert!(!manager.merge_entries(&[]).iter().find(|entry| entry.id == "my-own-skill").unwrap().enabled); manager.set_enabled("my-own-skill", true).unwrap(); assert_eq!(skill_path_identity(&local).unwrap(), identity); assert!(manager.merge_entries(&[]).iter().find(|entry| entry.id == "my-own-skill").unwrap().enabled); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); } /// 没有 SKILL.md 的目录、以及 `.system` 自带目录,都不应被当成「本地自建」重复收录。 diff --git a/crates/codex-plus-core/src/skills/backup_usage.rs b/crates/codex-plus-core/src/skills/backup_usage.rs new file mode 100644 index 0000000000..2004f591e5 --- /dev/null +++ b/crates/codex-plus-core/src/skills/backup_usage.rs @@ -0,0 +1,126 @@ +use anyhow::Context; +use serde::Serialize; + +use super::{metadata_is_link, SkillsManager}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillBackupUsage { + pub backup_count: usize, + pub measured_count: usize, + pub bytes: u64, + pub errors: Vec, +} + +impl SkillsManager { + /// 仅在用户请求时统计备份文件的逻辑大小,不跟随目录链接。 + pub fn backup_usage(&self) -> anyhow::Result { + let backups = self.try_list_backups()?; + let mut usage = SkillBackupUsage { + backup_count: backups.len(), + measured_count: 0, + bytes: 0, + errors: Vec::new(), + }; + for backup in backups { + let result = (|| -> anyhow::Result { + let (root, _) = self.resolve_backup(&backup.id)?; + let metadata = std::fs::symlink_metadata(&root) + .with_context(|| format!("读取备份失败:{}", root.display()))?; + anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), + "备份不是实体目录:{}", root.display()); + let mut pending = vec![root]; + let mut bytes = 0; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir) + .with_context(|| format!("读取目录失败:{}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path) + .with_context(|| format!("读取文件信息失败:{}", path.display()))?; + anyhow::ensure!(!metadata_is_link(&metadata), + "备份含有链接,未统计其目标:{}", path.display()); + if metadata.is_dir() { + pending.push(path); + } else if metadata.is_file() { + bytes += metadata.len(); + } + } + } + Ok(bytes) + })(); + match result { + Ok(bytes) => { + usage.measured_count += 1; + usage.bytes += bytes; + } + Err(error) => usage.errors.push(format!("{}:{error:#}", backup.backup_path)), + } + } + Ok(usage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::skills::{skill_path_identity, InstalledSkill, SkillBackupOrigin, SkillPublishedSnapshot, SkillUpdateBackupGroup}; + + #[test] + fn usage_counts_nested_files_and_reports_missing_registered_backup() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let manager = SkillsManager::new(root.join("skills"), root.join("backups"), root.join("state.json"), root.join("codex")); + let first = root.join("backups/alpha-123"); + std::fs::create_dir_all(first.join("nested")).unwrap(); + std::fs::write(first.join("SKILL.md"), "alpha").unwrap(); + std::fs::write(first.join("nested/data"), "1234567").unwrap(); + let second = root.join("backups/beta-456"); + std::fs::create_dir_all(&second).unwrap(); + std::fs::write(second.join("SKILL.md"), "beta").unwrap(); + + let original = root.join("external/gamma"); + std::fs::create_dir_all(&original).unwrap(); + let missing_id = format!("@external/{}/gamma-789", uuid::Uuid::new_v4()); + let mut state = manager.load_state(); + state.backup_origins.insert(missing_id.clone(), SkillBackupOrigin { + path: original.clone(), purpose: "update".into(), + }); + state.update_backup_groups.insert("group".into(), SkillUpdateBackupGroup { + installed: InstalledSkill { id: "gamma".into(), ..Default::default() }, + backup_ids: vec![missing_id], + published: vec![SkillPublishedSnapshot { + path: original.clone(), identity: skill_path_identity(&original).unwrap(), digest: [0; 32], + }], + instance_path: None, + partially_deleted: false, + }); + manager.save_state_unlocked(&state).unwrap(); + + let usage = manager.backup_usage().unwrap(); + assert_eq!(usage.backup_count, 3); + assert_eq!(usage.measured_count, 2); + assert_eq!(usage.bytes, 16); + assert_eq!(usage.errors.len(), 1); + assert!(usage.errors[0].contains("gamma-789")); + } + + #[cfg(unix)] + #[test] + fn usage_marks_a_backup_with_links_incomplete() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let manager = SkillsManager::new(root.join("skills"), root.join("backups"), root.join("state.json"), root.join("codex")); + let backup = root.join("backups/alpha-123"); + std::fs::create_dir_all(&backup).unwrap(); + std::fs::write(backup.join("SKILL.md"), "alpha").unwrap(); + std::os::unix::fs::symlink(root, backup.join("link")).unwrap(); + + let usage = manager.backup_usage().unwrap(); + assert_eq!(usage.backup_count, 1); + assert_eq!(usage.measured_count, 0); + assert_eq!(usage.bytes, 0); + assert!(usage.errors[0].contains("未统计其目标")); + } +} diff --git a/crates/codex-plus-core/src/skills/in_place_update.rs b/crates/codex-plus-core/src/skills/in_place_update.rs index 544a106bb3..acf8e97560 100644 --- a/crates/codex-plus-core/src/skills/in_place_update.rs +++ b/crates/codex-plus-core/src/skills/in_place_update.rs @@ -312,9 +312,6 @@ impl SkillsManager { skipped_paths: update.skipped_paths.clone(), }; let mut previews = update_previews(); - previews.retain(|_, item| { - item.installed.id != id || !coordination_scopes_overlap(&item.scope, &update.scope) - }); while previews.len() >= 4 { previews.pop_first(); } @@ -378,6 +375,10 @@ impl SkillsManager { Ok(update.remote.clone()) } + pub fn discard_in_place_update_preview(preview_id: &str) { + update_previews().remove(preview_id); + } + /// 只执行后端保存的预览,仓库 ZIP 和目标路径均不由调用方在确认时提供。 pub fn execute_skill_in_place_update( &self, @@ -397,31 +398,47 @@ impl SkillsManager { self.check_in_place_state(&update, &coordination)?; } let id = &update.installed.id; - let mut staged: Vec = Vec::new(); + let mut staged: Vec<(PathBuf, SkillPathIdentity)> = Vec::new(); + let cleanup = |error: anyhow::Error, paths: &[(PathBuf, SkillPathIdentity)]| { + paths.iter().fold(error, |error, (path, identity)| { + match recover_published_path(path, *identity) { + Ok(Some(recovery)) => match std::fs::remove_dir_all(&recovery) { + Ok(()) => error, + Err(cleanup) => error.context(format!("清理 Skill 暂存目录失败,保留在 {}:{cleanup}", recovery.display())), + }, + Ok(None) => error, + Err(cleanup) => error.context(format!("清理 Skill 暂存目录失败:{cleanup:#}")), + } + }) + }; for path in &update.paths { let parent = path.path.parent().context("Skill 原目录没有父目录")?; let staging = parent.join(format!(".codex-plus-update-{id}-{}", uuid::Uuid::new_v4())); if let Err(error) = std::fs::create_dir(&staging) { let error = anyhow::Error::new(error) .context(format!("创建 Skill 暂存目录失败:{}", staging.display())); - return Err(staged.iter().fold(error, |error, path| retain_staging_context(error, path))); + return Err(cleanup(error, &staged)); } - staged.push(staging); + let identity = match skill_path_identity(&staging) { + Ok(identity) => identity, + Err(error) => return Err(cleanup(retain_staging_context(error, &staging), &staged)), + }; + staged.push((staging, identity)); } let result = (|| -> anyhow::Result { let remote = archive::stage_downloaded_zip( - &update.repo, &update.zip, id, Some(&update.installed.repo_path), &staged[0], + &update.repo, &update.zip, id, Some(&update.installed.repo_path), &staged[0].0, )?; anyhow::ensure!(remote.repo_path == update.remote.repo_path && remote.content_hash == update.remote.content_hash, "仓库压缩包与预览版本不一致,请重新预览"); if update.paths[0].marked { - std::fs::write(staged[0].join(MANAGED_COPY_MARKER), "")?; + std::fs::write(staged[0].0.join(MANAGED_COPY_MARKER), "")?; } for (index, path) in update.paths.iter().enumerate().skip(1) { - copy_dir_all(&staged[0], &staged[index])?; + copy_dir_all(&staged[0].0, &staged[index].0)?; if path.marked { - std::fs::write(staged[index].join(MANAGED_COPY_MARKER), "")?; + std::fs::write(staged[index].0.join(MANAGED_COPY_MARKER), "")?; } } self.check_in_place_contents(&update)?; @@ -441,11 +458,12 @@ impl SkillsManager { anyhow::ensure!(skill_directory_digest(&backup)? == Some(path.digest), "更新期间 Skill 内容已改变,请重新预览:{}", path.path.display()); } - for (path, staging) in update.paths.iter().zip(&staged) { - let identity = skill_path_identity(staging)?; + for (path, (staging, identity)) in update.paths.iter().zip(&staged) { + anyhow::ensure!(skill_path_identity(staging)? == *identity, + "Skill 暂存目录已被替换:{}", staging.display()); move_path_no_replace(staging, &path.path)?; - published.push((path.path.clone(), identity)); - anyhow::ensure!(skill_path_identity(&path.path)? == identity, + published.push((path.path.clone(), *identity)); + anyhow::ensure!(skill_path_identity(&path.path)? == *identity, "Skill 更新期间目标路径发生变化"); } let mut backup_ids = Vec::new(); @@ -529,9 +547,7 @@ impl SkillsManager { coordination.changed_skill(self.coordination_scope(), id); Ok(state) })(); - result.map_err(|error| { - staged.iter().fold(error, |error, path| retain_staging_context(error, path)) - }) + result.map_err(|error| cleanup(error, &staged)) } } @@ -584,6 +600,40 @@ mod tests { (temp, manager, repo, remote, zip) } + #[test] + fn discarded_preview_cannot_be_executed() { + let (_temp, manager, _repo, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + SkillsManager::discard_in_place_update_preview(&preview.preview_id); + assert!(manager.in_place_update_preview_remote(&preview.preview_id).is_err()); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + SkillsManager::discard_in_place_update_preview(&preview.preview_id); + } + + #[test] + fn discarding_an_older_preview_preserves_another_preview_for_the_same_skill() { + let (_temp, manager, _repo, remote, zip) = fixture(); + let current = manager.preview_skill_in_place_update("alpha", remote.clone(), zip.clone()).unwrap(); + let late = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + SkillsManager::discard_in_place_update_preview(&late.preview_id); + assert_eq!(manager.in_place_update_preview_remote(¤t.preview_id).unwrap().content_hash, remote.content_hash); + SkillsManager::discard_in_place_update_preview(¤t.preview_id); + } + + #[test] + fn update_without_a_baseline_still_checks_files_after_preview() { + let (temp, manager, _repo, remote, zip) = fixture(); + let mut state = manager.load_state(); + state.installed.get_mut("alpha").unwrap().content_hash.clear(); + manager.save_state_unlocked(&state).unwrap(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + assert!(preview.current_hash.is_empty()); + assert!(preview.conflicts.is_empty()); + std::fs::write(temp.path().join("shared/alpha/details.txt"), "changed after preview").unwrap(); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &remote).is_err()); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "changed after preview"); + } + #[cfg(unix)] fn test_directory_link(source: &Path, target: &Path) { std::os::unix::fs::symlink(source, target).unwrap(); @@ -1186,6 +1236,59 @@ mod tests { assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "old details"); } + #[test] + fn failed_archive_staging_cleans_only_created_directories() { + let (temp, manager, _, remote, zip) = fixture(); + let unrelated = temp.path().join("shared/.codex-plus-update-alpha-unrelated"); + std::fs::create_dir(&unrelated).unwrap(); + std::fs::write(unrelated.join("user.txt"), "preserve").unwrap(); + for invalid_zip in [true, false] { + let mut selected = remote.clone(); + let bytes = if invalid_zip { + b"invalid archive".to_vec() + } else { + selected.content_hash = "different".into(); + zip.clone() + }; + let preview = manager.preview_skill_in_place_update("alpha", selected.clone(), bytes).unwrap(); + assert!(manager.execute_skill_in_place_update(&preview.preview_id, &selected).is_err()); + for parent in [temp.path().join("shared"), manager.source_dir.clone(), temp.path().join("agent")] { + let staging = std::fs::read_dir(parent).unwrap().map(|entry| entry.unwrap().path()) + .filter(|path| path.file_name().unwrap().to_string_lossy().starts_with(".codex-plus-update-")) + .collect::>(); + assert!(staging.iter().all(|path| path == &unrelated)); + } + assert_eq!(std::fs::read_to_string(unrelated.join("user.txt")).unwrap(), "preserve"); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "old details"); + } + } + + #[test] + fn replaced_staging_directory_is_not_published_or_deleted() { + let (temp, manager, _, remote, zip) = fixture(); + let preview = manager.preview_skill_in_place_update("alpha", remote.clone(), zip).unwrap(); + let parent = temp.path().join("shared"); + let held = temp.path().join("moved-staging"); + BEFORE_UPDATE_BACKUP.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move || { + let staging = std::fs::read_dir(&parent).unwrap().map(|entry| entry.unwrap().path()) + .find(|path| path.file_name().unwrap().to_string_lossy().starts_with(".codex-plus-update-")) + .unwrap(); + std::fs::rename(&staging, &held).unwrap(); + std::fs::create_dir(&staging).unwrap(); + std::fs::write(staging.join("user.txt"), "preserve").unwrap(); + })); + }); + let error = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap_err(); + assert!(format!("{error:#}").contains("暂存目录已被替换")); + let replacement = std::fs::read_dir(temp.path().join("shared")).unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.file_name().unwrap().to_string_lossy().starts_with(".codex-plus-update-")) + .unwrap(); + assert_eq!(std::fs::read_to_string(replacement.join("user.txt")).unwrap(), "preserve"); + assert_eq!(std::fs::read_to_string(temp.path().join("shared/alpha/details.txt")).unwrap(), "old details"); + } + #[test] fn git_worktree_at_original_path_cannot_be_replaced() { let (temp, manager, _, remote, zip) = fixture(); diff --git a/crates/codex-plus-core/tests/all/skills_lifecycle.rs b/crates/codex-plus-core/tests/all/skills_lifecycle.rs index 1bb9c03e60..9d77a13047 100644 --- a/crates/codex-plus-core/tests/all/skills_lifecycle.rs +++ b/crates/codex-plus-core/tests/all/skills_lifecycle.rs @@ -76,7 +76,7 @@ fn failed_restore_preserves_the_original_backup() { assert_eq!(std::fs::read_to_string(backup.join("SKILL.md")).unwrap(), "original backup"); assert!(!source.join("alpha").exists()); assert!(!home.join("skills/alpha").exists()); - assert_eq!(manager.list_backups().len(), 1); + assert_eq!(manager.list_backups().unwrap().len(), 1); } #[test] @@ -90,7 +90,7 @@ fn disabling_does_not_stage_inside_the_original_skill() { let manager = SkillsManager::new(&source, temp.path().join("backups"), temp.path().join("skills.json"), &home); manager.set_enabled("alpha", false).unwrap(); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "original"); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); assert!(!source.exists()); assert!(!manager.local_inventory().iter().find(|skill| skill.id == "alpha").unwrap().enabled); } @@ -112,7 +112,7 @@ fn disabling_a_copied_installation_must_preserve_user_edits() { manager.set_enabled("alpha", false).unwrap(); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user edits"); assert_eq!(std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap(), "original source"); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); assert!(!manager.local_inventory().iter().find(|skill| skill.id == "alpha").unwrap().enabled); let installed = &manager.load_state().installed["alpha"]; assert_eq!(installed.repo_key, "acme/kit@main"); @@ -149,7 +149,7 @@ fn failed_uninstall_must_not_leave_stale_installed_state() { assert!(manager.load_state().installed.contains_key("alpha")); assert!(local.is_dir(), "failed uninstall removed the installation but retained its installed record; original contents only remain in backup"); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user original"); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); } #[test] @@ -180,7 +180,7 @@ fn failed_local_disable_must_not_hide_the_skill() { assert!(manager.local_inventory().iter().any(|skill| skill.id == "alpha"), "failed disable moved the only local directory to source without registering it; inventory and backups no longer expose the skill"); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user original"); assert!(!source.join("alpha").exists()); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); } #[test] @@ -213,7 +213,7 @@ fn failed_copy_disable_restores_both_directories_and_metadata() { assert_eq!(manager.load_state(), before); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "user edits"); assert_eq!(std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap(), "old source"); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); } #[test] @@ -242,5 +242,5 @@ fn failed_second_backup_restores_the_first_uninstall_move() { assert_eq!(manager.load_state(), before); assert_eq!(std::fs::read_to_string(local.join("SKILL.md")).unwrap(), "local edits"); assert_eq!(std::fs::read_to_string(source.join("alpha/SKILL.md")).unwrap(), "source content"); - assert!(manager.list_backups().is_empty()); + assert!(manager.list_backups().unwrap().is_empty()); } From fc714c5b55aaf5c8279e7663e77e13244b3d4f2f Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 12 Nov 2026 22:39:18 +0800 Subject: [PATCH 4/4] refactor(skills): defer duplicate archive and file times --- .../src-tauri/src/commands.rs | 40 - apps/codex-plus-manager/src-tauri/src/lib.rs | 3 - .../src/ManagementExtra.tsx | 18 +- .../src/ManagementPages.css | 6 - .../src/SkillDuplicatesDrawer.tsx | 62 -- .../codex-plus-manager/src/SkillFileTimes.tsx | 38 - .../src/skill-backup-usage.test.ts | 3 +- .../src/skill-duplicates-ui.test.ts | 79 -- .../src/skill-file-times.test.ts | 99 -- .../src/skill-location-association.test.ts | 2 +- crates/codex-plus-core/src/skills.rs | 876 +----------------- .../src/skills/in_place_update.rs | 43 - 12 files changed, 47 insertions(+), 1222 deletions(-) delete mode 100644 apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx delete mode 100644 apps/codex-plus-manager/src/SkillFileTimes.tsx delete mode 100644 apps/codex-plus-manager/src/skill-duplicates-ui.test.ts delete mode 100644 apps/codex-plus-manager/src/skill-file-times.test.ts diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 50f0c2128d..3591dd40cf 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -7482,14 +7482,6 @@ pub async fn list_installed_skills() -> Result, Str }).await.map_err(|error| format!("读取本地 Skills 失败:{error}")) } -#[tauri::command] -pub async fn list_skill_file_times(id: String, root_kind: String, backup_id: Option) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - default_skills_manager().skill_file_times(&id, &root_kind, backup_id.as_deref()) - .map_err(|error| format!("读取 Skill 文件时间失败:{error:#}")) - }).await.map_err(|error| format!("读取 Skill 文件时间失败:{error}"))? -} - async fn resolve_skill_repo_for_operation( manager: &codex_plus_core::skills::SkillsManager, repo_key: &str, @@ -7615,38 +7607,6 @@ pub async fn discover_skill_source_evidence(id: String, local_path: Option Result, String> { - tauri::async_runtime::spawn_blocking(|| { - match default_skills_manager().preview_skill_duplicates() { - Ok(preview) => ok("重复副本已检查。", preview), - Err(error) => failed( - &format!("检查重复副本失败:{error:#}"), - codex_plus_core::skills::SkillDuplicatePreview { - preview_id: String::new(), - candidates: Vec::new(), - }, - ), - } - }).await.map_err(|error| format!("检查重复副本失败:{error}")) -} - -#[tauri::command] -pub async fn archive_skill_duplicate(preview_id: String, id: String, kind: Option) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let manager = default_skills_manager(); - let result = match kind.as_deref() { - None | Some("shared") => manager.archive_skill_duplicate(&preview_id, &id), - Some("source") => manager.archive_skill_managed_source(&preview_id, &id), - Some(_) => Err(anyhow::anyhow!("未知的重复副本类型")), - }; - match result { - Ok(_) => ok("重复副本已归档,可恢复至原位置。", current_skills_payload(&manager)), - Err(error) => failed(&format!("归档重复副本失败:{error:#}"), current_skills_payload(&manager)), - } - }).await.map_err(|error| format!("归档重复副本失败:{error}")) -} - #[tauri::command] pub async fn preview_skill_in_place_update( id: String, diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index cd6ac96513..ed1d362bc3 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -589,12 +589,9 @@ pub fn run() { commands::delete_user_script, commands::refresh_skill_catalog, commands::list_installed_skills, - commands::list_skill_file_times, commands::find_skill_repository_matches, commands::apply_skill_repository_matches, commands::discover_skill_source_evidence, - commands::preview_skill_duplicates, - commands::archive_skill_duplicate, commands::preview_skill_in_place_update, commands::discard_skill_in_place_update_preview, commands::execute_skill_in_place_update, diff --git a/apps/codex-plus-manager/src/ManagementExtra.tsx b/apps/codex-plus-manager/src/ManagementExtra.tsx index 1b3ca9bbfd..5b028d9ef9 100644 --- a/apps/codex-plus-manager/src/ManagementExtra.tsx +++ b/apps/codex-plus-manager/src/ManagementExtra.tsx @@ -4,7 +4,7 @@ import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalList import { CSS } from "@dnd-kit/utilities"; import { invoke } from "@tauri-apps/api/core"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; -import { ArrowDownAZ, ArrowDownUp, ArrowDownZA, ArrowLeft, ArrowRight, BookOpen, ChevronDown, ChevronsDown, ChevronsUp, CloudDownload, Copy, Edit3, ExternalLink, FolderOpen, FolderPlus, GripVertical, History, Link, Plus, RefreshCw, Search, Trash2, X } from "lucide-react"; +import { ArrowDownAZ, ArrowDownUp, ArrowDownZA, ArrowLeft, ArrowRight, BookOpen, ChevronDown, ChevronsDown, ChevronsUp, CloudDownload, Copy, Edit3, ExternalLink, FolderPlus, GripVertical, History, Link, Plus, RefreshCw, Search, Trash2, X } from "lucide-react"; import { t, tf } from "./i18n"; import { appConfirm, appPrompt } from "./app-dialog"; import { matchesManagementQuery, sessionGroups, type ManagedSession } from "./management-model"; @@ -20,8 +20,6 @@ import { SkillListViewport } from "./SkillListViewport"; import { aggregateSkillGroupItems, customSkillGroupKey, groupSkills, legacySkillCustomGroupName, migrateSkillGroupAssignments, migrateSkillInstanceAssignments, moveSkillGroup, nextSkillGroupSortDirection, readSkillGroupStorage, skillGroupAssignment, skillGroupIdentityKey, skillIdentityKey, sortSkillGroupKeys, sortSkillGroupsByName, type SkillGroup, type SkillGroupSortDirection } from "./skill-groups"; import { mergeSkillAgentResult, skillAgents, skillBatchHasPending, skillBatchState, skillBatchTargets, skillCodexStatusError, SkillBatchRegistry, type PendingSkillBatchOperation } from "./skill-agent-batch"; import { SkillSourceMatchDrawer, type SourceSelection } from "./SkillSourceMatchDrawer"; -import { SkillDuplicatesDrawer } from "./SkillDuplicatesDrawer"; -import { SkillFileTimes } from "./SkillFileTimes"; import { SkillInPlaceUpdateDrawer } from "./SkillInPlaceUpdateDrawer"; import { SkillBackupUsage } from "./SkillBackupUsage"; import { TopbarActions } from "./TopbarActions"; @@ -220,7 +218,7 @@ function SkillBackupList({ backups, busy, onRestore, onDelete }: {
{backup.purpose === "duplicate" ? t("重复副本归档") : backup.purpose === "managed-source" ? t("受控源归档") : backup.purpose === "disabled" ? t("停用副本备份") : backup.purpose === "update" ? t("更新前备份") : t("技能备份")} {t("备份位置")}:{backup.backupPath || t("未知")} {t("恢复位置")}:{backup.restorePath || t("未记录")} - {backup.missing ? {t("备份文件已丢失")} : } + {backup.missing && {t("备份文件已丢失")}} {backup.restoreBlockedReason && {backup.restoreBlockedReason}}
@@ -234,9 +232,9 @@ function SkillBackupsDrawer({ backups, busy, onClose, onRestore, onDelete }: { {backups.length ? :

{t("暂无 Skill 备份。")}

} ; } -function SkillDetailDrawer({ skill, count, backups, busy, onClose, onOpenUrl, onOpenRepo, onMatch, onRestore, onDeleteBackup, onUpdate, onManageSource }: { +function SkillDetailDrawer({ skill, count, backups, busy, onClose, onOpenUrl, onOpenRepo, onMatch, onRestore, onDeleteBackup, onUpdate }: { skill: Skill; count?: number; backups: SkillBackup[]; busy: boolean; onClose: () => void; onOpenUrl: (url: string) => void; - onOpenRepo: () => void; onMatch: (localPath?: string, context?: SkillMatchContext) => void; onRestore: (backup: SkillBackup) => void; onDeleteBackup: (backup: SkillBackup) => void; onUpdate: (repoKey: string, localPath?: string) => void; onManageSource: () => void; + onOpenRepo: () => void; onMatch: (localPath?: string, context?: SkillMatchContext) => void; onRestore: (backup: SkillBackup) => void; onDeleteBackup: (backup: SkillBackup) => void; onUpdate: (repoKey: string, localPath?: string) => void; }) { const url = repoUrl(skill); const channel = skillChannelLabel(skill); @@ -277,7 +275,6 @@ function SkillDetailDrawer({ skill, count, backups, busy, onClose, onOpenUrl, on const sharedAssociation = associationPath && (skill.locations || []).filter((item) => (item.selectable && item.originalPath ? item.originalPath : item.associationPath) === associationPath).length > 1; return [
{rootLabel[location.rootKind] || location.rootKind}
,
{location.manifestPath}
{location.managed ? t("Codex++ 受控") : t("本地安装")} · {location.statusError ? t("状态未知") : location.enabled ? t("已启用") : t("未启用")} - {(skill.conflict || skill.displayInstances) && } {location.statusError && <>
{location.statusError}} {instance && <>
{t("安装渠道")}:{skillChannelLabel(instance)}} {(instance || associationPath) && <>
{t(hasRepo ? "上游来源" : "来源")}:{hasRepo ? `${locationRepo}${locationRepoPath ? ` / ${locationRepoPath}` : ""}` : t("本地")} @@ -289,8 +286,7 @@ function SkillDetailDrawer({ skill, count, backups, busy, onClose, onOpenUrl, on {location.selectable && location.originalPath && (skill.displayInstances || skill.conflictKind === "equal-independent") && hasRepo && locationOwner !== "manager" && }}}
]; })} {managedSourcePath && <> -
{t("受控源目录")}
{managedSourcePath} - +
{t("受控源目录")}
{managedSourcePath} {skill.managedSourceStatus === "referenced" && {t("正在被安装位置引用")}} {skill.managedSourceStatus === "matching" && {t("未被目录链接引用,内容与安装位置一致")}} {skill.managedSourceStatus === "stored" && {t("保留的工作源,可用于重新启用")}} @@ -358,7 +354,6 @@ export function SkillsPage({ notify }: { notify: Notify }) { const [repoOpen, setRepoOpen] = useState(false); const [detail, setDetail] = useState(null); const [matchTarget, setMatchTarget] = useState<({ id?: string; localPath?: string } & SkillMatchContext) | null>(null); - const [duplicatesOpen, setDuplicatesOpen] = useState(false); const [backupsOpen, setBackupsOpen] = useState(false); const [updateTarget, setUpdateTarget] = useState<{ id: string; repoKey: string; localPath?: string } | null>(null); const [repoSelection, setRepoSelection] = useState(null); @@ -886,7 +881,6 @@ export function SkillsPage({ notify }: { notify: Notify }) { void importLocalSkills()} disabled={busy}>{t("从本地目录导入")} void run("import_ccs_skills", {})} disabled={busy}>{t("从 cc-switch 导入")} - setDuplicatesOpen(true)} disabled={busy}>{t("预览重复副本")} setBackupsOpen(true)} disabled={busy}>{t("查看 Skill 备份")} } /> @@ -962,7 +956,6 @@ export function SkillsPage({ notify }: { notify: Notify }) { {currentDetail && backup.skillId === currentDetail.id)} busy={busy} onClose={() => setDetail(null)} onOpenUrl={(url) => void openUrl(url)} onOpenRepo={() => repoOpen ? setDetail(null) : openRepo(currentDetail.repoKey)} - onManageSource={() => { setDetail(null); setDuplicatesOpen(true); }} onMatch={(localPath, context) => { setDetail(null); setMatchTarget({ id: currentDetail.id, localPath, ...context }); }} onRestore={(backup) => void restoreBackup(backup)} onDeleteBackup={(backup) => void deleteBackup(backup)} @@ -972,7 +965,6 @@ export function SkillsPage({ notify }: { notify: Notify }) { localSkillIds={knownSkills.filter((skill) => skill.installed && !skill.bundled && (!skill.repoPath || !skill.repoKey.includes("/"))).map((skill) => skill.id)} onClose={() => setMatchTarget(null)} onAddRepo={addMatchingRepo} onApply={applySourceMatches}/>} - {duplicatesOpen && setDuplicatesOpen(false)} onArchive={(previewId, id, kind) => run("archive_skill_duplicate", { previewId, id, kind })}/>} {backupsOpen && setBackupsOpen(false)} onRestore={(backup) => void restoreBackup(backup)} onDelete={(backup) => void deleteBackup(backup)}/>} {updateTarget && setUpdateTarget(null)} onExecute={(previewId) => run("execute_skill_in_place_update", { previewId })}/>} ; diff --git a/apps/codex-plus-manager/src/ManagementPages.css b/apps/codex-plus-manager/src/ManagementPages.css index 8ba77b432a..5e52462876 100644 --- a/apps/codex-plus-manager/src/ManagementPages.css +++ b/apps/codex-plus-manager/src/ManagementPages.css @@ -347,9 +347,3 @@ /* 多 Agent 表头(MCP / Skills):Agent 列是图标 + 名字 + 数量三行,比普通表头高,上下留出边距 */ .shell .mg-agent-matrix .mg-matrix-head { padding-top: 8px; padding-bottom: 8px; } .shell .mg-agent-matrix .mg-agent-column { gap: 3px; } -.mg-skill-file-times { position:relative; margin-top:6px; font-size:12px; } -.mg-skill-file-times summary { min-height:26px; padding-right:32px; line-height:26px; cursor:pointer; color:hsl(var(--muted-foreground)); } -.mg-skill-file-times>button.mg-icon-button { position:absolute; top:0; right:0; width:26px; height:26px; } -.mg-skill-file-times ul { display:grid; gap:4px; max-height:220px; margin:6px 0; padding:0; overflow:auto; list-style:none; } -.mg-skill-file-times li { display:flex; flex-wrap:wrap; justify-content:space-between; gap:2px 12px; overflow-wrap:anywhere; } -.mg-skill-file-times time { white-space:nowrap; color:hsl(var(--muted-foreground)); } diff --git a/apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx b/apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx deleted file mode 100644 index 5e3c27138c..0000000000 --- a/apps/codex-plus-manager/src/SkillDuplicatesDrawer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { RefreshCw } from "lucide-react"; -import { ManagementDrawer } from "./ManagementPages"; -import { appConfirm } from "./app-dialog"; -import { t } from "./i18n"; -import { SkillFileTimes } from "./SkillFileTimes"; -import "./SkillSourceMatchDrawer.css"; - -type Duplicate = { id: string; kind?: "shared" | "source"; sharedPath: string; codexPath: string; originalRootKind?: string; status: "identical-managed" | "identical-source" | "different" | "unmanaged" | "unsupported" | "enabled-only-copy"; detail?: string | null }; -type Preview = { status: string; message: string; previewId: string; candidates: Duplicate[] }; -const statusLabel = (status: Duplicate["status"]) => ({ - "identical-managed": t("内容一致,可归档受控副本"), - "identical-source": t("内容一致,可将闲置受控源归档"), - different: t("内容不同,保留两份"), - unmanaged: t("归属未确认,保留两份"), - unsupported: t("无法比较,保留两份"), - "enabled-only-copy": t("共享位置已停用,保留当前启用的副本"), -})[status]; - -export function SkillDuplicatesDrawer({ onClose, onArchive }: { - onClose: () => void; - onArchive: (previewId: string, id: string, kind: "shared" | "source") => Promise; -}) { - const [preview, setPreview] = useState(null); - const [revision, setRevision] = useState(0); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - useEffect(() => { - let active = true; - setLoading(true); setPreview(null); setError(""); - void invoke("preview_skill_duplicates").then((result) => { - if (!active) return; - if (result.status === "ok") setPreview(result); - else setError(result.message); - }, (reason) => { if (active) setError(String(reason)); }).finally(() => { if (active) setLoading(false); }); - return () => { active = false; }; - }, [revision]); - const archive = async (item: Duplicate) => { - if (!preview || (item.status !== "identical-managed" && item.status !== "identical-source")) return; - const source = item.kind === "source"; - const message = source ? `${t("将闲置受控源移入备份,并保留此安装位置作为原件?")}\n${item.codexPath}\n${t("保留的安装位置")}:${item.sharedPath}` - : `${t("将受控副本移入备份?")}\n${item.codexPath}`; - if (!(await appConfirm(message, { title: t("归档重复副本"), confirmText: t("归档") }))) return; - setBusy(true); - try { if (await onArchive(preview.previewId, item.id, item.kind || "shared")) setRevision((old) => old + 1); } - finally { setBusy(false); } - }; - return {} : onClose} - footer={<>}> - {loading &&

{t("正在检查…")}

} - {error &&

{error}

} - {preview && !preview.candidates.length &&

{t("没有发现可比较的重复副本。")}

} - {preview?.candidates.map((item) =>
-
{item.id}{statusLabel(item.status)}{item.detail && {item.detail}}
-
{t(item.kind === "source" ? "保留的安装位置" : "共享位置")}
{item.sharedPath}
{item.kind === "source" ? t("受控源目录") : "Codex"}
{item.codexPath}
- {(item.status === "identical-managed" || item.status === "identical-source") && } -
-
)} -
; -} diff --git a/apps/codex-plus-manager/src/SkillFileTimes.tsx b/apps/codex-plus-manager/src/SkillFileTimes.tsx deleted file mode 100644 index 841b709c8c..0000000000 --- a/apps/codex-plus-manager/src/SkillFileTimes.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; -import { t } from "./i18n"; - -type SkillFileTime = { path: string; modifiedAtMs: number; isLink: boolean }; - -export function SkillFileTimes({ id, rootKind = "", backupId }: { id: string; rootKind?: string; backupId?: string }) { - const [files, setFiles] = useState(null); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [page, setPage] = useState(0); - const pending = useRef(false); - const mounted = useRef(false); - useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); - const load = () => { - if (pending.current) return; - pending.current = true; - setLoading(true); - setError(""); - void invoke("list_skill_file_times", { id, rootKind, backupId: backupId || null }) - .then((result) => { if (mounted.current) { setFiles(result); setPage(0); } }, - (reason) => { if (mounted.current) setError(String(reason)); }) - .finally(() => { pending.current = false; if (mounted.current) setLoading(false); }); - }; - return
{ if (event.currentTarget.open && !files) load(); }}> - {t("文件修改时间")} - {loading && {t("正在读取…")}} - {error && {error}} - {(files || error) && } - {files && <>
    {files.slice(page * 100, (page + 1) * 100).map((file) =>
  • {file.path}{file.isLink && <> · {t("链接(未展开)")}}
  • )}
- {files.length > 100 &&
- - {page + 1} / {Math.ceil(files.length / 100)} - -
}} -
; -} diff --git a/apps/codex-plus-manager/src/skill-backup-usage.test.ts b/apps/codex-plus-manager/src/skill-backup-usage.test.ts index fcba43114d..404e0e2184 100644 --- a/apps/codex-plus-manager/src/skill-backup-usage.test.ts +++ b/apps/codex-plus-manager/src/skill-backup-usage.test.ts @@ -174,7 +174,7 @@ test("a missing backup can be cleared but cannot be restored", () => { }).outputText; const context: Record = { exports: {}, fragment: "fragment", jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), - Button: "Button", SkillFileTimes: "SkillFileTimes", Trash2: "Trash2", t: (value: string) => value, + Button: "Button", Trash2: "Trash2", t: (value: string) => value, }; vm.createContext(context); vm.runInContext(compiled, context); @@ -185,5 +185,4 @@ test("a missing backup can be cleared but cannot be restored", () => { assert.ok(content(root).includes("备份文件已丢失")); assert.equal(find(root, (item) => item.type === "Button").props.disabled, true); assert.ok(find(root, (item) => item.props["aria-label"] === "清除记录 alpha")); - assert.equal(find(root, (item) => item.type === "SkillFileTimes"), undefined); }); diff --git a/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts b/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts deleted file mode 100644 index 4df3ec566e..0000000000 --- a/apps/codex-plus-manager/src/skill-duplicates-ui.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; -import ts from "typescript"; - -type Element = { type: any; props: Record; children: any[] }; -const source = readFileSync(new URL("./SkillDuplicatesDrawer.tsx", import.meta.url), "utf8"); -const file = ts.createSourceFile("SkillDuplicatesDrawer.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); -const nodes = file.statements.filter((node) => - ts.isFunctionDeclaration(node) && node.name?.text === "SkillDuplicatesDrawer" - || ts.isVariableStatement(node) && node.declarationList.declarations.some((item) => - ts.isIdentifier(item.name) && item.name.text === "statusLabel")); -const code = ts.transpileModule(nodes.map((node) => node.getText(file)).join("\n"), { - compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, -}).outputText; -const tick = () => new Promise((resolve) => setImmediate(resolve)); - -test("a verified managed duplicate can be archived despite other independent same-name skills", async () => { - const slots: any[] = []; - const effects: Array<() => void> = []; - const archives: Array<[string, string, string]> = []; - const confirmations: string[] = []; - let cursor = 0; - let elements: Element[] = []; - const context: Record = { - exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), - fragment: "fragment", - useState: (initial: any) => { - const index = cursor++; - if (!(index in slots)) slots[index] = typeof initial === "function" ? initial() : initial; - return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; - }, - useEffect: (run: () => void, deps: any[]) => { - const index = cursor++; - const previous = slots[index] as any[] | undefined; - if (!previous || deps.some((value, position) => !Object.is(value, previous[position]))) effects.push(run); - slots[index] = deps; - }, - t: (value: string) => value, appConfirm: async (message: string) => { confirmations.push(message); return true; }, - invoke: async () => ({ status: "ok", previewId: "verified-preview", candidates: [ - { id: "alpha", sharedPath: "C:/agents/alpha", codexPath: "C:/codex/alpha", status: "identical-managed" }, - { id: "alpha", kind: "source", sharedPath: "C:/codex/alpha", codexPath: "C:/managed/alpha", originalRootKind: "codex", status: "identical-source" }, - { id: "other", sharedPath: "C:/agents/other", codexPath: "C:/codex/other", status: "different" }, - { id: "unknown", sharedPath: "C:/agents/unknown", codexPath: "C:/codex/unknown", status: "unsupported", detail: "无法确定名称规则的启用状态" }, - ] }), - ManagementDrawer: "ManagementDrawer", RefreshCw: "RefreshCw", SkillFileTimes: "SkillFileTimes", - }; - vm.createContext(context); - vm.runInContext(code, context); - const collect = (node: any) => { - if (!node || typeof node !== "object") return; - if (Array.isArray(node)) { node.forEach(collect); return; } - elements.push(node); - node.children?.forEach(collect); - if (node.props?.footer) collect(node.props.footer); - }; - const props = { - onClose: () => {}, - onArchive: async (previewId: string, id: string, kind: string) => { archives.push([previewId, id, kind]); return true; }, - }; - const render = () => { - cursor = 0; elements = []; effects.length = 0; - collect(context.exports.SkillDuplicatesDrawer(props)); - effects.splice(0).forEach((run) => run()); - }; - render(); await tick(); render(); - const buttons = elements.filter((node) => node.type === "button" && node.children.includes("归档副本")); - assert.equal(buttons.length, 2); - assert.ok(elements.some((node) => node.type === "small" && node.children.includes("无法确定名称规则的启用状态"))); - assert.equal(elements.filter((node) => node.type === "SkillFileTimes").length, 8); - assert.equal(buttons[0].props.disabled, false); - await buttons[0].props.onClick(); - await buttons[1].props.onClick(); - await tick(); - assert.deepEqual(archives, [["verified-preview", "alpha", "shared"], ["verified-preview", "alpha", "source"]]); - assert.ok(confirmations[1].includes("C:/managed/alpha")); - assert.ok(confirmations[1].includes("C:/codex/alpha")); -}); diff --git a/apps/codex-plus-manager/src/skill-file-times.test.ts b/apps/codex-plus-manager/src/skill-file-times.test.ts deleted file mode 100644 index 73c43db185..0000000000 --- a/apps/codex-plus-manager/src/skill-file-times.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import vm from "node:vm"; -import ts from "typescript"; - -const source = readFileSync(new URL("./SkillFileTimes.tsx", import.meta.url), "utf8"); -const file = ts.createSourceFile("SkillFileTimes.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); -const component = file.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "SkillFileTimes"); -assert.ok(component); -const code = ts.transpileModule(component.getText(file), { - compilerOptions: { jsx: ts.JsxEmit.React, jsxFactory: "jsx", jsxFragmentFactory: "fragment", module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, -}).outputText; -const tick = () => new Promise((resolve) => setImmediate(resolve)); - -test("file dates are read only on expansion and identify every returned file", async () => { - const slots: any[] = []; - const effects: Array<() => void> = []; - const calls: any[] = []; - let cursor = 0; - let resolveFiles!: (value: any[]) => void; - let rejectFiles!: (error: Error) => void; - const context: Record = { - exports: {}, fragment: "fragment", jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), - ChevronLeft: "ChevronLeft", ChevronRight: "ChevronRight", RefreshCw: "RefreshCw", - useState: (initial: any) => { - const index = cursor++; - if (!(index in slots)) slots[index] = initial; - return [slots[index], (value: any) => { slots[index] = typeof value === "function" ? value(slots[index]) : value; }]; - }, - useRef: (value: any) => { const index = cursor++; return slots[index] ??= { current: value }; }, - useEffect: (run: () => void) => { const index = cursor++; if (!(index in slots)) { slots[index] = true; effects.push(run); } }, - t: (value: string) => value, - invoke: (command: string, args: any) => { - calls.push({ command, args }); - return new Promise((resolve, reject) => { resolveFiles = resolve; rejectFiles = reject; }); - }, - }; - vm.createContext(context); - vm.runInContext(code, context); - const render = () => { - cursor = 0; - const root = context.exports.SkillFileTimes({ id: "alpha", backupId: "alpha-123" }); - effects.splice(0).forEach((run) => run()); - return root; - }; - let root = render(); - assert.equal(calls.length, 0); - root.props.onToggle({ currentTarget: { open: true } }); - root.props.onToggle({ currentTarget: { open: true } }); - assert.equal(calls.length, 1); - assert.equal(calls[0].command, "list_skill_file_times"); - assert.equal(calls[0].args.backupId, "alpha-123"); - rejectFiles(new Error("cannot read folder")); - await tick(); - root = render(); - assert.ok(root.children.some((child: any) => child?.props?.role === "alert")); - root.children.find((child: any) => child?.props?.["aria-label"] === "刷新").props.onClick(); - assert.equal(calls.length, 2); - resolveFiles([{ path: "SKILL.md", modifiedAtMs: Date.parse("2026-11-10T00:00:00Z") }, - { path: "scripts/run.js", modifiedAtMs: Date.parse("2026-11-09T00:00:00Z"), isLink: true }, - ...Array.from({ length: 248 }, (_, index) => ({ path: `file-${index}`, modifiedAtMs: 0 }))]); - await tick(); - root = render(); - let content = root.children.find((child: any) => child?.type === "fragment"); - let list = content.children.find((child: any) => child?.type === "ul"); - assert.equal(list.children[0].length, 100); - assert.deepEqual(Array.from(list.children[0].slice(0, 2), (item: any) => item.children[0].children[0]), ["SKILL.md", "scripts/run.js"]); - assert.deepEqual(Array.from(list.children[0].slice(0, 2), (item: any) => item.children[1].props.dateTime), - ["2026-11-10T00:00:00.000Z", "2026-11-09T00:00:00.000Z"]); - assert.equal(list.children[0][1].children[0].children[1].children[1], "链接(未展开)"); - let controls = content.children.find((child: any) => child?.type === "div"); - assert.equal(controls.children[0].props.disabled, true); - controls.children[2].props.onClick(); - root = render(); - content = root.children.find((child: any) => child?.type === "fragment"); - list = content.children.find((child: any) => child?.type === "ul"); - assert.equal(list.children[0].length, 100); - controls = content.children.find((child: any) => child?.type === "div"); - controls.children[2].props.onClick(); - root = render(); - content = root.children.find((child: any) => child?.type === "fragment"); - list = content.children.find((child: any) => child?.type === "ul"); - assert.equal(list.children[0].length, 50); - controls = content.children.find((child: any) => child?.type === "div"); - assert.equal(controls.children[2].props.disabled, true); - controls.children[0].props.onClick(); - root = render(); - root.props.onToggle({ currentTarget: { open: true } }); - assert.equal(calls.length, 2); - root.children.find((child: any) => child?.props?.["aria-label"] === "刷新").props.onClick(); - assert.equal(calls.length, 3); - resolveFiles([{ path: "updated.md", modifiedAtMs: 0 }]); - await tick(); - root = render(); - content = root.children.find((child: any) => child?.type === "fragment"); - list = content.children.find((child: any) => child?.type === "ul"); - assert.equal(list.children[0][0].children[0].children[0], "updated.md"); -}); diff --git a/apps/codex-plus-manager/src/skill-location-association.test.ts b/apps/codex-plus-manager/src/skill-location-association.test.ts index 1d7ef9d6f7..10d9b90de8 100644 --- a/apps/codex-plus-manager/src/skill-location-association.test.ts +++ b/apps/codex-plus-manager/src/skill-location-association.test.ts @@ -18,7 +18,7 @@ test("each Agent installation associates its own repository path", () => { const context: Record = { exports: {}, jsx: (type: any, props: any, ...children: any[]) => ({ type, props: props || {}, children }), fragment: "fragment", t: (value: string) => value, repoUrl: () => "", sourceLabel: () => "本地", - skillChannelLabel: () => "本地安装", SkillFileTimes: "SkillFileTimes", CloudDownload: "CloudDownload", + skillChannelLabel: () => "本地安装", CloudDownload: "CloudDownload", ManagementDrawer: "ManagementDrawer", Button: "Button", Edit3: "Edit3", ExternalLink: "ExternalLink", ArrowRight: "ArrowRight", }; vm.createContext(context); diff --git a/crates/codex-plus-core/src/skills.rs b/crates/codex-plus-core/src/skills.rs index 5e27752a23..994309bd56 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -334,67 +334,6 @@ pub struct SkillBackup { pub delete_warning: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillFileTime { - pub path: String, - pub modified_at_ms: i64, - pub is_link: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillDuplicate { - pub id: String, - pub kind: String, - pub shared_path: String, - pub codex_path: String, - pub original_root_kind: String, - pub status: String, - pub detail: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillDuplicatePreview { - pub preview_id: String, - pub candidates: Vec, -} - -#[derive(Clone)] -struct DuplicateSnapshot { - shared_path: PathBuf, - shared_target: PathBuf, - codex_path: PathBuf, - shared_identity: SkillPathIdentity, - shared_target_identity: SkillPathIdentity, - codex_identity: SkillPathIdentity, - digest: [u8; 32], -} - -#[derive(Clone)] -struct DuplicatePreviewSnapshot { - scope: CoordinationScope, - candidates: BTreeMap, - managed_sources: BTreeMap, -} - -#[derive(Clone)] -struct ManagedSourceSnapshot { - source_path: PathBuf, - source_identity: SkillPathIdentity, - original_path: PathBuf, - original_identity: SkillPathIdentity, - managed_copies: Vec, - digest: [u8; 32], - installed: InstalledSkill, -} - -fn duplicate_previews() -> MutexGuard<'static, BTreeMap> { - static PREVIEWS: OnceLock>> = OnceLock::new(); - PREVIEWS.get_or_init(|| Mutex::new(BTreeMap::new())).lock().expect("Skill 重复预览锁中毒") -} - /// 下载前的状态快照。公开字段供调用方定位仓库和原安装路径,代次只在本进程有效。 #[derive(Debug, Clone)] pub struct SkillDownloadGuard { @@ -3214,347 +3153,6 @@ impl SkillsManager { Ok(()) } - /// 预览受控副本和闲置受控源的重复关系,不修改任何路径。 - pub fn preview_skill_duplicates(&self) -> anyhow::Result { - let shared_root = self.shared_skills_dir(); - let shared_skills = match shared_root { - Some(root) => scan_shared_skill_dirs(root)?, - None => Vec::new(), - }; - let native_config = self.read_native_skill_config()?; - let state = { - let _guard = skills_coordination(); - self.load_state_checked_unlocked()? - }; - let mut candidates = Vec::new(); - let mut snapshots = BTreeMap::new(); - let mut managed_sources = BTreeMap::new(); - for (id, _) in shared_skills { - let shared = shared_root.unwrap().join(&id); - let codex = self.linked_dir().join(&id); - if !codex.join(SKILL_MANIFEST_FILE).is_file() { continue; } - let mut candidate = SkillDuplicate { - id: id.clone(), - kind: "shared".into(), - shared_path: shared.to_string_lossy().into_owned(), - codex_path: codex.to_string_lossy().into_owned(), - original_root_kind: "shared".into(), - status: String::new(), - detail: None, - }; - let comparison = (|| -> anyhow::Result> { - let shared_target = shared.canonicalize().context("无法解析共享 Skill 路径")?; - if codex.canonicalize()? == shared_target { - candidate.status = "same-location".into(); - return Ok(None); - } - if !is_managed_copy(&codex) || !state.installed.contains_key(&id) - || !self.source_dir.join(&id).is_dir() - { - candidate.status = "unmanaged".into(); - return Ok(None); - } - self.ensure_duplicate_has_no_discovered_references(&codex)?; - if shared_target.join(MANAGED_COPY_MARKER).exists() { - anyhow::bail!("共享目录含有管理器标记,无法确认完整内容"); - } - let shared_identity = skill_path_identity(&shared)?; - let shared_target_identity = skill_path_identity(&shared_target)?; - let codex_identity = skill_path_identity(&codex)?; - let shared_digest = skill_directory_digest(&shared_target)? - .context("共享 Skill 目录已不存在")?; - let codex_digest = skill_directory_digest(&codex)? - .context("Codex Skill 目录已不存在")?; - if shared_digest != codex_digest { - candidate.status = "different".into(); - return Ok(None); - } - let (shared_name, _) = read_skill_manifest(&shared.join(SKILL_MANIFEST_FILE), &id); - let (codex_name, _) = read_skill_manifest(&codex.join(SKILL_MANIFEST_FILE), &id); - let shared_manifest = shared.join(SKILL_MANIFEST_FILE); - let codex_manifest = codex.join(SKILL_MANIFEST_FILE); - let shared_name = native_config.name_for_matching(&shared_manifest, &shared_name)?; - let codex_name = native_config.name_for_matching(&codex_manifest, &codex_name)?; - if !native_config.enabled(&shared_manifest, &shared_name)? - && native_config.enabled(&codex_manifest, &codex_name)? - { - candidate.status = "enabled-only-copy".into(); - candidate.detail = Some("共享路径已停用,归档会使 Codex 无法使用此 Skill".into()); - return Ok(None); - } - if skill_path_identity(&shared)? != shared_identity - || skill_path_identity(&shared_target)? != shared_target_identity - || skill_path_identity(&codex)? != codex_identity - { - anyhow::bail!("比较期间 Skill 路径已改变,请重试"); - } - candidate.status = "identical-managed".into(); - Ok(Some(DuplicateSnapshot { - shared_path: shared.clone(), shared_target, codex_path: codex.clone(), - shared_identity, shared_target_identity, codex_identity, digest: shared_digest, - })) - })(); - match comparison { - Ok(Some(snapshot)) => { snapshots.insert(id, snapshot); } - Ok(None) => {} - Err(error) => { - candidate.status = "unsupported".into(); - candidate.detail = Some(format!("{error:#}")); - } - } - if candidate.status != "same-location" { candidates.push(candidate); } - } - for (id, installed) in &state.installed { - let source = self.source_dir.join(id); - if !source.join(SKILL_MANIFEST_FILE).is_file() { continue; } - let mut roots = vec![("codex".to_string(), self.linked_dir())]; - roots.extend(self.shared_skills_dir().map(|root| ("shared".to_string(), root.to_path_buf()))); - roots.extend(self.agent_dirs.iter().cloned()); - let mut originals = BTreeMap::new(); - for (kind, root) in roots { - let path = root.join(id); - if path.join(SKILL_MANIFEST_FILE).is_file() { - originals.entry(path.canonicalize()?).or_insert((kind, path)); - } - } - let recorded = installed.original_dir.as_ref().and_then(|path| path.canonicalize().ok()); - let original = originals.iter() - .find(|(path, _)| Some(*path) == recorded.as_ref()) - .or_else(|| originals.iter().find(|(_, (kind, path))| - kind == "shared" && !is_managed_copy(path))) - .or_else(|| originals.iter().find(|(_, (kind, _))| kind == "codex")) - .or_else(|| originals.iter().find(|(_, (kind, _))| kind == "shared")); - let Some((original, (kind, displayed))) = original else { continue }; - let mut candidate = SkillDuplicate { - id: id.clone(), kind: "source".into(), - shared_path: displayed.to_string_lossy().into_owned(), - codex_path: source.to_string_lossy().into_owned(), - original_root_kind: kind.clone(), status: String::new(), detail: None, - }; - let comparison = (|| -> anyhow::Result> { - let metadata = std::fs::symlink_metadata(&source)?; - anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata) - && !source.join(MANAGED_COPY_MARKER).exists(), "受控源不是可归档的实体目录"); - self.ensure_duplicate_has_no_discovered_references(&source)?; - anyhow::ensure!(matches!(kind.as_str(), "codex" | "shared"), - "缺少 Codex 安装原件,请分别处理"); - anyhow::ensure!(installed.update_owner != "manager", - "此受控源负责当前更新,不能归档"); - if let Some(recorded) = &installed.original_dir { - anyhow::ensure!(recorded.canonicalize().ok().as_ref() == Some(original), - "当前安装记录指向其他原件,不能归档"); - } - anyhow::ensure!(!state.instance_targets.get(id).is_some_and(|target| - target.path.canonicalize().ok().as_ref() != Some(original)), - "当前关联指向其他原件,不能归档"); - anyhow::ensure!(state.instance_records.get(id).is_none_or(|records| records.is_empty()), - "受控源属于现有更新记录,不能归档"); - anyhow::ensure!(!state.update_backup_groups.values().any(|group| - group.published.iter().any(|published| published.path == source)), - "现有更新备份需要此受控源,不能归档"); - let source_identity = skill_path_identity(&source)?; - let original_identity = skill_path_identity(original)?; - let digest = skill_directory_digest(&source)?.context("受控源已不存在")?; - if skill_directory_digest(original)? != Some(digest) { - candidate.status = "different".into(); - return Ok(None); - } - let mut managed_copies = Vec::new(); - for (path, (_, displayed)) in &originals { - if path == original { continue; } - anyhow::ensure!(is_managed_copy(displayed), - "其他安装位置的归属不明确,不能归档:{}", displayed.display()); - anyhow::ensure!(skill_directory_digest(path)? == Some(digest), - "其他受控副本内容不同,不能归档:{}", displayed.display()); - managed_copies.push(SkillInstanceTarget { - path: displayed.clone(), identity: skill_path_identity(displayed)?, - }); - } - anyhow::ensure!(skill_path_identity(&source)? == source_identity - && skill_path_identity(original)? == original_identity, - "比较期间 Skill 路径已改变,请重试"); - candidate.status = "identical-source".into(); - Ok(Some(ManagedSourceSnapshot { - source_path: source.clone(), source_identity, - original_path: original.clone(), original_identity, managed_copies, digest, - installed: installed.clone(), - })) - })(); - match comparison { - Ok(Some(snapshot)) => { managed_sources.insert(id.clone(), snapshot); } - Ok(None) => {} - Err(error) => { - candidate.status = "unsupported".into(); - candidate.detail = Some(format!("{error:#}")); - } - } - candidates.push(candidate); - } - let preview_id = uuid::Uuid::new_v4().to_string(); - let mut previews = duplicate_previews(); - if previews.len() >= 32 { - let oldest = previews.keys().next().cloned().unwrap(); - previews.remove(&oldest); - } - previews.insert(preview_id.clone(), DuplicatePreviewSnapshot { - scope: self.coordination_scope(), candidates: snapshots, managed_sources, - }); - Ok(SkillDuplicatePreview { preview_id, candidates }) - } - - /// 将未被引用的受控源归档,并把唯一的实际安装位置登记为原件。 - pub fn archive_skill_managed_source(&self, preview_id: &str, id: &str) -> anyhow::Result { - validate_skill_id(id)?; - let preview = duplicate_previews().get(preview_id).cloned() - .context("重复 Skill 预览已失效,请重新预览")?; - let snapshot = preview.managed_sources.get(id).context("此受控源不在可归档的预览中")?; - let mut coordination = skills_coordination(); - anyhow::ensure!(coordination_scope_is_current(&preview.scope, &self.coordination_scope()) - && snapshot.source_path == self.source_dir.join(id), "Skill 管理范围已改变,请重新预览"); - let mut state = self.load_state_checked_unlocked()?; - anyhow::ensure!(state.installed.get(id) == Some(&snapshot.installed) - && state.instance_records.get(id).is_none_or(|records| records.is_empty()) - && !state.update_backup_groups.values().any(|group| - group.published.iter().any(|published| published.path == snapshot.source_path)) - && state.instance_targets.get(id).is_none_or(|target| - target.path.canonicalize().ok().as_ref() == Some(&snapshot.original_path) - && skill_path_identity(&target.path).ok() == Some(target.identity)), - "Skill 安装记录已改变,请重新预览"); - self.ensure_duplicate_has_no_discovered_references(&snapshot.source_path)?; - let mut roots = vec![self.linked_dir()]; - roots.extend(self.shared_skills_dir().map(Path::to_path_buf)); - roots.extend(self.agent_dirs.iter().map(|(_, root)| root.clone())); - let root_enabled = roots.iter().any(|root| - root.join(id).canonicalize().ok().as_ref() == Some(&snapshot.original_path)); - let originals = roots.into_iter().map(|root| root.join(id)) - .filter(|path| path.join(SKILL_MANIFEST_FILE).is_file()) - .map(|path| path.canonicalize()) - .collect::>>()?; - let mut expected = BTreeSet::from([snapshot.original_path.clone()]); - for copy in &snapshot.managed_copies { - anyhow::ensure!(is_managed_copy(©.path) - && skill_path_identity(©.path)? == copy.identity - && skill_directory_digest(©.path)? == Some(snapshot.digest), - "受控副本在预览后发生改变,请重新预览:{}", copy.path.display()); - expected.insert(copy.path.canonicalize()?); - } - let source_metadata = std::fs::symlink_metadata(&snapshot.source_path)?; - anyhow::ensure!(root_enabled && originals == expected - && source_metadata.is_dir() && !metadata_is_link(&source_metadata) - && !snapshot.source_path.join(MANAGED_COPY_MARKER).exists() - && skill_path_identity(&snapshot.source_path)? == snapshot.source_identity - && skill_path_identity(&snapshot.original_path)? == snapshot.original_identity - && skill_directory_digest(&snapshot.source_path)? == Some(snapshot.digest) - && skill_directory_digest(&snapshot.original_path)? == Some(snapshot.digest), - "Skill 内容或路径在预览后发生改变,请重新预览"); - let backup = self.backup_directory_checked(id, &snapshot.source_path, snapshot.source_identity)?; - let result = (|| -> anyhow::Result<()> { - #[cfg(test)] - AFTER_DUPLICATE_BACKUP.with(|hook| { - if let Some(hook) = hook.borrow_mut().take() { hook(); } - }); - anyhow::ensure!(skill_path_identity(&snapshot.original_path)? == snapshot.original_identity - && skill_directory_digest(&snapshot.original_path)? == Some(snapshot.digest) - && skill_directory_digest(&backup)? == Some(snapshot.digest), - "归档期间 Skill 内容或路径已改变,请重新预览"); - for copy in &snapshot.managed_copies { - anyhow::ensure!(is_managed_copy(©.path) - && skill_path_identity(©.path)? == copy.identity - && skill_directory_digest(©.path)? == Some(snapshot.digest), - "归档期间受控副本已改变:{}", copy.path.display()); - } - let mut installed = snapshot.installed.clone(); - installed.original_dir = Some(snapshot.original_path.clone()); - let target = SkillInstanceTarget { - path: snapshot.original_path.clone(), identity: snapshot.original_identity, - }; - state.installed.insert(id.to_string(), installed.clone()); - state.instance_targets.insert(id.to_string(), target.clone()); - state.instance_records.entry(id.to_string()).or_default().insert( - target.path.clone(), SkillInstanceRecord { - installed, target, managed_copies: snapshot.managed_copies.clone(), - }, - ); - state.backup_origins.insert(self.backup_id_for_path(&backup)?, SkillBackupOrigin { - path: snapshot.source_path.clone(), purpose: "managed-source".into(), - }); - self.save_state_unlocked(&state) - })(); - if let Err(error) = result { - return match restore_skill_backup(&backup, &snapshot.source_path, snapshot.source_identity) { - Ok(()) => Err(error), - Err(rollback) => Err(error.context(format!("受控源保留在 {}:{rollback:#}", backup.display()))), - }; - } - coordination.changed_nested_skill(self.coordination_scope(), id); - Ok(state) - } - - /// 仅归档预览后路径及全部内容仍未改变的受控副本。 - pub fn archive_skill_duplicate(&self, preview_id: &str, id: &str) -> anyhow::Result { - validate_skill_id(id)?; - let preview = duplicate_previews().get(preview_id).cloned() - .context("重复 Skill 预览已失效,请重新预览")?; - let snapshot = preview.candidates.get(id).context("此 Skill 不在可归档的预览中")?; - let mut coordination = skills_coordination(); - if !coordination_scope_is_current(&preview.scope, &self.coordination_scope()) - || snapshot.codex_path != self.linked_dir().join(id) - || self.shared_skills_dir().map(|root| root.join(id)).as_ref() != Some(&snapshot.shared_path) - { - anyhow::bail!("Skill 路径或管理范围已改变,请重新预览"); - } - let mut state = self.load_state_checked_unlocked()?; - self.ensure_duplicate_has_no_discovered_references(&snapshot.codex_path)?; - let native_config = self.read_native_skill_config()?; - let (shared_name, _) = read_skill_manifest(&snapshot.shared_path.join(SKILL_MANIFEST_FILE), id); - let (codex_name, _) = read_skill_manifest(&snapshot.codex_path.join(SKILL_MANIFEST_FILE), id); - let shared_name = native_config.name_for_matching(&snapshot.shared_path.join(SKILL_MANIFEST_FILE), &shared_name)?; - let codex_name = native_config.name_for_matching(&snapshot.codex_path.join(SKILL_MANIFEST_FILE), &codex_name)?; - anyhow::ensure!( - native_config.enabled(&snapshot.shared_path.join(SKILL_MANIFEST_FILE), &shared_name)? - || !native_config.enabled(&snapshot.codex_path.join(SKILL_MANIFEST_FILE), &codex_name)?, - "共享路径已停用,归档会改变 Codex 启用状态,请重新预览" - ); - if !state.installed.contains_key(id) || !self.source_dir.join(id).is_dir() - || !is_managed_copy(&snapshot.codex_path) - || skill_path_identity(&snapshot.codex_path)? != snapshot.codex_identity - || skill_path_identity(&snapshot.shared_path)? != snapshot.shared_identity - || snapshot.shared_path.canonicalize()? != snapshot.shared_target - || skill_path_identity(&snapshot.shared_target)? != snapshot.shared_target_identity - || snapshot.shared_target.join(MANAGED_COPY_MARKER).exists() - || skill_directory_digest(&snapshot.shared_target)? != Some(snapshot.digest) - || skill_directory_digest(&snapshot.codex_path)? != Some(snapshot.digest) - { - anyhow::bail!("Skill 内容或路径在预览后发生改变,请重新预览"); - } - let backup = self.backup_directory_checked(id, &snapshot.codex_path, snapshot.codex_identity)?; - let result = (|| -> anyhow::Result<()> { - #[cfg(test)] - AFTER_DUPLICATE_BACKUP.with(|hook| { - if let Some(hook) = hook.borrow_mut().take() { hook(); } - }); - anyhow::ensure!(skill_path_identity(&snapshot.shared_path)? == snapshot.shared_identity - && snapshot.shared_path.canonicalize()? == snapshot.shared_target - && skill_path_identity(&snapshot.shared_target)? == snapshot.shared_target_identity - && skill_directory_digest(&snapshot.shared_target)? == Some(snapshot.digest) - && skill_directory_digest(&backup)? == Some(snapshot.digest), - "归档期间 Skill 内容或路径已改变,请重新预览"); - let backup_id = self.backup_id_for_path(&backup)?; - state.backup_origins.insert(backup_id, SkillBackupOrigin { - path: snapshot.codex_path.clone(), purpose: "duplicate".into(), - }); - self.save_state_unlocked(&state) - })(); - if let Err(error) = result { - return match restore_skill_backup(&backup, &snapshot.codex_path, snapshot.codex_identity) { - Ok(()) => Err(error), - Err(rollback) => Err(error.context(format!("重复副本保留在 {}:{rollback:#}", backup.display()))), - }; - } - coordination.changed_nested_skill(self.coordination_scope(), id); - Ok(state) - } - fn resolve_external_backup(id: &str, state: &SkillsState) -> anyhow::Result<(PathBuf, String)> { let rest = id.strip_prefix("@external/").context("外部 Skill 备份标识无效")?; let (token, name) = rest.split_once('/').context("外部 Skill 备份标识无效")?; @@ -3588,55 +3186,6 @@ impl SkillsManager { Ok((self.backups_dir.join(id), id.to_string())) } - pub fn skill_file_times(&self, id: &str, root_kind: &str, backup_id: Option<&str>) -> anyhow::Result> { - validate_skill_id(id)?; - let root = if let Some(backup_id) = backup_id { - let (path, name) = self.resolve_backup(backup_id)?; - anyhow::ensure!(split_backup_id(&name).0 == id, "备份与 Skill 不匹配"); - let metadata = std::fs::symlink_metadata(&path)?; - anyhow::ensure!(metadata.is_dir() && !metadata_is_link(&metadata), "Skill 备份不是实体目录"); - path - } else { - let dir = match root_kind { - "codex" => self.linked_dir(), - "system" => self.linked_dir().join(BUNDLED_SKILLS_DIR), - "shared" => self.shared_skills_dir().context("未配置共享 Skill 目录")?.to_path_buf(), - "source" => self.source_dir.clone(), - agent => self.agent_dir(agent)?, - }; - let path = dir.join(id); - anyhow::ensure!(path.join(SKILL_MANIFEST_FILE).is_file(), "Skill 安装位置不存在"); - path - }; - fn collect(root: &Path, dir: &Path, files: &mut Vec) -> anyhow::Result<()> { - for entry in std::fs::read_dir(dir).with_context(|| format!("读取目录失败:{}", dir.display()))? { - let entry = entry?; - let path = entry.path(); - let metadata = std::fs::symlink_metadata(&path) - .with_context(|| format!("读取文件信息失败:{}", path.display()))?; - if metadata.is_dir() && !metadata_is_link(&metadata) { - collect(root, &path, files)?; - } else if metadata.is_file() || metadata_is_link(&metadata) { - let modified = metadata.modified()?; - let modified_at_ms = match modified.duration_since(std::time::UNIX_EPOCH) { - Ok(duration) => i64::try_from(duration.as_millis())?, - Err(error) => -i64::try_from(error.duration().as_millis())?, - }; - files.push(SkillFileTime { - path: path.strip_prefix(root)?.to_string_lossy().into_owned(), - modified_at_ms, - is_link: metadata_is_link(&metadata), - }); - } - } - Ok(()) - } - let mut files = Vec::new(); - collect(&root, &root, &mut files)?; - files.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(files) - } - pub fn list_backups(&self) -> anyhow::Result> { self.try_list_backups() } @@ -5403,8 +4952,6 @@ const MANAGED_COPY_MARKER: &str = ".codex-plus-managed"; #[cfg(test)] thread_local! { static DIGEST_HOOK: std::cell::RefCell>> = std::cell::RefCell::new(None); - static AFTER_DUPLICATE_BACKUP: std::cell::RefCell>> = - std::cell::RefCell::new(None); static FORCE_SKILL_COPY: std::cell::Cell = const { std::cell::Cell::new(false) }; } @@ -5905,74 +5452,6 @@ mod tests { use std::io::Write; use std::sync::Arc; - #[test] - fn skill_file_times_list_each_file_for_locations_and_backups() { - let temp = tempfile::tempdir().unwrap(); - let manager = manager(&temp).with_shared_skills_dir(temp.path().join("shared")); - let shared = temp.path().join("shared").join("alpha"); - let backup = temp.path().join("skill-backups").join("alpha-123"); - let second_backup = temp.path().join("skill-backups").join("alpha-456"); - let source = temp.path().join("skills").join("alpha"); - write_skill(&shared, "shared"); - write_skill(&backup, "backup"); - write_skill(&second_backup, "second backup"); - write_skill(&source, "source"); - let first_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); - let second_time = first_time + std::time::Duration::from_secs(60); - for (path, time) in [(&backup, first_time), (&second_backup, second_time)] { - std::fs::OpenOptions::new().write(true).open(path.join("SKILL.md")).unwrap() - .set_times(std::fs::FileTimes::new().set_modified(time)).unwrap(); - } - std::fs::create_dir_all(shared.join("scripts")).unwrap(); - std::fs::write(shared.join("scripts").join("run.js"), "run").unwrap(); - std::fs::write(backup.join("notes.txt"), "old").unwrap(); - - let shared_files = manager.skill_file_times("alpha", "shared", None).unwrap(); - assert_eq!(shared_files.iter().map(|file| file.path.as_str()).collect::>(), - vec!["SKILL.md", if cfg!(windows) { "scripts\\run.js" } else { "scripts/run.js" }]); - let actual_modified = std::fs::metadata(shared.join("SKILL.md")).unwrap().modified().unwrap() - .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64; - assert_eq!(shared_files[0].modified_at_ms, actual_modified); - let backup_files = manager.skill_file_times("alpha", "", Some("alpha-123")).unwrap(); - assert_eq!(backup_files.iter().map(|file| file.path.as_str()).collect::>(), vec!["SKILL.md", "notes.txt"]); - let second_files = manager.skill_file_times("alpha", "", Some("alpha-456")).unwrap(); - assert_eq!(second_files.iter().map(|file| file.path.as_str()).collect::>(), vec!["SKILL.md"]); - assert_eq!(backup_files[0].modified_at_ms, 1_700_000_000_000); - assert_eq!(second_files[0].modified_at_ms, 1_700_000_060_000); - assert_eq!(manager.skill_file_times("alpha", "source", None).unwrap()[0].path, "SKILL.md"); - assert!(shared_files.iter().all(|file| !file.is_link)); - assert!(manager.skill_file_times("other", "", Some("alpha-123")).is_err()); - assert!(manager.skill_file_times("../alpha", "shared", None).is_err()); - assert!(manager.skill_file_times("alpha", "unknown", None).is_err()); - } - - #[test] - fn skill_file_times_follow_root_links_but_mark_nested_links() { - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let target = temp.path().join("target"); - let nested_target = temp.path().join("nested-target"); - write_skill(&target, "shared"); - write_skill(&nested_target, "not traversed"); - std::fs::create_dir_all(&shared).unwrap(); - #[cfg(windows)] - for (link, destination) in [(shared.join("alpha"), &target), (target.join("nested"), &nested_target)] { - let output = std::process::Command::new("cmd") - .args(["/C", "mklink", "/J"]).arg(link).arg(destination).output().unwrap(); - assert!(output.status.success(), "could not create directory junction: {output:?}"); - } - #[cfg(unix)] - { - std::os::unix::fs::symlink(&target, shared.join("alpha")).unwrap(); - std::os::unix::fs::symlink(&nested_target, target.join("nested")).unwrap(); - } - let files = manager(&temp).with_shared_skills_dir(&shared) - .skill_file_times("alpha", "shared", None).unwrap(); - assert_eq!(files.iter().map(|file| file.path.as_str()).collect::>(), vec!["SKILL.md", "nested"]); - assert!(!files[0].is_link); - assert!(files[1].is_link); - } - #[test] fn disabled_managed_copy_is_reused_without_accumulating_backups() { let temp = tempfile::tempdir().unwrap(); @@ -6071,126 +5550,6 @@ mod tests { assert_eq!(skill_directory_digest(&source).unwrap(), skill_directory_digest(©).unwrap()); } - fn legacy_managed_source_fixture() -> (tempfile::TempDir, SkillsManager, PathBuf, PathBuf) { - let temp = tempfile::tempdir().unwrap(); - let manager = multi_agent_manager(&temp); - let source = manager.source_dir.join("alpha"); - let original = manager.linked_dir().join("alpha"); - write_skill(&original, "---\nname: alpha\n---\noriginal"); - copy_dir_all(&original, &source).unwrap(); - std::fs::write(original.join(MANAGED_COPY_MARKER), "").unwrap(); - let mut state = manager.load_state(); - state.installed.insert("alpha".into(), InstalledSkill { - id: "alpha".into(), name: "alpha".into(), description: String::new(), - repo_key: LOCAL_IMPORT_REPO_KEY.into(), repo_path: String::new(), content_hash: String::new(), - installed_at: String::new(), verified_cc_switch: false, install_channel: "local".into(), - original_dir: None, update_owner: String::new(), - }); - manager.save_state_unlocked(&state).unwrap(); - (temp, manager, source, original) - } - - #[test] - fn idle_managed_source_is_archived_and_reused_for_enabling() { - let (temp, manager, source, original) = legacy_managed_source_fixture(); - let original_identity = skill_path_identity(&original).unwrap(); - let source_identity = skill_path_identity(&source).unwrap(); - let existing_copy = temp.path().join("claude-skills").join("alpha"); - copy_dir_all(&original, &existing_copy).unwrap(); - std::fs::write(existing_copy.join(MANAGED_COPY_MARKER), "").unwrap(); - let copy_identity = skill_path_identity(&existing_copy).unwrap(); - let preview = manager.preview_skill_duplicates().unwrap(); - assert_eq!(preview.candidates.iter().find(|item| item.kind == "source").unwrap().status, "identical-source"); - manager.archive_skill_managed_source(&preview.preview_id, "alpha").unwrap(); - assert!(!source.exists()); - assert_eq!(skill_path_identity(&original).unwrap(), original_identity); - let backup = manager.try_list_backups().unwrap().pop().unwrap(); - assert_eq!(backup.purpose.as_deref(), Some("managed-source")); - assert_eq!(Path::new(backup.restore_path.as_deref().unwrap()), source); - let before = manager.load_state(); - FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); - assert!(manager.restore_backup(&backup.id).is_err()); - assert!(!source.exists()); - assert!(Path::new(&backup.backup_path).join(SKILL_MANIFEST_FILE).is_file()); - assert_eq!(manager.load_state(), before); - let inventory = manager.try_local_inventory().unwrap(); - let item = entry(&inventory, "alpha"); - assert!(item.locations.iter().any(|location| location.root_kind == "codex" - && location.selectable && location.original_path.as_ref() == Some(&original.canonicalize().unwrap()))); - - FORCE_SKILL_COPY.with(|flag| flag.set(true)); - let enabled = manager.set_enabled_for("grok", "alpha", true); - FORCE_SKILL_COPY.with(|flag| flag.set(false)); - enabled.unwrap(); - assert_eq!(skill_path_identity(&source).unwrap(), source_identity); - assert!(temp.path().join("grok-skills/alpha/SKILL.md").is_file()); - assert_eq!(skill_path_identity(&existing_copy).unwrap(), copy_identity); - assert!(manager.try_list_backups().unwrap().is_empty()); - let state = manager.load_state(); - let record = state.instance_records["alpha"].values().next().unwrap(); - assert!(record.managed_copies.iter().any(|copy| copy.path == source && copy.identity == source_identity)); - assert!(record.managed_copies.iter().any(|copy| copy.path == existing_copy && copy.identity == copy_identity)); - } - - #[test] - fn idle_managed_source_restore_rejects_occupied_path_and_rolls_back_state_errors() { - let (_temp, manager, source, original) = legacy_managed_source_fixture(); - let source_identity = skill_path_identity(&source).unwrap(); - let before = manager.load_state(); - let preview = manager.preview_skill_duplicates().unwrap(); - FAIL_SKILL_STATE_SAVE.with(|flag| flag.set(true)); - assert!(manager.archive_skill_managed_source(&preview.preview_id, "alpha").is_err()); - assert_eq!(skill_path_identity(&source).unwrap(), source_identity); - assert_eq!(manager.load_state(), before); - assert!(manager.try_list_backups().unwrap().is_empty()); - - let preview = manager.preview_skill_duplicates().unwrap(); - manager.archive_skill_managed_source(&preview.preview_id, "alpha").unwrap(); - let backup = manager.try_list_backups().unwrap().pop().unwrap(); - write_skill(&source, "new independent content"); - assert!(manager.restore_backup(&backup.id).is_err()); - assert_eq!(std::fs::read_to_string(source.join(SKILL_MANIFEST_FILE)).unwrap(), "new independent content"); - assert!(original.join(SKILL_MANIFEST_FILE).is_file()); - assert!(Path::new(&backup.backup_path).join(SKILL_MANIFEST_FILE).is_file()); - } - - #[test] - fn managed_source_archive_rechecks_complete_contents_after_preview() { - let (_temp, manager, source, original) = legacy_managed_source_fixture(); - let preview = manager.preview_skill_duplicates().unwrap(); - std::fs::write(original.join("new-file"), "independent edit").unwrap(); - assert!(manager.archive_skill_managed_source(&preview.preview_id, "alpha").is_err()); - assert!(source.join(SKILL_MANIFEST_FILE).is_file()); - let preview = manager.preview_skill_duplicates().unwrap(); - assert_eq!(preview.candidates.iter().find(|item| item.kind == "source").unwrap().status, "different"); - assert!(manager.try_list_backups().unwrap().is_empty()); - } - - #[cfg(any(windows, unix))] - #[test] - fn managed_source_archive_rejects_new_or_existing_agent_links() { - let (temp, manager, source, _original) = legacy_managed_source_fixture(); - let preview = manager.preview_skill_duplicates().unwrap(); - let root = temp.path().join("grok-skills"); - std::fs::create_dir_all(&root).unwrap(); - let link = root.join("alpha"); - #[cfg(windows)] - { - let output = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) - .arg(&link).arg(&source).output().unwrap(); - assert!(output.status.success()); - } - #[cfg(unix)] - std::os::unix::fs::symlink(&source, &link).unwrap(); - assert!(manager.archive_skill_managed_source(&preview.preview_id, "alpha").is_err()); - let preview = manager.preview_skill_duplicates().unwrap(); - let candidate = preview.candidates.iter().find(|item| item.kind == "source").unwrap(); - assert_eq!(candidate.status, "unsupported"); - assert!(candidate.detail.as_deref().unwrap().contains("引用")); - assert!(link.join(SKILL_MANIFEST_FILE).is_file()); - assert!(source.join(SKILL_MANIFEST_FILE).is_file()); - } - fn published_snapshot(path: &Path) -> SkillPublishedSnapshot { SkillPublishedSnapshot { path: path.to_path_buf(), @@ -6393,209 +5752,56 @@ mod tests { } #[test] - fn shared_duplicate_preview_archives_and_restores_only_the_managed_copy() { - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = manager(&temp); - let external = shared.join("alpha"); - write_skill(&external, "---\nname: alpha\n---\noriginal"); - manager.import_from_local_directory("alpha", &external).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - let copy = manager.linked_dir().join("alpha"); - copy_dir_all(&external, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - let preview = manager.preview_skill_duplicates().unwrap(); - assert_eq!(preview.candidates[0].status, "identical-managed"); - manager.archive_skill_duplicate(&preview.preview_id, "alpha").unwrap(); - assert!(!copy.exists()); - assert!(external.join("SKILL.md").is_file()); - let backup = manager.try_list_backups().unwrap().into_iter() - .find(|item| item.purpose.as_deref() == Some("duplicate")).unwrap(); - assert_eq!(backup.restore_path.as_deref(), Some(copy.to_str().unwrap())); - assert!(manager.load_state().installed.contains_key("alpha")); - manager.restore_backup(&backup.id).unwrap(); - assert!(copy.join(MANAGED_COPY_MARKER).is_file()); - assert!(external.join("SKILL.md").is_file()); - } - - #[test] - fn restoring_duplicate_preserves_shared_enabled_state_and_rolls_back_on_save_failure() { - for enabled in [false, true] { + fn legacy_duplicate_and_managed_source_backups_restore_without_overwriting() { + for purpose in ["duplicate", "managed-source"] { let temp = tempfile::tempdir().unwrap(); let shared = temp.path().join("shared"); - let manager = manager(&temp); + let manager = manager(&temp).with_shared_skills_dir(&shared); let original = shared.join("alpha"); - let copy = manager.linked_dir().join("alpha"); write_skill(&original, "---\nname: alpha\n---\noriginal"); - manager.import_from_local_directory("alpha", &original).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - copy_dir_all(&original, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - manager.set_enabled("alpha", !enabled).unwrap(); - let preview = manager.preview_skill_duplicates().unwrap(); - manager.archive_skill_duplicate(&preview.preview_id, "alpha").unwrap(); - manager.set_enabled("alpha", enabled).unwrap(); - let backup = manager.try_list_backups().unwrap().into_iter() - .find(|item| item.purpose.as_deref() == Some("duplicate")).unwrap(); - let before = std::fs::read(manager.codex_home.join("config.toml")).unwrap(); + let target = if purpose == "duplicate" { + manager.linked_dir().join("alpha") + } else { + manager.source_dir.join("alpha") + }; + write_skill(&target, "---\nname: alpha\n---\nbackup"); + if purpose == "duplicate" { + std::fs::write(target.join(MANAGED_COPY_MARKER), "").unwrap(); + manager.set_native_skill_paths( + &[(original.join(SKILL_MANIFEST_FILE), "alpha".into())], false, false, + ).unwrap(); + } + let identity = skill_path_identity(&target).unwrap(); + let backup = manager.backup_directory_checked("alpha", &target, identity).unwrap(); + let backup_id = manager.backup_id_for_path(&backup).unwrap(); + let mut state = manager.load_state(); + state.backup_origins.insert(backup_id.clone(), SkillBackupOrigin { + path: target.clone(), purpose: purpose.into(), + }); + manager.save_state_unlocked(&state).unwrap(); FAIL_SKILL_STATE_SAVE.with(|fail| fail.set(true)); - assert!(manager.restore_backup(&backup.id).is_err()); - assert!(!copy.exists()); - assert_eq!(std::fs::read(manager.codex_home.join("config.toml")).unwrap(), before); - assert!(manager.try_list_backups().unwrap().iter().any(|item| item.id == backup.id)); - - manager.restore_backup(&backup.id).unwrap(); - let config = manager.read_native_skill_config().unwrap(); - assert_eq!(config.enabled(&original.join(SKILL_MANIFEST_FILE), "alpha").unwrap(), enabled); - assert_eq!(config.enabled(©.join(SKILL_MANIFEST_FILE), "alpha").unwrap(), enabled); - } - } - - #[test] - fn duplicate_archive_rejects_changes_after_preview_and_preserves_differences() { - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = manager(&temp); - let external = shared.join("alpha"); - write_skill(&external, "---\nname: alpha\n---\noriginal"); - manager.import_from_local_directory("alpha", &external).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - let copy = manager.linked_dir().join("alpha"); - copy_dir_all(&external, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - let preview = manager.preview_skill_duplicates().unwrap(); - std::fs::write(copy.join(SKILL_MANIFEST_FILE), "modified").unwrap(); - assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); - assert_eq!(manager.preview_skill_duplicates().unwrap().candidates[0].status, "different"); - assert!(external.join(SKILL_MANIFEST_FILE).is_file()); - assert_eq!(std::fs::read_to_string(copy.join(SKILL_MANIFEST_FILE)).unwrap(), "modified"); - } - - #[cfg(any(unix, windows))] - #[test] - fn duplicate_archive_rejects_agent_link_created_before_or_after_preview() { - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = multi_agent_manager(&temp); - let original = shared.join("alpha"); - let copy = manager.linked_dir().join("alpha"); - let agent_root = temp.path().join("claude-skills"); - write_skill(&original, "---\nname: alpha\n---\noriginal"); - write_skill(&original.join("nested"), "---\nname: nested\n---\n"); - manager.import_from_local_directory("alpha", &original).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - copy_dir_all(&original, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - std::fs::create_dir_all(&agent_root).unwrap(); - for (name, target) in [ - ("alpha", copy.clone()), - ("other", copy.clone()), - ("alpha", copy.join("nested")), - ("other", copy.join("nested")), - ] { - let agent = agent_root.join(name); - let before_link = manager.preview_skill_duplicates().unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink(&target, &agent).unwrap(); - #[cfg(windows)] - { - let result = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]) - .arg(&agent).arg(&target).output().unwrap(); - assert!(result.status.success(), "could not create directory junction: {result:?}"); + assert!(manager.restore_backup(&backup_id).is_err()); + assert!(!target.exists()); + assert!(backup.join(SKILL_MANIFEST_FILE).is_file()); + assert_eq!(manager.load_state(), state); + + write_skill(&target, "independent"); + assert!(manager.restore_backup(&backup_id).is_err()); + assert_eq!(std::fs::read_to_string(target.join(SKILL_MANIFEST_FILE)).unwrap(), "independent"); + std::fs::remove_file(target.join(SKILL_MANIFEST_FILE)).unwrap(); + std::fs::remove_dir(&target).unwrap(); + + manager.restore_backup(&backup_id).unwrap(); + assert_eq!(skill_path_identity(&target).unwrap(), identity); + assert!(manager.try_list_backups().unwrap().is_empty()); + if purpose == "duplicate" { + let config = manager.read_native_skill_config().unwrap(); + assert!(!config.enabled(&target.join(SKILL_MANIFEST_FILE), "alpha").unwrap()); } - assert!(manager.archive_skill_duplicate(&before_link.preview_id, "alpha").is_err()); - let after_link = manager.preview_skill_duplicates().unwrap(); - let candidate = after_link.candidates.iter().find(|candidate| candidate.id == "alpha").unwrap(); - assert_eq!(candidate.status, "unsupported"); - assert!(candidate.detail.as_deref().unwrap().contains("引用")); - assert!(agent.join(SKILL_MANIFEST_FILE).is_file()); - assert!(copy.join(SKILL_MANIFEST_FILE).is_file()); - #[cfg(windows)] - std::fs::remove_dir(&agent).unwrap(); - #[cfg(unix)] - std::fs::remove_file(&agent).unwrap(); - } - } - - #[cfg(unix)] - #[test] - fn duplicate_archive_preserves_different_file_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = manager(&temp); - let original = shared.join("alpha"); - let copy = manager.linked_dir().join("alpha"); - write_skill(&original, "---\nname: alpha\n---\noriginal"); - std::fs::write(original.join("run.sh"), "#!/bin/sh\n").unwrap(); - manager.import_from_local_directory("alpha", &original).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - copy_dir_all(&original, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - std::fs::set_permissions(copy.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap(); - std::fs::set_permissions(original.join("run.sh"), std::fs::Permissions::from_mode(0o644)).unwrap(); - - let preview = manager.preview_skill_duplicates().unwrap(); - - assert_eq!(preview.candidates[0].status, "different"); - assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); - } - - #[test] - fn duplicate_archive_rolls_back_content_changes_during_move() { - for mutate_shared in [false, true] { - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = manager(&temp); - let original = shared.join("alpha"); - let copy = manager.linked_dir().join("alpha"); - write_skill(&original, "---\nname: alpha\n---\noriginal"); - manager.import_from_local_directory("alpha", &original).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - copy_dir_all(&original, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - let preview = manager.preview_skill_duplicates().unwrap(); - let before = manager.load_state(); - let backups = manager.backups_dir.clone(); - let shared_manifest = original.join(SKILL_MANIFEST_FILE); - AFTER_DUPLICATE_BACKUP.with(|hook| *hook.borrow_mut() = Some(Box::new(move || { - let path = if mutate_shared { - shared_manifest - } else { - std::fs::read_dir(backups).unwrap().map(|entry| entry.unwrap().path()) - .find(|path| path.is_dir()).unwrap().join(SKILL_MANIFEST_FILE) - }; - std::fs::write(path, "changed during archive").unwrap(); - }))); - assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); - assert_eq!(manager.load_state(), before); - assert!(copy.join(MANAGED_COPY_MARKER).is_file()); - let changed = if mutate_shared { &original } else { © }; - assert_eq!(std::fs::read_to_string(changed.join(SKILL_MANIFEST_FILE)).unwrap(), "changed during archive"); } } - #[test] - fn duplicate_archive_preserves_native_enabled_state() { - let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = manager(&temp); - let original = shared.join("alpha"); - let copy = manager.linked_dir().join("alpha"); - write_skill(&original, "---\nname: alpha\n---\noriginal"); - manager.import_from_local_directory("alpha", &original).unwrap(); - let manager = manager.with_shared_skills_dir(&shared); - copy_dir_all(&original, ©).unwrap(); - std::fs::write(copy.join(MANAGED_COPY_MARKER), "").unwrap(); - let preview = manager.preview_skill_duplicates().unwrap(); - manager.set_native_skill_paths(&[(original.join(SKILL_MANIFEST_FILE), "alpha".into())], false, false).unwrap(); - assert!(manager.archive_skill_duplicate(&preview.preview_id, "alpha").is_err()); - assert_eq!(manager.preview_skill_duplicates().unwrap().candidates[0].status, "enabled-only-copy"); - assert!(copy.join(SKILL_MANIFEST_FILE).is_file()); - } - #[test] fn restoring_an_in_place_update_keeps_the_current_version_as_a_backup() { let temp = tempfile::tempdir().unwrap(); @@ -7410,8 +6616,6 @@ mod tests { let updated = repo_zip(&[("kit-main/alpha/SKILL.md", "---\nname: alpha\n---\nupdated")]); manager.install_from_zip(&skill, &updated).unwrap(); assert!(std::fs::read_to_string(linked.join("alpha/SKILL.md")).unwrap().ends_with("updated")); - assert!(manager.preview_skill_duplicates().unwrap().candidates.iter() - .all(|candidate| candidate.kind != "shared")); manager.uninstall("alpha").unwrap(); assert!(!manager.source_dir.join("alpha").exists()); assert!(!linked.join("alpha").exists()); diff --git a/crates/codex-plus-core/src/skills/in_place_update.rs b/crates/codex-plus-core/src/skills/in_place_update.rs index acf8e97560..82f4288bbd 100644 --- a/crates/codex-plus-core/src/skills/in_place_update.rs +++ b/crates/codex-plus-core/src/skills/in_place_update.rs @@ -906,49 +906,6 @@ mod tests { } } - #[test] - fn archived_source_preserves_repository_association_and_updates_the_codex_original() { - let (temp, manager, repo, remote, zip) = fixture(); - let shared = temp.path().join("shared/alpha"); - let codex = manager.linked_dir().join("alpha"); - std::fs::create_dir_all(manager.linked_dir()).unwrap(); - std::fs::rename(&shared, &codex).unwrap(); - std::fs::write(codex.join(MANAGED_COPY_MARKER), "").unwrap(); - std::fs::rename(temp.path().join("agent/alpha"), temp.path().join("held-agent")).unwrap(); - let mut state = manager.load_state(); - state.installed.get_mut("alpha").unwrap().original_dir = None; - manager.save_state_unlocked(&state).unwrap(); - - let duplicates = manager.preview_skill_duplicates().unwrap(); - assert_eq!(duplicates.candidates.iter().find(|item| item.kind == "source").unwrap().status, "identical-source"); - manager.archive_skill_managed_source(&duplicates.preview_id, "alpha").unwrap(); - let original = codex.canonicalize().unwrap(); - let unselected = manager.preview_repository_matches(&repo, &[remote.clone()], Some("alpha")).unwrap(); - assert!(unselected.matches.iter().any(|item| item.conflict.is_none())); - let matches = manager.preview_repository_matches_for_instance( - &repo, &[remote.clone()], "alpha", &original, - ).unwrap(); - let selected = matches.matches.iter().find(|item| item.repo_path == remote.repo_path).unwrap(); - let selection = SkillRepositoryMatchSelection { - preview_id: matches.preview_id.clone(), local_id: "alpha".into(), - repo_key: repo.key(), repo_path: selected.repo_path.clone(), - local_path: Some(original.clone()), manual: false, - }; - let result = manager.apply_repository_matches(&[selection], &[remote.clone()]).unwrap(); - assert!(result.errors.is_empty(), "{:?}", result.errors); - let preview = manager.preview_skill_in_place_update_for_instance( - "alpha", &original, remote.clone(), zip, - ).unwrap(); - assert!(preview.conflicts.is_empty(), "{:?}", preview.conflicts); - let state = manager.execute_skill_in_place_update(&preview.preview_id, &remote).unwrap(); - assert!(codex.join(MANAGED_COPY_MARKER).is_file()); - assert_eq!(std::fs::read_to_string(codex.join("details.txt")).unwrap(), "new details"); - let group = state.update_backup_groups.values().next().unwrap(); - manager.restore_backup(&group.backup_ids[0]).unwrap(); - assert!(codex.join(MANAGED_COPY_MARKER).is_file()); - assert_eq!(std::fs::read_to_string(codex.join("details.txt")).unwrap(), "old details"); - } - #[test] fn legacy_single_instance_source_remains_available_at_its_location() { let (temp, manager, repo, remote, zip) = fixture();