feat(pi): add native catalog and gateway data plane

This commit is contained in:
SaladDay
2026-08-02 22:52:26 +00:00
parent 26a95aeb05
commit 7609ff799e
70 changed files with 10738 additions and 367 deletions
+412 -50
View File
@@ -565,6 +565,9 @@ impl SkillService {
return Ok(custom.join("skills"));
}
}
AppType::Pi => {
return Ok(crate::pi_config::native::get_pi_agent_dir()?.join("skills"));
}
}
// 默认路径:回退到用户主目录下的标准位置。
@@ -581,6 +584,7 @@ impl SkillService {
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),
AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?.join("skills"),
})
}
@@ -637,8 +641,19 @@ impl SkillService {
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
let mut updated = existing.clone();
updated.apps.set_enabled_for(current_app, true);
if matches!(current_app, AppType::Pi) {
let guard = crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
&guard,
db,
&mut updated,
true,
)
.map_err(|error| anyhow!(error.to_string()))?;
return Ok(updated);
}
db.save_skill(&updated)?;
Self::sync_to_app_dir(&updated.directory, current_app)?;
Self::sync_installed_skill_to_app(db, &updated, current_app)?;
log::info!(
"Skill {} 已存在,更新 {:?} 启用状态",
updated.name,
@@ -671,6 +686,7 @@ impl SkillService {
}
let dest = ssot_dir.join(&install_name);
let destination_preexisted = dest.exists();
let mut repo_branch = skill.repo_branch.clone();
@@ -788,11 +804,39 @@ impl SkillService {
updated_at: 0,
};
// 保存到数据库
db.save_skill(&installed_skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
let installed_skill = if matches!(current_app, AppType::Pi) {
let guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
let mut persisted = installed_skill.clone();
// Let the deployment coordinator commit the desired bit and ledger
// evidence together. Until then the row is deliberately disabled.
persisted.apps.pi = false;
if let Err(error) = db.save_skill(&persisted) {
if !destination_preexisted {
let _ = fs::remove_dir_all(&dest);
}
return Err(error.into());
}
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard(
&guard,
db,
&mut persisted,
true,
)
{
let _ = db.delete_skill(&persisted.id);
if !destination_preexisted {
let _ = fs::remove_dir_all(&dest);
}
return Err(anyhow!(error.to_string()));
}
persisted
} else {
db.save_skill(&installed_skill)?;
Self::sync_installed_skill_to_app(db, &installed_skill, current_app)?;
installed_skill
};
log::info!(
"Skill {} 安装成功,已启用 {:?}",
@@ -810,6 +854,8 @@ impl SkillService {
/// 2. 从 SSOT 删除
/// 3. 从数据库删除
pub fn uninstall(db: &Arc<Database>, id: &str) -> Result<SkillUninstallResult> {
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 获取 skill 信息
let skill = db
.get_installed_skill(id)?
@@ -828,8 +874,15 @@ impl SkillService {
let backup_path = Self::create_uninstall_backup(&skill)?
.map(|path| path.to_string_lossy().to_string());
crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard(
&deployment_guard,
db,
&skill,
)
.map_err(|error| anyhow!(error.to_string()))?;
// 从所有应用目录删除
for app in AppType::all() {
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
let _ = Self::remove_from_app(&directory, &app);
}
@@ -1113,15 +1166,40 @@ impl SkillService {
))
})?;
// All Pi deployment mutations, SSOT replacement, and ledger
// reconciliation share one process boundary. Downloading remains
// outside the lock so a slow network cannot block toggles.
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 备份旧文件
let _ = Self::create_uninstall_backup(&skill);
// 删除旧 SSOT 目录并复制新文件
// Stage the exact old SSOT tree in the same directory. Reconstructing
// it from the remote source is not a rollback: local files may differ.
let dest = ssot_dir.join(&skill.directory);
if dest.exists() {
fs::remove_dir_all(&dest)?;
let staged_previous = if fs::symlink_metadata(&dest).is_ok() {
let staged = ssot_dir.join(format!(
".{}.cc-switch-update-{}",
skill.directory,
uuid::Uuid::new_v4().simple()
));
fs::rename(&dest, &staged)?;
Some(staged)
} else {
None
};
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).with_context(|| {
format!(
"Skill update copy failed ({error}); restoring {} also failed",
dest.display()
)
})?;
}
return Err(error);
}
Self::copy_dir_recursive(&source, &dest)?;
// 计算新哈希 + 解析新元数据
let new_hash = Self::compute_dir_hash(&dest).ok();
@@ -1151,14 +1229,71 @@ impl SkillService {
updated_at: chrono::Utc::now().timestamp(),
};
db.save_skill(&updated_skill)?;
if let Err(error) = db.save_skill(&updated_skill) {
let _ = Self::remove_path(&dest);
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).with_context(|| {
format!(
"Skill metadata update failed ({error}); restoring {} also failed",
dest.display()
)
})?;
}
return Err(error.into());
}
// 同步到所有已启用的应用目录
for app in updated_skill.apps.enabled_apps() {
if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) {
// Pi is the consistency-critical consumer: update its owned
// deployment before best-effort legacy app copies.
if updated_skill.apps.pi {
if let Err(error) =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill_under_guard(
&deployment_guard,
db,
&updated_skill,
)
{
let db_rollback = db.save_skill(&skill);
let file_rollback = Self::remove_path(&dest).and_then(|_| {
if let Some(staged) = staged_previous.as_deref() {
fs::rename(staged, &dest).map_err(anyhow::Error::from)
} else {
Ok(())
}
});
return match (db_rollback, file_rollback) {
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
(db_result, file_result) => Err(anyhow!(
"Pi Skill update failed ({error}); DB rollback: {}; file rollback: {}",
db_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
file_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string())
)),
};
}
}
// 同步到所有已启用的其他应用目录
for app in updated_skill
.apps
.enabled_apps()
.into_iter()
.filter(|app| !matches!(app, AppType::Pi))
{
if let Err(e) = Self::sync_installed_skill_to_app(db, &updated_skill, &app) {
log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app);
}
}
if let Some(staged) = staged_previous {
if let Err(error) = Self::remove_path(&staged) {
log::warn!(
"Failed to remove committed Skill update rollback staging '{}': {error}",
staged.display()
);
}
}
log::info!("Skill {} 更新成功", updated_skill.name);
Ok(updated_skill)
@@ -1201,7 +1336,10 @@ impl SkillService {
/// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件)
///
/// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。
/// Safety strategy: copy first while the old SSOT remains live, switch the
/// setting, reconcile every app, then delete the old trees. Keeping both
/// roots during reconciliation lets the Pi ownership ledger verify its old
/// symlink/copy before atomically replacing it.
pub fn migrate_storage(
db: &Arc<Database>,
target: SkillStorageLocation,
@@ -1215,6 +1353,9 @@ impl SkillService {
});
}
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
// 1. 解析旧目录和新目录(不改设置)
let old_dir = Self::get_ssot_dir()?;
let new_dir = match target {
@@ -1225,18 +1366,18 @@ impl SkillService {
};
fs::create_dir_all(&new_dir)?;
// 2. 逐个移动 skill 目录
// 2. Copy every valid tree. Do not rename/delete the old root before
// Pi has verified the ownership identity recorded in its ledger.
let skills = db.get_all_installed_skills()?;
let mut result = MigrationResult {
migrated_count: 0,
skipped_count: 0,
errors: vec![],
};
let mut copied = Vec::<(PathBuf, PathBuf)>::new();
for skill in skills.values() {
// 下面是 rename 与 remove_dir_all,脏 directory 可把任意目录搬走或删掉。
// 软失败:本函数已有 errors 收集通道,记一条继续处理其余 skill,
// 不要整体中断——用户只是在切换存储位置。
// Invalid DB rows are reported but never joined to either root.
let directory = match Self::require_valid_directory(&skill.directory) {
Ok(directory) => directory,
Err(err) => {
@@ -1253,32 +1394,90 @@ impl SkillService {
result.skipped_count += 1;
continue;
}
if dst.exists() {
result.skipped_count += 1;
continue;
if fs::symlink_metadata(&dst).is_ok() {
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(anyhow!(
"Skill storage target already contains an unowned entry: {}",
dst.display()
));
}
// 优先 rename(同文件系统原子操作),失败则 copy+delete
match fs::rename(&src, &dst) {
Ok(()) => result.migrated_count += 1,
Err(_) => match Self::copy_dir_recursive(&src, &dst) {
Ok(()) => {
let _ = fs::remove_dir_all(&src);
result.migrated_count += 1;
}
Err(e) => {
result.errors.push(format!("{}: {e}", skill.directory));
}
},
if let Err(error) = Self::copy_dir_recursive(&src, &dst) {
let _ = Self::remove_path(&dst);
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(error);
}
copied.push((src, dst));
result.migrated_count += 1;
}
// 3. 文件移动完成后才持久化设置
crate::settings::set_skill_storage_location(target)?;
// 3. Switch authority only after every new tree is complete.
if let Err(error) = crate::settings::set_skill_storage_location(target) {
for (_, copied_destination) in copied.iter().rev() {
let _ = Self::remove_path(copied_destination);
}
return Err(error.into());
}
// 4. 刷新所有应用目录的 symlink(指向新 SSOT
for app in AppType::all() {
let _ = Self::sync_to_app(db, &app);
// 4. Reconcile Pi under the same mutex, then all legacy app views.
let reconcile_result =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
&deployment_guard,
db,
)
.map_err(|error| anyhow!(error.to_string()))
.and_then(|()| {
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
Self::sync_to_app(db, &app)?;
}
Ok(())
});
if let Err(error) = reconcile_result {
let mut rollback_errors = Vec::new();
if let Err(rollback) = crate::settings::set_skill_storage_location(current) {
rollback_errors.push(format!("settings: {rollback}"));
} else {
if let Err(rollback) =
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard(
&deployment_guard,
db,
)
{
rollback_errors.push(format!("Pi deployment: {rollback}"));
}
for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) {
if let Err(rollback) = Self::sync_to_app(db, &app) {
rollback_errors.push(format!("{app:?}: {rollback}"));
}
}
}
for (_, copied_destination) in copied.iter().rev() {
if let Err(rollback) = Self::remove_path(copied_destination) {
rollback_errors.push(format!("{}: {rollback}", copied_destination.display()));
}
}
return if rollback_errors.is_empty() {
Err(error)
} else {
Err(anyhow!(
"Skill storage migration failed ({error}); rollback failures: {}",
rollback_errors.join("; ")
))
};
}
// 5. Only after every consumer points at the new root may the old
// sources be removed. Cleanup errors are visible but do not roll back
// an already-consistent authority switch.
for (old_source, _) in &copied {
if let Err(error) = Self::remove_path(old_source) {
result
.errors
.push(format!("{}: {error}", old_source.display()));
}
}
log::info!(
@@ -1403,7 +1602,7 @@ impl SkillService {
}
if !restored_skill.apps.is_empty() {
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
if let Err(err) = Self::sync_installed_skill_to_app(db, &restored_skill, current_app) {
let _ = db.delete_skill(&restored_skill.id);
let _ = fs::remove_dir_all(&restore_path);
return Err(err);
@@ -1429,6 +1628,14 @@ impl SkillService {
.get_installed_skill(id)?
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
if matches!(app, AppType::Pi) {
crate::services::skill_deployment::PiSkillDeploymentService::toggle(
db, &mut skill, enabled,
)
.map_err(|error| anyhow!(error.to_string()))?;
return Ok(());
}
// 更新状态
skill.apps.set_enabled_for(app, enabled);
@@ -1517,6 +1724,11 @@ impl SkillService {
db: &Arc<Database>,
imports: Vec<ImportSkillSelection>,
) -> Result<Vec<InstalledSkill>> {
// Import can explicitly acquire or release Pi filesystem ownership.
// Serialize the source scan, SSOT establishment, ownership decision and
// desired-state transaction with every other Pi deployment operation.
let deployment_guard =
crate::services::skill_deployment::PiSkillDeploymentService::operation_guard();
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut imported = Vec::new();
@@ -1579,8 +1791,12 @@ impl SkillService {
// 复制到 SSOT
let dest = ssot_dir.join(&dir_name);
if !dest.exists() {
Self::copy_dir_recursive(&source, &dest)?;
let created_ssot = !dest.exists();
if created_ssot {
if let Err(error) = Self::copy_dir_recursive(&source, &dest) {
let _ = Self::remove_path(&dest);
return Err(error);
}
}
// 解析元数据
@@ -1588,7 +1804,7 @@ impl SkillService {
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
let apps = selection.apps;
let requested_apps = selection.apps;
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
@@ -1599,7 +1815,8 @@ impl SkillService {
let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok();
// 创建记录
let skill = InstalledSkill {
let previous = db.get_installed_skill(&id)?;
let mut skill = InstalledSkill {
id,
name,
description,
@@ -1608,14 +1825,54 @@ impl SkillService {
repo_name,
repo_branch,
readme_url,
apps,
// save_skill intentionally preserves an existing Pi desired bit.
// For a new row keep it disabled until the deployment ledger and
// desired bit can commit in one transaction below.
apps: SkillApps {
pi: previous.as_ref().is_some_and(|installed| installed.apps.pi),
..requested_apps.clone()
},
installed_at: chrono::Utc::now().timestamp(),
content_hash,
updated_at: 0,
};
// 保存到数据库
db.save_skill(&skill)?;
if let Err(error) = db.save_skill(&skill) {
if created_ssot {
let _ = Self::remove_path(&dest);
}
return Err(error.into());
}
if let Err(error) = crate::services::skill_deployment::PiSkillDeploymentService::import_desired_state_under_guard(
&deployment_guard,
db,
&mut skill,
requested_apps.pi,
) {
let db_rollback = if let Some(previous) = previous.as_ref() {
db.save_skill(previous).map(|_| ())
} else {
db.delete_skill(&skill.id).map(|_| ())
};
let file_rollback = if created_ssot {
Self::remove_path(&dest)
} else {
Ok(())
};
return match (db_rollback, file_rollback) {
(Ok(()), Ok(())) => Err(anyhow!(error.to_string())),
(db_result, file_result) => Err(anyhow!(
"Pi Skill import failed ({error}); DB rollback: {}; SSOT rollback: {}",
db_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string()),
file_result
.err()
.map_or_else(|| "ok".to_string(), |value| value.to_string())
)),
};
}
imported.push(skill);
}
@@ -1655,6 +1912,19 @@ impl SkillService {
crate::settings::get_skill_sync_method()
}
fn sync_installed_skill_to_app(
db: &Arc<Database>,
skill: &InstalledSkill,
app: &AppType,
) -> Result<()> {
if matches!(app, AppType::Pi) {
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill(db, skill)
.map_err(|error| anyhow!(error.to_string()))
} else {
Self::sync_to_app_dir(&skill.directory, app)
}
}
/// 同步 Skill 到应用目录(使用 symlink 或 copy
///
/// 根据配置和平台选择最佳同步方式:
@@ -1665,6 +1935,11 @@ impl SkillService {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
if matches!(app, AppType::Pi) {
return Err(anyhow!(
"Pi Skill deployment requires the ownership ledger; use the database-aware reconciler"
));
}
// directory 可能来自被污染的 DB 行(如同步导入的远端快照),join 前必须校验。
let directory = Self::require_valid_directory(directory)?;
@@ -1861,6 +2136,10 @@ impl SkillService {
if matches!(app, AppType::ClaudeDesktop) {
return Ok(());
}
if matches!(app, AppType::Pi) {
return crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(db)
.map_err(|error| anyhow!(error.to_string()));
}
let skills = db.get_all_installed_skills()?;
let ssot_dir = Self::get_ssot_dir()?;
@@ -2113,7 +2392,7 @@ impl SkillService {
}
/// 静态方法:解析技能元数据
fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
pub(crate) fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
let content = fs::read_to_string(path)?;
let content = content.trim_start_matches('\u{feff}');
@@ -3101,7 +3380,7 @@ impl SkillService {
db.save_skill(&skill)?;
// 同步到当前应用目录
Self::sync_to_app_dir(&install_name, current_app)?;
Self::sync_installed_skill_to_app(db, &skill, current_app)?;
log::info!(
"Skill {} installed from ZIP, enabled for {:?}",
@@ -4142,6 +4421,89 @@ mod tests {
}
}
#[test]
#[serial_test::serial]
fn importing_a_native_pi_skill_adopts_exact_content_and_can_disable_it() {
struct PiDirGuard(Option<std::ffi::OsString>);
impl Drop for PiDirGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value),
None => std::env::remove_var("PI_CODING_AGENT_DIR"),
}
}
}
struct StorageLocationGuard(SkillStorageLocation);
impl Drop for StorageLocationGuard {
fn drop(&mut self) {
let _ = crate::settings::set_skill_storage_location(self.0);
}
}
let temp = tempdir().expect("tempdir");
let _home_guard = TestHomeGuard::set(temp.path());
let pi_agent_dir = temp.path().join("pi-agent");
let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR"));
std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir);
let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location());
crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch)
.expect("select isolated SSOT");
let native = pi_agent_dir.join("skills").join("native-skill");
write_skill(&native, "Native Skill");
fs::write(native.join("details.txt"), "pinned native bytes").expect("native detail");
let db = Arc::new(Database::memory().expect("memory db"));
let imported = SkillService::import_from_apps(
&db,
vec![ImportSkillSelection {
directory: "native-skill".to_string(),
apps: SkillApps::only(&AppType::Pi),
}],
)
.expect("explicit Pi import should adopt the exact native tree");
assert_eq!(imported.len(), 1);
assert!(imported[0].apps.pi);
let statuses =
crate::services::skill_deployment::PiSkillDeploymentService::inspect_all(&db)
.expect("inspect Pi deployment");
let status = statuses
.get(&imported[0].id)
.expect("imported status must exist");
assert_eq!(
status.ownership,
crate::services::skill_deployment::PiSkillOwnership::Owned
);
assert_eq!(
status.discovery,
crate::services::skill_deployment::PiSkillDiscovery::Active
);
assert!(status.effectively_discovered);
SkillService::toggle_app(&db, &imported[0].id, &AppType::Pi, false)
.expect("owned imported Pi Skill can be disabled");
assert!(
!native.exists(),
"disabling an explicitly adopted native tree removes that owned deployment"
);
assert!(
SkillService::get_ssot_dir()
.expect("SSOT")
.join("native-skill")
.join("SKILL.md")
.is_file(),
"disabling Pi must preserve the managed SSOT"
);
assert!(
!db.get_installed_skill(&imported[0].id)
.expect("read imported row")
.expect("row")
.apps
.pi
);
}
fn poisoned_skill(id: &str, directory: &str) -> InstalledSkill {
InstalledSkill {
id: id.to_string(),