mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-11 09:59:34 +08:00
Configurable data directory: choose, change, and move with progress (#80)
Let users pick where orx stores everything (orx.db + WAL, run-logs/, artifacts/, chat-attachments/, agent-*.log) instead of assuming ~/.local/share/openresearch. The default is unchanged; the location is now choosable from the product. Resolution order in store::data_dir(): $ORX_DATA_DIR (forced override) → persisted user choice → $XDG_DATA_HOME → default. An explicit UI choice beats the ambient XDG base; the env var stays authoritative for sandboxes/tests. Empty env vars are treated as unset. - Persisted path lives in config_dir()/settings.json, OWNED by telemetry's locked+atomic mutate_settings (in-process mutex + cross-process flock + temp -and-rename + corrupt-file refusal). data_dir is a field on telemetry::Settings so a write can't clobber installId/telemetryDisabled and vice-versa. - Move engine (src/local/datadir.rs): validate → WAL checkpoint → same-fs rename (atomic, consumes old) or cross-fs copy-with-progress (verify, then KEEP the old dir, reported as oldPathLeft — never deletes data a stray writer might still be using) → swap the persisted path. Free-space pre-check via statvfs; EXDEV falls back to copy. - Endpoints (up.rs): GET/POST /api/settings/data-dir, /validate, /move. Move streams datadir.move.progress/done/error over the existing SSE broadcast and returns 202. A move-in-progress flag (claimed via compare_exchange) makes every store-mutating handler back off with 409 for the duration. - After a move, harness children are restarted so any that pinned the old $ORX_DATA_DIR (Codex) respawn on the new path. UI: - Settings → Storage card: current path + source, validate, move with a new byte-level progress bar, env-forced read-only state, in-flight 409 handling. - Onboarding gains a "Storage" step (default pre-filled, skippable). - Files view links "Change storage location →" to Settings → Storage. - Chat send surfaces the move-in-progress 409 and restores the draft instead of dropping it. human_bytes shared via store::human_bytes; fmtBytes shared via api.ts.
This commit is contained in:
@@ -7,3 +7,11 @@ Claude.local.md
|
||||
# never be committed — they get recorded as orphan gitlinks with no .gitmodules
|
||||
# entry, which breaks `git checkout --recurse-submodules` in CI.
|
||||
.claude/worktrees/
|
||||
|
||||
# Stray SQLite store if the binary is ever run from the repo root
|
||||
/orx.db
|
||||
/orx.db-wal
|
||||
/orx.db-shm
|
||||
|
||||
# This repo uses pnpm; ignore an accidental npm lockfile
|
||||
ui/package-lock.json
|
||||
|
||||
Generated
+1
@@ -938,6 +938,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"fd-lock",
|
||||
"futures",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rust-embed",
|
||||
|
||||
@@ -36,6 +36,11 @@ futures = "0.3"
|
||||
# async fns in the Harness trait (object-safe `dyn Harness` in the registry).
|
||||
async-trait = "0.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# Free-space pre-check for the data-dir move (statvfs). Already in the tree
|
||||
# transitively; taken as a direct dep only on unix, where we call it.
|
||||
libc = "0.2"
|
||||
|
||||
# The profile that 'dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
|
||||
@@ -27,7 +27,12 @@ pub async fn run(args: crate::ArtifactsArgs) -> Result<()> {
|
||||
|
||||
let rows: Vec<Vec<String>> = artifacts
|
||||
.iter()
|
||||
.map(|a| vec![a.key.clone(), human_bytes(a.size)])
|
||||
.map(|a| {
|
||||
vec![
|
||||
a.key.clone(),
|
||||
crate::store::human_bytes(a.size.max(0) as u64),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print_table(&["KEY", "SIZE"], &rows);
|
||||
|
||||
@@ -38,18 +43,3 @@ pub async fn run(args: crate::ArtifactsArgs) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compact human-readable byte size (e.g. `1.2 KB`, `3.4 MB`).
|
||||
fn human_bytes(n: i64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
if n < 1024 {
|
||||
return format!("{} B", n);
|
||||
}
|
||||
let mut size = n as f64;
|
||||
let mut unit = 0;
|
||||
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
format!("{:.1} {}", size, UNITS[unit])
|
||||
}
|
||||
|
||||
+302
-5
@@ -52,6 +52,7 @@ pub async fn run(args: UpArgs) -> Result<()> {
|
||||
agent: agent.clone(),
|
||||
chat: Arc::new(ChatHost::new(agent.clone(), codex.clone())),
|
||||
harnesses: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
data_dir_move_in_progress: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
spawn_hf_preflight();
|
||||
@@ -129,6 +130,10 @@ struct AppState {
|
||||
/// Harness detection cache — detection shells out to CLIs, so it's rate-
|
||||
/// limited to once per TTL unless the UI asks for a refresh.
|
||||
harnesses: Arc<tokio::sync::Mutex<Option<(std::time::Instant, Value)>>>,
|
||||
/// Set while a data-dir move is running. New chat turns and run launches
|
||||
/// check it and refuse (409) so nothing starts writing the store mid-move —
|
||||
/// closing the window between the move's in-flight check and its completion.
|
||||
data_dir_move_in_progress: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
fn router(state: AppState) -> Router {
|
||||
@@ -180,6 +185,12 @@ fn router(state: AppState) -> Router {
|
||||
"/api/settings/env/{key}",
|
||||
axum::routing::delete(delete_env_var),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/data-dir",
|
||||
get(data_dir_settings).post(set_data_dir),
|
||||
)
|
||||
.route("/api/settings/data-dir/validate", post(validate_data_dir))
|
||||
.route("/api/settings/data-dir/move", post(move_data_dir))
|
||||
.route(
|
||||
"/api/settings/git",
|
||||
get(git_settings).post(set_git_settings),
|
||||
@@ -377,7 +388,11 @@ struct CreateProjectReq {
|
||||
fork_repo: bool,
|
||||
}
|
||||
|
||||
async fn create_project(Json(req): Json<CreateProjectReq>) -> ApiResult {
|
||||
async fn create_project(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateProjectReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let name = req.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad_request("name is required"));
|
||||
@@ -476,7 +491,12 @@ struct UpdateProjectReq {
|
||||
run_command: Option<Option<String>>,
|
||||
}
|
||||
|
||||
async fn update_project(Path(id): Path<String>, Json(req): Json<UpdateProjectReq>) -> ApiResult {
|
||||
async fn update_project(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateProjectReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
if req.name.is_none() && req.run_command.is_none() {
|
||||
return Err(bad_request(
|
||||
"nothing to update: pass name and/or runCommand",
|
||||
@@ -509,6 +529,7 @@ async fn update_project(Path(id): Path<String>, Json(req): Json<UpdateProjectReq
|
||||
/// requests their cancellation, so a retry shortly after goes through. The
|
||||
/// GitHub repo and the cache clone are left untouched.
|
||||
async fn delete_project(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let store = Store::open()?;
|
||||
let project = store
|
||||
.get_local_project(&id)?
|
||||
@@ -604,9 +625,11 @@ struct CreateExperimentReq {
|
||||
}
|
||||
|
||||
async fn create_experiment(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<CreateExperimentReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
// Branch create + push shells out to git (network); off the async workers.
|
||||
let experiment = tokio::task::spawn_blocking(move || {
|
||||
let store = Store::open()?;
|
||||
@@ -655,7 +678,12 @@ struct RunReq {
|
||||
org: Option<String>,
|
||||
}
|
||||
|
||||
async fn run_experiment(Path(id): Path<String>, body: Bytes) -> ApiResult {
|
||||
async fn run_experiment(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
body: Bytes,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
// Tolerate an empty body — every field is optional in the schema.
|
||||
let req: RunReq = if body.is_empty() {
|
||||
RunReq::default()
|
||||
@@ -1013,7 +1041,12 @@ async fn file_report(Path(id): Path<String>, Query(q): Query<FilePathQuery>) ->
|
||||
}
|
||||
|
||||
/// Delete a file or report folder in the files dir, by relative path.
|
||||
async fn delete_file(Path(id): Path<String>, Query(q): Query<FilePathQuery>) -> ApiResult {
|
||||
async fn delete_file(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<FilePathQuery>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
blocking_api(move || {
|
||||
let store = Store::open()?;
|
||||
let project = store
|
||||
@@ -1364,6 +1397,263 @@ async fn delete_env_var(Path(key): Path<String>) -> ApiResult {
|
||||
.map_err(|e| ApiError::from(anyhow!("env task failed: {e}")))?
|
||||
}
|
||||
|
||||
// --- data directory ---------------------------------------------------------
|
||||
|
||||
/// Current data-dir state for the Storage settings card: where it resolves,
|
||||
/// whether that's the default, the path we'd fall back to, and *why* it resolves
|
||||
/// where it does (so the UI can lock the field when `$ORX_DATA_DIR` forces it).
|
||||
fn data_dir_json() -> Value {
|
||||
use crate::store::DataDirSource;
|
||||
let current = crate::store::data_dir();
|
||||
let default = crate::store::default_data_dir();
|
||||
let source = crate::store::data_dir_source();
|
||||
json!({
|
||||
"current": current.to_string_lossy(),
|
||||
"defaultPath": default.to_string_lossy(),
|
||||
// "On the fallback chain" = no explicit choice (env pin or saved config).
|
||||
// Env can happen to equal the default path but is still a forced override.
|
||||
"isDefault": matches!(source, DataDirSource::Xdg | DataDirSource::Default),
|
||||
// env | config | xdg | default — env means a forced override (read-only).
|
||||
"source": source,
|
||||
})
|
||||
}
|
||||
|
||||
async fn data_dir_settings() -> ApiResult {
|
||||
tokio::task::spawn_blocking(|| Ok(Json(data_dir_json())))
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("data-dir task failed: {e}")))?
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DataDirReq {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Reject a mutation when `$ORX_DATA_DIR` is forcing the path — the config value
|
||||
/// would be shadowed, so honoring the request would silently do nothing.
|
||||
fn ensure_not_env_forced() -> std::result::Result<(), ApiError> {
|
||||
if crate::store::data_dir_source() == crate::store::DataDirSource::Env {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"The data directory is pinned by the ORX_DATA_DIR environment \
|
||||
variable, which overrides this setting. Unset it to choose a path here."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-flight a candidate path for a **move** without committing: absolute? empty
|
||||
/// target? room? Returns `{ ok, error?, treeBytes, freeBytes?, sameFilesystem }`.
|
||||
async fn validate_data_dir(Json(req): Json<DataDirReq>) -> ApiResult {
|
||||
use crate::local::datadir::TargetIntent;
|
||||
let path = req.path.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return Err(bad_request("path is required"));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match crate::local::datadir::validate_target(
|
||||
std::path::Path::new(&path),
|
||||
TargetIntent::Move,
|
||||
) {
|
||||
Ok(report) => Ok(Json(json!({
|
||||
"ok": true,
|
||||
"treeBytes": report.tree_bytes,
|
||||
"freeBytes": report.free_bytes,
|
||||
"sameFilesystem": report.same_filesystem,
|
||||
}))),
|
||||
Err(e) => Ok(Json(json!({ "ok": false, "error": e.to_string() }))),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("validate task failed: {e}")))?
|
||||
}
|
||||
|
||||
/// Set the data dir *without moving* — for onboarding on an empty install, or
|
||||
/// reconnecting to an already-populated location (a second machine, after config
|
||||
/// loss). The UI routes here only when the current dir has nothing to migrate;
|
||||
/// otherwise it calls `/move`. Uses `TargetIntent::Set`, which (unlike `Move`)
|
||||
/// permits a populated existing dir since nothing is copied.
|
||||
async fn set_data_dir(State(state): State<AppState>, Json(req): Json<DataDirReq>) -> ApiResult {
|
||||
use crate::local::datadir::TargetIntent;
|
||||
reject_if_moving(&state)?;
|
||||
ensure_not_env_forced()?;
|
||||
let path = req.path.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return Err(bad_request("path is required"));
|
||||
}
|
||||
// Validate before persisting so we never store a bad path.
|
||||
let validate_path = path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::local::datadir::validate_target(
|
||||
std::path::Path::new(&validate_path),
|
||||
TargetIntent::Set,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("validate task failed: {e}")))?
|
||||
.map_err(bad_request)?;
|
||||
|
||||
tokio::task::spawn_blocking(move || crate::config::set_settings_data_dir(Some(path)))
|
||||
.await
|
||||
.map_err(|e| ApiError::from(anyhow!("settings task failed: {e}")))??;
|
||||
Ok(Json(data_dir_json()))
|
||||
}
|
||||
|
||||
/// Relocate the data dir to `path`, streaming `datadir.move.*` progress events
|
||||
/// over `/api/events`. Returns 202 immediately; the UI watches the SSE stream.
|
||||
///
|
||||
/// Concurrency safety: sets `data_dir_move_in_progress` *first*, then refuses
|
||||
/// (409) if a run or chat turn is already active. The substantive store-mutating
|
||||
/// handlers (`send`/`launch`, project/experiment/chat CRUD, file delete,
|
||||
/// `set_data_dir`) check the flag on entry and back off, so once the move is
|
||||
/// underway nothing new writes the store. Even the residual races don't lose
|
||||
/// data: a request that passed its own flag check in the tiny window before this
|
||||
/// one set the flag — or an unguarded incidental write (an `open_project`
|
||||
/// timestamp touch, an `ssh_preflight` test row) — lands in the *old* dir, but
|
||||
/// the cross-filesystem path never deletes it (it's returned as `oldPathLeft`),
|
||||
/// so the write is preserved there; only the atomic same-filesystem rename
|
||||
/// consumes the old dir, and that path has no copy window.
|
||||
async fn move_data_dir(State(state): State<AppState>, Json(req): Json<DataDirReq>) -> Response {
|
||||
use crate::local::datadir::TargetIntent;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
if let Err(e) = ensure_not_env_forced() {
|
||||
return e.into_response();
|
||||
}
|
||||
let path = req.path.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return bad_request("path is required").into_response();
|
||||
}
|
||||
|
||||
// Claim the move slot first (compare-exchange): only one move at a time, and
|
||||
// once claimed, new turns/launches see the flag and back off.
|
||||
if state
|
||||
.data_dir_move_in_progress
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"A data-directory move is already in progress.".into(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Helper to release the slot on any early return.
|
||||
let release = |state: &AppState| {
|
||||
state
|
||||
.data_dir_move_in_progress
|
||||
.store(false, Ordering::SeqCst);
|
||||
};
|
||||
|
||||
// In-flight guard: block if a chat turn or a run is active right now. (The
|
||||
// flag we just set prevents *new* ones from starting past this point.)
|
||||
let busy = state.chat.busy_sessions().await;
|
||||
let active_runs = tokio::task::spawn_blocking(active_run_count)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if !busy.is_empty() || active_runs > 0 {
|
||||
release(&state);
|
||||
return ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"Can't move while work is in progress ({} active chat turn(s), \
|
||||
{active_runs} active run(s)). Finish or stop them, then retry.",
|
||||
busy.len()
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Validate before kicking off the background move.
|
||||
let vpath = path.clone();
|
||||
let validated = tokio::task::spawn_blocking(move || {
|
||||
crate::local::datadir::validate_target(std::path::Path::new(&vpath), TargetIntent::Move)
|
||||
})
|
||||
.await;
|
||||
match validated {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => {
|
||||
release(&state);
|
||||
return bad_request(e).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
release(&state);
|
||||
return ApiError::from(anyhow!("validate task failed: {e}")).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the move on a blocking task (it does synchronous FS work); forward
|
||||
// throttled progress onto the SSE broadcast, clear the flag when done.
|
||||
let chat = state.chat.clone();
|
||||
let flag = state.data_dir_move_in_progress.clone();
|
||||
let target = std::path::PathBuf::from(path);
|
||||
tokio::spawn(async move {
|
||||
use crate::local::datadir::MoveProgress;
|
||||
let chat_for_progress = chat.clone();
|
||||
// Throttle: forward at most one progress event per ~120ms of copy, but
|
||||
// always emit phase edges (copied==0 or ==total) so the first/last tick
|
||||
// of every phase gets through.
|
||||
let last = std::sync::Mutex::new(0i64);
|
||||
let on_progress = move |p: MoveProgress| {
|
||||
let now = crate::store::now_ms();
|
||||
let mut guard = last.lock().unwrap();
|
||||
let is_edge = p.copied_bytes == 0 || p.copied_bytes >= p.total_bytes;
|
||||
if is_edge || now - *guard >= 120 {
|
||||
*guard = now;
|
||||
chat_for_progress.emit_event("datadir.move.progress", json!(p));
|
||||
}
|
||||
};
|
||||
let target_for_move = target.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
crate::local::datadir::move_data_dir(target_for_move, on_progress)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(outcome)) => {
|
||||
// Restart harness children so any that pinned the old data dir
|
||||
// (Codex hard-pins $ORX_DATA_DIR at spawn) respawn on the new one.
|
||||
chat.shutdown_harnesses().await;
|
||||
chat.emit_event("datadir.move.done", json!(outcome));
|
||||
}
|
||||
Ok(Err(e)) => chat.emit_event("datadir.move.error", json!({ "error": e.to_string() })),
|
||||
Err(e) => chat.emit_event(
|
||||
"datadir.move.error",
|
||||
json!({ "error": format!("move task panicked: {e}") }),
|
||||
),
|
||||
}
|
||||
flag.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
(StatusCode::ACCEPTED, Json(json!({ "started": true }))).into_response()
|
||||
}
|
||||
|
||||
/// Count runs currently in an active state (`starting`/`running`), for the
|
||||
/// data-dir move's in-flight guard. SQL-side and unbounded (see
|
||||
/// `Store::count_active_runs`).
|
||||
fn active_run_count() -> usize {
|
||||
Store::open()
|
||||
.and_then(|s| s.count_active_runs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Refuse an operation that would write the store while a data-dir move is in
|
||||
/// progress — the move relies on nothing new touching the old dir mid-flight.
|
||||
fn reject_if_moving(state: &AppState) -> std::result::Result<(), ApiError> {
|
||||
if state
|
||||
.data_dir_move_in_progress
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"A data-directory move is in progress. Try again once it finishes.".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- git settings -----------------------------------------------------------
|
||||
|
||||
fn git_out(args: &[&str]) -> Option<String> {
|
||||
@@ -1799,7 +2089,11 @@ struct CreateChatSessionReq {
|
||||
reasoning_level: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_chat_session(Json(req): Json<CreateChatSessionReq>) -> ApiResult {
|
||||
async fn create_chat_session(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateChatSessionReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
if !local::harness::is_chat_harness(&req.harness) {
|
||||
return Err(bad_request(format!("unknown harness: {}", req.harness)));
|
||||
}
|
||||
@@ -1828,6 +2122,7 @@ async fn create_chat_session(Json(req): Json<CreateChatSessionReq>) -> ApiResult
|
||||
}
|
||||
|
||||
async fn delete_chat_session(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
state.chat.delete_session(&id).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -1844,6 +2139,7 @@ async fn update_chat_session(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateChatSessionReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let session = if let Some(title) = req.title {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
@@ -1893,6 +2189,7 @@ async fn send_chat_message(
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SendChatReq>,
|
||||
) -> ApiResult {
|
||||
reject_if_moving(&state)?;
|
||||
let text = req.text.trim().to_string();
|
||||
if text.is_empty() && req.images.is_empty() {
|
||||
return Err(bad_request("text is required"));
|
||||
|
||||
@@ -100,6 +100,27 @@ pub async fn clear_credentials() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-chosen data dir, if one is persisted and non-empty. Consumed by
|
||||
/// `store::data_dir()` between the `$ORX_DATA_DIR` override and the XDG default.
|
||||
///
|
||||
/// `settings.json` (in `config_dir()`, deliberately *outside* the data dir so it
|
||||
/// can point *at* it without a chicken-and-egg) is owned by `crate::telemetry`,
|
||||
/// which already guards it with an in-process mutex + cross-process flock +
|
||||
/// atomic temp-and-rename RMW. We delegate rather than add a second writer — a
|
||||
/// naive whole-object write here would clobber `installId`/`telemetryDisabled`
|
||||
/// (and vice-versa). Sync because `store::data_dir()` calls it on every open.
|
||||
pub fn settings_data_dir() -> Option<PathBuf> {
|
||||
crate::telemetry::persisted_data_dir().map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Set or clear the persisted data dir, preserving every other settings field.
|
||||
/// Delegates to `telemetry::set_persisted_data_dir` (the locked, atomic RMW).
|
||||
/// `None` clears it (revert to the env/XDG/default chain).
|
||||
pub fn set_settings_data_dir(data_dir: Option<String>) -> Result<()> {
|
||||
crate::telemetry::set_persisted_data_dir(data_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a var from the box's synced env file (`~/.openresearch/env`, written
|
||||
/// by the api's env sync). Needed because non-interactive shells never source
|
||||
/// it via .bashrc (Ubuntu's interactive guard returns first), so an agent's
|
||||
|
||||
@@ -373,6 +373,22 @@ impl ChatHost {
|
||||
let _ = self.events.send((name, data));
|
||||
}
|
||||
|
||||
/// Publish an arbitrary named event onto the SSE broadcast that `/api/events`
|
||||
/// forwards. Used by non-chat features (e.g. the data-dir move) that want to
|
||||
/// stream progress to the UI without standing up a second channel.
|
||||
pub fn emit_event(&self, name: &'static str, data: Value) {
|
||||
self.emit(name, data);
|
||||
}
|
||||
|
||||
/// Shut down both harness hosts' long-lived child processes. They respawn
|
||||
/// lazily on the next turn — used after a data-dir move so a child that
|
||||
/// captured the old path (Codex hard-pins `$ORX_DATA_DIR` at spawn) comes
|
||||
/// back resolving the new one.
|
||||
pub async fn shutdown_harnesses(&self) {
|
||||
self.opencode.shutdown().await;
|
||||
self.codex.shutdown().await;
|
||||
}
|
||||
|
||||
pub async fn busy_sessions(&self) -> Vec<String> {
|
||||
self.turns.lock().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Relocating the orx data dir (`orx.db` + `run-logs/` + `artifacts/` +
|
||||
//! `chat-attachments/` + `agent-*.log`) to a user-chosen path.
|
||||
//!
|
||||
//! The whole dir is a self-contained, relocatable unit (the api already tars it
|
||||
//! for R2 snapshot/restore), so a move is: validate target → checkpoint the DB →
|
||||
//! copy the tree (streaming byte progress) → verify → swap the persisted path in
|
||||
//! `settings.json` (after which `store::data_dir()` returns the new path) →
|
||||
//! delete the old copy. Non-destructive: the old tree survives until the copy is
|
||||
//! verified and the path is swapped, so an interruption never loses data.
|
||||
//!
|
||||
//! The caller (the `up` handler) is responsible for refusing a move while a run
|
||||
//! or chat turn is in flight — this module assumes writers are quiesced enough
|
||||
//! that the source tree is stable for the copy.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{anyhow, Result};
|
||||
use crate::store::{self, human_bytes, Store};
|
||||
|
||||
/// Outcome of `validate_target`, carried to the UI so it can show free/needed
|
||||
/// bytes before the user commits.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValidateReport {
|
||||
/// Total bytes the current data dir occupies (what will be copied).
|
||||
pub tree_bytes: u64,
|
||||
/// Free bytes on the target's filesystem, if determinable.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub free_bytes: Option<u64>,
|
||||
/// True when source and target resolve to the same filesystem, so the move
|
||||
/// can use an instant `rename` instead of a copy.
|
||||
pub same_filesystem: bool,
|
||||
}
|
||||
|
||||
/// Validate a proposed data-dir path, returning a report for the UI. Errors are
|
||||
/// human-readable (surfaced inline in the Storage settings form).
|
||||
///
|
||||
/// What the caller intends to do with the validated target — the emptiness rule
|
||||
/// differs. A **move** copies the current tree in, so the target must be absent
|
||||
/// or empty. A **set-without-move** just re-points at a location, so an existing
|
||||
/// populated orx dir (reconnecting on a second machine, after config loss) is
|
||||
/// allowed — only a plain file or the current dir is rejected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TargetIntent {
|
||||
Move,
|
||||
Set,
|
||||
}
|
||||
|
||||
/// Validate a proposed data-dir `target` for the given `intent`, returning a
|
||||
/// report for the UI.
|
||||
///
|
||||
/// Always rejects: non-absolute paths, the current data dir itself, any path
|
||||
/// *inside* the current data dir (a move would copy into itself), a plain file,
|
||||
/// and a non-existent parent. For `Move`, additionally rejects a non-empty
|
||||
/// existing target and a target volume without room for the tree; for `Set`, a
|
||||
/// populated existing dir is allowed (nothing is copied).
|
||||
pub fn validate_target(target: &Path, intent: TargetIntent) -> Result<ValidateReport> {
|
||||
if !target.is_absolute() {
|
||||
return Err(anyhow!("Path must be absolute (start with /)."));
|
||||
}
|
||||
|
||||
let current = store::data_dir();
|
||||
// Normalize both for comparison without requiring the target to exist yet.
|
||||
let current_norm = current.canonicalize().unwrap_or_else(|_| current.clone());
|
||||
if paths_equal(target, ¤t_norm) {
|
||||
return Err(anyhow!("That's already the current data directory."));
|
||||
}
|
||||
if target.starts_with(¤t_norm) {
|
||||
return Err(anyhow!(
|
||||
"Can't put the data directory inside itself ({}).",
|
||||
current_norm.display()
|
||||
));
|
||||
}
|
||||
|
||||
// The parent must exist (a move/create won't `mkdir -p` arbitrary ancestors).
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("Path has no parent directory."))?;
|
||||
if !parent.exists() {
|
||||
return Err(anyhow!(
|
||||
"Parent directory doesn't exist: {}",
|
||||
parent.display()
|
||||
));
|
||||
}
|
||||
|
||||
// A plain file (or symlink) at the target is never valid.
|
||||
if target.exists() {
|
||||
let md = std::fs::symlink_metadata(target)
|
||||
.map_err(|e| anyhow!("Can't stat {}: {e}", target.display()))?;
|
||||
if md.is_file() || md.file_type().is_symlink() {
|
||||
return Err(anyhow!("A file already exists at {}.", target.display()));
|
||||
}
|
||||
// A move copies into the target, so it must be empty. A set just points
|
||||
// at it, so an existing (possibly populated) dir is fine.
|
||||
if intent == TargetIntent::Move
|
||||
&& md.is_dir()
|
||||
&& std::fs::read_dir(target)
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"Directory {} already exists and isn't empty. Choose an empty \
|
||||
or new folder to move into.",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Free-space only matters for a move (a set copies nothing).
|
||||
let tree_bytes = dir_size(¤t);
|
||||
let free_bytes = available_bytes(parent);
|
||||
if intent == TargetIntent::Move {
|
||||
if let Some(free) = free_bytes {
|
||||
if free < tree_bytes {
|
||||
return Err(anyhow!(
|
||||
"Not enough space: need {}, only {} free on the target volume.",
|
||||
human_bytes(tree_bytes),
|
||||
human_bytes(free)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let same_filesystem = same_device(¤t, parent);
|
||||
|
||||
Ok(ValidateReport {
|
||||
tree_bytes,
|
||||
free_bytes,
|
||||
same_filesystem,
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase of an in-progress move, for the UI's progress label.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MovePhase {
|
||||
Preparing,
|
||||
Copying,
|
||||
Verifying,
|
||||
Finalizing,
|
||||
}
|
||||
|
||||
/// How a completed move disposed of the old directory. Returned so the handler
|
||||
/// can tell the user whether a copy was left behind to delete manually.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MoveOutcome {
|
||||
/// The new (now-active) data dir.
|
||||
pub path: String,
|
||||
/// The old dir, if it still exists (cross-filesystem copy leaves it in place;
|
||||
/// a same-filesystem rename consumes it). `None` = nothing left behind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub old_path_left: Option<String>,
|
||||
}
|
||||
|
||||
/// A progress tick: bytes copied so far out of the total, plus the phase.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MoveProgress {
|
||||
pub phase: MovePhase,
|
||||
pub copied_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
/// Move the current data dir to `target`, invoking `on_progress` as bytes are
|
||||
/// copied. Persists the new path only after the copy verifies.
|
||||
///
|
||||
/// Disposal of the old tree is deliberately conservative:
|
||||
/// * **Same filesystem** → `rename`, which atomically consumes the old dir (no
|
||||
/// copy, no window where a stray writer's data could be deleted).
|
||||
/// * **Cross filesystem** → copy, then **leave the old dir in place**. Deleting
|
||||
/// it would be unsafe: a detached `supervise` process, an idle harness child
|
||||
/// pinned to the old path, or a request that resolved the old path just before
|
||||
/// the swap could still be writing there, and a `remove_dir_all` would drop
|
||||
/// that data. The old copy is instead reported back so the user (or a later
|
||||
/// cleanup) can remove it once nothing references it.
|
||||
///
|
||||
/// `on_progress` is called frequently during copy; the caller should throttle
|
||||
/// before forwarding to SSE. This function blocks — run it on a blocking task.
|
||||
pub fn move_data_dir(
|
||||
target: PathBuf,
|
||||
on_progress: impl Fn(MoveProgress) + Send,
|
||||
) -> Result<MoveOutcome> {
|
||||
let source = store::data_dir();
|
||||
on_progress(MoveProgress {
|
||||
phase: MovePhase::Preparing,
|
||||
copied_bytes: 0,
|
||||
total_bytes: 0,
|
||||
});
|
||||
|
||||
// Coalesce the WAL into orx.db so a file copy captures a consistent DB.
|
||||
// Failure is fatal for a cross-fs copy (the sidecar files would be copied in
|
||||
// an inconsistent state); the rename path is atomic so it wouldn't matter,
|
||||
// but we checkpoint unconditionally and bail rather than risk a torn copy.
|
||||
match Store::open() {
|
||||
Ok(store) => store
|
||||
.checkpoint()
|
||||
.map_err(|e| anyhow!("Could not checkpoint the database before moving: {e}"))?,
|
||||
Err(e) => return Err(anyhow!("Could not open the store to checkpoint it: {e}")),
|
||||
}
|
||||
|
||||
// Size the tree AFTER the checkpoint — TRUNCATE reclaims the WAL, so a
|
||||
// pre-checkpoint total would mismatch the post-checkpoint copy and fail
|
||||
// verification (or mask a short copy).
|
||||
let total = dir_size(&source);
|
||||
|
||||
// Same-filesystem + non-existent target → instant, atomic rename.
|
||||
let target_parent = target.parent().unwrap_or(&target);
|
||||
if same_device(&source, target_parent) && !target.exists() {
|
||||
on_progress(MoveProgress {
|
||||
phase: MovePhase::Copying,
|
||||
copied_bytes: 0,
|
||||
total_bytes: total,
|
||||
});
|
||||
match std::fs::rename(&source, &target) {
|
||||
Ok(()) => {
|
||||
finalize(&target, total, &on_progress)?;
|
||||
return Ok(MoveOutcome {
|
||||
path: target.to_string_lossy().into_owned(),
|
||||
old_path_left: None,
|
||||
});
|
||||
}
|
||||
// EXDEV: the ancestor-walk mis-judged the device (e.g. the volume
|
||||
// mounts *at* the target). Fall through to the copy path.
|
||||
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {}
|
||||
Err(e) => return Err(anyhow!("Rename to {} failed: {e}", target.display())),
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-filesystem: recursive copy with byte-level progress, old dir kept.
|
||||
std::fs::create_dir_all(&target)
|
||||
.map_err(|e| anyhow!("Can't create {}: {e}", target.display()))?;
|
||||
let mut copied = 0u64;
|
||||
copy_tree(&source, &target, &mut copied, total, &on_progress)
|
||||
.map_err(|e| anyhow!("Copy failed: {e}"))?;
|
||||
|
||||
// Verify aggregate size (checkpoint made orx.db the single source of truth,
|
||||
// so a size match is a strong signal the copy is complete).
|
||||
on_progress(MoveProgress {
|
||||
phase: MovePhase::Verifying,
|
||||
copied_bytes: copied,
|
||||
total_bytes: total,
|
||||
});
|
||||
let copied_size = dir_size(&target);
|
||||
if copied_size < total {
|
||||
return Err(anyhow!(
|
||||
"Verification failed: copied {} but expected {}. Old data left untouched.",
|
||||
human_bytes(copied_size),
|
||||
human_bytes(total)
|
||||
));
|
||||
}
|
||||
|
||||
finalize(&target, total, &on_progress)?;
|
||||
Ok(MoveOutcome {
|
||||
path: target.to_string_lossy().into_owned(),
|
||||
// Copy path leaves the source in place — reported so it can be cleaned up.
|
||||
old_path_left: Some(source.to_string_lossy().into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist the new path and emit the finalizing tick. After this, every
|
||||
/// subsequent `Store::open()` resolves `store::data_dir()` to `target`.
|
||||
fn finalize(target: &Path, total: u64, on_progress: &impl Fn(MoveProgress)) -> Result<()> {
|
||||
on_progress(MoveProgress {
|
||||
phase: MovePhase::Finalizing,
|
||||
copied_bytes: total,
|
||||
total_bytes: total,
|
||||
});
|
||||
crate::config::set_settings_data_dir(Some(target.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Recursively copy `src` into `dst` (both dirs), accumulating copied bytes into
|
||||
/// `*copied` and reporting progress. Preserves mtimes best-effort.
|
||||
fn copy_tree(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
copied: &mut u64,
|
||||
total: u64,
|
||||
on_progress: &impl Fn(MoveProgress),
|
||||
) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let from = entry.path();
|
||||
let to = dst.join(entry.file_name());
|
||||
let ft = entry.file_type()?;
|
||||
if ft.is_dir() {
|
||||
std::fs::create_dir_all(&to)?;
|
||||
copy_tree(&from, &to, copied, total, on_progress)?;
|
||||
} else if ft.is_file() {
|
||||
let n = std::fs::copy(&from, &to)?;
|
||||
*copied += n;
|
||||
on_progress(MoveProgress {
|
||||
phase: MovePhase::Copying,
|
||||
copied_bytes: *copied,
|
||||
total_bytes: total,
|
||||
});
|
||||
}
|
||||
// Symlinks and other special files are skipped — the data dir holds none.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursive byte size of a directory tree (files only). Missing dir → 0.
|
||||
fn dir_size(dir: &Path) -> u64 {
|
||||
let mut total = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(ft) = entry.file_type() else { continue };
|
||||
if ft.is_dir() {
|
||||
total += dir_size(&entry.path());
|
||||
} else if ft.is_file() {
|
||||
total += entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Case/normalization-tolerant path equality after best-effort canonicalization.
|
||||
fn paths_equal(a: &Path, b: &Path) -> bool {
|
||||
let ca = a.canonicalize();
|
||||
match ca {
|
||||
Ok(ca) => ca == *b || ca == b.canonicalize().unwrap_or_else(|_| b.to_path_buf()),
|
||||
Err(_) => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two paths live on the same filesystem device (so a `rename` works).
|
||||
/// Best-effort: unknown → false (forces the safe copy path).
|
||||
fn same_device(a: &Path, b: &Path) -> bool {
|
||||
match (device_of(a), device_of(b)) {
|
||||
(Some(da), Some(db)) => da == db,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn device_of(path: &Path) -> Option<u64> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Walk up to the first existing ancestor (target may not exist yet).
|
||||
let mut p = path;
|
||||
loop {
|
||||
if let Ok(md) = std::fs::metadata(p) {
|
||||
return Some(md.dev());
|
||||
}
|
||||
p = p.parent()?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn device_of(_path: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Free bytes available to a non-root user on the filesystem containing `path`.
|
||||
/// `None` when it can't be determined (non-unix, or the syscall fails).
|
||||
#[cfg(unix)]
|
||||
fn available_bytes(path: &Path) -> Option<u64> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
// Walk up to the first existing ancestor.
|
||||
let mut p = path;
|
||||
while !p.exists() {
|
||||
p = p.parent()?;
|
||||
}
|
||||
let c = CString::new(p.as_os_str().as_bytes()).ok()?;
|
||||
// SAFETY: `stat` is zeroed then filled by statvfs; c is a valid CString.
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::statvfs(c.as_ptr(), &mut stat) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// Available blocks to non-root × fragment size.
|
||||
Some(stat.f_bavail as u64 * stat.f_frsize as u64)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn available_bytes(_path: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp() -> PathBuf {
|
||||
// Unique-enough temp dir without external crates: pid + a monotonic-ish
|
||||
// counter baked into the caller's path.
|
||||
std::env::temp_dir().join(format!("orx-datadir-test-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_absolute() {
|
||||
let err = validate_target(Path::new("relative/path"), TargetIntent::Move).unwrap_err();
|
||||
assert!(err.to_string().contains("absolute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_size_sums_files() {
|
||||
let base = tmp().join("size");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("sub")).unwrap();
|
||||
std::fs::write(base.join("a.txt"), b"hello").unwrap(); // 5
|
||||
std::fs::write(base.join("sub/b.txt"), b"world!!").unwrap(); // 7
|
||||
assert_eq!(dir_size(&base), 12);
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_tree_roundtrip() {
|
||||
let base = tmp().join("copy");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let src = base.join("src");
|
||||
let dst = base.join("dst");
|
||||
std::fs::create_dir_all(src.join("run-logs")).unwrap();
|
||||
std::fs::write(src.join("orx.db"), vec![7u8; 2048]).unwrap();
|
||||
std::fs::write(src.join("run-logs/r.log"), b"log line\n").unwrap();
|
||||
std::fs::create_dir_all(&dst).unwrap();
|
||||
|
||||
let total = dir_size(&src);
|
||||
let mut copied = 0u64;
|
||||
copy_tree(&src, &dst, &mut copied, total, &|_| {}).unwrap();
|
||||
|
||||
assert_eq!(dir_size(&dst), total);
|
||||
assert_eq!(copied, total);
|
||||
assert_eq!(std::fs::read(dst.join("orx.db")).unwrap().len(), 2048);
|
||||
assert_eq!(
|
||||
std::fs::read(dst.join("run-logs/r.log")).unwrap(),
|
||||
b"log line\n"
|
||||
);
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod chat;
|
||||
pub mod codex;
|
||||
pub mod datadir;
|
||||
pub mod experiments;
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
|
||||
+124
-9
@@ -19,20 +19,100 @@ use crate::error::{anyhow, Result};
|
||||
use crate::local::model::{LocalExperiment, LocalProject};
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Some(dir) = std::env::var_os("ORX_DATA_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
// Resolution order (most to least authoritative):
|
||||
// 1. $ORX_DATA_DIR — explicit imperative override (launch.json, tests,
|
||||
// the Codex sandbox pin). Stays on top so a forced path always wins.
|
||||
// 2. persisted user choice (config_dir()/settings.json `dataDir`) — set
|
||||
// from the UI's Storage settings. Read fresh every call (no cache) so a
|
||||
// just-completed data-dir move is picked up by the next Store::open().
|
||||
// 3. $XDG_DATA_HOME/openresearch — ambient system default *base*; an
|
||||
// explicit UI choice rightly beats it, so it sits below (2).
|
||||
// 4. ~/.local/share/openresearch — hardcoded default.
|
||||
if let Some(dir) = env_path("ORX_DATA_DIR") {
|
||||
return dir;
|
||||
}
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
if let Some(dir) = crate::config::settings_data_dir() {
|
||||
return dir;
|
||||
}
|
||||
xdg_default_data_dir()
|
||||
}
|
||||
|
||||
/// Read an env var as a path, treating unset **and empty** the same (an empty
|
||||
/// `export ORX_DATA_DIR=` is a shell footgun that must not resolve to `""`).
|
||||
fn env_path(key: &str) -> Option<PathBuf> {
|
||||
std::env::var_os(key)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".local")
|
||||
.join("share")
|
||||
});
|
||||
}
|
||||
|
||||
/// `$XDG_DATA_HOME/openresearch` else `~/.local/share/openresearch` — the tail
|
||||
/// of the resolution chain, shared by `data_dir()` and `default_data_dir()`.
|
||||
fn xdg_default_data_dir() -> PathBuf {
|
||||
let base = env_path("XDG_DATA_HOME").unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".local")
|
||||
.join("share")
|
||||
});
|
||||
base.join("openresearch")
|
||||
}
|
||||
|
||||
/// The data dir ignoring any persisted user choice — where resolution would
|
||||
/// land if `settings.json` had no `dataDir`. Used by the Storage UI to show the
|
||||
/// "(default)" path and offer resetting to it. `$ORX_DATA_DIR` still wins, since
|
||||
/// it's a forced override.
|
||||
pub fn default_data_dir() -> PathBuf {
|
||||
if let Some(dir) = env_path("ORX_DATA_DIR") {
|
||||
return dir;
|
||||
}
|
||||
xdg_default_data_dir()
|
||||
}
|
||||
|
||||
/// Where `data_dir()`'s answer came from — surfaced by the Storage settings API
|
||||
/// so the UI can explain a forced env override (read-only) vs. a user choice.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DataDirSource {
|
||||
/// `$ORX_DATA_DIR` is set — forces the path, UI field is read-only.
|
||||
Env,
|
||||
/// Persisted user choice in `settings.json`.
|
||||
Config,
|
||||
/// Derived from `$XDG_DATA_HOME` (no user choice).
|
||||
Xdg,
|
||||
/// Hardcoded `~/.local/share/openresearch`.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// Classify the current `data_dir()` resolution for the Storage settings UI.
|
||||
pub fn data_dir_source() -> DataDirSource {
|
||||
if env_path("ORX_DATA_DIR").is_some() {
|
||||
return DataDirSource::Env;
|
||||
}
|
||||
if crate::config::settings_data_dir().is_some() {
|
||||
return DataDirSource::Config;
|
||||
}
|
||||
if env_path("XDG_DATA_HOME").is_some() {
|
||||
return DataDirSource::Xdg;
|
||||
}
|
||||
DataDirSource::Default
|
||||
}
|
||||
|
||||
/// Compact human-readable byte size (e.g. `1.2 KB`, `3.4 MB`). Shared by the
|
||||
/// artifacts listing and the data-dir move so the two don't drift.
|
||||
pub fn human_bytes(n: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
if n < 1024 {
|
||||
return format!("{n} B");
|
||||
}
|
||||
let mut size = n as f64;
|
||||
let mut unit = 0;
|
||||
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
format!("{:.1} {}", size, UNITS[unit])
|
||||
}
|
||||
|
||||
pub fn log_path(run_id: &str) -> PathBuf {
|
||||
// Run ids are server-issued UUIDs; sanitize anyway so a hostile id can't
|
||||
// escape the log dir.
|
||||
@@ -211,6 +291,17 @@ impl Store {
|
||||
Ok(self.conn.unchecked_transaction()?)
|
||||
}
|
||||
|
||||
/// Coalesce the WAL back into the main `orx.db` file and truncate it, so a
|
||||
/// filesystem-level copy of `orx.db` alone captures all committed data.
|
||||
/// Best-effort — used before relocating the data dir. Errors are returned so
|
||||
/// the caller can decide, but a busy checkpoint is non-fatal (the WAL sidecar
|
||||
/// gets copied too when present).
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
self.conn
|
||||
.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_run(&self, run: &StoredRun) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO runs (id, experiment_id, project_id, status, backend_json, command,
|
||||
@@ -281,6 +372,18 @@ impl Store {
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
/// Count runs in an active state (`starting`/`running`) — SQL-side and
|
||||
/// unbounded, so a long-running job older than the newest N rows still
|
||||
/// counts. Used by the data-dir move's in-flight guard.
|
||||
pub fn count_active_runs(&self) -> Result<usize> {
|
||||
let n: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM runs WHERE status IN ('starting', 'running')",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
pub fn list_runs_by_project(&self, project_id: &str) -> Result<Vec<StoredRun>> {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"{SELECT_RUN} WHERE project_id = ?1 ORDER BY created_at DESC"
|
||||
@@ -798,3 +901,15 @@ pub fn now_ms() -> i64 {
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::human_bytes;
|
||||
|
||||
#[test]
|
||||
fn human_bytes_scales() {
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
assert_eq!(human_bytes(2048), "2.0 KB");
|
||||
assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MB");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,30 @@ pub(crate) struct Settings {
|
||||
/// Set by `orx telemetry off`. `Some(true)` = user opted out persistently.
|
||||
#[serde(default)]
|
||||
pub telemetry_disabled: Option<bool>,
|
||||
/// User-chosen data directory (Storage settings). Absent = fall back to the
|
||||
/// env/XDG/default chain in `store::data_dir()`. Persisted here — in the one
|
||||
/// `settings.json` — so a write can't clobber `install_id`/`telemetry_disabled`
|
||||
/// (each mutation re-reads and patches only its own field via `mutate_settings`).
|
||||
#[serde(default)]
|
||||
pub data_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// The persisted data-dir choice, if any (non-empty). Read by `store::data_dir()`
|
||||
/// on every open — so it goes through the plain `load_settings` reader, not the
|
||||
/// locked RMW path. `crate::config` re-exports this as `settings_data_dir`.
|
||||
pub(crate) fn persisted_data_dir() -> Option<String> {
|
||||
load_settings()
|
||||
.and_then(|s| s.data_dir)
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Set or clear the persisted data dir, preserving every other settings field.
|
||||
/// Routes through `mutate_settings` so it inherits the in-process mutex, the
|
||||
/// cross-process flock, the atomic temp+rename, and the corrupt-file refusal —
|
||||
/// the same guarantees `orx telemetry off` relies on. `crate::config` re-exports
|
||||
/// this as `set_settings_data_dir`.
|
||||
pub(crate) fn set_persisted_data_dir(data_dir: Option<String>) -> std::io::Result<()> {
|
||||
mutate_settings(|s| s.data_dir = data_dir.filter(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
fn settings_path() -> PathBuf {
|
||||
@@ -690,6 +714,44 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_and_telemetry_fields_dont_clobber_each_other() {
|
||||
// Regression: the Storage feature persists `data_dir` in the *same*
|
||||
// settings.json telemetry owns. Both must go through mutate_settings so a
|
||||
// data-dir write preserves install_id/telemetry_disabled and vice-versa —
|
||||
// otherwise a completed data-dir move reverts on the next CLI run.
|
||||
let _g = EnvGuard::new(OPT_VARS);
|
||||
let dir = std::env::temp_dir().join(format!("orx-tel-datadir-{}", uuid::Uuid::new_v4()));
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
|
||||
set_persisted_disabled(true).unwrap(); // telemetry_disabled
|
||||
let id = install_id().expect("install id"); // install_id
|
||||
set_persisted_data_dir(Some("/tmp/orx-moved".into())).unwrap(); // data_dir
|
||||
|
||||
let s = load_settings().expect("settings present");
|
||||
assert_eq!(s.data_dir.as_deref(), Some("/tmp/orx-moved"));
|
||||
assert_eq!(
|
||||
s.telemetry_disabled,
|
||||
Some(true),
|
||||
"opt-out survived data-dir write"
|
||||
);
|
||||
assert_eq!(
|
||||
s.install_id.as_deref(),
|
||||
Some(id.as_str()),
|
||||
"install id survived"
|
||||
);
|
||||
assert_eq!(persisted_data_dir().as_deref(), Some("/tmp/orx-moved"));
|
||||
|
||||
// Clearing the data dir leaves the telemetry fields intact.
|
||||
set_persisted_data_dir(None).unwrap();
|
||||
let s = load_settings().expect("settings present");
|
||||
assert!(s.data_dir.is_none(), "data_dir cleared");
|
||||
assert_eq!(s.telemetry_disabled, Some(true), "opt-out still intact");
|
||||
assert!(persisted_data_dir().is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_id_is_stable() {
|
||||
let _g = EnvGuard::new(OPT_VARS);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+106
-98
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
||||
html { background: #ffffff; }
|
||||
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-CdAQVowh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtziJBfO.css">
|
||||
<script type="module" crossorigin src="/assets/index-kwecO5jT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CdGBhMp-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+6
-1
@@ -395,7 +395,12 @@ export default function App() {
|
||||
(() => {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
return project ? (
|
||||
<FilesTab project={project} files={files} onChanged={refreshFiles} />
|
||||
<FilesTab
|
||||
project={project}
|
||||
files={files}
|
||||
onChanged={refreshFiles}
|
||||
onOpenStorage={() => setMainView("storage")}
|
||||
/>
|
||||
) : null;
|
||||
})()
|
||||
) : mainView !== "chat" ? (
|
||||
|
||||
@@ -325,6 +325,40 @@ export const deleteEnvVar = (key: string) =>
|
||||
.then((r) => json<{ vars: EnvVar[] }>(r))
|
||||
.then((r) => r.vars);
|
||||
|
||||
/** Where `source` says the resolved data dir came from. `env` means the
|
||||
* `$ORX_DATA_DIR` override forces it — the UI shows the field read-only. */
|
||||
export type DataDirSource = "env" | "config" | "xdg" | "default";
|
||||
|
||||
export interface DataDirSettings {
|
||||
current: string;
|
||||
defaultPath: string;
|
||||
isDefault: boolean;
|
||||
source: DataDirSource;
|
||||
}
|
||||
|
||||
export const getDataDir = () => get<DataDirSettings>("/api/settings/data-dir");
|
||||
|
||||
export interface DataDirValidation {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
treeBytes?: number;
|
||||
freeBytes?: number;
|
||||
sameFilesystem?: boolean;
|
||||
}
|
||||
|
||||
export const validateDataDir = (path: string) =>
|
||||
post<DataDirValidation>("/api/settings/data-dir/validate", { path });
|
||||
|
||||
/** Set the path without moving (onboarding / already-populated target). */
|
||||
export const setDataDir = (path: string) =>
|
||||
post<DataDirSettings>("/api/settings/data-dir", { path });
|
||||
|
||||
/** Kick off a relocate. Resolves once the move has *started* (HTTP 202); watch
|
||||
* `onDataDirMove` (events.ts) for `progress` / `done` / `error`. Throws on the
|
||||
* 409 in-flight guard with the server's message. */
|
||||
export const moveDataDir = (path: string) =>
|
||||
post<{ started: boolean }>("/api/settings/data-dir/move", { path });
|
||||
|
||||
export interface SshHost {
|
||||
host: string;
|
||||
hostname?: string;
|
||||
@@ -725,6 +759,19 @@ export function fmtDuration(ms: number): string {
|
||||
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
||||
}
|
||||
|
||||
/** Compact byte size, e.g. "512 B", "2.0 KB", "5.3 MB". Mirrors the backend's
|
||||
* `store::human_bytes`. */
|
||||
export function fmtBytes(n: number): string {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = n;
|
||||
let u = 0;
|
||||
while (v >= 1024 && u < units.length - 1) {
|
||||
v /= 1024;
|
||||
u += 1;
|
||||
}
|
||||
return u === 0 ? `${n} B` : `${v.toFixed(1)} ${units[u]}`;
|
||||
}
|
||||
|
||||
export function shortId(id: string): string {
|
||||
return id.length > 10 ? `${id.slice(0, 10)}…` : id;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ type Action =
|
||||
| { type: "seed"; sessionId: string; messages: ChatMessage[] }
|
||||
| { type: "upsertMessage"; sessionId: string; message: ChatMessage }
|
||||
| { type: "optimisticUser"; sessionId: string; text: string; imageUrls: string[] }
|
||||
| { type: "dropOptimisticUser"; sessionId: string }
|
||||
| { type: "busy"; sessionId: string; busy: boolean }
|
||||
| { type: "seedBusy"; sessions: string[] }
|
||||
| { type: "forget"; sessionId: string };
|
||||
@@ -126,6 +127,22 @@ function reducer(state: ChatState, action: Action): ChatState {
|
||||
messagesBySession: { ...state.messagesBySession, [action.sessionId]: [...list, msg] },
|
||||
};
|
||||
}
|
||||
case "dropOptimisticUser": {
|
||||
// Roll back the last optimistic user bubble when the send failed (e.g. a
|
||||
// 409 during a data-dir move) — otherwise it lingers until reload since no
|
||||
// server message ever arrives to replace it.
|
||||
const list = state.messagesBySession[action.sessionId];
|
||||
if (!list?.length) return state;
|
||||
const last = list[list.length - 1];
|
||||
if (last.role !== "user" || !last.id.startsWith(LOCAL_PREFIX)) return state;
|
||||
return {
|
||||
...state,
|
||||
messagesBySession: {
|
||||
...state.messagesBySession,
|
||||
[action.sessionId]: list.slice(0, -1),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "busy": {
|
||||
const busySessions = new Set(state.busySessions);
|
||||
if (action.busy) busySessions.add(action.sessionId);
|
||||
@@ -1008,8 +1025,20 @@ export function ChatPanel({
|
||||
dataBase64: a.dataUrl.slice(a.dataUrl.indexOf(",") + 1),
|
||||
}));
|
||||
await sendChatMessage(sid, text, turnOpts, images.length ? images : undefined);
|
||||
} catch {
|
||||
if (sid) dispatch({ type: "busy", sessionId: sid, busy: false });
|
||||
} catch (err) {
|
||||
if (sid) {
|
||||
dispatch({ type: "busy", sessionId: sid, busy: false });
|
||||
// Roll back the optimistic bubble so a failed send leaves no phantom.
|
||||
dispatch({ type: "dropOptimisticUser", sessionId: sid });
|
||||
}
|
||||
// Surface the failure and restore the composer so the message isn't lost.
|
||||
// A data-dir move in progress returns 409 with an explanatory message —
|
||||
// now a reachable outcome, so it must be shown rather than swallowed.
|
||||
setDraft((d) => (d ? d : text));
|
||||
setAttachments((a) => (a.length ? a : pending));
|
||||
window.alert(
|
||||
`Couldn't send: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
deleteFile,
|
||||
fileUrl,
|
||||
fmtBytes,
|
||||
getFileReport,
|
||||
type FileEntry,
|
||||
type Project,
|
||||
@@ -28,12 +29,6 @@ function stripFrontmatter(md: string): string {
|
||||
return end === -1 ? md : md.slice(end + 4).replace(/^\r?\n/, "");
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||
|
||||
/** Depth-first lookup of a tree entry by its dir-relative path. */
|
||||
@@ -329,10 +324,13 @@ export function FilesTab({
|
||||
project,
|
||||
files,
|
||||
onChanged,
|
||||
onOpenStorage,
|
||||
}: {
|
||||
project: Project;
|
||||
files: ProjectFiles | null;
|
||||
onChanged: () => void;
|
||||
/** Navigate to Settings → Storage (where the data dir can be changed). */
|
||||
onOpenStorage: () => void;
|
||||
}) {
|
||||
const [openPath, setOpenPath] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
@@ -409,6 +407,9 @@ export function FilesTab({
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
<DirPath dir={files.dir} />
|
||||
<button type="button" className="files-pill files-pill-link" onClick={onOpenStorage}>
|
||||
Change storage location →
|
||||
</button>
|
||||
</div>
|
||||
<p className="files-hint">
|
||||
An explorer over this folder — the agent writes each experiment's reports and figures
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ArrowLeft, ArrowRight, Check, Copy, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getDataDir,
|
||||
getGitSettings,
|
||||
getHarnesses,
|
||||
getTelemetry,
|
||||
modelLabel,
|
||||
setDataDir,
|
||||
recordTelemetryConsent,
|
||||
setTelemetry,
|
||||
type DataDirSettings,
|
||||
type GitSettings,
|
||||
type Harness,
|
||||
type TelemetrySettings,
|
||||
@@ -17,9 +20,10 @@ import { GitTokenForm } from "./GitTokenForm";
|
||||
* model, then the usage-analytics choice, then hand off to the (empty)
|
||||
* projects page. Purely informative — nothing here gates anything. */
|
||||
export function Onboarding({ onDone }: { onDone: () => void }) {
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||
const [step, setStep] = useState<0 | 1 | 2 | 3>(0);
|
||||
const [harnesses, setHarnesses] = useState<Harness[] | null>(null);
|
||||
const [git, setGit] = useState<GitSettings | null>(null);
|
||||
const [dataDir, setDataDirState] = useState<DataDirSettings | null>(null);
|
||||
const [telemetry, setTelemetryState] = useState<TelemetrySettings | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
@@ -28,6 +32,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) {
|
||||
void Promise.allSettled([
|
||||
getHarnesses(refresh).then(setHarnesses),
|
||||
getGitSettings().then(setGit),
|
||||
getDataDir().then(setDataDirState),
|
||||
getTelemetry().then(setTelemetryState),
|
||||
]).finally(() => setChecking(false));
|
||||
};
|
||||
@@ -48,7 +53,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) {
|
||||
{step === 0 ? (
|
||||
<>
|
||||
<div className="onb-eyebrow">
|
||||
Open<span>Research</span> · step 1 of 3
|
||||
Open<span>Research</span> · step 1 of 4
|
||||
</div>
|
||||
<h2 className="onb-title">Your coding agents</h2>
|
||||
<p className="onb-sub">
|
||||
@@ -77,7 +82,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) {
|
||||
) : step === 1 ? (
|
||||
<>
|
||||
<div className="onb-eyebrow">
|
||||
Open<span>Research</span> · step 2 of 3
|
||||
Open<span>Research</span> · step 2 of 4
|
||||
</div>
|
||||
<h2 className="onb-title">Git & GitHub</h2>
|
||||
<p className="onb-sub">
|
||||
@@ -101,10 +106,34 @@ export function Onboarding({ onDone }: { onDone: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<>
|
||||
<div className="onb-eyebrow">
|
||||
Open<span>Research</span> · step 3 of 4
|
||||
</div>
|
||||
<h2 className="onb-title">Where orx keeps your data</h2>
|
||||
<p className="onb-sub">
|
||||
Your local database, run logs, artifacts, and chat attachments live in one folder on
|
||||
this machine. The default works for most people — change it if you'd rather keep
|
||||
it on another disk.
|
||||
</p>
|
||||
<div className="onb-cards">
|
||||
<StorageCard dataDir={dataDir} onUpdate={setDataDirState} />
|
||||
</div>
|
||||
<div className="onb-actions">
|
||||
<button className="btn ghost" onClick={() => setStep(1)}>
|
||||
<ArrowLeft size={12} /> Back
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn primary" onClick={() => setStep(3)}>
|
||||
Continue <ArrowRight size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="onb-eyebrow">
|
||||
Open<span>Research</span> · step 3 of 3
|
||||
Open<span>Research</span> · step 4 of 4
|
||||
</div>
|
||||
<h2 className="onb-title">Usage analytics</h2>
|
||||
<p className="onb-sub">
|
||||
@@ -116,7 +145,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) {
|
||||
<TelemetryCard telemetry={telemetry} onUpdate={setTelemetryState} />
|
||||
</div>
|
||||
<div className="onb-actions">
|
||||
<button className="btn ghost" onClick={() => setStep(1)}>
|
||||
<button className="btn ghost" onClick={() => setStep(2)}>
|
||||
<ArrowLeft size={12} /> Back
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
@@ -282,6 +311,118 @@ function GitCard({
|
||||
);
|
||||
}
|
||||
|
||||
function StorageCard({
|
||||
dataDir,
|
||||
onUpdate,
|
||||
}: {
|
||||
dataDir: DataDirSettings | null;
|
||||
onUpdate: (d: DataDirSettings) => void;
|
||||
}) {
|
||||
const [path, setPath] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Seed the input once the current path loads.
|
||||
useEffect(() => {
|
||||
if (dataDir && !path) setPath(dataDir.current);
|
||||
}, [dataDir, path]);
|
||||
|
||||
if (dataDir === null) {
|
||||
return (
|
||||
<div className="onb-loading">
|
||||
<span className="spinner" /> Checking storage…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// $ORX_DATA_DIR forces the path — nothing to choose here.
|
||||
if (dataDir.source === "env") {
|
||||
return (
|
||||
<div className="onb-card">
|
||||
<div className="onb-card-row">
|
||||
<span className="onb-card-name">Data folder</span>
|
||||
<span className="onb-card-detail mono">{dataDir.current}</span>
|
||||
<span className="status-badge st-done">
|
||||
<Check size={12} strokeWidth={3} /> pinned
|
||||
</span>
|
||||
</div>
|
||||
<div className="onb-card-meta">
|
||||
Set by the <code>ORX_DATA_DIR</code> environment variable.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmed = path.trim();
|
||||
const changed = trimmed !== "" && trimmed !== dataDir.current;
|
||||
|
||||
async function save() {
|
||||
if (saving || !changed) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
onUpdate(await setDataDir(trimmed));
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="onb-card">
|
||||
<div className="onb-card-row">
|
||||
<span className="onb-card-name">Data folder</span>
|
||||
<span className="onb-card-detail mono">{dataDir.current}</span>
|
||||
<span className={`status-badge ${dataDir.isDefault ? "st-idle" : "st-done"}`}>
|
||||
{dataDir.isDefault ? <span className="dot" /> : <Check size={12} strokeWidth={3} />}
|
||||
{dataDir.isDefault ? "default" : "custom"}
|
||||
</span>
|
||||
</div>
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
className="mono"
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/absolute/path/to/openresearch"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
style={{ width: "100%", marginTop: 10 }}
|
||||
/>
|
||||
{error && <div className="error" style={{ marginTop: 8 }}>{error}</div>}
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 10 }}>
|
||||
<button className="btn primary" onClick={save} disabled={saving || !changed}>
|
||||
{saving ? "Saving…" : "Use this folder"}
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setPath(dataDir.current);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="onb-card-meta" style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
The default is fine for most setups.
|
||||
<button className="btn ghost" onClick={() => setEditing(true)}>
|
||||
Change…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TelemetryCard({
|
||||
telemetry,
|
||||
onUpdate,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Cpu,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
HardDrive,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
deleteEnvVar,
|
||||
fmtBytes,
|
||||
fmtDuration,
|
||||
getEnvVars,
|
||||
getGitSettings,
|
||||
@@ -30,6 +32,11 @@ import {
|
||||
saveK8sSettings,
|
||||
saveSlurmSettings,
|
||||
setEnvVar,
|
||||
getDataDir,
|
||||
validateDataDir,
|
||||
moveDataDir,
|
||||
type DataDirSettings,
|
||||
type DataDirValidation,
|
||||
shortId,
|
||||
slurmPreflight,
|
||||
sshPreflight,
|
||||
@@ -50,11 +57,18 @@ import {
|
||||
type SshPreflight,
|
||||
modelLabel,
|
||||
} from "../api";
|
||||
import { onDataDirMove } from "../events";
|
||||
import { GitTokenForm } from "./GitTokenForm";
|
||||
import { BackendBadge } from "./BackendLogos";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
|
||||
export type SettingsTab = "harnesses" | "compute" | "instances" | "environment" | "git";
|
||||
export type SettingsTab =
|
||||
| "harnesses"
|
||||
| "compute"
|
||||
| "instances"
|
||||
| "environment"
|
||||
| "git"
|
||||
| "storage";
|
||||
type Tab = SettingsTab;
|
||||
|
||||
// --- harnesses ---------------------------------------------------------------
|
||||
@@ -1308,6 +1322,251 @@ function GitTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- storage (data directory) ------------------------------------------------
|
||||
|
||||
/** Determinate progress bar with a percent + optional byte caption. */
|
||||
function ProgressBar({ value, max, label }: { value: number; max: number; label?: string }) {
|
||||
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0;
|
||||
return (
|
||||
<div className="progress" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="progress-caption">
|
||||
<span>{label ?? `${pct}%`}</span>
|
||||
{max > 0 && (
|
||||
<span className="mono">
|
||||
{fmtBytes(value)} / {fmtBytes(max)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DATA_DIR_SOURCE_LABEL: Record<DataDirSettings["source"], string> = {
|
||||
env: "ORX_DATA_DIR environment variable",
|
||||
config: "your saved setting",
|
||||
xdg: "XDG_DATA_HOME",
|
||||
default: "default location",
|
||||
};
|
||||
|
||||
type MoveState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "moving"; phase: string; copied: number; total: number }
|
||||
| { kind: "done"; oldPathLeft?: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
function StorageTab() {
|
||||
const [settings, setSettings] = useState<DataDirSettings | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [path, setPath] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [validation, setValidation] = useState<DataDirValidation | null>(null);
|
||||
const [move, setMove] = useState<MoveState>({ kind: "idle" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = () =>
|
||||
getDataDir()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
// Seed the input to the current path only when empty — preserves an
|
||||
// in-progress edit, and (after a move clears it) re-seeds to the new path.
|
||||
setPath((p) => (p ? p : s.current));
|
||||
})
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// Subscribe to move progress streamed over the shared SSE.
|
||||
useEffect(() => {
|
||||
return onDataDirMove((ev) => {
|
||||
if (ev.type === "progress") {
|
||||
// The "preparing" tick reports total 0 (sized after the checkpoint);
|
||||
// keep the last known non-zero total so the bar doesn't flicker to 0.
|
||||
setMove((m) => {
|
||||
const prevTotal = m.kind === "moving" ? m.total : 0;
|
||||
return {
|
||||
kind: "moving",
|
||||
phase: ev.phase,
|
||||
copied: ev.copiedBytes,
|
||||
total: ev.totalBytes || prevTotal,
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "done") {
|
||||
setMove({ kind: "done", oldPathLeft: ev.oldPathLeft });
|
||||
setValidation(null);
|
||||
// Clear so load()'s empty-guard re-seeds the input to the new path.
|
||||
setPath("");
|
||||
void load();
|
||||
} else if (ev.type === "error") {
|
||||
setMove({ kind: "error", message: ev.error });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const envForced = settings?.source === "env";
|
||||
const trimmed = path.trim();
|
||||
const unchanged = settings !== null && trimmed === settings.current;
|
||||
|
||||
async function check() {
|
||||
if (checking || !trimmed) return;
|
||||
setChecking(true);
|
||||
setError(null);
|
||||
setValidation(null);
|
||||
try {
|
||||
setValidation(await validateDataDir(trimmed));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startMove(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (move.kind === "moving" || !trimmed || unchanged) return;
|
||||
setError(null);
|
||||
// Confirm — this relocates all projects' data. Same-disk moves are atomic;
|
||||
// cross-disk moves copy and leave the old folder for you to remove.
|
||||
if (
|
||||
!window.confirm(
|
||||
`Move all orx data to:\n${trimmed}\n\nThe store is copied to the new location and ` +
|
||||
`activated there. Active runs or chats will block the move.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setMove({ kind: "moving", phase: "preparing", copied: 0, total: validation?.treeBytes ?? 0 });
|
||||
try {
|
||||
await moveDataDir(trimmed);
|
||||
// 202 accepted — progress/done arrive over SSE. Nothing else to do here.
|
||||
} catch (err) {
|
||||
// 409 in-flight guard or a validation error surfaces here.
|
||||
setMove({ kind: "idle" });
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Storage</h1>
|
||||
<p className="settings-sub">
|
||||
Where orx keeps everything on this machine — the local database, run logs, artifacts, and
|
||||
chat attachments for <strong>all</strong> projects. Moving it copies the whole store to the
|
||||
new location and activates it there.
|
||||
</p>
|
||||
{loadError ? (
|
||||
<div className="settings-card">
|
||||
<div className="error">{loadError}</div>
|
||||
</div>
|
||||
) : !settings ? (
|
||||
<div className="settings-loading">
|
||||
<span className="spinner" /> Loading…
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-head">
|
||||
<h3>Data directory</h3>
|
||||
<div className="spacer" style={{ flex: 1 }} />
|
||||
<span className="badge">{settings.isDefault ? "default" : "custom"}</span>
|
||||
</div>
|
||||
<div className="kv">
|
||||
<span className="k">Current</span>
|
||||
<span className="v mono">{settings.current}</span>
|
||||
<span className="k">Source</span>
|
||||
<span className="v">{DATA_DIR_SOURCE_LABEL[settings.source]}</span>
|
||||
{!settings.isDefault && (
|
||||
<>
|
||||
<span className="k">Default</span>
|
||||
<span className="v mono">{settings.defaultPath}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{envForced ? (
|
||||
<p className="settings-note">
|
||||
The data directory is pinned by the <code>ORX_DATA_DIR</code> environment variable,
|
||||
which overrides this setting. Unset it to choose a location here.
|
||||
</p>
|
||||
) : (
|
||||
<form className="form settings-form" onSubmit={startMove}>
|
||||
<label>
|
||||
New location
|
||||
<input
|
||||
className="mono"
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
setPath(e.target.value);
|
||||
setValidation(null);
|
||||
}}
|
||||
placeholder="/absolute/path/to/openresearch"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={move.kind === "moving"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{validation && !validation.error && validation.ok && (
|
||||
<p className="settings-note">
|
||||
Ready to move {fmtBytes(validation.treeBytes ?? 0)}
|
||||
{validation.freeBytes != null && ` — ${fmtBytes(validation.freeBytes)} free at target`}
|
||||
{validation.sameFilesystem ? " (same disk, instant)" : ""}.
|
||||
</p>
|
||||
)}
|
||||
{validation && validation.ok === false && validation.error && (
|
||||
<div className="error">{validation.error}</div>
|
||||
)}
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{move.kind === "moving" && (
|
||||
<ProgressBar
|
||||
value={move.copied}
|
||||
max={move.total}
|
||||
label={`${move.phase.charAt(0).toUpperCase()}${move.phase.slice(1)}…`}
|
||||
/>
|
||||
)}
|
||||
{move.kind === "done" && (
|
||||
<p className="settings-note">
|
||||
Moved. orx is now using the new location.
|
||||
{move.oldPathLeft && (
|
||||
<>
|
||||
{" "}
|
||||
The old copy was left at <code>{move.oldPathLeft}</code> (different disk) — you
|
||||
can delete it once you've confirmed everything works.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{move.kind === "error" && <div className="error">Move failed: {move.message}</div>}
|
||||
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={check}
|
||||
disabled={checking || !trimmed || unchanged || move.kind === "moving"}
|
||||
>
|
||||
{checking ? "Checking…" : "Check"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={!trimmed || unchanged || move.kind === "moving"}
|
||||
>
|
||||
{move.kind === "moving" ? "Moving…" : "Move data here"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- instances ---------------------------------------------------------------
|
||||
|
||||
const isLive = (status: string) => status === "running" || status === "starting";
|
||||
@@ -1452,6 +1711,7 @@ export const SETTINGS_NAV: { id: Tab; label: string; icon: React.ReactNode }[] =
|
||||
{ id: "instances", label: "Instances", icon: <Server size={15} /> },
|
||||
{ id: "environment", label: "Environment", icon: <SquareTerminal size={15} /> },
|
||||
{ id: "git", label: "Git", icon: <GitBranch size={15} /> },
|
||||
{ id: "storage", label: "Storage", icon: <HardDrive size={15} /> },
|
||||
];
|
||||
|
||||
/** One settings section's content, shown in the middle pane in place of chat. */
|
||||
@@ -1471,6 +1731,7 @@ export function SettingsView({ tab }: { tab: Tab }) {
|
||||
)}
|
||||
{tab === "instances" && <InstancesTab />}
|
||||
{tab === "git" && <GitTab />}
|
||||
{tab === "storage" && <StorageTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,27 @@ function emitChat(ev: ChatEvent) {
|
||||
chatListeners.forEach((fn) => fn(ev));
|
||||
}
|
||||
|
||||
// Data-dir move progress fans out the same way so the Storage settings card can
|
||||
// subscribe without touching the shared useOrxEvents handler set.
|
||||
export type DataDirMoveEvent =
|
||||
| { type: "progress"; phase: string; copiedBytes: number; totalBytes: number }
|
||||
| { type: "done"; path: string; oldPathLeft?: string }
|
||||
| { type: "error"; error: string };
|
||||
|
||||
type DataDirMoveListener = (ev: DataDirMoveEvent) => void;
|
||||
const dataDirMoveListeners = new Set<DataDirMoveListener>();
|
||||
|
||||
export function onDataDirMove(fn: DataDirMoveListener): () => void {
|
||||
dataDirMoveListeners.add(fn);
|
||||
return () => {
|
||||
dataDirMoveListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
function emitDataDirMove(ev: DataDirMoveEvent) {
|
||||
dataDirMoveListeners.forEach((fn) => fn(ev));
|
||||
}
|
||||
|
||||
export interface OrxEventHandlers {
|
||||
onRun: (run: Run) => void;
|
||||
onExperiment: (experiment: Experiment) => void;
|
||||
@@ -109,6 +130,20 @@ export function useOrxEvents(handlers: OrxEventHandlers) {
|
||||
const d = parse<{ sessionId: string; busy: boolean }>(e as MessageEvent);
|
||||
if (d?.sessionId) emitChat({ type: "busy", sessionId: d.sessionId, busy: d.busy });
|
||||
});
|
||||
es.addEventListener("datadir.move.progress", (e) => {
|
||||
const d = parse<{ phase: string; copiedBytes: number; totalBytes: number }>(
|
||||
e as MessageEvent,
|
||||
);
|
||||
if (d) emitDataDirMove({ type: "progress", ...d });
|
||||
});
|
||||
es.addEventListener("datadir.move.done", (e) => {
|
||||
const d = parse<{ path: string; oldPathLeft?: string }>(e as MessageEvent);
|
||||
if (d) emitDataDirMove({ type: "done", path: d.path, oldPathLeft: d.oldPathLeft });
|
||||
});
|
||||
es.addEventListener("datadir.move.error", (e) => {
|
||||
const d = parse<{ error: string }>(e as MessageEvent);
|
||||
if (d) emitDataDirMove({ type: "error", error: d.error });
|
||||
});
|
||||
return () => es.close();
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -2283,6 +2283,31 @@ textarea::placeholder {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Determinate progress bar (data-dir move). */
|
||||
.progress {
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.progress-caption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
@@ -2376,6 +2401,18 @@ a.files-pill:hover {
|
||||
height: 22px;
|
||||
margin: -4px -6px -4px 0;
|
||||
}
|
||||
/* "Change storage location →" — a pill-shaped button, not a path pill. */
|
||||
button.files-pill-link {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
button.files-pill-link:hover {
|
||||
border-color: var(--muted);
|
||||
color: var(--text);
|
||||
}
|
||||
.files-hint {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
|
||||
Reference in New Issue
Block a user