diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 63b8abd3e..3591dd40c 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, @@ -7606,6 +7607,75 @@ pub async fn discover_skill_source_evidence(id: 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 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(); + 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 +7898,43 @@ 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, + missing_only: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let manager = default_skills_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), + ), + } + }).await.map_err(|error| format!("删除备份失败:{error}")) } #[tauri::command] @@ -8080,13 +8166,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 711e3a86b..ed1d362bc 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; @@ -591,6 +592,9 @@ pub fn run() { commands::find_skill_repository_matches, commands::apply_skill_repository_matches, commands::discover_skill_source_evidence, + 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, commands::install_skill, @@ -600,6 +604,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 000000000..32c996373 --- /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 601ac674b..5b028d9ef 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,6 +20,8 @@ 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 { SkillInPlaceUpdateDrawer } from "./SkillInPlaceUpdateDrawer"; +import { SkillBackupUsage } from "./SkillBackupUsage"; import { TopbarActions } from "./TopbarActions"; type Result = { status: string; message: string }; @@ -33,10 +35,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; 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 }> }; const SKILL_GROUP_STORAGE_KEY = "codex-plus-plus.skill-groups.v1"; @@ -68,6 +72,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 +210,97 @@ 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.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 备份。")}

} +
; +} +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; }) { 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("未启用")} + {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 +354,8 @@ 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 [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 +435,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 +711,20 @@ 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}` : ""; + 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); const knownSkills = useMemo(() => data?.skills || [], [data]); @@ -798,6 +881,7 @@ export function SkillsPage({ notify }: { notify: Notify }) { void importLocalSkills()} disabled={busy}>{t("从本地目录导入")} void run("import_ccs_skills", {})} disabled={busy}>{t("从 cc-switch 导入")} + setBackupsOpen(true)} disabled={busy}>{t("查看 Skill 备份")} } /> @@ -828,7 +912,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 +953,20 @@ 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 }); }}/>} + 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}/>} + {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 974fc23ac..5e5246287 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 页共用;这里只保留仓库抽屉自己的分隔线和强调色。 */ diff --git a/apps/codex-plus-manager/src/SkillBackupUsage.tsx b/apps/codex-plus-manager/src/SkillBackupUsage.tsx new file mode 100644 index 000000000..0d64384a6 --- /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 new file mode 100644 index 000000000..d10ebcf38 --- /dev/null +++ b/apps/codex-plus-manager/src/SkillInPlaceUpdateDrawer.tsx @@ -0,0 +1,66 @@ +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; + 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 (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; if (previewId) discard(previewId); }; + }, [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.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}
  • )}
} + {preview.conflicts.length > 0 &&
{t("存在冲突,无法更新")}
    {preview.conflicts.map((conflict) =>
  • {conflict}
  • )}
} + } +
; +} diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index e6917e055..40c246771 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 000000000..404e0e218 --- /dev/null +++ b/apps/codex-plus-manager/src/skill-backup-usage.test.ts @@ -0,0 +1,188 @@ +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", 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")); +}); 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 000000000..cbb1ffab2 --- /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/apps/codex-plus-manager/src/skill-location-association.test.ts b/apps/codex-plus-manager/src/skill-location-association.test.ts index 872e10d81..10d9b90de 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: () => "本地安装", 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 7e2896bca..994309bd5 100644 --- a/crates/codex-plus-core/src/skills.rs +++ b/crates/codex-plus-core/src/skills.rs @@ -24,8 +24,12 @@ use serde_json::Value; 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; @@ -119,6 +123,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 +148,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 +326,12 @@ pub struct SkillBackup { pub skill_id: String, 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, + pub delete_warning: Option, } /// 下载前的状态快照。公开字段供调用方定位仓库和原安装路径,代次只在本进程有效。 @@ -788,6 +831,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) { @@ -865,7 +919,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)) } /// 路径不完整时返回全局范围;任意受管路径相交都不能互相隔离。 @@ -969,18 +1023,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(()) } @@ -1000,17 +1064,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(()) })(); @@ -1018,6 +1116,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 的真实目录,纳入受控源。 /// /// 只在各安装位置内容一致时复制受控源,原安装位置保持不动。 @@ -1039,6 +1189,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 受控源已不存在")?; @@ -1071,7 +1271,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)) @@ -1128,6 +1328,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(), @@ -2089,7 +2293,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( @@ -2174,6 +2378,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(); @@ -2230,6 +2435,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; @@ -2556,7 +2778,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); @@ -2605,7 +2827,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)) } @@ -2692,7 +2914,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(()); } @@ -2710,10 +2932,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), @@ -2835,10 +3090,91 @@ 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(()) + } + + 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 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()) { if let Some(name) = id.strip_prefix(&prefix) { @@ -2850,37 +3186,402 @@ impl SkillsManager { Ok((self.backups_dir.join(id), id.to_string())) } - pub fn list_backups(&self) -> Vec { + 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.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) + }; 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(), + missing: false, + 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, path, name, missing) in external { + let (skill_id, backed_up_at) = split_backup_id(&name); + 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, + }); + } 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()?; + 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::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) @@ -2894,7 +3595,7 @@ impl SkillsManager { 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) { + if let Err(error) = copy_dir_all_inner(&backup, &staging, true, true) { return Err(retain_staging_context(error, &staging)); } let (name, description) = @@ -2909,12 +3610,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))?; @@ -2927,13 +3632,71 @@ 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 (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 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(); + 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; + } + if state_changed { + if let Err(error) = self.save_state_unlocked(&state) { + 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); + 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()); + } } - Ok(self.list_backups()) + self.try_list_backups() } fn staging_dir(&self, id: &str) -> PathBuf { @@ -4378,6 +5141,11 @@ 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) }; + 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<()> { @@ -4540,6 +5308,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 @@ -4547,13 +5319,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()))?; } @@ -4677,38 +5452,959 @@ mod tests { use std::io::Write; use std::sync::Arc; - fn manager(temp: &tempfile::TempDir) -> SkillsManager { - SkillsManager::new( - temp.path().join("skills"), - temp.path().join("skill-backups"), - temp.path().join("skills.json"), - temp.path().join("codex-home"), - ) - } - - fn multi_agent_manager(temp: &tempfile::TempDir) -> SkillsManager { - manager(temp).with_agent_dirs(vec![ - ("claude".into(), temp.path().join("claude-skills")), - ("grok".into(), temp.path().join("grok-skills")), - ]) - } - - fn write_skill(dir: &Path, content: &str) { - std::fs::create_dir_all(dir).unwrap(); - std::fs::write(dir.join("SKILL.md"), content).unwrap(); + #[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()); } - fn entry<'a>(entries: &'a [SkillEntry], id: &str) -> &'a SkillEntry { - entries.iter().find(|item| item.id == id).unwrap() + #[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 shared_skill_is_discovered_and_toggled_without_creating_a_copy() { + fn disabled_copy_move_rolls_back_if_state_save_fails() { let temp = tempfile::tempdir().unwrap(); - let shared = temp.path().join("shared"); - let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); - write_skill(&shared.join("alpha"), "---\nname: alpha\n---\noriginal"); - write_skill(&temp.path().join("claude-skills/alpha"), "---\nname: alpha\n---\nanother agent"); + 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 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 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).with_shared_skills_dir(&shared); + let original = shared.join("alpha"); + write_skill(&original, "---\nname: alpha\n---\noriginal"); + 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!(!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()); + } + } + } + + #[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() { + 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| { + 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"); + } + } + + #[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() { + 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"), + temp.path().join("skill-backups"), + temp.path().join("skills.json"), + temp.path().join("codex-home"), + ) + } + + fn multi_agent_manager(temp: &tempfile::TempDir) -> SkillsManager { + manager(temp).with_agent_dirs(vec![ + ("claude".into(), temp.path().join("claude-skills")), + ("grok".into(), temp.path().join("grok-skills")), + ]) + } + + fn write_skill(dir: &Path, content: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("SKILL.md"), content).unwrap(); + } + + fn entry<'a>(entries: &'a [SkillEntry], id: &str) -> &'a SkillEntry { + entries.iter().find(|item| item.id == id).unwrap() + } + + #[test] + fn shared_skill_is_discovered_and_toggled_without_creating_a_copy() { + let temp = tempfile::tempdir().unwrap(); + let shared = temp.path().join("shared"); + let manager = multi_agent_manager(&temp).with_shared_skills_dir(&shared); + write_skill(&shared.join("alpha"), "---\nname: alpha\n---\noriginal"); + write_skill(&temp.path().join("claude-skills/alpha"), "---\nname: alpha\n---\nanother agent"); let entries = manager.try_local_inventory().unwrap(); let found = entries.iter().filter(|item| item.id == "alpha").collect::>(); @@ -5024,7 +6720,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()); @@ -5122,7 +6818,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] @@ -5275,12 +6971,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(manager.source_dir.join("alpha/SKILL.md")).unwrap(), "agent edit"); - assert!(!manager.list_backups().iter().any(|item| item.id == backup.id)); + 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().unwrap().iter().any(|item| item.id == backup.id)); } #[test] @@ -5293,7 +6990,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()); } @@ -5353,7 +7050,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"); } @@ -5393,7 +7090,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("检查副本期间")); } @@ -5429,7 +7126,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")); @@ -5466,7 +7163,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()); } @@ -5486,7 +7183,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] @@ -5597,7 +7296,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()); } @@ -6250,7 +7949,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] @@ -6295,7 +7994,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")); @@ -6345,7 +8044,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")); @@ -6502,7 +8201,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()); @@ -6530,7 +8229,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()); @@ -8680,7 +10379,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(); @@ -8696,7 +10395,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()); } @@ -8733,7 +10432,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"); @@ -8746,7 +10445,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] @@ -8871,13 +10570,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/archive.rs b/crates/codex-plus-core/src/skills/archive.rs index 367c75e55..24e5c19bd 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/backup_usage.rs b/crates/codex-plus-core/src/skills/backup_usage.rs new file mode 100644 index 000000000..2004f591e --- /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 new file mode 100644 index 000000000..82f4288bb --- /dev/null +++ b/crates/codex-plus-core/src/skills/in_place_update.rs @@ -0,0 +1,1259 @@ +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(); + 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()) + } + + pub fn discard_in_place_update_preview(preview_id: &str) { + update_previews().remove(preview_id); + } + + /// 只执行后端保存的预览,仓库 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<(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(cleanup(error, &staged)); + } + 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].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].0.join(MANAGED_COPY_MARKER), "")?; + } + for (index, path) in update.paths.iter().enumerate().skip(1) { + copy_dir_all(&staged[0].0, &staged[index].0)?; + if path.marked { + std::fs::write(staged[index].0.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, 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, + "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| cleanup(error, &staged)) + } +} + +#[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) + } + + #[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(); + } + + #[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 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 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(); + 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"); + } +} diff --git a/crates/codex-plus-core/tests/all/skills_lifecycle.rs b/crates/codex-plus-core/tests/all/skills_lifecycle.rs index 1bb9c03e6..9d77a1304 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()); }