Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 53 additions & 32 deletions crates/skilld-command/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,8 @@ pub trait Host {
))
}

fn update_selected(&self, names: &[String]) -> Result<Vec<String>, CommandError> {
let names = parse_update_selection(names)?;
if let [name] = names.as_slice() {
return self.update(Some(name));
}
fn update_selected(&self, items: &[UpdatePlanItem]) -> Result<Vec<String>, CommandError> {
validate_update_selection(items)?;
Err(CommandError::unsupported_host(
"Selected Skill updates are unavailable on this host",
))
Expand Down Expand Up @@ -1498,12 +1495,12 @@ impl Host for LocalHost {
.collect())
}

fn update_selected(&self, names: &[String]) -> Result<Vec<String>, CommandError> {
let names = parse_update_selection(names)?;
fn update_selected(&self, items: &[UpdatePlanItem]) -> Result<Vec<String>, CommandError> {
validate_update_selection(items)?;
let scope = InstallScope::Project;
let known = self.known_targets(scope)?;
let store = self.store(scope);
apply_update_selection(self, names, store, known)
apply_update_selection(self, items, store, known)
}

fn update_check(&self, requested: Option<&str>) -> Result<UpdatePlanV1, CommandError> {
Expand Down Expand Up @@ -1691,20 +1688,31 @@ struct PreparedUpdateSelection {

fn apply_update_selection(
host: &LocalHost,
names: Vec<String>,
items: &[UpdatePlanItem],
store: LocalStore,
known: Vec<ResolvedTarget>,
) -> Result<Vec<String>, CommandError> {
let provider = host.remote_provider()?;
let mut pending = Vec::new();
for name in names {
let skill_name =
skilld_core::SkillName::parse(name.clone()).map_err(CommandError::domain)?;
for item in items {
let name = item.name().as_str().to_owned();
let UpdateRelation::Available {
locked_commit_sha,
latest_commit_sha,
..
} = item.relation()
else {
unreachable!("the update selection was validated")
};
let skill_name = item.name().clone();
let view = store
.verify_content(&skill_name, &known)
.map_err(CommandError::store)?;
let LockedSource::Remote { source, .. } = &view.skill.source else {
continue;
let LockedSource::Remote {
source, commit_sha, ..
} = &view.skill.source
else {
return Err(stale_update_plan(&name));
};
if !matches!(
view.skill.source_status,
Expand All @@ -1717,24 +1725,25 @@ fn apply_update_selection(
}
let selector = skilld_core::RemoteSelector::parse(source).map_err(CommandError::remote)?;
if matches!(selector.source().r#ref, Some(SourceRef::Commit { .. })) {
continue;
return Err(stale_update_plan(&name));
}
let installed_commit_sha =
CommitSha::parse(commit_sha.clone()).map_err(update_model_error)?;
if &installed_commit_sha != locked_commit_sha {
return Err(stale_update_plan(&name));
}
let latest_commit = provider
.latest_commit(&selector, false)
.map_err(CommandError::remote)?;
let LockedSource::Remote { commit_sha, .. } = &view.skill.source else {
unreachable!("the update candidate has a remote source")
};
let locked_commit_sha = CommitSha::parse(commit_sha.clone()).map_err(update_model_error)?;
if latest_commit.commit_sha == locked_commit_sha {
continue;
if &latest_commit.commit_sha != latest_commit_sha {
return Err(stale_update_plan(&name));
}
let comparison = RemoteUpdateComparison::new(
skill_name.as_str(),
&selector.source().owner,
&selector.source().repository,
locked_commit_sha,
latest_commit.commit_sha.clone(),
locked_commit_sha.clone(),
latest_commit_sha.clone(),
latest_commit.access,
)
.map_err(CommandError::remote)?;
Expand All @@ -1743,7 +1752,7 @@ fn apply_update_selection(
skill_name,
view,
selector,
expected_commit: latest_commit.commit_sha,
expected_commit: latest_commit_sha.clone(),
comparison,
});
}
Expand Down Expand Up @@ -2081,26 +2090,38 @@ fn selected_names(
}
}

fn parse_update_selection(names: &[String]) -> Result<Vec<String>, CommandError> {
if names.is_empty() {
fn validate_update_selection(items: &[UpdatePlanItem]) -> Result<(), CommandError> {
if items.is_empty() {
return Err(CommandError::usage(
"INVALID_SELECTION",
"Select at least one Skill",
));
}
let mut unique = BTreeSet::new();
let mut parsed = Vec::with_capacity(names.len());
for name in names {
let name = skilld_core::SkillName::parse(name.clone()).map_err(CommandError::domain)?;
if !unique.insert(name.clone()) {
for item in items {
if !unique.insert(item.name()) {
return Err(CommandError::usage(
"INVALID_SELECTION",
"Select each Skill once",
));
}
parsed.push(name.to_string());
}
Ok(parsed)
for item in items {
if !matches!(item.relation(), UpdateRelation::Available { .. }) {
return Err(CommandError::usage(
"INVALID_SELECTION",
"Select only Skills with available updates",
));
}
}
Ok(())
}

fn stale_update_plan(name: &str) -> CommandError {
CommandError::operation(
"STALE_UPDATE_PLAN",
format!("Skill {name} changed after review. Review its commits again"),
)
}

fn unavailable_update(
Expand Down
91 changes: 79 additions & 12 deletions crates/skilld-command/tests/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use skilld_core::{
CheckResult, CommitAuthor, CommitSha, CommitSummary, InstallMode, InstallOperation,
InstallRequest, InstallScope, InstallSource, LockedSource, PreparedFile, RemoteError,
RemoteSelector, RepositoryVisibility, ResolvedSource, SearchResponse, SignatureAlgorithm,
SourceProvider, SourceStatus, TrustedRootPin, UpdatePlanV1, UpdateRelation,
SourceProvider, SourceStatus, TrustedRootPin, UpdatePlanItem, UpdatePlanV1, UpdateRelation,
};

const ROOT_DOMAIN: &[u8] = b"skilld-trusted-key-v1\0";
Expand Down Expand Up @@ -1528,6 +1528,7 @@ fn provider(content: &str) -> Arc<FakeProvider> {

struct BatchProvider {
version: Mutex<&'static str>,
latest_commit: Mutex<char>,
prepared_names: Mutex<Vec<String>>,
fail_name: Mutex<Option<&'static str>>,
relation: Mutex<RemoteComparisonRelation>,
Expand Down Expand Up @@ -1615,7 +1616,8 @@ impl RemoteProvider for BatchProvider {
_direct: bool,
) -> Result<skilld_command::RemoteLatestCommit, RemoteError> {
Ok(skilld_command::RemoteLatestCommit {
commit_sha: CommitSha::parse("f".repeat(40)).unwrap(),
commit_sha: CommitSha::parse(self.latest_commit.lock().unwrap().to_string().repeat(40))
.unwrap(),
access: RemoteComparisonAccess::PublicGithub,
})
}
Expand Down Expand Up @@ -1678,6 +1680,7 @@ fn multi_skill_update_prepares_then_commits_every_artifact() {
fs::create_dir_all(&project).unwrap();
let provider = Arc::new(BatchProvider {
version: Mutex::new("first"),
latest_commit: Mutex::new('f'),
prepared_names: Mutex::new(vec![]),
fail_name: Mutex::new(None),
relation: Mutex::new(RemoteComparisonRelation::Ahead),
Expand Down Expand Up @@ -1721,6 +1724,7 @@ fn multi_skill_update_changes_nothing_when_one_artifact_cannot_prepare() {
fs::create_dir_all(&project).unwrap();
let provider = Arc::new(BatchProvider {
version: Mutex::new("first"),
latest_commit: Mutex::new('f'),
prepared_names: Mutex::new(vec![]),
fail_name: Mutex::new(None),
relation: Mutex::new(RemoteComparisonRelation::Ahead),
Expand Down Expand Up @@ -1766,6 +1770,7 @@ fn plain_update_rejects_a_source_that_moved_behind() {
fs::create_dir_all(&project).unwrap();
let provider = Arc::new(BatchProvider {
version: Mutex::new("first"),
latest_commit: Mutex::new('f'),
prepared_names: Mutex::new(vec![]),
fail_name: Mutex::new(None),
relation: Mutex::new(RemoteComparisonRelation::Ahead),
Expand Down Expand Up @@ -1795,13 +1800,28 @@ fn plain_update_rejects_a_source_that_moved_behind() {
);
}

fn reviewed_updates(host: &LocalHost, names: &[&str]) -> Vec<UpdatePlanItem> {
let plan = host.update_check(None).unwrap();
names
.iter()
.map(|name| {
plan.items()
.iter()
.find(|item| item.name().as_str() == *name)
.unwrap()
.clone()
})
.collect()
}

#[test]
fn selected_skill_update_commits_only_the_exact_subset() {
let temporary = tempfile::tempdir().unwrap();
let project = temporary.path().join("project");
fs::create_dir_all(&project).unwrap();
let provider = Arc::new(BatchProvider {
version: Mutex::new("first"),
latest_commit: Mutex::new('f'),
prepared_names: Mutex::new(vec![]),
fail_name: Mutex::new(None),
relation: Mutex::new(RemoteComparisonRelation::Ahead),
Expand All @@ -1821,10 +1841,9 @@ fn selected_skill_update_commits_only_the_exact_subset() {
}
provider.prepared_names.lock().unwrap().clear();
*provider.version.lock().unwrap() = "second";
let reviewed = reviewed_updates(&host, &["gamma", "alpha"]);

let lines = host
.update_selected(&["gamma".to_owned(), "alpha".to_owned()])
.unwrap();
let lines = host.update_selected(&reviewed).unwrap();

assert_eq!(lines, ["Updated Skill gamma.", "Updated Skill alpha."]);
assert_eq!(*provider.prepared_names.lock().unwrap(), ["gamma", "alpha"]);
Expand All @@ -1837,24 +1856,34 @@ fn selected_skill_update_commits_only_the_exact_subset() {
}

#[test]
fn selected_skill_update_rejects_empty_duplicate_and_invalid_names() {
fn selected_skill_update_rejects_empty_duplicate_and_unavailable_items() {
let temporary = tempfile::tempdir().unwrap();
let host = LocalHost::new(
temporary.path().join("project"),
temporary.path().join("data"),
);

let current = UpdatePlanItem::new(
skilld_core::SkillName::parse("alpha").unwrap(),
UpdateRelation::Current {
commit_sha: CommitSha::parse("1".repeat(40)).unwrap(),
},
);
let empty = host.update_selected(&[]).unwrap_err();
let duplicate = host
.update_selected(&["alpha".to_owned(), "alpha".to_owned()])
.update_selected(&[current.clone(), current.clone()])
.unwrap_err();
let invalid = host.update_selected(&["../alpha".to_owned()]).unwrap_err();
let invalid_relation = host.update_selected(&[current]).unwrap_err();

assert_eq!(empty.code, "INVALID_SELECTION");
assert_eq!(empty.message, "Select at least one Skill");
assert_eq!(duplicate.code, "INVALID_SELECTION");
assert_eq!(duplicate.message, "Select each Skill once");
assert_eq!(invalid.code, "INVALID_SOURCE");
assert_eq!(invalid_relation.code, "INVALID_SELECTION");
assert_eq!(
invalid_relation.message,
"Select only Skills with available updates"
);
}

#[test]
Expand All @@ -1864,6 +1893,7 @@ fn selected_skill_update_changes_nothing_when_one_selected_artifact_fails() {
fs::create_dir_all(&project).unwrap();
let provider = Arc::new(BatchProvider {
version: Mutex::new("first"),
latest_commit: Mutex::new('f'),
prepared_names: Mutex::new(vec![]),
fail_name: Mutex::new(None),
relation: Mutex::new(RemoteComparisonRelation::Ahead),
Expand All @@ -1883,11 +1913,10 @@ fn selected_skill_update_changes_nothing_when_one_selected_artifact_fails() {
}
provider.prepared_names.lock().unwrap().clear();
*provider.version.lock().unwrap() = "second";
let reviewed = reviewed_updates(&host, &["alpha", "gamma"]);
*provider.fail_name.lock().unwrap() = Some("gamma");

let error = host
.update_selected(&["alpha".to_owned(), "gamma".to_owned()])
.unwrap_err();
let error = host.update_selected(&reviewed).unwrap_err();

assert_eq!(error.code, "CHECK_BLOCKED");
assert_eq!(*provider.prepared_names.lock().unwrap(), ["alpha", "gamma"]);
Expand All @@ -1899,6 +1928,44 @@ fn selected_skill_update_changes_nothing_when_one_selected_artifact_fails() {
}
}

#[test]
fn selected_skill_update_rejects_a_head_that_changed_after_review() {
let temporary = tempfile::tempdir().unwrap();
let project = temporary.path().join("project");
fs::create_dir_all(&project).unwrap();
let provider = Arc::new(BatchProvider {
version: Mutex::new("first"),
latest_commit: Mutex::new('f'),
prepared_names: Mutex::new(vec![]),
fail_name: Mutex::new(None),
relation: Mutex::new(RemoteComparisonRelation::Ahead),
});
let host = LocalHost::new(project.clone(), temporary.path().join("data"))
.with_remote_provider(provider.clone());
host.install_request(InstallRequest {
operation: InstallOperation::Install(InstallSource::Remote(
"skilld:skilld-dev/skills/alpha".to_owned(),
)),
scope: InstallScope::Project,
targets: vec![AgentTargetId::Codex],
mode: Some(InstallMode::Copy),
})
.unwrap();
provider.prepared_names.lock().unwrap().clear();
*provider.version.lock().unwrap() = "second";
let reviewed = reviewed_updates(&host, &["alpha"]);
*provider.latest_commit.lock().unwrap() = 'e';

let error = host.update_selected(&reviewed).unwrap_err();

assert_eq!(error.code, "STALE_UPDATE_PLAN");
assert!(provider.prepared_names.lock().unwrap().is_empty());
assert_eq!(
fs::read_to_string(project.join(".skills/alpha/SKILL.md")).unwrap(),
"---\nname: alpha\ndescription: first\n---\n"
);
}

#[test]
fn verify_reports_changed_bytes_and_stale_sources() {
let temporary = tempfile::tempdir().unwrap();
Expand Down
Loading