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
98 changes: 83 additions & 15 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,17 @@ pub struct AppState {
/// HTTP client for uteke-serve (all memory CRUD + graph operations).
/// None if server is not running.
pub uteke_client: Option<crate::uteke_client::UtekeClient>,
/// Cached installed uteke version ("X.Y.Z"), probed once at startup via
/// `uteke --version`. `None` if the CLI couldn't be probed. Used to gate
/// features that require a minimum server version (e.g. Documents → 0.7.0).
/// Cached installed uteke version ("X.Y.Z"), probed once at startup.
/// For local servers this is the `uteke --version` CLI output; for remote
/// servers it is the version reported by the server's `/health` (if any).
/// `None` if unknown. Used to gate features that require a minimum server
/// version (e.g. Documents → 0.7.1).
pub uteke_version: Option<String>,
/// `true` when Corin talks to a remote uteke-serve (https/host URL).
/// Remote servers are user-managed, so an unknown version is treated
/// leniently (no upgrade banner, no hard gate) — the server's own
/// responses surface any real incompatibility.
pub uteke_remote: bool,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2669,7 +2676,7 @@ pub async fn disconnect_connection(
//
// Since uteke v0.7.0 (#614) documents are global — unique slugs across all
// namespaces. `namespace` is no longer accepted. Every command gates on
// `MIN_UTEKE_FOR_DOCS` ("0.7.0") so older servers never get ambiguous
// `MIN_UTEKE_FOR_DOCS` ("0.7.1") so older servers never get ambiguous
// global-slug requests. Frontend reads `uteke_version_status` to render an
// upgrade banner; `uteke_self_update` runs `uteke upgrade` and re-detects.

Expand All @@ -2678,46 +2685,89 @@ pub async fn disconnect_connection(
pub struct VersionStatus {
/// Detected `X.Y.Z`, or `None` if the `uteke` CLI couldn't be probed.
pub current: Option<String>,
/// Minimum uteke version that supports global documents ("0.7.0").
/// Minimum uteke version that supports global documents ("0.7.1").
pub required: String,
/// `true` iff `current >= required` (false when current is unknown).
pub supported: bool,
}

/// Reject the call unless the cached uteke version meets `min`.
/// Resolve the effective uteke version for gating.
///
/// Reads `AppState::uteke_version` (set once at startup). Returns a clear,
/// upgrade-instructing message when unsupported or when the version is unknown.
/// - **Remote server**: probe the server's self-reported version via
/// `/health` (authoritative for a user-managed server). The local CLI
/// version is irrelevant — it may be older or newer than the server.
/// - **Local server**: use the cached `uteke --version` output.
async fn resolve_uteke_version(
state: &tauri::State<'_, Arc<Mutex<AppState>>>,
) -> (bool, Option<String>) {
let (is_remote, client, cached) = {
let s = state.lock().await;
(
s.uteke_remote,
s.uteke_client.clone(),
s.uteke_version.clone(),
)
};
if is_remote {
if let Some(client) = client
&& client.is_available().await
{
let v = client.server_version().await;
return (true, v);
}
// Remote unreachable during this probe — fall back to whatever we know.
(true, cached)
} else {
(false, cached)
}
}

/// Reject the call unless the effective uteke version meets `min`.
///
/// For **remote** servers with an unknown version (e.g. an older server
/// that doesn't report version in `/health`) the call is allowed through —
/// remote servers are user-managed, and the server's own response surfaces
/// any real incompatibility (e.g. a 404/400 for a missing route).
async fn require_uteke_version(
state: &tauri::State<'_, Arc<Mutex<AppState>>>,
min: &str,
) -> Result<(), CommandError> {
let current = state.lock().await.uteke_version.clone();
let (is_remote, current) = resolve_uteke_version(state).await;
let ok = current
.as_ref()
.map(|c| crate::version_meets(c, min))
.unwrap_or(false);
// Remote + unknown version → don't block (user-managed server).
// Local + unknown version → block (we can't confirm capability).
.unwrap_or(is_remote);
if ok {
Ok(())
} else {
let hint = if is_remote {
"upgrade the uteke-serve on the remote host"
} else {
"run 'uteke upgrade' or use the Update button in Documents"
};
Err(CommandError::Uteke(format!(
"uteke {min} or newer is required for documents (current: {}). \
Run 'uteke upgrade' or use the Update button in Documents.",
"uteke {min} or newer is required for documents (current: {}). Please {hint}.",
current.as_deref().unwrap_or("unknown")
)))
}
}

/// Report the installed uteke version and whether it supports Documents.
/// Report the effective uteke version and whether it supports Documents.
///
/// Drives the Documents upgrade banner. For remote servers with an unknown
/// version, `supported` is `true` so users are not nagged to upgrade a server
/// they manage (and which may well be newer than the local CLI).
#[tauri::command]
pub async fn uteke_version_status(
state: tauri::State<'_, Arc<Mutex<AppState>>>,
) -> Result<VersionStatus, CommandError> {
let current = state.lock().await.uteke_version.clone();
let (is_remote, current) = resolve_uteke_version(&state).await;
let supported = current
.as_ref()
.map(|c| crate::version_meets(c, crate::MIN_UTEKE_FOR_DOCS))
.unwrap_or(false);
.unwrap_or(is_remote);
Ok(VersionStatus {
current,
required: crate::MIN_UTEKE_FOR_DOCS.to_string(),
Expand All @@ -2733,6 +2783,21 @@ pub async fn uteke_version_status(
pub async fn uteke_self_update(
state: tauri::State<'_, Arc<Mutex<AppState>>>,
) -> Result<VersionStatus, CommandError> {
let is_remote = state.lock().await.uteke_remote;
if is_remote {
// Upgrading the local CLI won't affect a user-managed remote server.
// Just re-probe the server's version so the banner can refresh.
let (_, current) = resolve_uteke_version(&state).await;
let supported = current
.as_ref()
.map(|c| crate::version_meets(c, crate::MIN_UTEKE_FOR_DOCS))
.unwrap_or(true);
return Ok(VersionStatus {
current,
required: crate::MIN_UTEKE_FOR_DOCS.to_string(),
supported,
});
}
let uteke = crate::find_uteke_cli().ok_or_else(|| {
CommandError::Uteke("uteke CLI not found — install via the setup script".into())
})?;
Expand Down Expand Up @@ -2783,6 +2848,9 @@ pub async fn doc_list(
if !client.is_available().await {
return Err(CommandError::Uteke("uteke-serve not reachable".into()));
}
// uteke-serve defaults /doc/list limit to 5 (shared with memory pagination).
// The document tree needs the full set client-side, so default high here.
let limit = Some(limit.unwrap_or(1000));
let docs = client
.doc_list(limit, roots_only, parent.as_deref())
.await
Expand Down
24 changes: 21 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,10 @@ fn find_uteke_serve() -> Option<std::path::PathBuf> {
None
}

/// Minimum uteke version required for the global Documents feature (v0.7.0, #614).
pub(crate) const MIN_UTEKE_FOR_DOCS: &str = "0.7.0";
/// Minimum uteke version required for the global Documents feature.
/// v0.7.0 introduced global docs (#614), but document delete/move landed in
/// v0.7.1 — require it so the full doc feature set is available.
pub(crate) const MIN_UTEKE_FOR_DOCS: &str = "0.7.1";

/// Find the `uteke` CLI binary in PATH or ~/.local/bin.
pub(crate) fn find_uteke_cli() -> Option<std::path::PathBuf> {
Expand Down Expand Up @@ -509,8 +511,15 @@ pub fn run() {
s.uteke_client = Some(client);
}

// Cache whether we're talking to a user-managed remote
// server (vs. a locally spawned uteke-serve).
s.uteke_remote = config::is_remote_url(&server_url);

// Cache the installed uteke version for feature
// gating (e.g. Documents requires >= 0.7.0).
// gating (e.g. Documents requires >= 0.7.1).
// For remote servers the authoritative version is
// probed live from /health in the gating commands;
// the local CLI version is only a fallback there.
s.uteke_version = detect_uteke_version();
}
Err(e) => {
Expand Down Expand Up @@ -602,6 +611,15 @@ mod tests {
assert!(version_meets("1.0.0", "0.7.0"));
}

#[test]
fn docs_gate_requires_0_7_1() {
// Document delete/move landed in 0.7.1; 0.7.0 must be rejected so the
// UI prompts for an upgrade instead of hitting a missing /doc/delete.
assert!(!version_meets("0.7.0", MIN_UTEKE_FOR_DOCS));
assert!(version_meets("0.7.1", MIN_UTEKE_FOR_DOCS));
assert!(version_meets("0.8.0", MIN_UTEKE_FOR_DOCS));
}

#[test]
fn version_meets_lower_rejected() {
assert!(!version_meets("0.6.7", "0.7.0"));
Expand Down
23 changes: 22 additions & 1 deletion src-tauri/src/uteke_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,27 @@ impl UtekeClient {
.unwrap_or(false)
}

/// Probe the server's self-reported version via `/health`.
///
/// Returns `Some("X.Y.Z")` when the server includes a `version` field
/// (uteke ≥ the /health-version patch), otherwise `None`. Used to gate
/// features for remote servers where the local `uteke` CLI version is
/// irrelevant — the server may be newer or older than the CLI.
pub async fn server_version(&self) -> Option<String> {
let resp = self
.authed(self.client.get(format!("{}/health", self.base_url)))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}

/// Semantic recall (vector + FTS5 hybrid search via RRF).
pub async fn recall(
&self,
Expand Down Expand Up @@ -931,7 +952,7 @@ impl UtekeClient {
//
// Since uteke v0.7.0 (#614) documents are GLOBAL — unique slugs across all
// namespaces. These methods send no `namespace`. Callers MUST gate access
// on `crate::MIN_UTEKE_FOR_DOCS` ("0.7.0") so older servers (where docs
// on `crate::MIN_UTEKE_FOR_DOCS` ("0.7.1") so older servers (where docs
// were namespace-scoped) never receive ambiguous global-slug requests.

/// List documents via POST /doc/list.
Expand Down