oxios 1.21.0

Oxios Agent OS — Agent Operating System powered by oxi-sdk
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! Web UI dist directory resolution and auto-download.
//!
//! This module runs before surfaces start to ensure the web UI is available.
//! If `~/.oxios/web/dist/index.html` doesn't exist, it downloads from GitHub Releases.
//!
//! The resolved path is passed to surfaces via [`SurfaceContext.web_dist`].
//! This avoids the race condition where the server starts listening before
//! the web UI is downloaded.

use anyhow::{Context, Result};
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::{Path, PathBuf};

const GITHUB_REPO: &str = "a7garden/oxios";

/// Returns `~/.oxios/web/` (parent of the dist directory).
fn user_web_root() -> Option<PathBuf> {
    dirs::home_dir().map(|h| h.join(".oxios").join("web"))
}

/// Returns `~/.oxios/web/dist/` path.
fn user_web_dist_dir() -> Option<PathBuf> {
    user_web_root().map(|r| r.join("dist"))
}

/// Returns the path to the active-dist marker file (`~/.oxios/web/.active`).
///
/// RFC-024 SP3: persists the path the in-memory atomic pointer last pointed
/// at, so a daemon restart resolves the same generation the previous process
/// was serving (the pointer itself does not survive restart).
pub fn active_marker_path() -> Option<PathBuf> {
    user_web_root().map(|r| r.join(".active"))
}

/// Diagnosis of the active web-dist, used by `oxios status` to report UI
/// integrity independently of process liveness. A daemon can be alive while
/// serving a dangling marker (a raced update deleted the active dir) —
/// exactly the "status says Running but the UI 404s" confusion this
/// disambiguates. Reads the *persisted* marker (not another process's
/// in-memory pointer).
pub enum WebUiHealth {
    /// Resolves to a self-consistent directory, optionally with a version.
    Ok {
        /// Absolute path of the served dist.
        path: PathBuf,
        /// Version string from `version.json`, when present.
        version: Option<String>,
    },
    /// Marker present but resolves to no usable directory.
    Broken {
        /// The marker path that would not resolve.
        marker: PathBuf,
    },
    /// No marker / nothing installed on this machine.
    NotInstalled,
}

/// Resolve the active web-dist health for status reporting.
pub fn diagnose_active() -> WebUiHealth {
    let Some(marker) = active_marker_path() else {
        return WebUiHealth::NotInstalled;
    };
    let legacy = user_web_dist_dir();
    match oxios_gateway::ActiveWebDist::resolve(&marker, legacy.as_deref()) {
        Some(p) => {
            let version = std::fs::read(p.join("version.json"))
                .ok()
                .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
                .and_then(|v| v["version"].as_str().map(str::to_string));
            WebUiHealth::Ok { path: p, version }
        }
        None => WebUiHealth::Broken { marker },
    }
}

/// Result of ensuring web UI availability.
#[derive(Debug)]
pub enum WebDistResult {
    /// Web UI found in `~/.oxios/web/dist/`.
    UserDir(PathBuf),
    /// Web UI found in `workspace/web/dist/`.
    WorkspaceDir(PathBuf),
    /// Downloaded from GitHub Releases.
    Downloaded { path: PathBuf, version: String },
    /// No filesystem web UI — embedded assets will be used.
    ///
    /// Reserved for future use when the binary is built with `rust-embed`.
    /// Currently not constructed by `ensure_web_dist` (downloaded dist is preferred)
    /// but exposed so callers can match exhaustively.
    #[allow(dead_code)]
    Embedded,
    /// Download failed — embedded assets will be used as fallback.
    DownloadFailed { reason: String },
}

impl WebDistResult {
    /// Returns the version tag without the leading 'v' prefix (for display).
    pub fn version_display(&self) -> Option<&str> {
        match self {
            WebDistResult::Downloaded { version, .. } => Some(version.trim_start_matches('v')),
            _ => None,
        }
    }
}

/// Format bytes into human-readable string.
fn format_size(bytes: u64) -> String {
    if bytes < 1024 {
        format!("{bytes} B")
    } else if bytes < 1024 * 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else {
        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
    }
}

/// Fetches the latest release tag from GitHub API.
async fn fetch_latest_release_tag() -> Result<String> {
    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
    let client = reqwest::Client::builder()
        .user_agent("oxios-web")
        .build()
        .context("failed to create HTTP client")?;
    let resp: serde_json::Value = client
        .get(&url)
        .send()
        .await
        .context("failed to fetch GitHub release info")?
        .json()
        .await
        .context("failed to parse GitHub response")?;
    let tag = resp["tag_name"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("tag_name not found in GitHub response"))?;
    Ok(tag.to_string())
}

/// Extract a `web-dist.zip` byte slice into `dest` (created if missing).
///
/// RFC-024 SP3: shared extraction used by both the startup download and the
/// daily health check so both land in a staging dir before an atomic publish.
/// Returns the number of files extracted. `dest` is cleared first if it
/// already exists (e.g. an interrupted prior run).
pub fn extract_zip_into(dest: &std::path::Path, bytes: &[u8]) -> Result<usize> {
    // Extract into a temp sibling first, then rename atomically into `dest`.
    // A crash mid-extract therefore never leaves a half-populated `dest`
    // that a later health check might publish, and two extracts to the same
    // deterministic staging path can't interleave (each owns its own temp
    // dir). `dest` is overwritten only once the new tree is fully written.
    let parent = dest.parent().context("staging dir has no parent")?;
    let tmp_name = format!(
        ".{}-extract.tmp",
        dest.file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("staging")
    );
    let tmp = parent.join(tmp_name);
    if tmp.exists() {
        std::fs::remove_dir_all(&tmp)?;
    }
    std::fs::create_dir_all(&tmp)?;

    let reader = std::io::Cursor::new(bytes);
    let mut archive = zip::ZipArchive::new(reader).context("invalid zip file")?;
    let mut count = 0usize;
    for i in 0..archive.len() {
        let mut file = archive.by_index(i).context("zip read error")?;
        let outpath = match file.enclosed_name() {
            Some(path) => tmp.join(path),
            None => continue,
        };
        if file.is_dir() {
            std::fs::create_dir_all(&outpath)?;
        } else {
            if let Some(p) = outpath.parent()
                && !p.exists()
            {
                std::fs::create_dir_all(p)?;
            }
            let mut outfile = std::fs::File::create(&outpath)?;
            std::io::copy(&mut file, &mut outfile)?;
            count += 1;
        }
    }

    // Publish the complete tree into place. Rename is atomic on the same
    // filesystem (parent is the same dir); remove a prior dest first so the
    // rename target never pre-exists.
    if dest.exists() {
        std::fs::remove_dir_all(dest)?;
    }
    std::fs::rename(&tmp, dest)
        .with_context(|| format!("failed to publish staging dir into {}", dest.display()))?;
    Ok(count)
}

/// Path for a versioned staging directory under `~/.oxios/web/`.
pub fn staging_dir_for(version_tag: &str) -> Option<PathBuf> {
    let id = version_tag.trim_start_matches('v');
    user_web_root().map(|r| r.join(format!("dist-{id}")))
}

/// Downloads `web-dist.zip` from a GitHub release and extracts it into a
/// **fresh, versioned staging directory** (`~/.oxios/web/dist-<version>/`).
///
/// RFC-024 SP3: never deletes the canonical `dist/` here — the caller
/// publishes the staging dir atomically via the in-memory pointer + marker
/// so concurrent requests never observe a half-extracted directory.
async fn download_and_extract_web_dist(version_tag: &str) -> Result<PathBuf> {
    let web_root =
        user_web_root().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
    // Versioned dir so multiple generations can coexist during the swap.
    let version_id = version_tag.trim_start_matches('v');
    let dist_dir = web_root.join(format!("dist-{version_id}"));

    let url =
        format!("https://github.com/{GITHUB_REPO}/releases/download/{version_tag}/web-dist.zip");

    let client = reqwest::Client::builder()
        .user_agent("oxios-web")
        .build()
        .context("failed to create HTTP client")?;

    // ── Download with progress bar ─────────────────────────────────────────
    let resp = client
        .get(&url)
        .send()
        .await
        .context("download request failed")?;

    if !resp.status().is_success() {
        anyhow::bail!("Failed to download web-dist.zip: HTTP {}", resp.status());
    }

    let total_size = resp.content_length().unwrap_or(0);
    let pb = ProgressBar::new(total_size);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("  {spinner} {msg}  [{bar:>.dim}] {bytes}/{total_bytes} ({bytes_per_sec})")
            .unwrap()
            .progress_chars("█▉▊▋▌▍▎▏  "),
    );
    let tag_label = style(version_tag).cyan().to_string();
    pb.set_message(format!("Downloading web UI {tag_label}"));

    let bytes = resp.bytes().await.context("failed to read response body")?;

    let ok = style("").green().to_string();
    let downloaded = style("Downloaded").green().to_string();
    let done_msg = format!(
        "  {} {} ({})",
        ok,
        downloaded,
        format_size(bytes.len() as u64)
    );
    pb.finish_with_message(done_msg);

    // ── Extract with progress ─────────────────────────────────────────────
    let reader = std::io::Cursor::new(bytes.as_ref());
    let mut archive = zip::ZipArchive::new(reader).context("invalid zip file")?;
    let file_count = archive.len();

    let extract_pb = ProgressBar::new(file_count as u64);
    extract_pb.set_style(
        ProgressStyle::default_bar()
            .template("  {spinner} {msg}  [{bar:>.dim}] {pos}/{len}")
            .unwrap()
            .progress_chars("█▉▊▋▌▍▎▏  "),
    );
    extract_pb.set_message("Extracting files".to_string());

    // Clear any pre-existing staging dir for this exact version (interrupted
    // prior run), then create fresh. The canonical `dist/` is left untouched.
    if dist_dir.exists() {
        std::fs::remove_dir_all(&dist_dir)?;
    }
    std::fs::create_dir_all(&dist_dir)?;

    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let outpath = match file.enclosed_name() {
            Some(path) => dist_dir.join(path),
            None => continue,
        };
        if file.is_dir() {
            std::fs::create_dir_all(&outpath)?;
        } else {
            if let Some(p) = outpath.parent()
                && !p.exists()
            {
                std::fs::create_dir_all(p)?;
            }
            let mut outfile = std::fs::File::create(&outpath)?;
            std::io::copy(&mut file, &mut outfile)?;
        }
        extract_pb.inc(1);
    }

    let ok = style("").green().to_string();
    let done_msg = format!("  {ok} {file_count} files extracted");
    extract_pb.finish_with_message(done_msg);

    tracing::info!(
        path = ?dist_dir,
        version = %version_tag,
        "Web UI downloaded and extracted"
    );

    Ok(dist_dir)
}

/// Ensures the web UI is available, downloading from GitHub if needed.
///
/// Resolution order (RFC-024 SP3, marker-aware):
///  1. `~/.oxios/web/.active` marker → generation last served (survives restart)
///  2. `~/.oxios/web/dist/index.html` — legacy / user override
///  3. `workspace/web/dist/index.html` — bundled / dev mode
///  4. Download from GitHub Releases into a fresh versioned staging dir,
///     then publish via marker so restarts resolve it.
///
/// Returns a [`WebDistResult`] describing what happened. The returned path
/// is the directory the caller should publish as the active dist.
pub async fn ensure_web_dist(workspace: &Path) -> WebDistResult {
    let marker = active_marker_path();
    let legacy = user_web_dist_dir();

    // 1. Marker (RFC-024): generation the previous process was serving.
    if let Some(m) = marker.as_ref()
        && let Some(p) = oxios_gateway::ActiveWebDist::resolve(m, legacy.as_deref())
    {
        tracing::info!(path = ?p, "Serving web UI from active marker");
        return WebDistResult::UserDir(p);
    }

    // 2. ~/.oxios/web/dist/ (legacy / user override). Must be internally
    //    self-consistent — a dir that mixes two builds (entry chunk 404s)
    //    is skipped so we fall through to a fresh download instead of
    //    serving a broken page forever.
    if let Some(dist) = &legacy
        && oxios_gateway::ActiveWebDist::dist_is_consistent(dist)
    {
        tracing::info!(path = ?dist, "Serving web UI from ~/.oxios/web/dist/");
        return WebDistResult::UserDir(dist.clone());
    }

    // 3. workspace/web/dist/ (bundled / dev)
    let workspace_dist = workspace.join("web").join("dist");
    if oxios_gateway::ActiveWebDist::dist_is_consistent(&workspace_dist) {
        tracing::info!(path = ?workspace_dist, "Serving web UI from workspace (web/dist/)");
        return WebDistResult::WorkspaceDir(workspace_dist);
    }

    // 4. Auto-download from GitHub Releases (with bounded retry so a transient
    //    network blip or rate-limit doesn't strand the daemon serving 503
    //    until a manual `oxios update --web-only`). Each attempt retries the
    //    full tag-lookup + download pair.
    tracing::info!("No web UI found locally, downloading from GitHub Releases...");
    const MAX_ATTEMPTS: u32 = 3;
    let mut last_reason = String::from("unknown error");
    for attempt in 1..=MAX_ATTEMPTS {
        let outcome = match fetch_latest_release_tag().await {
            Ok(tag) => match download_and_extract_web_dist(&tag).await {
                Ok(path) => Some((tag, path)),
                Err(e) => {
                    last_reason = e.to_string();
                    None
                }
            },
            Err(e) => {
                last_reason = e.to_string();
                None
            }
        };

        if let Some((tag, path)) = outcome {
            // Validate the freshly-extracted dist is internally consistent
            // before honoring it — a corrupt release asset (or a zip that
            // drops the entry chunk) must not strand the daemon serving a
            // broken page. Treat a failed check like a download failure so
            // the bounded retry loop kicks in.
            if !oxios_gateway::ActiveWebDist::dist_is_consistent(&path) {
                last_reason = format!(
                    "extracted dist for {tag} is not self-consistent \
                     (index.html references missing assets)"
                );
                tracing::warn!(
                    attempt,
                    tag = %tag,
                    "extracted web dist is not self-consistent; retrying"
                );
            } else {
                // Publish the freshly-extracted staging dir so restarts
                // resolve it via the marker.
                if let Some(m) = marker.as_ref() {
                    let _ = std::fs::write(m, path.to_string_lossy().as_bytes());
                }
                return WebDistResult::Downloaded { path, version: tag };
            }
        }

        if attempt < MAX_ATTEMPTS {
            tracing::warn!(
                attempt,
                max = MAX_ATTEMPTS,
                reason = %last_reason,
                "Web UI download failed, retrying"
            );
            tokio::time::sleep(std::time::Duration::from_secs(2 * attempt as u64)).await;
        } else {
            tracing::warn!(reason = %last_reason, "Web UI download failed (no retries left)");
        }
    }
    WebDistResult::DownloadFailed {
        reason: last_reason,
    }
}