Skip to content

Commit 21695fc

Browse files
authored
Merge pull request #218 from OpenMined/madhava/flow-update
added flow updating and new default flows
2 parents 5ca0628 + d0020ba commit 21695fc

8 files changed

Lines changed: 537 additions & 7 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ csv = "1.3"
3737
sha2 = "0.10"
3838
dirs = "5"
3939
serde_yaml = "0.9"
40+
semver = "1.0"
4041
reqwest = { version = "0.12", features = ["blocking"] }
4142
chrono = "0.4"
4243
tempfile = "3"

src-tauri/src/commands/flows.rs

Lines changed: 224 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,137 @@ pub struct FlowCreateRequest {
3535
pub overwrite: bool,
3636
}
3737

38+
const FLOW_SOURCE_METADATA_FILE: &str = ".biovault-flow-source.json";
39+
40+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41+
#[serde(rename_all = "camelCase")]
42+
pub struct FlowSourceMetadata {
43+
pub source_url: String,
44+
}
45+
46+
#[derive(Debug, Clone, Serialize)]
47+
#[serde(rename_all = "camelCase")]
48+
pub struct FlowListItem {
49+
#[serde(flatten)]
50+
pub flow: Flow,
51+
#[serde(skip_serializing_if = "Option::is_none")]
52+
pub version: Option<String>,
53+
#[serde(skip_serializing_if = "Option::is_none")]
54+
pub source_url: Option<String>,
55+
}
56+
57+
#[derive(Debug, Clone, Serialize)]
58+
#[serde(rename_all = "camelCase")]
59+
pub struct FlowUpdateInfo {
60+
pub flow_id: i64,
61+
pub name: String,
62+
pub source_url: String,
63+
pub current_version: Option<String>,
64+
pub remote_version: Option<String>,
65+
pub update_available: bool,
66+
pub error: Option<String>,
67+
}
68+
69+
fn normalize_flow_url(url: &str) -> String {
70+
let mut raw_url = url
71+
.replace("github.com", "raw.githubusercontent.com")
72+
.replace("/blob/", "/")
73+
.replace("/tree/", "/");
74+
if !raw_url.ends_with(".yaml") && !raw_url.ends_with(".yml") && !raw_url.ends_with(".json") {
75+
raw_url = format!("{}/flow.yaml", raw_url.trim_end_matches('/'));
76+
}
77+
raw_url
78+
}
79+
80+
pub fn write_flow_source_metadata(flow_path: &str, source_url: &str) -> Result<(), String> {
81+
let path = PathBuf::from(flow_path).join(FLOW_SOURCE_METADATA_FILE);
82+
let metadata = FlowSourceMetadata {
83+
source_url: normalize_flow_url(source_url),
84+
};
85+
let json = serde_json::to_string_pretty(&metadata)
86+
.map_err(|e| format!("Failed to serialize flow source metadata: {}", e))?;
87+
fs::write(&path, json).map_err(|e| format!("Failed to write flow source metadata: {}", e))
88+
}
89+
90+
fn read_flow_source_metadata(flow_path: &str) -> Option<FlowSourceMetadata> {
91+
let path = PathBuf::from(flow_path).join(FLOW_SOURCE_METADATA_FILE);
92+
fs::read_to_string(path)
93+
.ok()
94+
.and_then(|raw| serde_json::from_str::<FlowSourceMetadata>(&raw).ok())
95+
}
96+
97+
fn known_managed_flow_source_url(name: &str) -> Option<&'static str> {
98+
match name {
99+
"01_bv_paper_pca_qc_fast" => Some(
100+
"https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/01_bv_paper_pca_qc_fast/flow.yaml",
101+
),
102+
"02_bv_paper_gnomad_projection_fast" => Some(
103+
"https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/02_bv_paper_gnomad_projection_fast/flow.yaml",
104+
),
105+
"03_bv_paper_sex_biased_admixture_fast" => Some(
106+
"https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/03_bv_paper_sex_biased_admixture_fast/flow.yaml",
107+
),
108+
"04_bv_paper_population_level" => Some(
109+
"https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/04_bv_paper_population_level/flow.yaml",
110+
),
111+
_ => None,
112+
}
113+
}
114+
115+
fn is_managed_flow_path(flow_path: &str) -> bool {
116+
let Ok(home) = biovault::config::get_biovault_home() else {
117+
return false;
118+
};
119+
PathBuf::from(flow_path).starts_with(home.join("flows"))
120+
}
121+
122+
fn flow_source_metadata_for(flow: &Flow) -> Option<FlowSourceMetadata> {
123+
read_flow_source_metadata(&flow.flow_path).or_else(|| {
124+
if is_managed_flow_path(&flow.flow_path) {
125+
known_managed_flow_source_url(&flow.name).map(|source_url| FlowSourceMetadata {
126+
source_url: source_url.to_string(),
127+
})
128+
} else {
129+
None
130+
}
131+
})
132+
}
133+
134+
fn read_flow_file(flow_path: &str) -> Option<FlowFile> {
135+
let path = PathBuf::from(flow_path).join(FLOW_YAML_FILE);
136+
fs::read_to_string(path)
137+
.ok()
138+
.and_then(|raw| FlowFile::parse_yaml(&raw).ok())
139+
}
140+
141+
fn flow_list_item(flow: Flow) -> FlowListItem {
142+
let version = read_flow_file(&flow.flow_path).map(|file| file.metadata.version);
143+
let source_url = flow_source_metadata_for(&flow).map(|metadata| metadata.source_url);
144+
FlowListItem {
145+
flow,
146+
version,
147+
source_url,
148+
}
149+
}
150+
151+
fn parse_flow_version(value: &str) -> Result<semver::Version, String> {
152+
let normalized = value.trim().trim_start_matches('v');
153+
semver::Version::parse(normalized)
154+
.map_err(|e| format!("Invalid semantic version '{}': {}", value, e))
155+
}
156+
157+
fn remote_version_is_newer(
158+
current_version: Option<&str>,
159+
remote_version: &str,
160+
) -> Result<bool, String> {
161+
let remote = parse_flow_version(remote_version)?;
162+
let Some(current_version) = current_version else {
163+
return Ok(true);
164+
};
165+
let current = parse_flow_version(current_version)?;
166+
Ok(remote > current)
167+
}
168+
38169
#[derive(Debug, Serialize, Deserialize)]
39170
#[serde(rename_all = "camelCase")]
40171
pub struct FlowRunSelection {
@@ -1622,11 +1753,102 @@ fn append_flow_log(window: Option<&tauri::WebviewWindow>, log_path: &Path, messa
16221753
}
16231754

16241755
#[tauri::command]
1625-
pub async fn get_flows(state: tauri::State<'_, AppState>) -> Result<Vec<Flow>, String> {
1756+
pub async fn get_flows(state: tauri::State<'_, AppState>) -> Result<Vec<FlowListItem>, String> {
16261757
let biovault_db = state.biovault_db.lock().map_err(|e| e.to_string())?;
16271758
let flows = biovault_db.list_flows().map_err(|e| e.to_string())?;
16281759

1629-
Ok(flows)
1760+
Ok(flows.into_iter().map(flow_list_item).collect())
1761+
}
1762+
1763+
#[tauri::command]
1764+
pub async fn check_flow_updates(
1765+
state: tauri::State<'_, AppState>,
1766+
) -> Result<Vec<FlowUpdateInfo>, String> {
1767+
let flows = {
1768+
let biovault_db = state.biovault_db.lock().map_err(|e| e.to_string())?;
1769+
biovault_db.list_flows().map_err(|e| e.to_string())?
1770+
};
1771+
1772+
tauri::async_runtime::spawn_blocking(move || {
1773+
let mut updates = Vec::new();
1774+
for flow in flows {
1775+
let Some(source) = flow_source_metadata_for(&flow) else {
1776+
continue;
1777+
};
1778+
1779+
let current_version = read_flow_file(&flow.flow_path).map(|file| file.metadata.version);
1780+
let mut info = FlowUpdateInfo {
1781+
flow_id: flow.id,
1782+
name: flow.name,
1783+
source_url: source.source_url.clone(),
1784+
current_version,
1785+
remote_version: None,
1786+
update_available: false,
1787+
error: None,
1788+
};
1789+
1790+
match reqwest::blocking::get(&source.source_url) {
1791+
Ok(response) if response.status().is_success() => match response.text() {
1792+
Ok(raw) => match FlowFile::parse_yaml(&raw) {
1793+
Ok(remote) => {
1794+
let remote_version = remote.metadata.version;
1795+
match remote_version_is_newer(
1796+
info.current_version.as_deref(),
1797+
&remote_version,
1798+
) {
1799+
Ok(is_newer) => {
1800+
info.update_available = is_newer;
1801+
}
1802+
Err(e) => {
1803+
info.error = Some(e);
1804+
}
1805+
}
1806+
info.remote_version = Some(remote_version);
1807+
}
1808+
Err(e) => {
1809+
info.error = Some(format!("Failed to parse remote flow.yaml: {}", e));
1810+
}
1811+
},
1812+
Err(e) => {
1813+
info.error = Some(format!("Failed to read remote flow.yaml: {}", e));
1814+
}
1815+
},
1816+
Ok(response) => {
1817+
info.error = Some(format!("HTTP {}", response.status()));
1818+
}
1819+
Err(e) => {
1820+
info.error = Some(format!("Failed to fetch remote flow.yaml: {}", e));
1821+
}
1822+
}
1823+
1824+
updates.push(info);
1825+
}
1826+
Ok::<_, String>(updates)
1827+
})
1828+
.await
1829+
.map_err(|e| e.to_string())?
1830+
}
1831+
1832+
#[tauri::command]
1833+
pub async fn redownload_flow(
1834+
state: tauri::State<'_, AppState>,
1835+
flow_id: i64,
1836+
) -> Result<String, String> {
1837+
let flow = {
1838+
let biovault_db = state.biovault_db.lock().map_err(|e| e.to_string())?;
1839+
biovault_db
1840+
.get_flow(flow_id)
1841+
.map_err(|e| e.to_string())?
1842+
.ok_or_else(|| format!("Flow {} not found", flow_id))?
1843+
};
1844+
let source = flow_source_metadata_for(&flow)
1845+
.ok_or_else(|| "This flow was not imported from a URL".to_string())?;
1846+
let source_url = source.source_url.clone();
1847+
1848+
let flow_path =
1849+
crate::commands::modules::import_flow_with_deps(source_url.clone(), None, true).await?;
1850+
write_flow_source_metadata(&flow_path, &source_url)?;
1851+
Ok(flow_path)
16301852
}
16311853

16321854
#[tauri::command]

src-tauri/src/commands/modules.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,10 @@ pub async fn import_flow_with_deps(
339339
name_override: Option<String>,
340340
overwrite: bool,
341341
) -> Result<String, String> {
342+
let source_url = url.clone();
343+
let should_record_source = !source_url.starts_with('/') && !source_url.starts_with("file://");
342344
// Spawn blocking to avoid Send issues with BioVaultDb
343-
tauri::async_runtime::spawn_blocking(move || {
345+
let flow_path = tauri::async_runtime::spawn_blocking(move || {
344346
tauri::async_runtime::block_on(async {
345347
biovault::cli::commands::module_management::import_flow_with_deps(
346348
&url,
@@ -352,7 +354,13 @@ pub async fn import_flow_with_deps(
352354
})
353355
})
354356
.await
355-
.map_err(|e| e.to_string())?
357+
.map_err(|e| e.to_string())??;
358+
359+
if should_record_source {
360+
crate::commands::flows::write_flow_source_metadata(&flow_path, &source_url)?;
361+
}
362+
363+
Ok(flow_path)
356364
}
357365

358366
#[tauri::command]

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,8 @@ pub fn run() {
17231723
delete_run,
17241724
// Flow commands
17251725
get_flows,
1726+
check_flow_updates,
1727+
redownload_flow,
17261728
get_runs_base_dir,
17271729
create_flow,
17281730
import_flow_from_json,

src-tauri/src/ws_bridge.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,8 @@ fn get_commands_list() -> serde_json::Value {
363363
cmd("get_flow_template_catalog", "modules", true),
364364
// Flows
365365
cmd_async("get_flows", "flows", true),
366+
cmd_async("check_flow_updates", "flows", true),
367+
cmd_long("redownload_flow", "flows", false),
366368
cmd_async("create_flow", "flows", false),
367369
cmd_async("import_flow", "flows", false),
368370
cmd_async("import_flow_from_message", "flows", false),
@@ -3021,6 +3023,25 @@ async fn execute_command(app: &AppHandle, cmd: &str, args: Value) -> Result<Valu
30213023
.map_err(|e| e.to_string())?;
30223024
Ok(serde_json::to_value(result).unwrap())
30233025
}
3026+
"check_flow_updates" => {
3027+
let result = crate::commands::flows::check_flow_updates(state.clone())
3028+
.await
3029+
.map_err(|e| e.to_string())?;
3030+
Ok(serde_json::to_value(result).unwrap())
3031+
}
3032+
"redownload_flow" => {
3033+
let flow_id: i64 = serde_json::from_value(
3034+
args.get("flowId")
3035+
.or_else(|| args.get("flow_id"))
3036+
.cloned()
3037+
.ok_or_else(|| "Missing flowId".to_string())?,
3038+
)
3039+
.map_err(|e| format!("Failed to parse flowId: {}", e))?;
3040+
let result = crate::commands::flows::redownload_flow(state.clone(), flow_id)
3041+
.await
3042+
.map_err(|e| e.to_string())?;
3043+
Ok(serde_json::to_value(result).unwrap())
3044+
}
30243045
"import_flow_with_deps" => {
30253046
let url: String = serde_json::from_value(
30263047
args.get("url")

0 commit comments

Comments
 (0)