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:
Daniel Kim
2026-07-16 11:04:49 -07:00
committed by GitHub
parent 1b1a9453df
commit 203f63b900
22 changed files with 1663 additions and 146 deletions
+8
View File
@@ -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
View File
@@ -938,6 +938,7 @@ dependencies = [
"dirs",
"fd-lock",
"futures",
"libc",
"reqwest",
"rusqlite",
"rust-embed",
+5
View File
@@ -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"
+6 -16
View File
@@ -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
View File
@@ -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"));
+21
View File
@@ -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
+16
View File
@@ -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()
}
+437
View File
@@ -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, &current_norm) {
return Err(anyhow!("That's already the current data directory."));
}
if target.starts_with(&current_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(&current);
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(&current, 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();
}
}
+1
View File
@@ -10,6 +10,7 @@
pub mod chat;
pub mod codex;
pub mod datadir;
pub mod experiments;
pub mod files;
pub mod git;
+124 -9
View File
@@ -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");
}
}
+62
View File
@@ -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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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" ? (
+47
View File
@@ -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;
}
+31 -2
View File
@@ -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)}`,
);
}
}
+7 -6
View File
@@ -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
+146 -5
View File
@@ -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 &amp; 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&apos;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,
+262 -1
View File
@@ -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&apos;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>
);
}
+35
View File
@@ -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();
}, []);
}
+37
View File
@@ -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;