This commit is contained in:
Kelorastus
2026-10-01 13:54:02 +02:00
parent c1a3bf03b8
commit 3a9de4013f
9 changed files with 273 additions and 17 deletions
+10
View File
@@ -32,6 +32,16 @@ pub async fn release_run(
launcher.release_run(password, with_api, with_mcp).await
}
#[tauri::command]
pub async fn refresh_run(
launcher: State<'_, Launcher>,
password: String,
pg_password: String,
tile_root: String,
) -> CmdResult<()> {
launcher.refresh_run(password, pg_password, tile_root).await
}
#[tauri::command]
pub fn release_console(launcher: State<'_, Launcher>, since: usize) -> CmdResult<crate::release::ConsoleTail> {
Ok(launcher.release_console(since))
+26
View File
@@ -2539,6 +2539,32 @@ impl Launcher {
.map(|_| ())
}
/// Refresh the pack's data payload in its own run. Returns once it has started.
pub async fn refresh_run(&self, password: String, pg_password: String, tile_root: String) -> Result<(), String> {
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
return Err("Something is already running: wait for it to finish.".to_string());
}
if password.trim().is_empty() {
return Err("The node's sudo password is needed: the dump runs there.".to_string());
}
let (dev_root, install_dir) = {
let inner = self.inner.lock().await;
(inner.settings.dev_root.trim().to_string(), inner.settings.install_path())
};
if dev_root.is_empty() {
return Err("Dev mode has no checkout folder, so the refresh scripts cannot be found.".to_string());
}
crate::release::run_refresh(
&PathBuf::from(dev_root).join("ODIAN_MCP_Server"),
&install_dir.join("state").join("release"),
pg_password.trim(),
Some(tile_root.as_str()),
&password,
&self.release_console,
)
.map(|_| ())
}
/// Everything the release has printed since line `since`.
pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail {
match self.release_console.lock() {
+1
View File
@@ -60,6 +60,7 @@ pub fn run() {
commands::release_plan,
commands::release_run,
commands::release_console,
commands::refresh_run,
commands::get_market,
commands::apply_market,
commands::check_market,
+99 -13
View File
@@ -138,9 +138,14 @@ pub struct ReleaseData {
pub tile_bytes: u64,
/// Every distinct dataset the tile sets name.
pub tile_datasets: Vec<String>,
/// The age of the OLDEST dated dataset, which is what decides how stale the maps are. A
/// name carrying no date (`planet_heightmap`) is not counted: that one does not move.
pub tiles_age_days: Option<i64>,
/// The basemap's SOURCE vintage, as `YYYYMMDD`, taken from the dataset name
/// (`protomaps_20260423`). This is the date of the upstream extract the maps were drawn
/// from - NOT when anyone baked or froze them. Nothing in a tile index or a manifest
/// records a bake time, so that age simply does not exist to be shown, and calling this
/// one "old" read as if the pack were stale when it only means the world data is from
/// April. An undated name (`planet_heightmap`) is skipped: that set does not move.
pub basemap_vintage: Option<String>,
pub basemap_vintage_days: Option<i64>,
}
/// Days from 1970-01-01 to y-m-d, proleptic Gregorian. Hinnant's algorithm, which is exact
@@ -201,11 +206,13 @@ pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, n
}
}
}
let tiles_age_days = tile_datasets
// The oldest dated extract, since that is the one that limits what the maps show.
let basemap_vintage = tile_datasets
.iter()
.filter_map(|d| dataset_stamp(d))
.filter_map(|s| age_days(s, now_secs))
.max();
.filter_map(|s| age_days(s, now_secs).map(|age| (age, s.to_string())))
.max_by_key(|(age, _)| *age)
.map(|(_, s)| s);
ReleaseData {
version: version.to_string(),
is_live,
@@ -215,7 +222,8 @@ pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, n
tile_sets: sets.map(|s| s.len()).unwrap_or(0),
tile_bytes,
tile_datasets,
tiles_age_days,
basemap_vintage_days: basemap_vintage.as_deref().and_then(|s| age_days(s, now_secs)),
basemap_vintage,
}
}
@@ -441,18 +449,78 @@ pub fn run_release(
// No `pause` here any more: this runs into the launcher's own panel, where there is no
// console to press Enter in. The exit code carries the verdict instead - swallowing a
// failure and exiting 0 would make a failed release look like a finished one.
let ps = format!(
let ps = console_script(&body, &log, "Release finished.");
spawn_console(work_dir, "release.ps1", &ps, &[("ODIAN_NODE_SUDO", sudo)], console)
}
/// Refresh the data a pack carries: the database seed, and optionally the tile indexes.
///
/// Deliberately NOT part of a release, and deliberately its own press. The seed is a
/// per-table dump of a database operators are using, and the tiles are indexed where the
/// tiles are. Neither should ride along with shipping a code change - but both should be one
/// click when refreshing the data IS the intent, which is what this is.
///
/// It only prepares the inputs. Cutting a version from them stays a separate step, so a
/// refresh can be looked at before anything is published.
pub fn run_refresh(
mcp_repo: &Path,
work_dir: &Path,
pg_password: &str,
tile_root: Option<&str>,
sudo: &str,
console: &Shared,
) -> Result<PathBuf, String> {
let script = mcp_repo.join("scripts").join("refresh_data.ps1");
if !script.is_file() {
return Err(format!("refresh_data.ps1 is not in {}. Pull ODIAN_MCP_Server on this PC.", mcp_repo.join("scripts").display()));
}
let log = work_dir.join("refresh.log");
let mut body = String::new();
if !pg_password.is_empty() {
body.push_str(&format!(
"Write-Host '=== Database seed ===' -ForegroundColor Cyan
& '{}' -Seed -PgPassword $pgpw -NodeSudoPw $pw
if ($LASTEXITCODE -ne 0) {{ throw 'the seed refresh failed' }}
",
script.display()
));
}
if let Some(root) = tile_root.filter(|r| !r.trim().is_empty()) {
body.push_str(&format!(
"Write-Host '=== Tile indexes ===' -ForegroundColor Cyan
& '{}' -Tiles -TileRoot '{}' -NodeSudoPw $pw
if ($LASTEXITCODE -ne 0) {{ throw 'the tile refresh failed' }}
",
script.display(),
root.trim()
));
}
if body.is_empty() {
return Err("Nothing to refresh: give the database password, a tile folder, or both.".to_string());
}
let ps = console_script(&body, &log, "Refresh finished. Nothing is published yet.");
spawn_console(work_dir, "refresh.ps1", &ps, &[("ODIAN_NODE_SUDO", sudo), ("ODIAN_PG_PW", pg_password)], console)
}
/// Wrap a body so a step that throws cannot take the window with it, and everything said is
/// also on disk. No `pause`: this runs into the launcher's own panel, where there is nothing
/// to press Enter with, and the exit code carries the verdict instead.
fn console_script(body: &str, log: &Path, done: &str) -> String {
format!(
"$ErrorActionPreference = 'Stop'
# The pipe gets UTF-8 or Windows' own French messages arrive as mojibake.
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
$pw = $env:ODIAN_NODE_SUDO
$env:ODIAN_NODE_SUDO = $null
$pgpw = $env:ODIAN_PG_PW
$env:ODIAN_PG_PW = $null
Start-Transcript -Path '{}' -Force | Out-Null
$ok = $true
try {{
{}
Write-Host ''
Write-Host 'Release finished.'
Write-Host '{}'
}} catch {{
Write-Host ''
Write-Host \"FAILED: $_\"
@@ -466,16 +534,31 @@ if (-not $ok) {{ exit 1 }}
",
log.display(),
body,
done,
log.display()
);
)
}
/// Write `ps` and run it with its output going to the console panel.
///
/// Secrets go in the ENVIRONMENT, never the command line: arguments are readable by anyone
/// who can list processes.
fn spawn_console(
work_dir: &Path,
name: &str,
ps: &str,
env: &[(&str, &str)],
console: &Shared,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(work_dir).map_err(|e| format!("cannot use {}: {e}", work_dir.display()))?;
let file = work_dir.join("release.ps1");
let file = work_dir.join(name);
std::fs::write(&file, ps).map_err(|e| format!("cannot write {}: {e}", file.display()))?;
let mut cmd = Command::new("powershell");
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file);
cmd.env("ODIAN_NODE_SUDO", sudo);
for (k, v) in env {
cmd.env(k, v);
}
// Nothing can type into this, so stdin is closed: a script that ever asks a question
// then fails at once instead of hanging forever with no window to answer in.
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
@@ -587,7 +670,10 @@ mod tests {
assert_eq!(d.tile_bytes, 100);
// Deduplicated, and the undated set does not drag the age to None.
assert_eq!(d.tile_datasets, vec!["protomaps_20260423".to_string(), "planet_heightmap".to_string()]);
assert_eq!(d.tiles_age_days, Some(161));
// The dataset name carries the upstream extract's date, not a bake time - there is no
// bake time anywhere in a tile index or a manifest, which is why none is shown.
assert_eq!(d.basemap_vintage.as_deref(), Some("20260423"));
assert_eq!(d.basemap_vintage_days, Some(161));
// A manifest with no tiles at all must not panic or invent numbers.
let empty = data_from_manifest(&serde_json::json!({}), "0.1.0", false, now);