mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-10-02 01:34:34 +08:00
8735 lines
316 KiB
Rust
8735 lines
316 KiB
Rust
//! `orx up` — the local autoresearch dashboard server.
|
|
//!
|
|
//! One axum process on 127.0.0.1 serving three surfaces:
|
|
//! / embedded SPA (rust-embed over ui/dist, index.html fallback)
|
|
//! /api/* JSON over the local SQLite store + run-log files
|
|
//! /api/events SSE: 500ms store + log-file diff loop (serve.rs idiom)
|
|
//!
|
|
//! Fully local: no OpenResearch api anywhere on these paths (the /api/papers
|
|
//! routes proxy alphaXiv's public, token-free endpoints — needed because the
|
|
//! browser can't call api.alphaxiv.org cross-origin). The normal dashboard is
|
|
//! loopback-only; the hidden persistent-host mode also requires a session bearer.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::convert::Infallible;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
|
use axum::http::{header, HeaderMap, Method, StatusCode, Uri};
|
|
use axum::middleware::{self, Next};
|
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use base64::Engine as _;
|
|
use futures::Stream;
|
|
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest as _, Sha256};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::commands::remote_host::{DashboardLock, DashboardLockMode, HostDescriptor, RemoteAuth};
|
|
use crate::commands::up_remote::RemoteSessionStatus;
|
|
use crate::error::{anyhow, Result};
|
|
use crate::local;
|
|
use crate::local::chat::ChatHost;
|
|
use crate::local::is_terminal;
|
|
use crate::local::opencode::AgentHost;
|
|
use crate::store::{
|
|
log_path, now_ms, SshHostTest, Store, StoredAgentSelection, StoredChatSession, StoredRun,
|
|
};
|
|
use crate::updates;
|
|
use crate::workspace_state::{GlobalWorkspaceState, WorkspaceState};
|
|
use crate::{browser, UpArgs};
|
|
|
|
pub(crate) mod compute_settings;
|
|
mod harness_setup;
|
|
use compute_settings::*;
|
|
|
|
pub async fn run(args: UpArgs) -> Result<()> {
|
|
let port = args.port;
|
|
let persistent_host = args.remote_host;
|
|
let remote_auth = if persistent_host {
|
|
let callback = format!("{}{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
|
|
local::chat::set_up_auth_token(callback.clone());
|
|
Some(RemoteAuth::new(&callback))
|
|
} else {
|
|
None
|
|
};
|
|
let dashboard_lock = DashboardLock::acquire(
|
|
&crate::commands::remote_host::canonical_data_dir()?,
|
|
if persistent_host {
|
|
DashboardLockMode::Exclusive
|
|
} else {
|
|
DashboardLockMode::Shared
|
|
},
|
|
)?;
|
|
// Blocks only for a relaunched server, whose predecessor still holds the port.
|
|
updates::await_replaced_parent();
|
|
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
|
|
Ok(listener) => listener,
|
|
// A second double-click should reach the running dashboard, not fail on its port.
|
|
Err(error)
|
|
if error.kind() == std::io::ErrorKind::AddrInUse
|
|
&& crate::owns_its_console()
|
|
&& dashboard_is_serving(port).await =>
|
|
{
|
|
let url = format!("http://127.0.0.1:{port}");
|
|
eprintln!("orx up: already running — opening {url}");
|
|
if !args.no_browser {
|
|
browser::open_browser(&url);
|
|
}
|
|
return Ok(());
|
|
}
|
|
Err(error) => return Err(anyhow!("Could not bind 127.0.0.1:{}: {}", port, error)),
|
|
};
|
|
let actual_port = listener.local_addr()?.port();
|
|
// Open early so the schema exists before any request or agent spawn.
|
|
{
|
|
let store = Store::open()?;
|
|
local::chat::reconcile_unfinished_turns(&store)?;
|
|
for run in store.list_active_runs()? {
|
|
if store.get_local_experiment(&run.experiment_id)?.is_some() {
|
|
if let Err(err) = crate::commands::exp::spawn_detached_supervise(&run.id) {
|
|
eprintln!("could not recover supervisor for run {}: {err}", run.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Harnesses spawn lazily on the first message to one of their sessions;
|
|
// no eager agent bring-up. (--no-agent is now a no-op kept for compat.)
|
|
let agent = Arc::new(AgentHost::new(args.model.clone()));
|
|
let codex = Arc::new(local::codex::CodexHost::new());
|
|
let claude = Arc::new(local::claude::ClaudeHost::new());
|
|
claude.start_reaper();
|
|
let remote_instance_id = persistent_host.then(|| uuid::Uuid::new_v4().to_string());
|
|
let stopping = Arc::new(AtomicBool::new(false));
|
|
let state = AppState {
|
|
agent: agent.clone(),
|
|
chat: Arc::new(ChatHost::new(agent.clone(), codex.clone(), claude.clone())),
|
|
claude: claude.clone(),
|
|
harnesses: Arc::new(tokio::sync::Mutex::new(None)),
|
|
project_lifecycle: Arc::new(ProjectLifecycle::default()),
|
|
project_creation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
publication_locks: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
|
harness_fill_in_flight: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
claude_catalog_queue: Arc::new(std::sync::Mutex::new(ClaudeCatalogQueue::default())),
|
|
data_dir_move_in_progress: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
data_dir_gate: Arc::new(tokio::sync::Mutex::new(())),
|
|
remote_sessions: crate::commands::up_remote::RemoteSessionManager::new(),
|
|
remote_instance_id: remote_instance_id.clone(),
|
|
stopping: stopping.clone(),
|
|
dashboard_lock: Arc::new(std::sync::Mutex::new(Some(dashboard_lock))),
|
|
restart: Arc::new(tokio::sync::Notify::new()),
|
|
};
|
|
// Plan-mode turns hand this port to the `orx mcp-gate` permission bridge.
|
|
state.chat.set_up_port(actual_port);
|
|
state.chat.resume_persisted_queues();
|
|
{
|
|
let chat = state.chat.clone();
|
|
let moving = state.data_dir_move_in_progress.clone();
|
|
let gate = state.data_dir_gate.clone();
|
|
tokio::spawn(async move {
|
|
let interval =
|
|
Duration::from_millis((crate::store::CHAT_TURN_LEASE_TTL_MS + 1_000) as u64);
|
|
loop {
|
|
tokio::time::sleep(interval).await;
|
|
if moving.load(std::sync::atomic::Ordering::SeqCst) {
|
|
continue;
|
|
}
|
|
let _gate = gate.lock().await;
|
|
if moving.load(std::sync::atomic::Ordering::SeqCst) {
|
|
continue;
|
|
}
|
|
if let Err(err) = chat.reconcile_expired_turn_leases() {
|
|
eprintln!("orx up: could not reconcile expired chat turns: {err}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
spawn_agent_preflight(state.clone());
|
|
// A fresh install's demo worktree takes ~15 sequential git spawns — build
|
|
// it while the onboarding screen is up instead of inside the confirm click.
|
|
// On Windows those spawns run at idle priority (see `demo::prewarm`) so
|
|
// they only take cores the catalog fill's probes leave free. The data-dir
|
|
// gate keeps it from racing a mid-flight directory move.
|
|
if !persistent_host {
|
|
let move_in_progress = state.data_dir_move_in_progress.clone();
|
|
let gate = state.data_dir_gate.clone();
|
|
tokio::task::spawn_blocking(move || local::demo::prewarm(move_in_progress, gate));
|
|
}
|
|
// Deliver explicitly registered run wake-ups once their chat becomes idle.
|
|
tokio::spawn(local::chat::watch_runs(
|
|
state.chat.clone(),
|
|
state.data_dir_move_in_progress.clone(),
|
|
state.data_dir_gate.clone(),
|
|
));
|
|
spawn_claude_auth_monitor(
|
|
state.chat.clone(),
|
|
claude.clone(),
|
|
state.harness_fill_in_flight.clone(),
|
|
);
|
|
spawn_background_tasks(remote_auth.is_none());
|
|
if !persistent_host && !args.desktop_app {
|
|
spawn_restart_when_idle(state.clone());
|
|
}
|
|
let live_events = state.chat.clone();
|
|
local::overleaf_live::set_event_sink(Box::new(move |name, data| {
|
|
live_events.emit_event(name, data)
|
|
}));
|
|
|
|
let app = router(state.clone(), remote_auth.clone());
|
|
let url = format!("http://127.0.0.1:{actual_port}");
|
|
let (stop_tx, mut stop_rx) = tokio::sync::watch::channel(false);
|
|
let control_server = if persistent_host {
|
|
Some(
|
|
crate::commands::remote_host::start_control_server(
|
|
HostDescriptor {
|
|
instance_id: remote_instance_id.clone().expect("persistent instance id"),
|
|
hostname: crate::commands::remote_host::hostname(),
|
|
port: actual_port,
|
|
version: env!("CARGO_PKG_VERSION").into(),
|
|
dashboard_protocol: crate::commands::up_remote::DASHBOARD_PROTOCOL,
|
|
control_protocol: crate::commands::remote_host::CONTROL_PROTOCOL,
|
|
},
|
|
remote_auth.clone().expect("persistent remote auth"),
|
|
state.chat.clone(),
|
|
stopping.clone(),
|
|
stop_tx,
|
|
)
|
|
.await?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
// In an SSH session the loopback URL only works on the remote box and there's
|
|
// no local browser to open — print forwarding guidance instead of the bare
|
|
// URL, and skip the (futile) browser-open. Otherwise, today's local flow.
|
|
if let Some(session) = crate::remote::detect_ssh_session() {
|
|
eprint!("{}", session.instructions(actual_port));
|
|
} else {
|
|
eprintln!("orx up: dashboard on {url}");
|
|
if let Some(warning) = crate::local::bash::missing_toolchain() {
|
|
eprintln!("orx up: warning: {warning}");
|
|
}
|
|
if !args.no_browser {
|
|
browser::open_browser(&url);
|
|
}
|
|
}
|
|
|
|
// select! instead of graceful shutdown: open SSE streams never complete,
|
|
// so waiting on connections would hang Ctrl-C forever.
|
|
//
|
|
// We wait on SIGTERM/SIGHUP as well as SIGINT: when this server is started
|
|
// over SSH by `orx up --remote`, closing that tunnel (the launcher's Ctrl-C)
|
|
// delivers SIGHUP here as the channel tears down — without handling it the
|
|
// remote server would leak, staying bound to its port after the tunnel dies.
|
|
let restart = state.restart.clone();
|
|
let mut restarting = false;
|
|
let explicit_stop = if persistent_host {
|
|
tokio::select! {
|
|
r = axum::serve(listener, app) => {
|
|
r.map_err(|e| anyhow!("orx up: server error: {e}"))?;
|
|
false
|
|
}
|
|
changed = stop_rx.changed() => changed.is_ok() && *stop_rx.borrow(),
|
|
_ = restart.notified() => { restarting = true; false }
|
|
_ = persistent_shutdown_signal() => false,
|
|
}
|
|
} else {
|
|
tokio::select! {
|
|
r = axum::serve(listener, app) => r.map_err(|e| anyhow!("orx up: server error: {e}"))?,
|
|
_ = restart.notified() => restarting = true,
|
|
_ = shutdown_signal() => eprintln!("orx up: shutting down"),
|
|
}
|
|
false
|
|
};
|
|
if persistent_host {
|
|
stopping.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
if explicit_stop {
|
|
state.chat.interrupt_all().await;
|
|
}
|
|
state.remote_sessions.shutdown().await;
|
|
agent.shutdown().await;
|
|
codex.shutdown().await;
|
|
claude.shutdown().await;
|
|
if let Some(server) = control_server {
|
|
server.shutdown().await;
|
|
}
|
|
state.dashboard_lock.lock().unwrap().take();
|
|
if restarting {
|
|
eprintln!("orx up: restarting into the updated orx");
|
|
let err = updates::relaunch(actual_port);
|
|
return Err(anyhow!("orx up: could not restart: {err}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The desktop app's quit. A stored permit covers a quit that lands before the
|
|
/// server is waiting for one.
|
|
static SHUTDOWN_REQUESTED: tokio::sync::Notify = tokio::sync::Notify::const_new();
|
|
|
|
#[cfg(desktop_app)]
|
|
pub(crate) fn request_shutdown() {
|
|
SHUTDOWN_REQUESTED.notify_one();
|
|
}
|
|
|
|
/// Resolves when the process is asked to stop: SIGINT everywhere; on Unix also
|
|
/// SIGTERM and SIGHUP (SIGHUP is what an SSH tunnel delivers on disconnect, so
|
|
/// a `--remote`-launched server exits with its tunnel instead of leaking); and
|
|
/// [`request_shutdown`].
|
|
async fn shutdown_signal() {
|
|
tokio::select! {
|
|
_ = SHUTDOWN_REQUESTED.notified() => {}
|
|
_ = os_shutdown_signal() => {}
|
|
}
|
|
}
|
|
|
|
async fn os_shutdown_signal() {
|
|
#[cfg(unix)]
|
|
{
|
|
use tokio::signal::unix::{signal, Signal, SignalKind};
|
|
// If a signal stream can't be installed, that arm simply never fires —
|
|
// fall back to whatever handlers do register rather than aborting.
|
|
async fn wait(s: &mut Option<Signal>) {
|
|
match s {
|
|
Some(s) => {
|
|
s.recv().await;
|
|
}
|
|
None => std::future::pending().await,
|
|
}
|
|
}
|
|
let mut term = signal(SignalKind::terminate()).ok();
|
|
let mut hup = signal(SignalKind::hangup()).ok();
|
|
tokio::select! {
|
|
_ = tokio::signal::ctrl_c() => {}
|
|
_ = wait(&mut term) => {}
|
|
_ = wait(&mut hup) => {}
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
}
|
|
}
|
|
|
|
async fn persistent_shutdown_signal() {
|
|
#[cfg(unix)]
|
|
{
|
|
use tokio::signal::unix::{signal, Signal, SignalKind};
|
|
async fn wait(signal: &mut Option<Signal>) {
|
|
match signal {
|
|
Some(signal) => {
|
|
signal.recv().await;
|
|
}
|
|
None => std::future::pending().await,
|
|
}
|
|
}
|
|
let mut term = signal(SignalKind::terminate()).ok();
|
|
tokio::select! {
|
|
_ = tokio::signal::ctrl_c() => {}
|
|
_ = wait(&mut term) => {}
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
agent: Arc<AgentHost>,
|
|
chat: Arc<ChatHost>,
|
|
claude: Arc<local::claude::ClaudeHost>,
|
|
/// 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)>>>,
|
|
project_lifecycle: Arc<ProjectLifecycle>,
|
|
project_creation_lock: Arc<tokio::sync::Mutex<()>>,
|
|
publication_locks: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
|
|
/// Single-flight guard for the background catalog fill: without it every
|
|
/// expired read would start its own full detection sweep.
|
|
harness_fill_in_flight: Arc<std::sync::atomic::AtomicBool>,
|
|
claude_catalog_queue: Arc<std::sync::Mutex<ClaudeCatalogQueue>>,
|
|
/// 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>,
|
|
/// Serializes wake-up store writes with a live data-directory move.
|
|
data_dir_gate: Arc<tokio::sync::Mutex<()>>,
|
|
remote_sessions: crate::commands::up_remote::RemoteSessionManager,
|
|
remote_instance_id: Option<String>,
|
|
stopping: Arc<AtomicBool>,
|
|
dashboard_lock: Arc<std::sync::Mutex<Option<DashboardLock>>>,
|
|
/// Fired by `POST /api/update/restart`; the serve loop relaunches on it.
|
|
restart: Arc<tokio::sync::Notify>,
|
|
}
|
|
|
|
async fn project_publication_lock(
|
|
state: &AppState,
|
|
project_id: &str,
|
|
) -> tokio::sync::OwnedMutexGuard<()> {
|
|
let lock = {
|
|
let mut locks = state.publication_locks.lock().await;
|
|
locks
|
|
.entry(project_id.to_string())
|
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
|
.clone()
|
|
};
|
|
lock.lock_owned().await
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ProjectLifecycle {
|
|
inner: Arc<std::sync::Mutex<ProjectLifecycleState>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ProjectLifecycleState {
|
|
deleting: HashSet<String>,
|
|
admissions: HashMap<String, usize>,
|
|
}
|
|
|
|
struct ProjectAdmissionLease {
|
|
inner: Arc<std::sync::Mutex<ProjectLifecycleState>>,
|
|
project_id: String,
|
|
}
|
|
|
|
impl Drop for ProjectAdmissionLease {
|
|
fn drop(&mut self) {
|
|
let mut state = self.inner.lock().unwrap();
|
|
if let Some(count) = state.admissions.get_mut(&self.project_id) {
|
|
*count -= 1;
|
|
if *count == 0 {
|
|
state.admissions.remove(&self.project_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProjectDeletionLease {
|
|
inner: Arc<std::sync::Mutex<ProjectLifecycleState>>,
|
|
project_id: String,
|
|
}
|
|
|
|
impl Drop for ProjectDeletionLease {
|
|
fn drop(&mut self) {
|
|
self.inner.lock().unwrap().deleting.remove(&self.project_id);
|
|
}
|
|
}
|
|
|
|
impl ProjectLifecycle {
|
|
fn admit(&self, project_id: &str) -> Option<ProjectAdmissionLease> {
|
|
let mut state = self.inner.lock().unwrap();
|
|
if state.deleting.contains(project_id) {
|
|
return None;
|
|
}
|
|
*state.admissions.entry(project_id.to_string()).or_default() += 1;
|
|
Some(ProjectAdmissionLease {
|
|
inner: self.inner.clone(),
|
|
project_id: project_id.to_string(),
|
|
})
|
|
}
|
|
|
|
fn begin_delete(&self, project_id: &str) -> Option<ProjectDeletionLease> {
|
|
let mut state = self.inner.lock().unwrap();
|
|
if state.deleting.contains(project_id)
|
|
|| state
|
|
.admissions
|
|
.get("__project_create__")
|
|
.copied()
|
|
.unwrap_or_default()
|
|
> 0
|
|
|| state
|
|
.admissions
|
|
.get(project_id)
|
|
.copied()
|
|
.unwrap_or_default()
|
|
> 0
|
|
{
|
|
return None;
|
|
}
|
|
state.deleting.insert(project_id.to_string());
|
|
Some(ProjectDeletionLease {
|
|
inner: self.inner.clone(),
|
|
project_id: project_id.to_string(),
|
|
})
|
|
}
|
|
|
|
fn operation_count(&self) -> usize {
|
|
let state = self.inner.lock().unwrap();
|
|
state.admissions.values().copied().sum::<usize>() + state.deleting.len()
|
|
}
|
|
}
|
|
|
|
fn router(state: AppState, remote_auth: Option<RemoteAuth>) -> Router {
|
|
let app = Router::new()
|
|
.route("/api/health", get(health))
|
|
.route("/api/onboarding/complete", post(complete_onboarding))
|
|
.route("/api/project-path/status", get(project_path_status))
|
|
.route("/api/project-path/pick", post(pick_project_folder))
|
|
.route("/api/projects", get(list_projects).post(create_project))
|
|
.route(
|
|
"/api/projects/starter-prompts/prewarm",
|
|
post(prewarm_starter_prompts),
|
|
)
|
|
.route("/api/projects/activity", get(list_project_activity))
|
|
.route(
|
|
"/api/projects/{id}",
|
|
get(get_project)
|
|
.patch(update_project)
|
|
.delete(delete_project),
|
|
)
|
|
.route("/api/projects/{id}/open", post(open_project))
|
|
.route("/api/projects/{id}/git", get(project_git_status))
|
|
.route(
|
|
"/api/projects/{id}/starter-prompts",
|
|
get(project_starter_prompts),
|
|
)
|
|
.route("/api/projects/{id}/git/init", post(initialize_project_git))
|
|
.route("/api/projects/{id}/github", post(enable_project_github))
|
|
.route(
|
|
"/api/projects/{id}/github/disable",
|
|
post(disable_project_github),
|
|
)
|
|
.route("/api/projects/{id}/github/push", post(push_project_github))
|
|
.route("/api/github/account", get(github_account))
|
|
.route(
|
|
"/api/github/project-repo-preview",
|
|
get(github_project_repo_preview),
|
|
)
|
|
.route("/api/github/repo-access", get(github_repo_access))
|
|
.route("/api/projects/{id}/experiments", get(list_experiments))
|
|
.route("/api/projects/{id}/runs", get(list_project_runs))
|
|
.route("/api/papers/search", get(search_papers_api))
|
|
.route("/api/papers/resolve", get(resolve_paper_api))
|
|
.route("/api/compute/backends", get(compute_backends))
|
|
.route("/api/runs", post(create_run))
|
|
.route("/api/runs/{id}", get(get_run))
|
|
.route("/api/instances", get(list_instances))
|
|
.route("/api/runs/{id}/cancel", post(cancel_run))
|
|
.route("/api/runs/{id}/log", get(run_log))
|
|
.route("/api/runs/{id}/logs", get(run_logs))
|
|
.route("/api/runs/{id}/diff", get(run_diff))
|
|
.route("/api/experiments/{id}/diff", get(experiment_diff))
|
|
.route(
|
|
"/api/experiments/{id}/archive",
|
|
axum::routing::patch(set_experiment_archive),
|
|
)
|
|
.route("/api/experiments/{id}/commits", get(experiment_commits))
|
|
.route(
|
|
"/api/experiments/{id}/commits/{sha}/diff",
|
|
get(experiment_commit_diff),
|
|
)
|
|
.route("/api/projects/{id}/working-tree", get(project_working_tree))
|
|
.route("/api/projects/{id}/code-tree", get(project_code_tree))
|
|
.route(
|
|
"/api/projects/{id}/file",
|
|
get(project_file)
|
|
.put(write_project_file)
|
|
.patch(manage_project_file),
|
|
)
|
|
.route("/api/projects/{id}/file/raw", get(project_raw_file))
|
|
.route("/api/projects/{id}/file/open", post(open_project_file))
|
|
.route("/api/projects/{id}/file/reveal", post(reveal_project_file))
|
|
.route("/api/projects/{id}/file/latex", post(compile_project_latex))
|
|
.route("/api/latex/engine", get(latex_engine))
|
|
.route(
|
|
"/api/projects/{id}/file/overleaf",
|
|
get(overleaf_link)
|
|
.post(link_overleaf)
|
|
.delete(unlink_overleaf),
|
|
)
|
|
.route("/api/projects/{id}/file/overleaf/sync", post(sync_overleaf))
|
|
.route(
|
|
"/api/projects/{id}/file/overleaf/status",
|
|
get(overleaf_status),
|
|
)
|
|
.route(
|
|
"/api/projects/{id}/file/overleaf/upload",
|
|
get(overleaf_upload),
|
|
)
|
|
.route("/api/overleaf/settings", get(overleaf_settings))
|
|
.route(
|
|
"/api/overleaf/token",
|
|
post(set_overleaf_token).delete(delete_overleaf_token),
|
|
)
|
|
.route(
|
|
"/api/overleaf/session",
|
|
post(set_overleaf_session).delete(delete_overleaf_session),
|
|
)
|
|
.route(
|
|
"/api/overleaf/session/import",
|
|
post(import_overleaf_session),
|
|
)
|
|
.route(
|
|
"/api/projects/{id}/file/overleaf/live",
|
|
post(start_overleaf_live).delete(stop_overleaf_live),
|
|
)
|
|
.route("/api/files/abs", get(absolute_file))
|
|
.route("/api/files/abs/raw", get(absolute_raw_file))
|
|
.route(
|
|
"/api/settings/ssh/config",
|
|
get(ssh_config).put(save_ssh_config),
|
|
)
|
|
.route(
|
|
"/api/projects/{id}/files",
|
|
get(list_artifacts)
|
|
.patch(manage_artifact_file)
|
|
.delete(delete_artifact),
|
|
)
|
|
.route("/api/projects/{id}/files/file", get(serve_artifact))
|
|
.route("/api/projects/{id}/terminal", get(project_terminal))
|
|
.route("/api/events", get(events))
|
|
.route("/api/settings/hf", get(hf_settings).post(set_hf_token))
|
|
.route(
|
|
"/api/settings/tinker",
|
|
get(tinker_settings).post(set_tinker_key),
|
|
)
|
|
.route(
|
|
"/api/settings/k8s",
|
|
get(k8s_settings).post(set_k8s_settings),
|
|
)
|
|
.route(
|
|
"/api/settings/modal",
|
|
get(modal_settings).post(set_modal_token),
|
|
)
|
|
.route("/api/settings/env", get(env_settings).post(set_env_var))
|
|
.route(
|
|
"/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),
|
|
)
|
|
.route(
|
|
"/api/settings/projects",
|
|
get(project_defaults).post(set_project_defaults),
|
|
)
|
|
.route(
|
|
"/api/settings/telemetry",
|
|
get(telemetry_settings).post(set_telemetry_settings),
|
|
)
|
|
.route("/api/telemetry/event", post(record_ui_event))
|
|
.route("/api/telemetry/locale", post(set_dashboard_locale))
|
|
.route(
|
|
"/api/settings/profile",
|
|
get(profile_settings).post(set_profile_settings),
|
|
)
|
|
.route("/api/update", get(update_status))
|
|
.route("/api/update/apply", post(apply_update))
|
|
.route("/api/update/restart", post(restart_after_update))
|
|
.route("/api/update/auto", post(set_auto_update))
|
|
.route("/api/update/install-cli", post(install_cli))
|
|
.route("/api/settings/ui-state", get(ui_state).post(set_ui_state))
|
|
.route(
|
|
"/api/projects/{id}/ui-state",
|
|
get(project_ui_state).post(set_project_ui_state),
|
|
)
|
|
.route(
|
|
"/api/settings/ssh",
|
|
get(ssh_settings).post(save_ssh_settings),
|
|
)
|
|
.route("/api/settings/ssh/default", post(save_ssh_default))
|
|
.route("/api/settings/ssh/master", get(ssh_master_status))
|
|
.route("/api/settings/ssh/preflight", post(ssh_preflight))
|
|
.route("/api/settings/ssh/connect", get(ssh_connect))
|
|
.route(
|
|
"/api/remote/sessions",
|
|
get(remote_sessions).post(create_remote_session),
|
|
)
|
|
.route("/api/remote/sessions/{id}", get(remote_session))
|
|
.route(
|
|
"/api/remote/sessions/{id}/reconnect",
|
|
post(reconnect_remote_session),
|
|
)
|
|
.route(
|
|
"/api/remote/sessions/{id}/disconnect",
|
|
post(disconnect_remote_session),
|
|
)
|
|
.route("/_orx/runtime", get(local_runtime))
|
|
.route(
|
|
"/api/settings/slurm",
|
|
get(slurm_settings).post(set_slurm_settings),
|
|
)
|
|
.route("/api/settings/slurm/preflight", post(slurm_preflight))
|
|
.route(
|
|
"/api/settings/ray",
|
|
get(ray_settings).post(set_ray_settings),
|
|
)
|
|
.route("/api/settings/ray/preflight", post(ray_preflight))
|
|
.route("/api/settings/compute", get(compute_settings))
|
|
.route("/api/settings/compute/default", post(set_compute_default))
|
|
.route("/api/settings/local", get(local_machine_settings))
|
|
.route("/api/settings/openresearch", get(openresearch_settings))
|
|
.route("/api/settings/openresearch/login", get(openresearch_login))
|
|
.route("/api/settings/commands/run", get(run_settings_command))
|
|
.route(
|
|
"/api/settings/openresearch/ssh-key",
|
|
get(openresearch_ssh_key),
|
|
)
|
|
.route(
|
|
"/api/settings/lit-sources",
|
|
get(lit_sources_settings).post(set_lit_sources_settings),
|
|
)
|
|
.route("/api/harnesses", get(list_harnesses))
|
|
.route("/api/harnesses/{id}/snapshot", get(harness_snapshot))
|
|
.route(
|
|
"/api/harnesses/setup/commands",
|
|
get(harness_setup::commands),
|
|
)
|
|
.route("/api/harnesses/setup", get(harness_setup::connect))
|
|
.route(
|
|
"/api/local-models",
|
|
get(list_local_models).post(connect_local_model),
|
|
)
|
|
.route("/api/local-models/discover", post(discover_local_models))
|
|
.route("/api/local-models/{id}/check", post(check_local_model))
|
|
.route(
|
|
"/api/local-models/{id}",
|
|
axum::routing::delete(remove_local_model),
|
|
)
|
|
.route("/api/skills", get(list_skills))
|
|
.route("/api/skills/{name}", get(get_skill))
|
|
.route(
|
|
"/api/user-skills",
|
|
get(list_user_skills)
|
|
.post(upload_user_skill)
|
|
.delete(delete_user_skill),
|
|
)
|
|
.route(
|
|
"/api/latex-templates",
|
|
get(list_latex_templates)
|
|
.post(upload_latex_template)
|
|
.delete(delete_latex_template),
|
|
)
|
|
.route(
|
|
"/api/chat/sessions",
|
|
get(list_chat_sessions).post(create_chat_session),
|
|
)
|
|
.route(
|
|
"/api/chat/sessions/{id}",
|
|
axum::routing::delete(delete_chat_session).patch(update_chat_session),
|
|
)
|
|
.route("/api/chat/sessions/{id}/messages", get(chat_messages))
|
|
.route("/api/chat/sessions/{id}/worktree", get(session_worktree))
|
|
.route("/api/chat/sessions/{id}/message", post(send_chat_message))
|
|
.route("/api/chat/sessions/{id}/shell", post(run_shell_command))
|
|
.route(
|
|
"/api/chat/sessions/{id}/compact",
|
|
post(compact_chat_session),
|
|
)
|
|
.route("/api/chat/native-sessions", get(list_native_chats))
|
|
.route("/api/chat/native-sessions/import", post(import_native_chat))
|
|
.route(
|
|
"/api/chat/sessions/{id}/turns/{turnId}/recover",
|
|
post(recover_chat_turn),
|
|
)
|
|
.route("/api/chat/sessions/{id}/fork", post(fork_chat_turn))
|
|
.route("/api/chat/sessions/{id}/branch", post(select_chat_branch))
|
|
.route("/api/chat/sessions/{id}/interrupt", post(interrupt_chat))
|
|
.route(
|
|
"/api/chat/sessions/{id}/queue/{itemId}",
|
|
axum::routing::delete(cancel_queued_chat).post(retry_queued_chat),
|
|
)
|
|
.route("/api/chat/sessions/{id}/respond", post(respond_chat))
|
|
// Internal: the `orx mcp-gate` permission bridge's long-poll (plan
|
|
// mode). Token-authenticated in the handler; blocks until the surfaced
|
|
// card is answered.
|
|
.route("/api/internal/permissions", post(bridge_permission))
|
|
.route("/api/chat/attachments/{name}", get(chat_attachment))
|
|
.route("/api/agent/status", get(agent_status))
|
|
.fallback(spa)
|
|
// Chat attachments (PDFs, images) ride as base64 in the send-message
|
|
// JSON body; the 2 MB axum default rejects any real paper. Cap it well
|
|
// above the client-side per-file limit so a full message still fits.
|
|
.layer(DefaultBodyLimit::max(64 * 1024 * 1024))
|
|
.with_state(state);
|
|
let app = app
|
|
.layer(middleware::from_fn(track_active))
|
|
.layer(middleware::from_fn(
|
|
crate::commands::up_remote::loopback_guard,
|
|
));
|
|
match remote_auth {
|
|
Some(auth) => app.layer(middleware::from_fn_with_state(auth, require_remote_auth)),
|
|
None => app,
|
|
}
|
|
}
|
|
|
|
async fn require_remote_auth(
|
|
State(auth): State<RemoteAuth>,
|
|
request: axum::extract::Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let path = request.uri().path();
|
|
if remote_route_forbidden(path) {
|
|
return ApiError(
|
|
StatusCode::FORBIDDEN,
|
|
"This action is unavailable in an SSH workspace.".into(),
|
|
)
|
|
.into_response();
|
|
}
|
|
if path == "/api/internal/permissions" {
|
|
return next.run(request).await;
|
|
}
|
|
let provided = request
|
|
.headers()
|
|
.get(header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.strip_prefix("Bearer "))
|
|
.map(|value| Sha256::digest(value.as_bytes()));
|
|
let session = provided
|
|
.as_ref()
|
|
.is_some_and(|digest| auth.matches_attachment(digest));
|
|
let callback = is_remote_callback_route(request.method(), path)
|
|
&& provided
|
|
.as_ref()
|
|
.is_some_and(|digest| auth.matches_callback(digest));
|
|
if session || callback {
|
|
next.run(request).await
|
|
} else {
|
|
ApiError(
|
|
StatusCode::UNAUTHORIZED,
|
|
"Remote session authentication required.".into(),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
fn remote_route_forbidden(path: &str) -> bool {
|
|
matches!(
|
|
path,
|
|
"/api/project-path/pick"
|
|
| "/api/update"
|
|
| "/api/update/apply"
|
|
| "/api/update/restart"
|
|
| "/api/update/auto"
|
|
| "/api/update/install-cli"
|
|
| "/api/settings/data-dir"
|
|
| "/api/settings/data-dir/move"
|
|
| "/api/settings/openresearch/ssh-key"
|
|
| "/api/settings/openresearch/login"
|
|
| "/api/settings/commands/run"
|
|
| "/api/harnesses/setup"
|
|
) || path.starts_with("/api/remote/")
|
|
|| (path.starts_with("/api/projects/")
|
|
&& (path.ends_with("/file/open") || path.ends_with("/file/reveal")))
|
|
}
|
|
|
|
fn is_remote_callback_route(method: &Method, path: &str) -> bool {
|
|
if method == Method::GET {
|
|
// `orx agent spawn`'s install preflight (read-only).
|
|
return path
|
|
.strip_prefix("/api/harnesses/")
|
|
.and_then(|path| path.strip_suffix("/snapshot"))
|
|
.is_some_and(|id| !id.is_empty() && !id.contains('/'));
|
|
}
|
|
if method != Method::POST {
|
|
return false;
|
|
}
|
|
path == "/api/runs"
|
|
|| path
|
|
.strip_prefix("/api/runs/")
|
|
.and_then(|path| path.strip_suffix("/cancel"))
|
|
.is_some_and(|id| !id.is_empty() && !id.contains('/'))
|
|
}
|
|
|
|
// --- error plumbing -------------------------------------------------------
|
|
|
|
/// JSON error responses: `{"error": "..."}` with an explicit status.
|
|
struct ApiError(StatusCode, String);
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
(self.0, Json(json!({ "error": self.1 }))).into_response()
|
|
}
|
|
}
|
|
|
|
impl From<crate::error::Error> for ApiError {
|
|
fn from(err: crate::error::Error) -> Self {
|
|
Self(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
}
|
|
}
|
|
|
|
fn bad_request(err: impl std::fmt::Display) -> ApiError {
|
|
ApiError(StatusCode::BAD_REQUEST, err.to_string())
|
|
}
|
|
|
|
fn not_found(what: &str) -> ApiError {
|
|
ApiError(StatusCode::NOT_FOUND, format!("{what} not found"))
|
|
}
|
|
|
|
type ApiResult = std::result::Result<Json<Value>, ApiError>;
|
|
|
|
// --- wire types -----------------------------------------------------------
|
|
|
|
/// The Run entity the API serves: StoredRun with `backend_json` parsed into an
|
|
/// object and cancellation intent exposed for pending UI state.
|
|
#[derive(Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ApiRun {
|
|
id: String,
|
|
experiment_id: String,
|
|
project_id: String,
|
|
status: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
backend: Option<Value>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
command: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
commit_sha: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
result_markdown: Option<String>,
|
|
created_at: i64,
|
|
updated_at: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
ended_at: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
exit_code: Option<i64>,
|
|
cancel_requested: bool,
|
|
}
|
|
|
|
impl From<&StoredRun> for ApiRun {
|
|
fn from(run: &StoredRun) -> Self {
|
|
Self {
|
|
id: run.id.clone(),
|
|
experiment_id: run.experiment_id.clone(),
|
|
project_id: run.project_id.clone(),
|
|
status: run.status.clone(),
|
|
backend: serde_json::from_str(&run.backend_json).ok(),
|
|
command: Some(run.command.clone()).filter(|c| !c.is_empty()),
|
|
commit_sha: run.commit_sha.clone(),
|
|
result_markdown: run.result_markdown.clone(),
|
|
created_at: run.created_at,
|
|
updated_at: run.updated_at,
|
|
ended_at: run.ended_at,
|
|
exit_code: run.exit_code,
|
|
cancel_requested: run.cancel_requested,
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- basic routes ---------------------------------------------------------
|
|
|
|
/// Whether a dashboard this build can talk to, not some other server, holds `port`.
|
|
async fn dashboard_is_serving(port: u16) -> bool {
|
|
let Ok(response) = reqwest::Client::new()
|
|
.get(format!("http://127.0.0.1:{port}/api/health"))
|
|
.timeout(std::time::Duration::from_secs(2))
|
|
.send()
|
|
.await
|
|
else {
|
|
return false;
|
|
};
|
|
response.json::<Value>().await.is_ok_and(|body| {
|
|
body.get("dashboardProtocol").and_then(Value::as_u64)
|
|
== Some(u64::from(crate::commands::up_remote::DASHBOARD_PROTOCOL))
|
|
})
|
|
}
|
|
|
|
async fn health(State(state): State<AppState>) -> Json<Value> {
|
|
Json(json!({
|
|
"ok": true,
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"dashboardProtocol": crate::commands::up_remote::DASHBOARD_PROTOCOL,
|
|
"instanceId": state.remote_instance_id,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CompleteOnboardingReq {
|
|
harness: String,
|
|
model: Option<String>,
|
|
permission_mode: Option<String>,
|
|
reasoning_level: Option<String>,
|
|
#[serde(default)]
|
|
research_areas: Vec<String>,
|
|
#[serde(default)]
|
|
other_area: Option<String>,
|
|
#[serde(default)]
|
|
background: Option<String>,
|
|
#[serde(default)]
|
|
papers: Vec<crate::telemetry::ProfilePaper>,
|
|
}
|
|
|
|
const RESEARCH_AREAS: [&str; 4] = ["AI/ML", "Biology", "Physics", "Other"];
|
|
|
|
fn preferred_permission_mode(harness: &str, mode: Option<String>) -> Option<String> {
|
|
match mode.as_deref() {
|
|
Some("plan") => local::harness::effective_permission_id(harness, None),
|
|
_ => mode,
|
|
}
|
|
}
|
|
|
|
fn normalize_research_profile(
|
|
research_areas: Vec<String>,
|
|
other_area: Option<String>,
|
|
background: Option<String>,
|
|
papers: Vec<crate::telemetry::ProfilePaper>,
|
|
) -> std::result::Result<crate::telemetry::ResearchProfile, ApiError> {
|
|
if research_areas.is_empty() {
|
|
return Err(bad_request("choose at least one research area"));
|
|
}
|
|
let mut normalized_areas = Vec::with_capacity(research_areas.len());
|
|
for area in research_areas {
|
|
let area = area.trim().to_string();
|
|
if !RESEARCH_AREAS.contains(&area.as_str()) {
|
|
return Err(bad_request(format!("unknown research area: {area}")));
|
|
}
|
|
if normalized_areas.contains(&area) {
|
|
return Err(bad_request(format!("duplicate research area: {area}")));
|
|
}
|
|
normalized_areas.push(area);
|
|
}
|
|
let other_area = other_area.filter(|value| !value.trim().is_empty());
|
|
let includes_other = normalized_areas.iter().any(|area| area == "Other");
|
|
if includes_other && other_area.is_none() {
|
|
return Err(bad_request(
|
|
"describe your research area when choosing Other",
|
|
));
|
|
}
|
|
if !includes_other && other_area.is_some() {
|
|
return Err(bad_request(
|
|
"choose Other before describing another research area",
|
|
));
|
|
}
|
|
Ok(crate::telemetry::ResearchProfile {
|
|
research_areas: normalized_areas,
|
|
other_area,
|
|
background: background.filter(|value| !value.trim().is_empty()),
|
|
papers,
|
|
})
|
|
}
|
|
|
|
async fn complete_onboarding(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CompleteOnboardingReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
if !local::harness::is_chat_harness(&req.harness) {
|
|
return Err(bad_request(format!("unknown harness: {}", req.harness)));
|
|
}
|
|
let nonempty = |value: Option<String>| value.filter(|item| !item.trim().is_empty());
|
|
let permission_mode = nonempty(req.permission_mode);
|
|
if permission_mode
|
|
.as_deref()
|
|
.is_some_and(|mode| local::harness::permission_mode_for(&req.harness, mode).is_none())
|
|
{
|
|
return Err(bad_request("invalid permission mode for selected harness"));
|
|
}
|
|
let selection = local::demo::DemoSelection {
|
|
harness: req.harness,
|
|
model: nonempty(req.model),
|
|
permission_mode,
|
|
reasoning_level: nonempty(req.reasoning_level),
|
|
};
|
|
let profile = normalize_research_profile(
|
|
req.research_areas,
|
|
req.other_area,
|
|
req.background,
|
|
req.papers,
|
|
)?;
|
|
let profile_for_event = profile.clone();
|
|
let completion = tokio::task::spawn_blocking(move || -> Result<_> {
|
|
let _ = crate::telemetry::set_profile(profile);
|
|
let completion = local::demo::complete_onboarding(selection)?;
|
|
let store = Store::open()?;
|
|
store.set_preferred_agent(&StoredAgentSelection {
|
|
harness: completion.selection.harness.clone(),
|
|
model: completion.selection.model.clone(),
|
|
service_tier: None,
|
|
permission_mode: preferred_permission_mode(
|
|
&completion.selection.harness,
|
|
completion.selection.permission_mode.clone(),
|
|
),
|
|
reasoning_level: completion.selection.reasoning_level.clone(),
|
|
})?;
|
|
store.set_onboarding_completed(true)?;
|
|
Ok(completion)
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("demo seed task failed: {e}")))??;
|
|
if completion.newly_created {
|
|
crate::telemetry::capture_onboarding_completed();
|
|
crate::telemetry::capture_onboarding_research_profile(&profile_for_event);
|
|
}
|
|
Ok(Json(json!({
|
|
"project": project_json(&completion.project),
|
|
"selection": completion.selection,
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SkillsQ {
|
|
harness: Option<String>,
|
|
/// The open project, so a built-in skill's instructions can account for it.
|
|
project: Option<String>,
|
|
}
|
|
|
|
/// Slash-skills the composer's `/` dropdown offers (expanded server-side): the
|
|
/// built-in catalog, personal skills, and skills in the open project.
|
|
async fn list_skills(Query(q): Query<SkillsQ>) -> ApiResult {
|
|
tokio::task::spawn_blocking(move || {
|
|
let importing = crate::local::user_skills::refresh_imports();
|
|
let project = q.project.as_deref().and_then(|id| {
|
|
Store::open().ok()?.get_local_project(id).ok().flatten()
|
|
});
|
|
let project_skills = project
|
|
.as_ref()
|
|
.map(|project| {
|
|
crate::local::user_skills::list_project_skills(
|
|
std::path::Path::new(&project.repo_path),
|
|
q.harness.as_deref(),
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
let project_names: HashSet<_> = project_skills.iter().map(|skill| &skill.name).collect();
|
|
let mut skills: Vec<Value> = crate::local::skills::CATALOG
|
|
.iter()
|
|
.map(|s| {
|
|
json!({
|
|
"name": s.name,
|
|
"description": s.description,
|
|
"source": "builtin",
|
|
})
|
|
})
|
|
.collect();
|
|
for s in crate::local::user_skills::list_for_harness(q.harness.as_deref()) {
|
|
skills.push(json!({
|
|
"name": if project_names.contains(&s.name) { format!("{}@u", s.name) } else { s.name },
|
|
"description": s.description,
|
|
"source": "user",
|
|
"plugin": s.plugin,
|
|
"harness": q.harness,
|
|
}));
|
|
}
|
|
for s in project_skills {
|
|
skills.push(json!({
|
|
"name": format!("{}@p", s.name),
|
|
"description": s.description,
|
|
"source": "project",
|
|
"harness": q.harness,
|
|
}));
|
|
}
|
|
Json(json!({ "skills": skills, "importing": importing }))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!(e)))
|
|
}
|
|
|
|
async fn get_skill(Path(name): Path<String>, Query(q): Query<SkillsQ>) -> ApiResult {
|
|
if crate::local::user_skills::parse_selection(&name).is_none() {
|
|
return Err(bad_request("invalid skill name"));
|
|
}
|
|
let github_enabled = if let Some(project_id) = q.project.as_deref() {
|
|
Store::open()
|
|
.ok()
|
|
.and_then(|store| store.get_local_project(project_id).ok().flatten())
|
|
.is_some_and(|project| project.github_enabled())
|
|
} else {
|
|
false
|
|
};
|
|
if let Some(content) = crate::local::skills::instructions(&name, false, github_enabled) {
|
|
return Ok(Json(json!({ "name": name, "content": content })));
|
|
}
|
|
let skill_name = name.clone();
|
|
let project_repo = q
|
|
.project
|
|
.as_deref()
|
|
.and_then(|id| Store::open().ok()?.get_local_project(id).ok().flatten())
|
|
.map(|project| project.repo_path);
|
|
let content = tokio::task::spawn_blocking(move || {
|
|
crate::local::user_skills::content(
|
|
&skill_name,
|
|
q.harness.as_deref(),
|
|
project_repo.as_deref().map(std::path::Path::new),
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!(e)))?
|
|
.ok_or_else(|| not_found("skill"))?;
|
|
Ok(Json(json!({ "name": name, "content": content })))
|
|
}
|
|
|
|
// --- user skills --------------------------------------------------------------
|
|
|
|
fn user_skill_json(s: &crate::local::user_skills::UserSkill) -> Value {
|
|
json!({
|
|
"name": s.name,
|
|
"origin": s.origin,
|
|
"bytes": s.bytes,
|
|
"updatedAt": s.updated_at,
|
|
})
|
|
}
|
|
|
|
/// Everything the Customize tab lists: uploads plus the skills mirrored from the
|
|
/// coding agents installed on this machine.
|
|
async fn list_user_skills() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| {
|
|
let importing = crate::local::user_skills::refresh_imports();
|
|
let skills: Vec<Value> = crate::local::user_skills::list()
|
|
.iter()
|
|
.map(user_skill_json)
|
|
.collect();
|
|
Json(json!({ "skills": skills, "importing": importing }))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!(e)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct UploadSkillReq {
|
|
/// Original upload filename — its extension picks `.zip` vs single file.
|
|
filename: String,
|
|
/// The file bytes, base64 (same convention as chat attachments).
|
|
content_base64: String,
|
|
}
|
|
|
|
async fn upload_user_skill(Json(req): Json<UploadSkillReq>) -> ApiResult {
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(req.content_base64.trim())
|
|
.map_err(|e| bad_request(format!("invalid file data: {e}")))?;
|
|
|
|
let saved =
|
|
crate::local::user_skills::save_upload(&req.filename, &bytes).map_err(bad_request)?;
|
|
|
|
Ok(Json(json!({ "skill": user_skill_json(&saved) })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct DeleteByNameQ {
|
|
name: String,
|
|
}
|
|
|
|
async fn delete_user_skill(Query(q): Query<DeleteByNameQ>) -> ApiResult {
|
|
tokio::task::spawn_blocking(move || crate::local::user_skills::delete(&q.name))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!(e)))?
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
fn latex_template_json(t: &crate::local::latex_templates::LatexTemplate) -> Value {
|
|
json!({
|
|
"name": t.name,
|
|
"entry": t.entry,
|
|
"supportFiles": t.support_files,
|
|
"bytes": t.bytes,
|
|
"updatedAt": t.updated_at,
|
|
})
|
|
}
|
|
|
|
async fn list_latex_templates() -> ApiResult {
|
|
let templates: Vec<Value> = crate::local::latex_templates::list()
|
|
.iter()
|
|
.map(latex_template_json)
|
|
.collect();
|
|
Ok(Json(json!({ "templates": templates })))
|
|
}
|
|
|
|
async fn upload_latex_template(Json(req): Json<UploadSkillReq>) -> ApiResult {
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(req.content_base64.trim())
|
|
.map_err(|e| bad_request(format!("invalid file data: {e}")))?;
|
|
let saved =
|
|
crate::local::latex_templates::save_upload(&req.filename, &bytes).map_err(bad_request)?;
|
|
Ok(Json(json!({ "template": latex_template_json(&saved) })))
|
|
}
|
|
|
|
async fn delete_latex_template(Query(q): Query<DeleteByNameQ>) -> ApiResult {
|
|
crate::local::latex_templates::delete(&q.name).map_err(bad_request)?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
/// Serialize a project for the UI, injecting the absolute artifacts directory
|
|
/// so the dashboard can recognize artifact paths in chat links. `filesDir` is
|
|
/// retained as a compatibility alias for older clients. Every project the UI
|
|
/// receives must go through this — the SSE `project.updated` diff (fired on
|
|
/// every visit, since `open_project` bumps `updated_at`) and `create_project`
|
|
/// upsert into the same `projects` state as `list_projects`.
|
|
fn project_json(p: &local::model::LocalProject) -> Value {
|
|
project_json_with_artifacts_dir(p, local::files::files_dir_display(p))
|
|
}
|
|
|
|
fn project_json_with_artifacts_dir(p: &local::model::LocalProject, artifacts_dir: String) -> Value {
|
|
// LocalProject is all String/Option/i64, so this can't realistically fail;
|
|
// fail loud rather than emit a malformed `null` project if it ever does.
|
|
let mut v = serde_json::to_value(p).expect("LocalProject serializes");
|
|
if let Value::Object(map) = &mut v {
|
|
let dir = Value::String(artifacts_dir);
|
|
map.insert("artifactsDir".into(), dir.clone());
|
|
map.insert("filesDir".into(), dir);
|
|
map.insert("path".into(), Value::String(p.repo_path.clone()));
|
|
map.insert("githubEnabled".into(), Value::Bool(p.github_enabled()));
|
|
map.insert(
|
|
"githubUrl".into(),
|
|
p.github_url().map(Value::String).unwrap_or(Value::Null),
|
|
);
|
|
}
|
|
v
|
|
}
|
|
|
|
async fn list_projects() -> ApiResult {
|
|
let projects = Store::open()?.list_local_projects()?;
|
|
let projects: Vec<Value> = projects.iter().map(project_json).collect();
|
|
Ok(Json(json!({ "projects": projects })))
|
|
}
|
|
|
|
async fn list_project_activity(State(state): State<AppState>) -> ApiResult {
|
|
let busy: HashSet<String> = state.chat.busy_sessions().await.into_iter().collect();
|
|
tokio::task::spawn_blocking(move || -> Result<Json<Value>> {
|
|
let store = Store::open()?;
|
|
let mut active_agents = HashMap::<String, usize>::new();
|
|
for (session_id, project_id) in store.list_chat_session_project_ids()? {
|
|
if busy.contains(&session_id) {
|
|
*active_agents.entry(project_id).or_default() += 1;
|
|
}
|
|
}
|
|
let activity = store
|
|
.list_project_activity_summaries()?
|
|
.into_iter()
|
|
.map(|summary| {
|
|
let active_agents = active_agents.get(&summary.project_id).copied().unwrap_or(0);
|
|
json!({
|
|
"projectId": summary.project_id,
|
|
"activeAgents": active_agents,
|
|
"totalAgents": summary.total_agents,
|
|
"runningExperiments": summary.running_experiments,
|
|
"totalExperiments": summary.total_experiments,
|
|
"lastMessageAt": summary.last_message_at,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Ok(Json(json!({ "activity": activity })))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("project activity task failed: {error}")))?
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProjectPathStatusQ {
|
|
path: Option<String>,
|
|
}
|
|
|
|
async fn project_path_status(Query(q): Query<ProjectPathStatusQ>) -> ApiResult {
|
|
tokio::task::spawn_blocking(move || -> Result<Json<Value>> {
|
|
let git_version = local::git::version();
|
|
let Some(path) = q.path.filter(|path| !path.trim().is_empty()) else {
|
|
return Ok(Json(json!({
|
|
"gitVersion": git_version,
|
|
"resolvedPath": null,
|
|
"exists": null,
|
|
"directory": null,
|
|
"empty": null,
|
|
"initialized": null,
|
|
"gitState": null,
|
|
})));
|
|
};
|
|
let resolved = local::projects::expand_path(&path)?;
|
|
let exists = resolved.exists();
|
|
let directory = resolved.is_dir();
|
|
let empty = if directory {
|
|
Some(std::fs::read_dir(&resolved)?.next().is_none())
|
|
} else {
|
|
None
|
|
};
|
|
let git_state =
|
|
(git_version.is_some() && directory).then(|| local::git::repository_state(&resolved));
|
|
let initialized = git_state.is_some_and(local::git::RepositoryState::is_initialized);
|
|
let github_publication = initialized
|
|
.then(|| local::git::github_publication(&resolved))
|
|
.flatten();
|
|
Ok(Json(json!({
|
|
"gitVersion": git_version,
|
|
"resolvedPath": resolved.to_string_lossy(),
|
|
"exists": exists,
|
|
"directory": directory,
|
|
"empty": empty,
|
|
"initialized": initialized,
|
|
"gitState": git_state.map(local::git::RepositoryState::as_str),
|
|
"githubOwner": github_publication.as_ref().map(|(owner, _)| owner),
|
|
"githubRepo": github_publication.as_ref().map(|(_, repo)| repo),
|
|
})))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("project path task failed: {error}")))?
|
|
.map_err(bad_request)
|
|
}
|
|
|
|
async fn pick_project_folder() -> ApiResult {
|
|
let path = tokio::task::spawn_blocking(crate::folder_picker::pick_folder)
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("folder picker task failed: {error}")))?
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({
|
|
"path": path.map(|path| path.to_string_lossy().into_owned()),
|
|
})))
|
|
}
|
|
|
|
// --- papers (new-project "from a paper" flow; proxies alphaXiv) ------------
|
|
|
|
#[derive(Deserialize)]
|
|
struct PaperSearchQ {
|
|
q: String,
|
|
}
|
|
|
|
async fn search_papers_api(Query(q): Query<PaperSearchQ>) -> ApiResult {
|
|
let query = q.q.trim();
|
|
if query.is_empty() {
|
|
return Ok(Json(json!({ "papers": [] })));
|
|
}
|
|
let papers = crate::client::search_papers_fast(query)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({ "papers": papers })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PaperResolveQ {
|
|
id: String,
|
|
}
|
|
|
|
async fn resolve_paper_api(Query(q): Query<PaperResolveQ>) -> ApiResult {
|
|
let id = super::paper::parse_paper_id(&q.id);
|
|
if id.is_empty() {
|
|
return Err(bad_request("paper id is required"));
|
|
}
|
|
let paper = crate::client::resolve_paper(&id)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({ "paper": paper })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CreateProjectReq {
|
|
creation_mode: Option<crate::telemetry::ProjectCreationMode>,
|
|
name: String,
|
|
path: String,
|
|
run_command: Option<String>,
|
|
paper_id: Option<String>,
|
|
clone_url: Option<String>,
|
|
#[serde(default)]
|
|
create_folder: bool,
|
|
#[serde(default)]
|
|
require_new_folder: bool,
|
|
#[serde(default)]
|
|
initialize_git: bool,
|
|
github_sync_enabled: Option<bool>,
|
|
/// UI locale, for the starter prompts warmed up in the background.
|
|
locale: Option<String>,
|
|
}
|
|
|
|
async fn create_project(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CreateProjectReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let create_admission = state
|
|
.project_lifecycle
|
|
.admit("__project_create__")
|
|
.ok_or_else(|| bad_request("project creation is unavailable"))?;
|
|
reject_if_moving(&state)?;
|
|
let name = req.name.trim().to_string();
|
|
if name.is_empty() {
|
|
return Err(bad_request("name is required"));
|
|
}
|
|
let locale = req.locale.unwrap_or_else(|| "en".to_string());
|
|
let path = req.path;
|
|
let create_folder = req.create_folder;
|
|
let require_new_folder = req.require_new_folder;
|
|
let initialize_git = req.initialize_git;
|
|
let clone_url = req.clone_url.filter(|url| !url.trim().is_empty());
|
|
let paper_id = req
|
|
.paper_id
|
|
.map(|paper_id| paper_id.trim().to_string())
|
|
.filter(|paper_id| !paper_id.is_empty());
|
|
// A paper with no linked repository starts blank, seeded with its PDF — the
|
|
// only content such a project has, so a failed download fails the request.
|
|
let paper_pdf = match (paper_id.as_deref(), clone_url.as_deref()) {
|
|
(Some(id), None) => Some(
|
|
crate::client::fetch_paper_pdf(id)
|
|
.await
|
|
.map_err(bad_request)?,
|
|
),
|
|
_ => None,
|
|
};
|
|
let github_sync_enabled = req
|
|
.github_sync_enabled
|
|
.unwrap_or_else(crate::config::github_for_new_projects);
|
|
let repo_size_kb = match clone_url.as_deref() {
|
|
Some(url) => local::github::public_repo_size_kb(url).await,
|
|
None => None,
|
|
};
|
|
let shallow_clone = local::github::should_shallow_clone(repo_size_kb);
|
|
let run_command = req.run_command;
|
|
let creation_guard = state.project_creation_lock.lock().await;
|
|
let result = tokio::task::spawn_blocking(move || -> Result<local::model::LocalProject> {
|
|
let store = Store::open()?;
|
|
let project = local::projects::create_project(
|
|
&store,
|
|
&name,
|
|
&path,
|
|
local::projects::CreateProjectOptions {
|
|
create_folder,
|
|
require_new_folder,
|
|
initialize_git,
|
|
clone_url,
|
|
shallow_clone,
|
|
run_command,
|
|
paper_id,
|
|
paper_pdf,
|
|
},
|
|
)?;
|
|
Ok(project)
|
|
})
|
|
.await
|
|
.map_err(|e| anyhow!("project task failed: {e}"))?;
|
|
let project = result.map_err(bad_request)?;
|
|
// Starter prompts take a model call; start it now so the empty chat that
|
|
// opens next usually finds them cached.
|
|
local::starter::warm(project.clone(), locale);
|
|
drop(creation_guard);
|
|
let _project_admission = state
|
|
.project_lifecycle
|
|
.admit(&project.id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
drop(create_admission);
|
|
let (project, github_publication_error) = if github_sync_enabled {
|
|
match push_project_for_sync(project.clone(), &state.chat).await {
|
|
Ok((project, _)) => (project, None),
|
|
Err(error) => {
|
|
let project = Store::open()?
|
|
.get_local_project(&project.id)?
|
|
.unwrap_or(project);
|
|
(project, Some(error.to_string()))
|
|
}
|
|
}
|
|
} else {
|
|
(project, None)
|
|
};
|
|
crate::telemetry::capture_project_created(true, req.creation_mode);
|
|
Ok(Json(json!({
|
|
"project": project_json(&project),
|
|
"githubPublicationError": github_publication_error,
|
|
})))
|
|
}
|
|
|
|
async fn get_project(Path(id): Path<String>) -> ApiResult {
|
|
let project = Store::open()?
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
Ok(Json(json!({ "project": project_json(&project) })))
|
|
}
|
|
|
|
fn project_git_json(
|
|
project: &local::model::LocalProject,
|
|
github_status: local::github::Status,
|
|
) -> Value {
|
|
let path = std::path::Path::new(&project.repo_path);
|
|
let initialized = local::git::is_repository(path);
|
|
let branch = initialized
|
|
.then(|| local::git::require_current_branch(path).ok())
|
|
.flatten();
|
|
let clean = initialized
|
|
.then(|| local::git::is_clean(path).ok())
|
|
.flatten();
|
|
let remotes = if initialized {
|
|
local::git::remotes(path)
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|(name, url)| json!({ "name": name, "url": url }))
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let (name, email, name_source, email_source) = if initialized {
|
|
local::git::identity(path)
|
|
} else {
|
|
(None, None, None, None)
|
|
};
|
|
let sync_status = project.github_enabled().then(|| {
|
|
local::git::publication_sync_status(
|
|
path,
|
|
&project.baseline_branch,
|
|
&project.github_owner,
|
|
&project.github_repo,
|
|
)
|
|
});
|
|
json!({
|
|
"path": project.repo_path,
|
|
"gitVersion": local::git::version(),
|
|
"initialized": initialized,
|
|
"baselineBranch": project.baseline_branch,
|
|
"currentBranch": branch,
|
|
"clean": clean,
|
|
"remotes": remotes,
|
|
"identity": {
|
|
"name": name,
|
|
"email": email,
|
|
"nameSource": name_source,
|
|
"emailSource": email_source,
|
|
},
|
|
"github": {
|
|
"ghInstalled": github_status.installed,
|
|
"authenticated": github_status.authenticated,
|
|
"enabled": project.github_enabled(),
|
|
"owner": project.github_owner,
|
|
"repo": project.github_repo,
|
|
"url": project.github_url(),
|
|
"syncStatus": sync_status,
|
|
},
|
|
})
|
|
}
|
|
|
|
async fn project_git_status(Path(id): Path<String>) -> ApiResult {
|
|
let github_status = local::github::status().await;
|
|
tokio::task::spawn_blocking(move || {
|
|
let project = Store::open()?
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| anyhow!("project not found"))?;
|
|
Ok(Json(project_git_json(&project, github_status)))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?
|
|
}
|
|
|
|
async fn initialize_project_git(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
reject_if_moving(&state)?;
|
|
let _lock = project_publication_lock(&state, &id).await;
|
|
let _creation_guard = state.project_creation_lock.lock().await;
|
|
let github_status = local::github::status().await;
|
|
tokio::task::spawn_blocking(move || {
|
|
let store = Store::open()?;
|
|
let mut project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| anyhow!("project not found"))?;
|
|
let path = std::path::Path::new(&project.repo_path);
|
|
local::git::initialize_repository(path)?;
|
|
local::git::validate_project_repository(path)?;
|
|
project.baseline_branch = local::git::require_current_branch(path)?;
|
|
store.update_local_project(&project)?;
|
|
Ok(Json(project_git_json(&project, github_status)))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?
|
|
}
|
|
|
|
fn push_project(project: &local::model::LocalProject) -> Result<()> {
|
|
if !project.github_enabled() {
|
|
return Err(anyhow!(
|
|
"Enable GitHub syncing for this project before pushing."
|
|
));
|
|
}
|
|
let path = std::path::Path::new(&project.repo_path);
|
|
local::git::add_github_remote(path, &project.github_owner, &project.github_repo)?;
|
|
local::git::push_all(
|
|
path,
|
|
&project.baseline_branch,
|
|
&project.github_owner,
|
|
&project.github_repo,
|
|
)
|
|
}
|
|
|
|
fn github_push_was_rejected(error: &str) -> bool {
|
|
let error = error.to_ascii_lowercase();
|
|
error.contains("403")
|
|
|| error.contains("permission denied")
|
|
|| error.contains("write access")
|
|
|| error.contains("archived")
|
|
|| error.contains("read-only")
|
|
|| error.contains("repository not found")
|
|
|| error.contains("authentication failed")
|
|
|| error.contains("could not read username")
|
|
}
|
|
|
|
async fn create_independent_project_repository(
|
|
mut project: local::model::LocalProject,
|
|
chat: &ChatHost,
|
|
) -> Result<local::model::LocalProject> {
|
|
let store = Store::open()?;
|
|
let legacy = store
|
|
.list_chat_sessions_by_project(&project.id)?
|
|
.into_iter()
|
|
.map(|session| session.id)
|
|
.filter(|id| {
|
|
local::git::existing_session_worktree_path(&project, id)
|
|
!= local::git::session_worktree_path(&project.id, id)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
// `git worktree move` would pull a running turn's checkout out from under it.
|
|
chat.while_idle(&legacy, || {
|
|
local::git::migrate_legacy_project_worktrees(&project, &legacy)
|
|
})
|
|
.await?;
|
|
let source_repository = project
|
|
.has_github_repository()
|
|
.then(|| (project.github_owner.clone(), project.github_repo.clone()));
|
|
let reroot_shallow = local::git::prepare_shallow_repository_for_publication(
|
|
std::path::Path::new(&project.repo_path),
|
|
)?;
|
|
let (owner, repo) = local::github::create_project_repo(&project.slug).await?;
|
|
if reroot_shallow {
|
|
local::git::reroot_shallow_repository(
|
|
std::path::Path::new(&project.repo_path),
|
|
&project.baseline_branch,
|
|
source_repository.as_ref(),
|
|
)?;
|
|
}
|
|
project.github_owner = owner;
|
|
project.github_repo = repo;
|
|
project.github_sync_enabled = false;
|
|
store.update_local_project(&project)?;
|
|
Ok(project)
|
|
}
|
|
|
|
async fn push_project_for_sync(
|
|
mut project: local::model::LocalProject,
|
|
chat: &ChatHost,
|
|
) -> Result<(local::model::LocalProject, local::github::Status)> {
|
|
let github_status = local::github::status().await;
|
|
if !github_status.installed {
|
|
return Err(anyhow!(
|
|
"GitHub CLI (`gh`) is required — install it from https://cli.github.com."
|
|
));
|
|
}
|
|
if !github_status.authenticated {
|
|
return Err(anyhow!("Authenticate GitHub first with `gh auth login`."));
|
|
}
|
|
|
|
let mut using_existing_repository = project.has_github_repository();
|
|
if using_existing_repository {
|
|
let can_push = local::github::repo_meta(&project.github_owner, &project.github_repo)
|
|
.await?
|
|
.is_some_and(|meta| meta.can_push && !meta.archived);
|
|
if !can_push {
|
|
project = create_independent_project_repository(project, chat).await?;
|
|
using_existing_repository = false;
|
|
}
|
|
} else {
|
|
project = create_independent_project_repository(project, chat).await?;
|
|
using_existing_repository = false;
|
|
}
|
|
|
|
let push_once = |project: &local::model::LocalProject| {
|
|
let mut project = project.clone();
|
|
project.github_sync_enabled = true;
|
|
tokio::task::spawn_blocking(move || push_project(&project))
|
|
};
|
|
let first_push = push_once(&project)
|
|
.await
|
|
.map_err(|error| anyhow!("Git push task failed: {error}"))?;
|
|
if let Err(error) = first_push {
|
|
if !using_existing_repository || !github_push_was_rejected(&error.to_string()) {
|
|
return Err(error);
|
|
}
|
|
project = create_independent_project_repository(project, chat).await?;
|
|
push_once(&project)
|
|
.await
|
|
.map_err(|error| anyhow!("Git push task failed: {error}"))??;
|
|
}
|
|
|
|
project.github_sync_enabled = true;
|
|
Store::open()?.update_local_project(&project)?;
|
|
Ok((project, github_status))
|
|
}
|
|
|
|
async fn enable_project_github(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
reject_if_moving(&state)?;
|
|
let _lock = project_publication_lock(&state, &id).await;
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let (project, github_status) = push_project_for_sync(project, &state.chat)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
let git_status = project_git_json(&project, github_status);
|
|
Ok(Json(
|
|
json!({ "project": project_json(&project), "git": git_status }),
|
|
))
|
|
}
|
|
|
|
async fn disable_project_github(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
reject_if_moving(&state)?;
|
|
let _lock = project_publication_lock(&state, &id).await;
|
|
let store = Store::open()?;
|
|
let mut project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
project.github_sync_enabled = false;
|
|
store.update_local_project(&project)?;
|
|
let github_status = local::github::status().await;
|
|
Ok(Json(json!({
|
|
"project": project_json(&project),
|
|
"git": project_git_json(&project, github_status),
|
|
})))
|
|
}
|
|
|
|
async fn push_project_github(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
reject_if_moving(&state)?;
|
|
let _lock = project_publication_lock(&state, &id).await;
|
|
let project = Store::open()?
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let project_for_push = project.clone();
|
|
let github_status = local::github::status().await;
|
|
let git_status = tokio::task::spawn_blocking(move || -> Result<Value> {
|
|
push_project(&project_for_push)?;
|
|
Ok(project_git_json(&project_for_push, github_status))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("git task failed: {error}")))?
|
|
.map_err(bad_request)?;
|
|
Ok(Json(
|
|
json!({ "project": project_json(&project), "git": git_status }),
|
|
))
|
|
}
|
|
|
|
async fn github_account() -> ApiResult {
|
|
Ok(Json(
|
|
json!({ "login": local::github::viewer_login().await.ok() }),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProjectRepoPreviewQuery {
|
|
name: String,
|
|
}
|
|
|
|
async fn github_project_repo_preview(Query(q): Query<ProjectRepoPreviewQuery>) -> ApiResult {
|
|
let candidate = local::projects::project_slug_preview(&Store::open()?, q.name.trim())?;
|
|
let repo = local::github::available_project_repo_name(&candidate)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({ "repo": repo })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RepoAccessQuery {
|
|
owner: String,
|
|
repo: String,
|
|
}
|
|
|
|
async fn github_repo_access(Query(q): Query<RepoAccessQuery>) -> ApiResult {
|
|
let owner = q.owner.trim();
|
|
let repo = q.repo.trim();
|
|
if owner.is_empty() || repo.is_empty() {
|
|
return Err(bad_request("owner and repo are required"));
|
|
}
|
|
let meta = local::github::repo_meta(owner, repo)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({
|
|
"canPush": meta.is_some_and(|meta| meta.can_push && !meta.archived),
|
|
})))
|
|
}
|
|
|
|
/// Mark a project visited: bumps updated_at, which drives the recency sort
|
|
/// and the SSE project.updated diff.
|
|
async fn open_project(Path(id): Path<String>) -> ApiResult {
|
|
let store = Store::open()?;
|
|
store.touch_local_project(&id)?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
Ok(Json(json!({ "project": project_json(&project) })))
|
|
}
|
|
|
|
/// Present-vs-absent for PATCH fields: absent = leave, null = clear.
|
|
fn double_option<'de, D>(d: D) -> std::result::Result<Option<Option<String>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Option::<String>::deserialize(d).map(Some)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct UpdateProjectReq {
|
|
name: Option<String>,
|
|
#[serde(default, deserialize_with = "double_option")]
|
|
run_command: Option<Option<String>>,
|
|
}
|
|
|
|
async fn update_project(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<UpdateProjectReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
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",
|
|
));
|
|
}
|
|
let store = Store::open()?;
|
|
let mut project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
if let Some(name) = req.name {
|
|
if name.trim().is_empty() {
|
|
return Err(bad_request("name cannot be empty"));
|
|
}
|
|
project.name = name.trim().to_string();
|
|
}
|
|
if let Some(cmd) = req.run_command {
|
|
project.run_command = cmd.filter(|c| !c.trim().is_empty());
|
|
}
|
|
store.update_local_project(&project)?;
|
|
// Re-read: update bumps updated_at, which is also what fires the SSE
|
|
// project.updated diff.
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
Ok(Json(json!({ "project": project_json(&project) })))
|
|
}
|
|
|
|
/// Delete a project and everything hanging off it. Refuses while runs are in
|
|
/// flight (deleting their rows would strand the supervisor mid-job) — but
|
|
/// requests their cancellation, so a retry shortly after goes through. The
|
|
/// registered repository folder is left untouched.
|
|
async fn delete_project(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let _deleting_project = state
|
|
.project_lifecycle
|
|
.begin_delete(&id)
|
|
.ok_or_else(|| bad_request("project has an operation or deletion in progress"))?;
|
|
reject_if_moving(&state)?;
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let in_flight: Vec<_> = store
|
|
.list_runs_by_project(&id)?
|
|
.into_iter()
|
|
.filter(|r| !is_terminal(&r.status))
|
|
.collect();
|
|
if !in_flight.is_empty() {
|
|
let mut failures = Vec::new();
|
|
for run in &in_flight {
|
|
if let Err(err) = crate::commands::exp::request_local_run_cancel(&store, &run.id) {
|
|
failures.push(format!("{}: {err}", run.id));
|
|
}
|
|
}
|
|
if !failures.is_empty() {
|
|
let requested = in_flight.len() - failures.len();
|
|
return Err(bad_request(format!(
|
|
"{requested} run(s) cancellation requested; cancellation failed for {}",
|
|
failures.join(", ")
|
|
)));
|
|
}
|
|
return Err(bad_request(format!(
|
|
"{} run(s) still in flight — cancellation requested; retry once they stop",
|
|
in_flight.len()
|
|
)));
|
|
}
|
|
// Abort any in-flight chat turns before their rows disappear, and clean up
|
|
// each session's serve child + worktree (the rows cascade with the project).
|
|
let sessions = store.list_chat_sessions_by_project(&id)?;
|
|
let mut _session_deletions = Vec::with_capacity(sessions.len());
|
|
for session in &sessions {
|
|
_session_deletions.push(
|
|
state
|
|
.chat
|
|
.begin_session_delete(&session.id)
|
|
.ok_or_else(|| bad_request("a chat session deletion is already in progress"))?,
|
|
);
|
|
}
|
|
for session in &sessions {
|
|
state.chat.clear_queue(&session.id)?;
|
|
let _ = state.chat.interrupt(&session.id).await;
|
|
state.chat.opencode.kill_session(&session.id).await;
|
|
state.chat.codex.kill_session(&session.id).await;
|
|
state.chat.claude.forget_session(&session.id).await;
|
|
}
|
|
store.delete_local_project(&id)?;
|
|
for session in &sessions {
|
|
local::chat::cleanup_session_transcript_artifacts(&session.id);
|
|
local::chat::cleanup_session_worktree(&project, &session.id);
|
|
}
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
async fn list_experiments(Path(id): Path<String>) -> ApiResult {
|
|
let store = Store::open()?;
|
|
store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let experiments = store.list_experiments_by_project(&id)?;
|
|
Ok(Json(json!({ "experiments": experiments })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ArchiveExperimentRequest {
|
|
direction: String,
|
|
archived: bool,
|
|
}
|
|
|
|
async fn set_experiment_archive(
|
|
Path(id): Path<String>,
|
|
Json(request): Json<ArchiveExperimentRequest>,
|
|
) -> ApiResult {
|
|
let direction = match request.direction.as_str() {
|
|
"ancestors" => local::experiments::ArchiveDirection::Ancestors,
|
|
"descendants" => local::experiments::ArchiveDirection::Descendants,
|
|
"only" => local::experiments::ArchiveDirection::Only,
|
|
"region" => local::experiments::ArchiveDirection::Region,
|
|
"taskRegion" => local::experiments::ArchiveDirection::TaskRegion,
|
|
_ => {
|
|
return Err(bad_request(
|
|
"direction must be ancestors, descendants, only, region, or taskRegion",
|
|
))
|
|
}
|
|
};
|
|
let mut store = Store::open()?;
|
|
if store.get_local_experiment(&id)?.is_none() {
|
|
return Err(not_found("experiment"));
|
|
}
|
|
let ids = local::experiments::set_archived(&mut store, &id, direction, request.archived)?;
|
|
Ok(Json(json!({ "ids": ids })))
|
|
}
|
|
|
|
async fn list_project_runs(Path(id): Path<String>) -> ApiResult {
|
|
let store = Store::open()?;
|
|
store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let runs: Vec<ApiRun> = store
|
|
.list_runs_by_project(&id)?
|
|
.iter()
|
|
.map(ApiRun::from)
|
|
.collect();
|
|
Ok(Json(json!({ "runs": runs })))
|
|
}
|
|
|
|
async fn compute_backends() -> Json<Value> {
|
|
Json(json!({ "backends": crate::compute::capabilities() }))
|
|
}
|
|
|
|
/// Dashboard requests omit `chat_session_id`; forwarded agent CLI requests use
|
|
/// it only for run attribution and wakeups.
|
|
#[derive(Debug, Deserialize, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CreateRunReq {
|
|
invocation_context: Option<String>,
|
|
#[serde(default)]
|
|
telemetry_suppressed: bool,
|
|
experiment_id: String,
|
|
backend: Option<String>,
|
|
flavor: Option<String>,
|
|
host: Option<String>,
|
|
container: Option<String>,
|
|
#[serde(default)]
|
|
no_container: bool,
|
|
manifest: Option<String>,
|
|
image: Option<String>,
|
|
timeout: Option<String>,
|
|
org: Option<String>,
|
|
provider: Option<String>,
|
|
disk: Option<i64>,
|
|
#[serde(default)]
|
|
force: bool,
|
|
chat_session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CreateRunResponse {
|
|
run: ApiRun,
|
|
}
|
|
|
|
pub(crate) struct RunLaunchSummary {
|
|
pub run_id: String,
|
|
pub experiment_id: String,
|
|
pub job_id: Option<String>,
|
|
}
|
|
|
|
async fn decode_local_response<T: serde::de::DeserializeOwned>(
|
|
response: reqwest::Response,
|
|
action: &str,
|
|
) -> Result<T> {
|
|
let status = response.status();
|
|
let body = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|error| anyhow!("orx up {action} response failed: {error}"))?;
|
|
if !status.is_success() {
|
|
let detail = serde_json::from_slice::<Value>(&body)
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("error")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
})
|
|
.unwrap_or_else(|| String::from_utf8_lossy(&body).trim().to_string());
|
|
if status.is_client_error() {
|
|
return Err(anyhow!("{detail}"));
|
|
}
|
|
return Err(anyhow!("orx up could not {action}: {detail}"));
|
|
}
|
|
serde_json::from_slice(&body)
|
|
.map_err(|error| anyhow!("orx up returned an invalid {action} response: {error}"))
|
|
}
|
|
|
|
fn local_client() -> Result<reqwest::Client> {
|
|
reqwest::Client::builder()
|
|
.connect_timeout(Duration::from_secs(3))
|
|
.build()
|
|
.map_err(|error| anyhow!("Could not create the orx up client: {error}"))
|
|
}
|
|
|
|
fn authenticate_up_request(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
match std::env::var(local::chat::UP_AUTH_TOKEN_ENV)
|
|
.ok()
|
|
.filter(|token| !token.is_empty())
|
|
.or_else(|| local::chat::up_auth_token().map(str::to_string))
|
|
{
|
|
Some(token) => builder.bearer_auth(token),
|
|
None => builder,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn submit_run_via_up(
|
|
port: u16,
|
|
args: &crate::ExpRunArgs,
|
|
) -> Result<RunLaunchSummary> {
|
|
let request = CreateRunReq {
|
|
telemetry_suppressed: args.telemetry_suppressed
|
|
|| !crate::telemetry::accounting_reports_enabled(),
|
|
invocation_context: args
|
|
.invocation_identity()?
|
|
.map(|identity| serde_json::to_string(&identity))
|
|
.transpose()?,
|
|
experiment_id: args.exp_id.clone(),
|
|
backend: args.backend.clone(),
|
|
flavor: args.flavor.clone(),
|
|
host: args.host.clone(),
|
|
container: args.container.clone(),
|
|
no_container: args.no_container,
|
|
manifest: args.manifest.clone(),
|
|
image: args.image.clone(),
|
|
timeout: args.timeout.clone(),
|
|
org: args.org.clone(),
|
|
provider: args.provider.clone(),
|
|
disk: args.disk,
|
|
force: args.force,
|
|
chat_session_id: args.launching_chat_session(),
|
|
};
|
|
let response =
|
|
authenticate_up_request(local_client()?.post(format!("http://127.0.0.1:{port}/api/runs")))
|
|
.json(&request)
|
|
.send()
|
|
.await
|
|
.map_err(|error| anyhow!("Could not reach the trusted orx up process: {error}"))?;
|
|
let response: CreateRunResponse = decode_local_response(response, "start the run").await?;
|
|
let job_id = response
|
|
.run
|
|
.backend
|
|
.as_ref()
|
|
.and_then(|backend| backend.get("jobId"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string);
|
|
Ok(RunLaunchSummary {
|
|
run_id: response.run.id,
|
|
experiment_id: args.exp_id.clone(),
|
|
job_id,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct HarnessInstall {
|
|
pub name: String,
|
|
pub installed: bool,
|
|
}
|
|
|
|
/// `orx up`'s install evidence for `harness`, from its own PATH rather than the caller's.
|
|
pub(crate) async fn harness_install_via_up(port: u16, harness: &str) -> Result<HarnessInstall> {
|
|
let response = authenticate_up_request(local_client()?.get(format!(
|
|
"http://127.0.0.1:{port}/api/harnesses/{harness}/snapshot"
|
|
)))
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
.await
|
|
.map_err(|error| anyhow!("Could not reach the trusted orx up process: {error}"))?;
|
|
decode_local_response(response, "check the harness").await
|
|
}
|
|
|
|
pub(crate) async fn cancel_run_via_up(port: u16, run_id: &str) -> Result<()> {
|
|
let response = authenticate_up_request(
|
|
local_client()?.post(format!("http://127.0.0.1:{port}/api/runs/{run_id}/cancel")),
|
|
)
|
|
.send()
|
|
.await
|
|
.map_err(|error| anyhow!("Could not reach the trusted orx up process: {error}"))?;
|
|
let _: Value = decode_local_response(response, "cancel the run").await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn create_run(State(state): State<AppState>, Json(req): Json<CreateRunReq>) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
let store = Store::open()?;
|
|
let experiment = store
|
|
.get_local_experiment(&req.experiment_id)?
|
|
.ok_or_else(|| not_found("experiment"))?;
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&experiment.project_id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
let mut backend = req.backend;
|
|
let mut flavor = req.flavor;
|
|
// Dashboard callers may omit these; forwarded CLI requests arrive resolved.
|
|
local::apply_compute_default(&mut backend, &mut flavor);
|
|
let args = crate::ExpRunArgs {
|
|
invocation_context: req.invocation_context,
|
|
telemetry_suppressed: req.telemetry_suppressed,
|
|
exp_id: req.experiment_id,
|
|
disk: req.disk,
|
|
provider: req.provider,
|
|
backend: Some(backend.unwrap_or_else(|| "local".to_string())),
|
|
flavor,
|
|
org: req.org,
|
|
host: req.host,
|
|
container: req.container,
|
|
no_container: req.no_container,
|
|
manifest: req.manifest,
|
|
image: req.image,
|
|
timeout: req.timeout,
|
|
force: req.force,
|
|
chat_session_id: req.chat_session_id,
|
|
};
|
|
crate::compute::validate_run_args(&args).map_err(bad_request)?;
|
|
let run = crate::compute::submit(&args).await.map_err(bad_request)?;
|
|
Ok(Json(json!({ "run": ApiRun::from(&run) })))
|
|
}
|
|
|
|
fn backend_for_run(
|
|
run: &StoredRun,
|
|
) -> std::result::Result<Box<dyn crate::compute::ComputeBackend>, ApiError> {
|
|
let descriptor =
|
|
crate::jobs::BackendDescriptor::parse(&run.backend_json).map_err(bad_request)?;
|
|
let id = descriptor
|
|
.kind
|
|
.strip_suffix("_job")
|
|
.unwrap_or(&descriptor.kind);
|
|
let id = if id == "k8s" { "k8s" } else { id };
|
|
crate::compute::backend(id).map_err(bad_request)
|
|
}
|
|
|
|
async fn get_run(Path(id): Path<String>) -> ApiResult {
|
|
let run = Store::open()?
|
|
.get_run(&id)?
|
|
.ok_or_else(|| not_found("run"))?;
|
|
let backend = backend_for_run(&run)?;
|
|
let run = backend.status(&run).await.map_err(bad_request)?;
|
|
if is_terminal(&run.status) {
|
|
backend.cleanup(&run).await.map_err(bad_request)?;
|
|
}
|
|
Ok(Json(json!({ "run": ApiRun::from(&run) })))
|
|
}
|
|
|
|
/// Newest-first cap for the cross-project instances list. Generous: the store
|
|
/// is a local single-user SQLite db, so this only bounds pathological history.
|
|
const INSTANCES_LIMIT: usize = 500;
|
|
|
|
/// Every run across all projects (running first on the client), each tagged
|
|
/// with its owning project's name — the "instances" view of compute the agents
|
|
/// have spun up (Modal / HF / SSH / K8s), regardless of which project launched
|
|
/// it. Includes finished runs as history; the client surfaces live ones first.
|
|
async fn list_instances() -> ApiResult {
|
|
let store = Store::open()?;
|
|
let names: HashMap<String, String> = store
|
|
.list_local_projects()?
|
|
.into_iter()
|
|
.map(|p| (p.id, p.name))
|
|
.collect();
|
|
let mut instances: Vec<Value> = Vec::new();
|
|
for run in store.list_runs(INSTANCES_LIMIT)? {
|
|
// ApiRun is a plain serializable struct, so this can't realistically
|
|
// fail; propagate rather than emit a malformed row if it ever does.
|
|
let mut value = serde_json::to_value(ApiRun::from(&run))
|
|
.map_err(|e| anyhow!("serialize run {}: {e}", run.id))?;
|
|
if let (Some(obj), Some(name)) = (value.as_object_mut(), names.get(&run.project_id)) {
|
|
obj.insert("projectName".into(), json!(name));
|
|
}
|
|
instances.push(value);
|
|
}
|
|
Ok(Json(json!({ "instances": instances })))
|
|
}
|
|
|
|
async fn cancel_run(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
let store = Store::open()?;
|
|
let run = local::local_run(&store, &id)?.ok_or_else(|| not_found("run"))?;
|
|
// A terminal run must not gain a stale cancel_requested flag.
|
|
if is_terminal(&run.status) {
|
|
return Ok(Json(json!({ "ok": true, "alreadyTerminal": true })));
|
|
}
|
|
let backend = backend_for_run(&run)?;
|
|
backend.cancel(&run).await.map_err(bad_request)?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LogsQuery {
|
|
cursor: Option<u64>,
|
|
}
|
|
|
|
async fn run_logs(Path(id): Path<String>, Query(q): Query<LogsQuery>) -> ApiResult {
|
|
let run = Store::open()?
|
|
.get_run(&id)?
|
|
.ok_or_else(|| not_found("run"))?;
|
|
let batch = backend_for_run(&run)?
|
|
.logs(&run, crate::compute::LogCursor(q.cursor.unwrap_or(0)))
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!(batch)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LogQuery {
|
|
offset: Option<u64>,
|
|
}
|
|
|
|
async fn run_log(Path(id): Path<String>, Query(q): Query<LogQuery>) -> ApiResult {
|
|
let store = Store::open()?;
|
|
store.get_run(&id)?.ok_or_else(|| not_found("run"))?;
|
|
let offset = q.offset.unwrap_or(0);
|
|
let chunk = read_log_from(&id, offset, 4_000_000);
|
|
let next_offset = offset + chunk.len() as u64;
|
|
Ok(Json(json!({
|
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(&chunk),
|
|
"nextOffset": next_offset,
|
|
"eof": next_offset >= log_size(&id),
|
|
})))
|
|
}
|
|
|
|
// --- diffs ------------------------------------------------------------------
|
|
//
|
|
// Same payload shape as the OpenResearch api diff endpoints:
|
|
// `{diff, truncated, bytesRead, byteLimit}` with the raw unified-diff text.
|
|
// All of these shell out to git against the project's local clone, so they
|
|
// run on the blocking pool.
|
|
|
|
fn diff_json(d: local::git::DiffPayload) -> Value {
|
|
json!({
|
|
"diff": d.diff,
|
|
"truncated": d.truncated,
|
|
"bytesRead": d.bytes_read,
|
|
"byteLimit": local::git::MAX_DIFF_BYTES,
|
|
})
|
|
}
|
|
|
|
/// Off-worker helper for git-backed handlers.
|
|
async fn blocking_api<F>(f: F) -> ApiResult
|
|
where
|
|
F: FnOnce() -> std::result::Result<Json<Value>, ApiError> + Send + 'static,
|
|
{
|
|
tokio::task::spawn_blocking(f)
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
|
|
}
|
|
|
|
/// Cumulative diff of a run's commit vs its experiment's parent branch. A root
|
|
/// experiment on a distinct branch compares against the project's baseline.
|
|
async fn run_diff(Path(id): Path<String>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let run = store.get_run(&id)?.ok_or_else(|| not_found("run"))?;
|
|
let sha = run
|
|
.commit_sha
|
|
.clone()
|
|
.ok_or_else(|| bad_request("run has no commit to diff"))?;
|
|
let exp = store
|
|
.get_local_experiment(&run.experiment_id)?
|
|
.ok_or_else(|| not_found("experiment"))?;
|
|
let project = store
|
|
.get_local_project(&exp.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let base = match exp.parent_experiment_id {
|
|
Some(parent_id) => {
|
|
store
|
|
.get_local_experiment(&parent_id)?
|
|
.ok_or_else(|| not_found("parent experiment"))?
|
|
.branch_name
|
|
}
|
|
None => project.baseline_branch.clone(),
|
|
};
|
|
let repo = std::path::Path::new(&project.repo_path);
|
|
let payload = local::git::diff_range(repo, &base, &sha)?;
|
|
Ok(Json(diff_json(payload)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Cumulative committed diff of an experiment branch vs its parent branch.
|
|
async fn experiment_diff(Path(id): Path<String>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let exp = store
|
|
.get_local_experiment(&id)?
|
|
.ok_or_else(|| not_found("experiment"))?;
|
|
let project = store
|
|
.get_local_project(&exp.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let base = match &exp.parent_experiment_id {
|
|
Some(parent_id) => {
|
|
store
|
|
.get_local_experiment(parent_id)?
|
|
.ok_or_else(|| not_found("parent experiment"))?
|
|
.branch_name
|
|
}
|
|
None => project.baseline_branch.clone(),
|
|
};
|
|
let repo = std::path::Path::new(&project.repo_path);
|
|
Ok(Json(diff_json(local::git::diff_range(
|
|
repo,
|
|
&base,
|
|
&exp.branch_name,
|
|
)?)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Commits on the experiment branch: child experiments list `parent..branch`,
|
|
/// the baseline lists the branch's recent history.
|
|
async fn experiment_commits(Path(id): Path<String>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let exp = store
|
|
.get_local_experiment(&id)?
|
|
.ok_or_else(|| not_found("experiment"))?;
|
|
let project = store
|
|
.get_local_project(&exp.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let repo = std::path::Path::new(&project.repo_path);
|
|
let commits = match &exp.parent_experiment_id {
|
|
Some(pid) => {
|
|
let parent = store
|
|
.get_local_experiment(pid)?
|
|
.ok_or_else(|| not_found("parent experiment"))?;
|
|
local::git::list_commits_between(repo, &parent.branch_name, &exp.branch_name, 100)?
|
|
}
|
|
None if exp.branch_name != project.baseline_branch => local::git::list_commits_between(
|
|
repo,
|
|
&project.baseline_branch,
|
|
&exp.branch_name,
|
|
100,
|
|
)?,
|
|
None => local::git::list_commits(repo, &exp.branch_name, 25)?,
|
|
};
|
|
let commits: Vec<Value> = commits
|
|
.iter()
|
|
.map(|c| json!({ "sha": c.sha, "subject": c.subject, "committedAt": c.committed_at }))
|
|
.collect();
|
|
Ok(Json(json!({ "commits": commits })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn experiment_commit_diff(Path((id, sha)): Path<(String, String)>) -> ApiResult {
|
|
if !sha.chars().all(|c| c.is_ascii_hexdigit()) || sha.len() < 7 || sha.len() > 64 {
|
|
return Err(bad_request("invalid commit sha"));
|
|
}
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let exp = store
|
|
.get_local_experiment(&id)?
|
|
.ok_or_else(|| not_found("experiment"))?;
|
|
let project = store
|
|
.get_local_project(&exp.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let repo = std::path::Path::new(&project.repo_path);
|
|
let payload = local::git::commit_diff(repo, &sha)?;
|
|
Ok(Json(diff_json(payload)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct PrewarmStarterPromptsReq {
|
|
name: String,
|
|
paper_id: Option<String>,
|
|
/// An existing folder the project will be created from.
|
|
path: Option<String>,
|
|
locale: Option<String>,
|
|
}
|
|
|
|
/// Start generating starter prompts for a project the user is still naming in
|
|
/// the new-project form, so they are cached before the project exists.
|
|
async fn prewarm_starter_prompts(Json(req): Json<PrewarmStarterPromptsReq>) -> ApiResult {
|
|
let name = req.name.trim().to_string();
|
|
if name.is_empty() {
|
|
return Err(bad_request("name is required"));
|
|
}
|
|
let paper_id = req
|
|
.paper_id
|
|
.as_deref()
|
|
.map(super::paper::parse_paper_id)
|
|
.filter(|id| !id.is_empty());
|
|
let path = req
|
|
.path
|
|
.map(|p| p.trim().to_string())
|
|
.filter(|p| !p.is_empty());
|
|
local::starter::prewarm(
|
|
name,
|
|
paper_id,
|
|
path,
|
|
req.locale.unwrap_or_else(|| "en".to_string()),
|
|
);
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct StarterPromptsQuery {
|
|
/// Chat harness whose one-shot child writes the prompts; the composer's
|
|
/// current pick. Unknown or missing = no prompts.
|
|
harness: Option<String>,
|
|
/// The composer's model, so the child runs on what the chat will use.
|
|
model: Option<String>,
|
|
/// UI locale the prompts are written in.
|
|
locale: Option<String>,
|
|
}
|
|
|
|
/// Four starter prompts for the empty chat, written by a model that has read
|
|
/// the project (paper, README, code). Slow on a cache miss — one headless
|
|
/// model call — so the UI shows a placeholder while it waits. A blank project
|
|
/// is flagged instead so the UI shows its pre-written prompts.
|
|
async fn project_starter_prompts(
|
|
Path(id): Path<String>,
|
|
Query(q): Query<StarterPromptsQuery>,
|
|
) -> ApiResult {
|
|
let (project, experiment_count) = tokio::task::spawn_blocking(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let experiment_count = store.list_experiments_by_project(&project.id)?.len();
|
|
Ok::<_, ApiError>((project, experiment_count))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("starter task failed: {e}")))??;
|
|
let harness = q
|
|
.harness
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|h| local::harness::is_chat_harness(h));
|
|
// Past "getting started" or no chat harness named: nothing to offer
|
|
// (empty), as opposed to a harness that could not answer (null).
|
|
let Some(harness) = harness.filter(|_| experiment_count == 0) else {
|
|
return Ok(Json(json!({ "prompts": [], "blank": false })));
|
|
};
|
|
let locale = q.locale.as_deref().unwrap_or("en");
|
|
let agent = local::starter::Agent {
|
|
harness: harness.to_string(),
|
|
model: q
|
|
.model
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|m| !m.is_empty())
|
|
.map(String::from),
|
|
};
|
|
let (prompts, blank) = match local::starter::prompts(&project, &agent, locale).await {
|
|
local::starter::Starter::Blank => (Some(Vec::new()), true),
|
|
local::starter::Starter::Generated(prompts) => (prompts, false),
|
|
};
|
|
Ok(Json(json!({ "prompts": prompts, "blank": blank })))
|
|
}
|
|
|
|
/// Live uncommitted changes in the project's clone (the agent's working
|
|
/// tree), mapped back to the experiment whose branch is checked out.
|
|
async fn project_working_tree(Path(id): Path<String>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let repo = std::path::Path::new(&project.repo_path);
|
|
let (branch, payload) = local::git::working_tree_diff(repo)?;
|
|
let experiment_id = match &branch {
|
|
Some(b) => store
|
|
.list_experiments_by_project(&project.id)?
|
|
.into_iter()
|
|
.find(|e| &e.branch_name == b)
|
|
.map(|e| e.id),
|
|
None => None,
|
|
};
|
|
Ok(Json(json!({
|
|
"branch": branch,
|
|
"experimentId": experiment_id,
|
|
"diff": payload.diff,
|
|
"truncated": payload.truncated,
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Live view of one chat session's private worktree — what the agent has
|
|
/// changed, before any run exists. Unlike `project_working_tree` (clone-scoped,
|
|
/// diffed against HEAD), the session worktree starts detached on the baseline
|
|
/// tip and the agent commits to experiment branches, so "what it changed" is
|
|
/// the working tree diffed against the merge-base of the baseline and HEAD; a
|
|
/// bare HEAD diff would hide every committed edit. Read-only throughout: no
|
|
/// index-touching (`git add -N`) that would mutate the agent's checkout.
|
|
///
|
|
/// A never-started session (worktree is lazy) or a pruned worktree degrades to
|
|
/// `resolve_checkout_root`'s clone fallback; we report `{ exists: false }`
|
|
/// rather than pass off the clone's contents as the session's work.
|
|
async fn session_worktree(Path(id): Path<String>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let session = store
|
|
.get_chat_session(&id)?
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
let project = store
|
|
.get_local_project(&session.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let (root, root_kind) = resolve_checkout_root(&store, &project, Some(&id))?;
|
|
if root_kind != "worktree" {
|
|
return Ok(Json(json!({ "exists": false })));
|
|
}
|
|
let branch = local::git::current_branch(&root);
|
|
// Diff against the merge-base of the baseline tip and HEAD — the fork
|
|
// point of the agent's work. Every step that can't resolve (unrelated
|
|
// histories, unborn HEAD) falls back to HEAD, so
|
|
// the diff degrades to "uncommitted only" rather than erroring.
|
|
let baseline = &project.baseline_branch;
|
|
let base =
|
|
local::git::merge_base(&root, baseline, "HEAD")?.unwrap_or_else(|| "HEAD".to_string());
|
|
let files = local::git::changed_files(&root, &base)?;
|
|
let payload = local::git::working_tree_diff_against(&root, Some(&base))?;
|
|
Ok(Json(json!({
|
|
"exists": true,
|
|
"branch": branch,
|
|
"baselineBranch": baseline,
|
|
"baseSha": base,
|
|
"files": files,
|
|
"diff": diff_json(payload),
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Cap on file bytes served to the viewer (mirrors openresearch.sh).
|
|
const FILE_READ_LIMIT: u64 = 512_000;
|
|
|
|
/// Resolve which on-disk checkout answers a file/code request for a project.
|
|
///
|
|
/// The chat session's worktree is where the agent actually works, so it can
|
|
/// hold files the hub clone's checkout never sees. When `session_id` is given
|
|
/// it must be this project's session (the authorization boundary, which also
|
|
/// pins the worktree dir to a store-issued id); a missing worktree (pruned, or
|
|
/// never created) degrades to the clone rather than erroring, but any other
|
|
/// worktree failure is reported, not papered over. Returns the canonicalized
|
|
/// root and whether it is the `"worktree"` or the `"clone"`.
|
|
fn resolve_checkout_root(
|
|
store: &Store,
|
|
project: &local::model::LocalProject,
|
|
session_id: Option<&str>,
|
|
) -> std::result::Result<(std::path::PathBuf, &'static str), ApiError> {
|
|
let session_id = session_id.map(str::trim).filter(|s| !s.is_empty());
|
|
let worktree = match session_id {
|
|
Some(s) => {
|
|
let session = store
|
|
.get_chat_session(s)?
|
|
.filter(|sess| sess.project_id == project.id)
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
let dir = local::git::existing_session_worktree_path(project, &session.id);
|
|
match crate::paths::canonicalize(&dir) {
|
|
Ok(p) => Some(p),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
|
Err(e) => return Err(ApiError::from(anyhow!("session worktree unavailable: {e}"))),
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
match worktree {
|
|
Some(r) => Ok((r, "worktree")),
|
|
None => Ok((
|
|
crate::paths::canonicalize(&project.repo_path)
|
|
.map_err(|e| ApiError::from(anyhow!("repo clone unavailable: {e}")))?,
|
|
"clone",
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CodeTreeQuery {
|
|
/// Branch to list the committed tree of; absent lists a live checkout.
|
|
r#ref: Option<String>,
|
|
/// Chat session whose worktree to list (the live view the Worktree tab's
|
|
/// Files pane wants). Absent falls back to the hub clone's checkout.
|
|
/// Mutually exclusive with `ref` — a committed tree has no live worktree.
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
/// Cap on entries returned by the code-tree listing.
|
|
const CODE_TREE_LIMIT: usize = 20_000;
|
|
|
|
/// Flat file listing for the UI code browser. With `ref`: the committed tree
|
|
/// of that branch (local ref first, then origin's), independent of any
|
|
/// checkout. Without: the hub clone's checkout via `git ls-files`, so
|
|
/// gitignored trees are excluded and untracked-but-new files are included.
|
|
/// Paths are repo-relative; the client builds the nested tree.
|
|
async fn project_code_tree(Path(id): Path<String>, Query(q): Query<CodeTreeQuery>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let ref_name = q.r#ref.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
|
let session_id = q
|
|
.session_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty());
|
|
if ref_name.is_some() && session_id.is_some() {
|
|
return Err(bad_request("ref and sessionId are mutually exclusive"));
|
|
}
|
|
// Branch refs live in the shared object DB — any checkout resolves them;
|
|
// for a live listing the session's worktree is the live view when given
|
|
// (its untracked files the clone never sees), else the hub clone.
|
|
let (root, root_kind) = resolve_checkout_root(&store, &project, session_id)?;
|
|
let path = ref_name
|
|
.is_none()
|
|
.then(|| root.to_string_lossy().into_owned());
|
|
let (root_kind, branch, mut entries) = match ref_name {
|
|
Some(name) => {
|
|
let sha = local::git::resolve_branch_commit(&root, name)?
|
|
.ok_or_else(|| not_found("branch"))?;
|
|
let entries = local::git::list_tree_files(&root, &sha)?;
|
|
("branch", Some(name.to_string()), entries)
|
|
}
|
|
None => {
|
|
let branch = local::git::current_branch(&root);
|
|
let entries = local::git::list_worktree_files(&root)?;
|
|
(root_kind, branch, entries)
|
|
}
|
|
};
|
|
entries.sort();
|
|
// During a merge conflict `ls-files --cached` emits an unmerged path
|
|
// once per stage — collapse to one entry (they'd be duplicate keys).
|
|
entries.dedup();
|
|
let truncated = entries.len() > CODE_TREE_LIMIT;
|
|
entries.truncate(CODE_TREE_LIMIT);
|
|
Ok(Json(json!({
|
|
"root": root_kind,
|
|
"path": path,
|
|
"branch": branch,
|
|
"entries": entries,
|
|
"truncated": truncated,
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ProjectFileQuery {
|
|
path: String,
|
|
/// Chat session whose worktree holds the file. Absent (or the worktree
|
|
/// already pruned) falls back to the hub clone. Ignored when `ref` is given.
|
|
session_id: Option<String>,
|
|
/// Branch to read the committed file from, instead of a live checkout.
|
|
r#ref: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ProjectFileResponse {
|
|
path: String,
|
|
content: String,
|
|
truncated: bool,
|
|
binary: bool,
|
|
not_found: bool,
|
|
root: &'static str,
|
|
presentation: local::files::FilePresentation,
|
|
version: Option<String>,
|
|
}
|
|
|
|
impl ProjectFileResponse {
|
|
fn missing(
|
|
path: String,
|
|
root: &'static str,
|
|
presentation: local::files::FilePresentation,
|
|
) -> Self {
|
|
Self {
|
|
path,
|
|
content: String::new(),
|
|
truncated: false,
|
|
binary: false,
|
|
not_found: true,
|
|
root,
|
|
presentation,
|
|
version: None,
|
|
}
|
|
}
|
|
|
|
fn non_text(
|
|
path: String,
|
|
root: &'static str,
|
|
presentation: local::files::FilePresentation,
|
|
) -> Self {
|
|
Self {
|
|
path,
|
|
content: String::new(),
|
|
truncated: false,
|
|
binary: true,
|
|
not_found: false,
|
|
root,
|
|
presentation,
|
|
version: None,
|
|
}
|
|
}
|
|
|
|
fn text(
|
|
path: String,
|
|
root: &'static str,
|
|
content: String,
|
|
truncated: bool,
|
|
binary: bool,
|
|
presentation: local::files::FilePresentation,
|
|
version: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
path,
|
|
content,
|
|
truncated,
|
|
binary,
|
|
not_found: false,
|
|
root,
|
|
presentation,
|
|
version,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn file_version(bytes: &[u8]) -> String {
|
|
format!("{:x}", Sha256::digest(bytes))
|
|
}
|
|
|
|
fn file_version_on_disk(path: &std::path::Path) -> std::result::Result<String, ApiError> {
|
|
let mut file = std::fs::File::open(path)
|
|
.map_err(|error| ApiError::from(anyhow!("save failed: {error}")))?;
|
|
let mut hasher = Sha256::new();
|
|
std::io::copy(&mut file, &mut hasher)
|
|
.map_err(|error| ApiError::from(anyhow!("save failed: {error}")))?;
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
}
|
|
|
|
fn validated_project_file_path(
|
|
path: &str,
|
|
) -> std::result::Result<(String, std::path::PathBuf), ApiError> {
|
|
let rel = path.trim().trim_start_matches("./").to_string();
|
|
if rel.is_empty() || rel.len() > 1024 {
|
|
return Err(bad_request("invalid path"));
|
|
}
|
|
let rel_path = std::path::PathBuf::from(&rel);
|
|
let traversal = rel_path.is_absolute()
|
|
|| rel_path
|
|
.components()
|
|
.any(|c| !matches!(c, std::path::Component::Normal(_)));
|
|
if traversal {
|
|
return Err(bad_request("path must be repo-relative"));
|
|
}
|
|
Ok((rel, rel_path))
|
|
}
|
|
|
|
fn decode_project_file_text(bytes: Vec<u8>, truncated: bool) -> (String, bool) {
|
|
if bytes.contains(&0) {
|
|
return (String::new(), true);
|
|
}
|
|
match String::from_utf8(bytes) {
|
|
Ok(content) => (content, false),
|
|
Err(error) if truncated && error.utf8_error().error_len().is_none() => {
|
|
let valid_up_to = error.utf8_error().valid_up_to();
|
|
let mut bytes = error.into_bytes();
|
|
bytes.truncate(valid_up_to);
|
|
(String::from_utf8(bytes).unwrap_or_default(), false)
|
|
}
|
|
Err(_) => (String::new(), true),
|
|
}
|
|
}
|
|
|
|
/// One file for the UI file viewer. With `ref`: the committed content on that
|
|
/// branch (a streamed, capped `git cat-file` read), independent of any
|
|
/// checkout. Without: the project's checkout — the chat session's worktree
|
|
/// when `sessionId` is given, else the hub clone. Path is repo-relative;
|
|
/// traversal outside the checkout is rejected. The response's `root` says
|
|
/// which source actually answered, so the UI can flag fallback.
|
|
async fn project_file(
|
|
Path(id): Path<String>,
|
|
Query(q): Query<ProjectFileQuery>,
|
|
) -> std::result::Result<Json<ProjectFileResponse>, ApiError> {
|
|
tokio::task::spawn_blocking(move || {
|
|
use std::io::Read as _;
|
|
let (rel, rel_path) = validated_project_file_path(&q.path)?;
|
|
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let presentation = local::files::presentation_for_path(&rel);
|
|
let ref_name = q.r#ref.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
|
if let Some(name) = ref_name {
|
|
let (root, _) = resolve_checkout_root(&store, &project, None)?;
|
|
let sha = local::git::resolve_branch_commit(&root, name)?
|
|
.ok_or_else(|| not_found("branch"))?;
|
|
if !matches!(
|
|
presentation,
|
|
local::files::FilePresentation::Text | local::files::FilePresentation::Unknown
|
|
) {
|
|
return if local::git::file_size_at(&root, &sha, &rel)?.is_some() {
|
|
Ok(Json(ProjectFileResponse::non_text(
|
|
rel,
|
|
"branch",
|
|
presentation,
|
|
)))
|
|
} else {
|
|
Ok(Json(ProjectFileResponse::missing(
|
|
rel,
|
|
"branch",
|
|
presentation,
|
|
)))
|
|
};
|
|
}
|
|
// Streamed + capped: a committed multi-GB blob must not become a
|
|
// multi-GB allocation. Missing path is an exit-code check inside
|
|
// the helper (`cat-file -e`) — no error-message parsing.
|
|
return match local::git::file_bytes_at_capped(&root, &sha, &rel, FILE_READ_LIMIT)? {
|
|
Some((bytes, truncated)) => {
|
|
let (content, binary) = decode_project_file_text(bytes, truncated);
|
|
let presentation = if binary {
|
|
local::files::FilePresentation::Download
|
|
} else {
|
|
local::files::FilePresentation::Text
|
|
};
|
|
Ok(Json(ProjectFileResponse::text(
|
|
rel,
|
|
"branch",
|
|
content,
|
|
truncated,
|
|
binary,
|
|
presentation,
|
|
None,
|
|
)))
|
|
}
|
|
None => Ok(Json(ProjectFileResponse::missing(
|
|
rel,
|
|
"branch",
|
|
presentation,
|
|
))),
|
|
};
|
|
}
|
|
let (root, root_kind) = resolve_checkout_root(&store, &project, q.session_id.as_deref())?;
|
|
// Canonicalize so symlinks can't escape the checkout.
|
|
let full = match crate::paths::canonicalize(root.join(&rel_path)) {
|
|
Ok(p) => p,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(Json(ProjectFileResponse::missing(
|
|
rel,
|
|
root_kind,
|
|
presentation,
|
|
)))
|
|
}
|
|
Err(e) => return Err(ApiError::from(anyhow!("read failed: {e}"))),
|
|
};
|
|
if !full.starts_with(&root) {
|
|
return Err(bad_request("path escapes repository"));
|
|
}
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
if !matches!(
|
|
presentation,
|
|
local::files::FilePresentation::Text | local::files::FilePresentation::Unknown
|
|
) {
|
|
return Ok(Json(ProjectFileResponse::non_text(
|
|
rel,
|
|
root_kind,
|
|
presentation,
|
|
)));
|
|
}
|
|
let file = match std::fs::File::open(&full) {
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(Json(ProjectFileResponse::missing(
|
|
rel,
|
|
root_kind,
|
|
presentation,
|
|
)))
|
|
}
|
|
Err(e) => return Err(ApiError::from(anyhow!("read failed: {e}"))),
|
|
};
|
|
let mut buf = Vec::new();
|
|
std::io::Read::take(file, FILE_READ_LIMIT + 1)
|
|
.read_to_end(&mut buf)
|
|
.map_err(|e| ApiError::from(anyhow!("read failed: {e}")))?;
|
|
let truncated = buf.len() as u64 > FILE_READ_LIMIT;
|
|
buf.truncate(FILE_READ_LIMIT as usize);
|
|
let (content, binary) = decode_project_file_text(buf, truncated);
|
|
let version = (!truncated && !binary).then(|| file_version(content.as_bytes()));
|
|
let presentation = if binary {
|
|
local::files::FilePresentation::Download
|
|
} else {
|
|
local::files::FilePresentation::Text
|
|
};
|
|
Ok(Json(ProjectFileResponse::text(
|
|
rel,
|
|
root_kind,
|
|
content,
|
|
truncated,
|
|
binary,
|
|
presentation,
|
|
version,
|
|
)))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("file task failed: {e}")))?
|
|
}
|
|
|
|
/// Upper bound on a single save — generous room to grow past the read cap while
|
|
/// still bounding one write.
|
|
const FILE_WRITE_LIMIT: u64 = 8 * 1024 * 1024;
|
|
|
|
/// True when a repo-relative path steps into the `.git` metadata dir — writing
|
|
/// there (`config`, `hooks/*`) is an arbitrary-command vector. Case-insensitive
|
|
/// so `.GIT` can't slip past on macOS/Windows.
|
|
fn touches_git_dir(rel_path: &std::path::Path) -> bool {
|
|
rel_path.components().any(
|
|
|c| matches!(c, std::path::Component::Normal(name) if name.eq_ignore_ascii_case(".git")),
|
|
)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct WriteProjectFileReq {
|
|
path: String,
|
|
content: String,
|
|
/// Chat session whose worktree owns the file; absent writes the hub clone.
|
|
session_id: Option<String>,
|
|
/// Exact version returned by the read endpoint. Older clients may omit it.
|
|
expected_version: Option<String>,
|
|
}
|
|
|
|
enum WriteProjectFileOutcome {
|
|
Saved(Value),
|
|
Conflict {
|
|
current_version: Option<String>,
|
|
exists: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum FileAction {
|
|
Rename,
|
|
Duplicate,
|
|
Delete,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ManageFileReq {
|
|
path: String,
|
|
action: FileAction,
|
|
new_name: Option<String>,
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
fn validated_file_name(name: Option<&str>) -> std::result::Result<&str, ApiError> {
|
|
let name = name.map(str::trim).filter(|name| !name.is_empty());
|
|
match name {
|
|
Some(name)
|
|
if name != "." && name != ".." && name.len() <= 255 && !name.contains(['/', '\\']) =>
|
|
{
|
|
Ok(name)
|
|
}
|
|
_ => Err(bad_request("invalid file name")),
|
|
}
|
|
}
|
|
|
|
fn duplicate_file_name(name: &str, number: usize) -> String {
|
|
let suffix = if number == 1 {
|
|
" copy".to_string()
|
|
} else {
|
|
format!(" copy {number}")
|
|
};
|
|
match name.rsplit_once('.') {
|
|
Some((stem, extension)) if !stem.is_empty() => format!("{stem}{suffix}.{extension}"),
|
|
_ => format!("{name}{suffix}"),
|
|
}
|
|
}
|
|
|
|
/// API paths are `/`-separated; a Windows `PathBuf` would send backslashes back.
|
|
#[cfg(windows)]
|
|
fn api_rel_path(path: &std::path::Path) -> String {
|
|
path.to_string_lossy().replace('\\', "/")
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn api_rel_path(path: &std::path::Path) -> String {
|
|
path.to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn manage_local_file(
|
|
root: &std::path::Path,
|
|
rel: &str,
|
|
action: FileAction,
|
|
new_name: Option<&str>,
|
|
protect_git_dir: bool,
|
|
) -> std::result::Result<String, ApiError> {
|
|
let (rel, rel_path) = validated_project_file_path(rel)?;
|
|
if protect_git_dir && touches_git_dir(&rel_path) {
|
|
return Err(bad_request("cannot manage files under .git"));
|
|
}
|
|
let root = crate::paths::canonicalize(root)
|
|
.map_err(|e| ApiError::from(anyhow!("file root unavailable: {e}")))?;
|
|
let source = root.join(&rel_path);
|
|
let resolved = crate::paths::canonicalize(&source).map_err(|e| match e.kind() {
|
|
std::io::ErrorKind::NotFound => not_found("file"),
|
|
_ => ApiError::from(anyhow!("file unavailable: {e}")),
|
|
})?;
|
|
if !resolved.starts_with(&root) {
|
|
return Err(bad_request("path escapes file root"));
|
|
}
|
|
if protect_git_dir && resolved.strip_prefix(&root).is_ok_and(touches_git_dir) {
|
|
return Err(bad_request("cannot manage files under .git"));
|
|
}
|
|
if resolved.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
|
|
let parent = source.parent().ok_or_else(|| bad_request("invalid path"))?;
|
|
let parent = crate::paths::canonicalize(parent)
|
|
.map_err(|e| ApiError::from(anyhow!("parent directory unavailable: {e}")))?;
|
|
if !parent.starts_with(&root) {
|
|
return Err(bad_request("path escapes file root"));
|
|
}
|
|
if matches!(action, FileAction::Delete) {
|
|
std::fs::remove_file(&source).map_err(|e| ApiError::from(anyhow!("delete failed: {e}")))?;
|
|
return Ok(rel);
|
|
}
|
|
|
|
if matches!(action, FileAction::Duplicate)
|
|
&& std::fs::symlink_metadata(&source)
|
|
.map_err(|e| ApiError::from(anyhow!("file unavailable: {e}")))?
|
|
.file_type()
|
|
.is_symlink()
|
|
{
|
|
return Err(bad_request("cannot duplicate a symbolic link"));
|
|
}
|
|
let old_name = rel_path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.ok_or_else(|| bad_request("invalid file name"))?;
|
|
let destination_name = match action {
|
|
FileAction::Rename => validated_file_name(new_name)?.to_string(),
|
|
FileAction::Duplicate => {
|
|
let mut number = 1;
|
|
loop {
|
|
let candidate = duplicate_file_name(old_name, number);
|
|
match std::fs::symlink_metadata(parent.join(&candidate)) {
|
|
Ok(_) => number += 1,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break candidate,
|
|
Err(error) => {
|
|
return Err(ApiError::from(anyhow!(
|
|
"could not choose a copy name: {error}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
FileAction::Delete => unreachable!(),
|
|
};
|
|
let destination_rel = rel_path.with_file_name(&destination_name);
|
|
if protect_git_dir && touches_git_dir(&destination_rel) {
|
|
return Err(bad_request("cannot manage files under .git"));
|
|
}
|
|
if destination_rel == rel_path {
|
|
return Ok(rel);
|
|
}
|
|
let destination = parent.join(&destination_name);
|
|
match std::fs::symlink_metadata(&destination) {
|
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
|
return Err(bad_request("a file with that name already exists"));
|
|
}
|
|
Ok(_) => match crate::paths::canonicalize(&destination) {
|
|
Ok(path) if path == resolved => {}
|
|
Ok(_) | Err(_) => return Err(bad_request("a file with that name already exists")),
|
|
},
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => return Err(ApiError::from(anyhow!("destination unavailable: {error}"))),
|
|
}
|
|
match action {
|
|
FileAction::Rename => std::fs::rename(&source, &destination)
|
|
.map_err(|e| ApiError::from(anyhow!("rename failed: {e}")))?,
|
|
FileAction::Duplicate => {
|
|
std::fs::copy(&source, &destination)
|
|
.map_err(|e| ApiError::from(anyhow!("copy failed: {e}")))?;
|
|
}
|
|
FileAction::Delete => unreachable!(),
|
|
}
|
|
Ok(api_rel_path(&destination_rel))
|
|
}
|
|
|
|
async fn manage_project_file(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<ManageFileReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let (root, root_kind) = resolve_checkout_root(&store, &project, req.session_id.as_deref())?;
|
|
if req.session_id.is_some() && root_kind == "clone" {
|
|
return Err(bad_request(
|
|
"this session's worktree is no longer available — reload the files",
|
|
));
|
|
}
|
|
let path = manage_local_file(&root, &req.path, req.action, req.new_name.as_deref(), true)?;
|
|
Ok(Json(json!({ "ok": true, "path": path })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Overwrite an existing text file in the project's live checkout with edited
|
|
/// content. Only the worktree/clone is writable — committed branch trees have no
|
|
/// `ref` path here and stay read-only. Traversal and symlink escapes are
|
|
/// rejected by canonicalizing the target and confirming it stays under the root.
|
|
async fn write_project_file(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<WriteProjectFileReq>,
|
|
) -> std::result::Result<Response, ApiError> {
|
|
reject_if_moving(&state)?;
|
|
let outcome = tokio::task::spawn_blocking(move || {
|
|
let (rel, rel_path) = validated_project_file_path(&req.path)?;
|
|
if touches_git_dir(&rel_path) {
|
|
return Err(bad_request("cannot edit files under .git"));
|
|
}
|
|
if req.content.len() as u64 > FILE_WRITE_LIMIT {
|
|
return Err(bad_request("file too large to save"));
|
|
}
|
|
if !matches!(
|
|
local::files::presentation_for_path(&rel),
|
|
local::files::FilePresentation::Text | local::files::FilePresentation::Unknown
|
|
) {
|
|
return Err(bad_request("not an editable text file"));
|
|
}
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let (root, root_kind) = resolve_checkout_root(&store, &project, req.session_id.as_deref())?;
|
|
// A session write that fell back to the clone means the worktree was
|
|
// pruned mid-edit — refuse rather than silently write another checkout.
|
|
if req.session_id.is_some() && root_kind == "clone" {
|
|
return Err(bad_request(
|
|
"this session's worktree is no longer available — reload the file",
|
|
));
|
|
}
|
|
// Canonicalize the existing target so a symlinked path can't escape the
|
|
// checkout; a missing file means the editor's copy is stale.
|
|
let full = match crate::paths::canonicalize(root.join(&rel_path)) {
|
|
Ok(p) => p,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
if req.expected_version.is_some() {
|
|
return Ok(WriteProjectFileOutcome::Conflict {
|
|
current_version: None,
|
|
exists: false,
|
|
});
|
|
}
|
|
return Err(not_found("file"));
|
|
}
|
|
Err(e) => return Err(ApiError::from(anyhow!("save failed: {e}"))),
|
|
};
|
|
if !full.starts_with(&root) {
|
|
return Err(bad_request("path escapes repository"));
|
|
}
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
// ponytail: external writers do not share a lock; add platform file coordination if this race becomes observable.
|
|
if let Some(expected) = req.expected_version.as_deref() {
|
|
let current = file_version_on_disk(&full)?;
|
|
if current != expected {
|
|
return Ok(WriteProjectFileOutcome::Conflict {
|
|
current_version: Some(current),
|
|
exists: true,
|
|
});
|
|
}
|
|
}
|
|
std::fs::write(&full, req.content.as_bytes())
|
|
.map_err(|e| ApiError::from(anyhow!("save failed: {e}")))?;
|
|
// The live channel polls on a tick; a save through the dashboard is
|
|
// told at once so a collaborator sees it without the wait.
|
|
local::overleaf_live::nudge(&full);
|
|
Ok(WriteProjectFileOutcome::Saved(json!({
|
|
"ok": true,
|
|
"root": root_kind,
|
|
"bytesWritten": req.content.len(),
|
|
"version": file_version(req.content.as_bytes()),
|
|
})))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("file task failed: {e}")))??;
|
|
|
|
Ok(match outcome {
|
|
WriteProjectFileOutcome::Saved(value) => Json(value).into_response(),
|
|
WriteProjectFileOutcome::Conflict {
|
|
current_version,
|
|
exists,
|
|
} => (
|
|
StatusCode::CONFLICT,
|
|
Json(json!({
|
|
"error": "file changed on disk",
|
|
"code": "fileChanged",
|
|
"currentVersion": current_version,
|
|
"exists": exists,
|
|
})),
|
|
)
|
|
.into_response(),
|
|
})
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct OpenProjectFileReq {
|
|
path: String,
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
/// Canonical path of a checkout-relative file, confined to the checkout root.
|
|
fn confined_checkout_file(
|
|
id: &str,
|
|
req: &OpenProjectFileReq,
|
|
verb: &str,
|
|
) -> Result<std::path::PathBuf, ApiError> {
|
|
let (_, rel_path) = validated_project_file_path(&req.path)?;
|
|
if touches_git_dir(&rel_path) {
|
|
return Err(bad_request(format!("cannot {verb} files under .git")));
|
|
}
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let (root, _) = resolve_checkout_root(&store, &project, req.session_id.as_deref())?;
|
|
let full = match crate::paths::canonicalize(root.join(&rel_path)) {
|
|
Ok(p) => p,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found("file")),
|
|
Err(e) => return Err(ApiError::from(anyhow!("{verb} failed: {e}"))),
|
|
};
|
|
if !full.starts_with(&root) {
|
|
return Err(bad_request("path escapes repository"));
|
|
}
|
|
Ok(full)
|
|
}
|
|
|
|
/// Open a checkout file in the machine's default app for its type (the user's
|
|
/// editor for source files). Resolves the same worktree/clone the reader uses
|
|
/// and confirms the file is inside it before handing the path to the OS opener.
|
|
async fn open_project_file(
|
|
Path(id): Path<String>,
|
|
Json(req): Json<OpenProjectFileReq>,
|
|
) -> ApiResult {
|
|
blocking_api(move || {
|
|
let full = confined_checkout_file(&id, &req, "open")?;
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
crate::editors::open_in_default_app(&full)
|
|
.map_err(|e| ApiError::from(anyhow!("could not open file: {e}")))?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Reveal a checkout file in the OS file manager; unlike open, directories are allowed.
|
|
async fn reveal_project_file(
|
|
Path(id): Path<String>,
|
|
Json(req): Json<OpenProjectFileReq>,
|
|
) -> ApiResult {
|
|
blocking_api(move || {
|
|
let full = confined_checkout_file(&id, &req, "reveal")?;
|
|
crate::editors::reveal_in_file_manager(&full)
|
|
.map_err(|e| ApiError::from(anyhow!("could not reveal file: {e}")))?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Whether this machine can compile at all, so the dashboard can render a real
|
|
/// document by default and fall back to its approximate preview when it cannot.
|
|
async fn latex_engine() -> ApiResult {
|
|
blocking_api(move || {
|
|
let engine = local::latex::find_engine();
|
|
Ok(Json(json!({
|
|
"engine": engine,
|
|
"hint": engine.is_none().then(local::latex::install_hint),
|
|
"installCommand": engine.is_none().then(local::latex::install_command).flatten(),
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
// --- overleaf ------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct OverleafFileQ {
|
|
path: String,
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct OverleafFileReq {
|
|
path: String,
|
|
session_id: Option<String>,
|
|
/// Only on link: the project URL the user pasted.
|
|
#[serde(default)]
|
|
project: Option<String>,
|
|
/// Only on sync: how the user settled files both sides changed, keyed by
|
|
/// the checkout-relative path the panel showed them.
|
|
#[serde(default)]
|
|
resolve: std::collections::BTreeMap<String, String>,
|
|
/// Only on live: open the channel again after Overleaf refused it.
|
|
#[serde(default)]
|
|
retry: bool,
|
|
}
|
|
|
|
fn overleaf_link_json(link: Option<&crate::store::OverleafLink>) -> Value {
|
|
let Some(link) = link else {
|
|
return Value::Null;
|
|
};
|
|
let project = link.project();
|
|
json!({
|
|
"projectId": project.id,
|
|
"host": project.live_host(),
|
|
"url": project.web_url(),
|
|
})
|
|
}
|
|
|
|
fn overleaf_state_json(link: Option<&crate::store::OverleafLink>) -> Value {
|
|
json!({
|
|
"hasToken": local::overleaf::token().is_some(),
|
|
"hasSession": local::overleaf_live::session().is_some(),
|
|
"link": overleaf_link_json(link),
|
|
})
|
|
}
|
|
|
|
async fn overleaf_settings() -> ApiResult {
|
|
blocking_api(move || {
|
|
Ok(Json(json!({
|
|
"hasToken": local::overleaf::token().is_some(),
|
|
"hasSession": local::overleaf_live::session().is_some(),
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetOverleafTokenReq {
|
|
token: String,
|
|
}
|
|
|
|
/// Stored as given: the bridge is the only thing that can say whether a token
|
|
/// works, and it needs a project to say it about. `link_overleaf` is where a
|
|
/// bad token surfaces.
|
|
async fn set_overleaf_token(Json(req): Json<SetOverleafTokenReq>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let token = req.token.trim().to_string();
|
|
if token.is_empty() {
|
|
return Err(bad_request("token is required"));
|
|
}
|
|
// A newline would add a second field to the credential line the helper
|
|
// feeds git.
|
|
if token
|
|
.chars()
|
|
.any(|c| c.is_whitespace() || c.is_control() || !c.is_ascii())
|
|
{
|
|
return Err(bad_request(
|
|
"That does not look like an Overleaf token — it has spaces, line breaks, or characters a token does not contain. Copy it again from Overleaf's Account Settings.",
|
|
));
|
|
}
|
|
local::overleaf::set_token(&token)?;
|
|
Ok(Json(json!({ "hasToken": true })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn delete_overleaf_token() -> ApiResult {
|
|
blocking_api(move || {
|
|
local::overleaf::clear_token()?;
|
|
Ok(Json(json!({ "hasToken": false })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetOverleafSessionReq {
|
|
session: String,
|
|
/// The Overleaf site the cookie is for: the paper's linked host when the
|
|
/// panel asks, www.overleaf.com from Settings.
|
|
#[serde(default)]
|
|
host: Option<String>,
|
|
}
|
|
|
|
/// The browser session cookie the live channel signs in with. Like the token,
|
|
/// only a connection can say whether it works; `start_overleaf_live` is where
|
|
/// a stale one surfaces.
|
|
async fn set_overleaf_session(Json(req): Json<SetOverleafSessionReq>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let host = req.host.as_deref().unwrap_or(local::overleaf::CLOUD_HOST);
|
|
local::overleaf_live::set_session(host, &req.session).map_err(bad_request)?;
|
|
Ok(Json(json!({ "hasSession": true })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn delete_overleaf_session() -> ApiResult {
|
|
blocking_api(move || {
|
|
local::overleaf_live::clear_session()?;
|
|
Ok(Json(json!({ "hasSession": false })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ImportOverleafSessionReq {
|
|
/// The site to find a cookie for; the paper's linked host, or the cloud.
|
|
#[serde(default)]
|
|
host: Option<String>,
|
|
}
|
|
|
|
/// Read the session cookie from a signed-in browser on this machine, so the
|
|
/// user need not paste it. Reading a Chromium store asks the Keychain for its
|
|
/// key, which is the one prompt the user sees; a store with no Overleaf cookie
|
|
/// is a 400 that tells them to sign in there or paste instead.
|
|
async fn import_overleaf_session(Json(req): Json<ImportOverleafSessionReq>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let host = req
|
|
.host
|
|
.as_deref()
|
|
.unwrap_or(local::overleaf::CLOUD_HOST)
|
|
.to_string();
|
|
if !local::browser_cookies::SUPPORTED {
|
|
return Err(bad_request(
|
|
"Importing from a browser works on macOS only. Paste the cookie instead.",
|
|
));
|
|
}
|
|
let imported = local::browser_cookies::import_session(&host)
|
|
.map_err(bad_request)?
|
|
.ok_or_else(|| {
|
|
bad_request(
|
|
"No Overleaf session was found in a browser on this machine. Sign in to Overleaf in your browser, or paste the cookie.",
|
|
)
|
|
})?;
|
|
local::overleaf_live::set_session(&host, &imported.cookie).map_err(bad_request)?;
|
|
Ok(Json(json!({ "hasSession": true, "source": imported.source })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn overleaf_live_json(key: &str, status: Option<local::overleaf_live::Status>) -> Value {
|
|
json!({
|
|
"key": key,
|
|
"status": status.map(|status| status.json()),
|
|
})
|
|
}
|
|
|
|
/// The live session a paper tab would use. Building it is also the
|
|
/// permission check: a paper that is not linked, or a path that is not a
|
|
/// paper, gets the same 400 the sync would.
|
|
struct LiveTarget {
|
|
project: local::overleaf::Project,
|
|
/// The paper's folder, canonical, as `resolve_project_tex` hands it out.
|
|
dir: std::path::PathBuf,
|
|
scope: local::overleaf_live::Scope,
|
|
}
|
|
|
|
impl LiveTarget {
|
|
fn key(&self) -> String {
|
|
local::overleaf_live::key_for(&self.project, &self.dir, &self.scope)
|
|
}
|
|
}
|
|
|
|
/// The paper's folder — the Overleaf project's root, on disk.
|
|
fn paper_dir(full: &std::path::Path) -> std::result::Result<&std::path::Path, ApiError> {
|
|
full.parent()
|
|
.ok_or_else(|| ApiError::from(anyhow!("{} has no parent directory", full.display())))
|
|
}
|
|
|
|
fn overleaf_live_target(
|
|
id: &str,
|
|
path: &str,
|
|
session_id: Option<&str>,
|
|
) -> std::result::Result<LiveTarget, ApiError> {
|
|
let (rel, _, full) = resolve_project_tex(id, path, session_id)?;
|
|
let link = Store::open()?
|
|
.overleaf_link(id, &rel)?
|
|
.ok_or_else(|| bad_request("This paper is not linked to an Overleaf project yet."))?;
|
|
let dir = paper_dir(&full)?.to_path_buf();
|
|
Ok(LiveTarget {
|
|
project: link.project(),
|
|
dir,
|
|
scope: local::overleaf_live::Scope {
|
|
project_id: id.to_string(),
|
|
session_id: session_id.map(str::to_string),
|
|
folder: local::overleaf::folder_of(&rel),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Open the live channel for this paper, or keep it open: the tab calls this
|
|
/// again while it stays on the file, and a session nobody asks after closes.
|
|
async fn start_overleaf_live(
|
|
Path(id): Path<String>,
|
|
Json(req): Json<OverleafFileReq>,
|
|
) -> ApiResult {
|
|
blocking_api(move || {
|
|
let target = overleaf_live_target(&id, &req.path, req.session_id.as_deref())?;
|
|
let (host, cookie) = local::overleaf_live::session()
|
|
.ok_or_else(|| bad_request("Add an Overleaf session cookie in Settings first."))?;
|
|
// The cookie signs in to one site; it goes nowhere else.
|
|
if host != target.project.live_host() {
|
|
return Err(bad_request(format!(
|
|
"The saved session cookie is for {host}, but this paper is linked to a project on {}. Add one for that site.",
|
|
target.project.live_host()
|
|
)));
|
|
}
|
|
let config = local::overleaf_live::Config {
|
|
project: target.project,
|
|
dir: target.dir,
|
|
cookie,
|
|
scope: target.scope,
|
|
};
|
|
let (key, status) = local::overleaf_live::start(config, req.retry);
|
|
Ok(Json(overleaf_live_json(&key, Some(status))))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn stop_overleaf_live(Path(id): Path<String>, Query(q): Query<OverleafFileQ>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let key = overleaf_live_target(&id, &q.path, q.session_id.as_deref())?.key();
|
|
local::overleaf_live::stop(&key);
|
|
Ok(Json(overleaf_live_json(&key, None)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn overleaf_link(Path(id): Path<String>, Query(q): Query<OverleafFileQ>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let (rel, ..) = resolve_project_tex(&id, &q.path, q.session_id.as_deref())?;
|
|
let store = Store::open()?;
|
|
let link = store.overleaf_link(&id, &rel)?;
|
|
Ok(Json(overleaf_state_json(link.as_ref())))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Link this `.tex` to an Overleaf project, proving the account can reach it
|
|
/// before the link is stored — so a plan without Git integration is reported
|
|
/// here, once, rather than on every later push.
|
|
async fn link_overleaf(Path(id): Path<String>, Json(req): Json<OverleafFileReq>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let (rel, _, full) = resolve_project_tex(&id, &req.path, req.session_id.as_deref())?;
|
|
let token = local::overleaf::token()
|
|
.ok_or_else(|| bad_request("Add an Overleaf Git authentication token first."))?;
|
|
let raw = req.project.unwrap_or_default();
|
|
let project = local::overleaf::parse_project(&raw).map_err(bad_request)?;
|
|
local::overleaf::probe(&project, &token).map_err(|e| bad_request(e.to_string()))?;
|
|
let store = Store::open()?;
|
|
let link = crate::store::OverleafLink {
|
|
overleaf_project_id: project.id,
|
|
host: project.host,
|
|
head: String::new(),
|
|
baseline: Default::default(),
|
|
root: String::new(),
|
|
};
|
|
store.set_overleaf_link(&id, &rel, &link)?;
|
|
// Relinked: nothing may keep writing the folder on the old project's
|
|
// behalf, and what was agreed with it says nothing about the new one.
|
|
local::overleaf_live::stop_dir(paper_dir(&full)?);
|
|
Ok(Json(overleaf_state_json(Some(&link))))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Validates the path but does not resolve it: unlinking a paper that has since
|
|
/// been deleted or renamed must still work, or the link would outlive any way
|
|
/// to remove it.
|
|
async fn unlink_overleaf(Path(id): Path<String>, Query(q): Query<OverleafFileQ>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let (rel, _) = validated_project_file_path(&q.path)?;
|
|
// An unlinked folder must stop following; the target only resolves
|
|
// while the paper still exists, which is the only time it could be.
|
|
if let Ok(target) = overleaf_live_target(&id, &rel, q.session_id.as_deref()) {
|
|
local::overleaf_live::stop_dir(&target.dir);
|
|
}
|
|
let store = Store::open()?;
|
|
store.clear_overleaf_link(&id, &rel)?;
|
|
Ok(Json(overleaf_state_json(None)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Bring the paper and the linked Overleaf project into step, both ways. Like a
|
|
/// failed compile, a refusal is the answer the user came for, so it comes back
|
|
/// as a 400 carrying Overleaf's own words.
|
|
async fn sync_overleaf(Path(id): Path<String>, Json(req): Json<OverleafFileReq>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let (rel, root, full) = resolve_project_tex(&id, &req.path, req.session_id.as_deref())?;
|
|
let (token, link) = linked(&id, &rel)?;
|
|
let root = root.to_string_lossy().to_string();
|
|
// An agreement reached against another checkout says nothing about this
|
|
// one: starting from none makes every difference a conflict, which is
|
|
// the safe direction when we cannot tell who moved.
|
|
let baseline = if link.root == root {
|
|
link.baseline.clone()
|
|
} else {
|
|
Default::default()
|
|
};
|
|
let project = link.project();
|
|
let folder = local::overleaf::folder_of(&rel);
|
|
let mut resolutions = std::collections::BTreeMap::new();
|
|
for (path, how) in &req.resolve {
|
|
let how = match how.as_str() {
|
|
"take-overleaf" => local::overleaf::Resolution::TakeOverleaf,
|
|
"keep-local" => local::overleaf::Resolution::KeepLocal,
|
|
other => return Err(bad_request(format!("unknown resolution {other:?}"))),
|
|
};
|
|
if let Some(path) = local::overleaf::from_checkout(folder.as_deref(), path) {
|
|
resolutions.insert(path, how);
|
|
}
|
|
}
|
|
// The live channel writes the same files; it waits while the sync
|
|
// does, then starts again from what the sync agreed on.
|
|
// Held so the channel is let go even if the sync unwinds; a folder
|
|
// left paused would look live while syncing nothing.
|
|
let paused = local::overleaf_live::pause(paper_dir(&full)?);
|
|
let outcome = local::overleaf::collect(&full).and_then(|payload| {
|
|
local::overleaf::sync(&payload, &project, &token, &baseline, &resolutions)
|
|
});
|
|
if let Ok(outcome) = &outcome {
|
|
paused.synced(&outcome.baseline);
|
|
}
|
|
drop(paused);
|
|
let outcome = outcome.map_err(|e| bad_request(e.to_string()))?;
|
|
let store = Store::open()?;
|
|
store.set_overleaf_link(
|
|
&id,
|
|
&rel,
|
|
&crate::store::OverleafLink {
|
|
head: outcome.head.clone(),
|
|
baseline: outcome.baseline.clone(),
|
|
root,
|
|
..link
|
|
},
|
|
)?;
|
|
Ok(Json(json!({
|
|
"ok": true,
|
|
"pulled": local::overleaf::to_checkout(folder.as_deref(), &outcome.pulled),
|
|
"pushed": local::overleaf::to_checkout(folder.as_deref(), &outcome.pushed),
|
|
"conflicts": local::overleaf::to_checkout(folder.as_deref(), &outcome.conflicts),
|
|
"note": outcome.note,
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Whether Overleaf has moved since the last sync — one `ls-remote`, no clone,
|
|
/// so a linked paper can be watched while its tab is open without a transfer
|
|
/// every time.
|
|
async fn overleaf_status(Path(id): Path<String>, Query(q): Query<OverleafFileQ>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let (rel, ..) = resolve_project_tex(&id, &q.path, q.session_id.as_deref())?;
|
|
let (token, link) = linked(&id, &rel)?;
|
|
let head = local::overleaf::remote_head(&link.project(), &token)
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
Ok(Json(json!({ "remoteChanged": head != link.head })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// The token and link a sync needs, or the 400 that says which is missing.
|
|
fn linked(
|
|
id: &str,
|
|
path: &str,
|
|
) -> std::result::Result<(String, crate::store::OverleafLink), ApiError> {
|
|
let token = local::overleaf::token()
|
|
.ok_or_else(|| bad_request("Add an Overleaf Git authentication token first."))?;
|
|
let link = Store::open()?
|
|
.overleaf_link(id, path)?
|
|
.ok_or_else(|| bad_request("This paper is not linked to an Overleaf project yet."))?;
|
|
Ok((token, link))
|
|
}
|
|
|
|
/// The no-plan path: a page that posts the paper straight into a new Overleaf
|
|
/// project. Served rather than built in the browser because the files it
|
|
/// carries are read from the checkout, and returned as HTML because Overleaf
|
|
/// takes them as a form POST.
|
|
async fn overleaf_upload(Path(id): Path<String>, Query(q): Query<OverleafFileQ>) -> Response {
|
|
let built = tokio::task::spawn_blocking(move || {
|
|
let (_, _, full) = resolve_project_tex(&id, &q.path, q.session_id.as_deref())
|
|
.map_err(|e| anyhow!("{}", e.1))?;
|
|
let payload = local::overleaf::collect(&full)?;
|
|
local::overleaf::upload_form_html(&payload)
|
|
})
|
|
.await;
|
|
match built {
|
|
Ok(Ok(html)) => Html(html).into_response(),
|
|
Ok(Err(e)) => (
|
|
StatusCode::BAD_REQUEST,
|
|
Html(format!(
|
|
"<!doctype html><meta charset=\"utf-8\"><p>{}</p>",
|
|
local::overleaf::escape(&e.to_string())
|
|
)),
|
|
)
|
|
.into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Html(format!("<!doctype html><meta charset=\"utf-8\"><p>{e}</p>")),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Resolve a checkout `.tex` to (normalized repo-relative path, checkout root,
|
|
/// canonical file). Confines the path exactly like `open_project_file`, and
|
|
/// refuses a session whose worktree is gone for the same reason
|
|
/// `write_project_file` does: both compiling and syncing act on the file the
|
|
/// user is actually editing.
|
|
fn resolve_project_tex(
|
|
id: &str,
|
|
path: &str,
|
|
session_id: Option<&str>,
|
|
) -> std::result::Result<(String, std::path::PathBuf, std::path::PathBuf), ApiError> {
|
|
let (rel, rel_path) = validated_project_file_path(path)?;
|
|
if touches_git_dir(&rel_path) {
|
|
return Err(bad_request("cannot read files under .git"));
|
|
}
|
|
if !rel.to_ascii_lowercase().ends_with(".tex") {
|
|
return Err(bad_request("not a .tex file"));
|
|
}
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let (root, root_kind) = resolve_checkout_root(&store, &project, session_id)?;
|
|
if session_id.is_some() && root_kind == "clone" {
|
|
return Err(bad_request(
|
|
"this session's worktree is no longer available — reload the file",
|
|
));
|
|
}
|
|
let full = match crate::paths::canonicalize(root.join(&rel_path)) {
|
|
Ok(p) => p,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found("file")),
|
|
Err(e) => return Err(ApiError::from(anyhow!("could not read the file: {e}"))),
|
|
};
|
|
if !full.starts_with(&root) {
|
|
return Err(bad_request("path escapes repository"));
|
|
}
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
Ok((rel, root, full))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CompileLatexReq {
|
|
path: String,
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
/// Compile a checkout `.tex` file to a PDF beside it with the machine's own
|
|
/// LaTeX engine, so the dashboard's approximate preview has an exact
|
|
/// counterpart. Resolves and confines the path exactly like `open_project_file`;
|
|
/// a compile failure is a 200 carrying the log, not an error — the log is the
|
|
/// answer the user came for.
|
|
async fn compile_project_latex(
|
|
Path(id): Path<String>,
|
|
Json(req): Json<CompileLatexReq>,
|
|
) -> ApiResult {
|
|
blocking_api(move || {
|
|
let (_, root, full) = resolve_project_tex(&id, &req.path, req.session_id.as_deref())?;
|
|
// Asked here rather than inside `compile` so a machine that cannot build
|
|
// *this* document gets a 400 with the install hint instead of a 500.
|
|
if let Some(hint) = local::latex::missing_toolchain(&full) {
|
|
return Err(bad_request(hint));
|
|
}
|
|
let result = local::latex::compile(&full)?;
|
|
let pdf_path = match result.pdf.as_deref() {
|
|
Some(pdf) => {
|
|
Some(api_rel_path(pdf.strip_prefix(&root).map_err(|_| {
|
|
anyhow!("compiled PDF landed outside the checkout")
|
|
})?))
|
|
}
|
|
None => None,
|
|
};
|
|
Ok(Json(json!({
|
|
"ok": pdf_path.is_some(),
|
|
"pdfPath": pdf_path,
|
|
"engine": result.engine,
|
|
"note": result.note,
|
|
"hadErrors": result.had_errors,
|
|
"log": result.log,
|
|
})))
|
|
})
|
|
.await
|
|
}
|
|
|
|
enum RawProjectFileSource {
|
|
Disk(std::fs::File),
|
|
Git {
|
|
repo: std::path::PathBuf,
|
|
spec: String,
|
|
size: u64,
|
|
},
|
|
}
|
|
|
|
/// Byte-exact checkout file for browser-native media previews. It resolves the
|
|
/// same worktree/clone/branch source as `project_file`, but streams instead of
|
|
/// decoding or buffering the file in the API process.
|
|
async fn project_raw_file(
|
|
Path(id): Path<String>,
|
|
Query(q): Query<ProjectFileQuery>,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
) -> std::result::Result<Response, ApiError> {
|
|
let source = tokio::task::spawn_blocking(move || {
|
|
let (rel, rel_path) = validated_project_file_path(&q.path)?;
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let ref_name = q.r#ref.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
|
if let Some(name) = ref_name {
|
|
let (root, _) = resolve_checkout_root(&store, &project, None)?;
|
|
let sha = local::git::resolve_branch_commit(&root, name)?
|
|
.ok_or_else(|| not_found("branch"))?;
|
|
let size =
|
|
local::git::file_size_at(&root, &sha, &rel)?.ok_or_else(|| not_found("file"))?;
|
|
// `rel` is right here: git never follows a symlink in a tree, it
|
|
// serves the blob at that path.
|
|
return Ok((
|
|
rel.clone(),
|
|
RawProjectFileSource::Git {
|
|
repo: root,
|
|
spec: format!("{sha}:{rel}"),
|
|
size,
|
|
},
|
|
));
|
|
}
|
|
|
|
let (root, _) = resolve_checkout_root(&store, &project, q.session_id.as_deref())?;
|
|
let full = crate::paths::canonicalize(root.join(rel_path)).map_err(|e| {
|
|
if e.kind() == std::io::ErrorKind::NotFound {
|
|
not_found("file")
|
|
} else {
|
|
ApiError::from(anyhow!("read failed: {e}"))
|
|
}
|
|
})?;
|
|
if !full.starts_with(&root) {
|
|
return Err(bad_request("path escapes repository"));
|
|
}
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
let file =
|
|
std::fs::File::open(&full).map_err(|e| ApiError::from(anyhow!("read failed: {e}")))?;
|
|
// The resolved path, not `rel`: an in-repo symlink named `logo.png` must
|
|
// not make `.env`'s bytes an image.
|
|
Ok((
|
|
full.to_string_lossy().into_owned(),
|
|
RawProjectFileSource::Disk(file),
|
|
))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("file task failed: {e}")))??;
|
|
|
|
let (type_path, source) = source;
|
|
let presentation = local::files::presentation_for_path(&type_path);
|
|
match source {
|
|
RawProjectFileSource::Disk(file) => crate::commands::file_serve::disk_response(
|
|
&type_path,
|
|
file,
|
|
presentation,
|
|
&method,
|
|
&headers,
|
|
"no-cache",
|
|
)
|
|
.await
|
|
.map_err(ApiError::from),
|
|
RawProjectFileSource::Git { repo, spec, size } => {
|
|
crate::commands::file_serve::git_response(
|
|
&type_path,
|
|
repo,
|
|
spec,
|
|
size,
|
|
presentation,
|
|
&method,
|
|
&headers,
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct AbsoluteFileQuery {
|
|
path: String,
|
|
}
|
|
|
|
/// Validate an absolute-path request and resolve a leading `~`/`~/` to the home
|
|
/// dir (the shell never expands it for us, and agents inline `~/…` paths). The
|
|
/// display string stays as typed — it's what the tab shows and what the agent
|
|
/// wrote; only the returned `PathBuf` is home-expanded. `~otheruser` is left
|
|
/// alone and simply fails the absolute check. The bind is loopback-only and the
|
|
/// server runs as the user, so any file the user could read is fair game — this
|
|
/// only rejects malformed input, not location.
|
|
fn validated_absolute_file_path(
|
|
path: &str,
|
|
) -> std::result::Result<(String, std::path::PathBuf), ApiError> {
|
|
let trimmed = path.trim();
|
|
if trimmed.is_empty() || trimmed.len() > 4096 {
|
|
return Err(bad_request("invalid path"));
|
|
}
|
|
let resolved = match trimmed.strip_prefix('~') {
|
|
Some(rest) if rest.is_empty() || rest.starts_with('/') => dirs::home_dir()
|
|
.ok_or_else(|| bad_request("no home directory"))?
|
|
.join(rest.trim_start_matches('/')),
|
|
_ => std::path::PathBuf::from(trimmed),
|
|
};
|
|
if !resolved.is_absolute() {
|
|
return Err(bad_request("path must be absolute"));
|
|
}
|
|
Ok((trimmed.to_string(), resolved))
|
|
}
|
|
|
|
/// One file by absolute path, for the UI file viewer — the escape hatch for a
|
|
/// file an agent references that lives outside the project's checkout and
|
|
/// artifacts (e.g. `/Users/me/.ssh/config`). Same decoded/capped body shape as
|
|
/// `project_file`; `root: "abs"`. Loopback-only and no auth, so it reads
|
|
/// whatever the user running `orx up` can read — matching how the raw variant
|
|
/// and the OS-open endpoint already expose the local disk.
|
|
async fn absolute_file(
|
|
Query(q): Query<AbsoluteFileQuery>,
|
|
) -> std::result::Result<Json<ProjectFileResponse>, ApiError> {
|
|
tokio::task::spawn_blocking(move || {
|
|
use std::io::Read as _;
|
|
let (display, abs) = validated_absolute_file_path(&q.path)?;
|
|
let presentation = local::files::presentation_for_path(&display);
|
|
let full = match crate::paths::canonicalize(&abs) {
|
|
Ok(p) => p,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(Json(ProjectFileResponse::missing(
|
|
display,
|
|
"abs",
|
|
presentation,
|
|
)))
|
|
}
|
|
Err(e) => return Err(ApiError::from(anyhow!("read failed: {e}"))),
|
|
};
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
if !matches!(
|
|
presentation,
|
|
local::files::FilePresentation::Text | local::files::FilePresentation::Unknown
|
|
) {
|
|
return Ok(Json(ProjectFileResponse::non_text(
|
|
display,
|
|
"abs",
|
|
presentation,
|
|
)));
|
|
}
|
|
let file = match std::fs::File::open(&full) {
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(Json(ProjectFileResponse::missing(
|
|
display,
|
|
"abs",
|
|
presentation,
|
|
)))
|
|
}
|
|
Err(e) => return Err(ApiError::from(anyhow!("read failed: {e}"))),
|
|
};
|
|
let mut buf = Vec::new();
|
|
std::io::Read::take(file, FILE_READ_LIMIT + 1)
|
|
.read_to_end(&mut buf)
|
|
.map_err(|e| ApiError::from(anyhow!("read failed: {e}")))?;
|
|
let truncated = buf.len() as u64 > FILE_READ_LIMIT;
|
|
buf.truncate(FILE_READ_LIMIT as usize);
|
|
let (content, binary) = decode_project_file_text(buf, truncated);
|
|
let presentation = if binary {
|
|
local::files::FilePresentation::Download
|
|
} else {
|
|
local::files::FilePresentation::Text
|
|
};
|
|
Ok(Json(ProjectFileResponse::text(
|
|
display,
|
|
"abs",
|
|
content,
|
|
truncated,
|
|
binary,
|
|
presentation,
|
|
None,
|
|
)))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("file task failed: {e}")))?
|
|
}
|
|
|
|
/// Byte-exact absolute-path file for browser-native media previews and
|
|
/// downloads — the streamed counterpart to `absolute_file`, mirroring
|
|
/// `project_raw_file` for arbitrary on-disk paths.
|
|
async fn absolute_raw_file(
|
|
Query(q): Query<AbsoluteFileQuery>,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
) -> std::result::Result<Response, ApiError> {
|
|
let (type_path, file) = tokio::task::spawn_blocking(
|
|
move || -> std::result::Result<(String, std::fs::File), ApiError> {
|
|
let (_, abs) = validated_absolute_file_path(&q.path)?;
|
|
let full = crate::paths::canonicalize(&abs).map_err(|e| {
|
|
if e.kind() == std::io::ErrorKind::NotFound {
|
|
not_found("file")
|
|
} else {
|
|
ApiError::from(anyhow!("read failed: {e}"))
|
|
}
|
|
})?;
|
|
if full.is_dir() {
|
|
return Err(bad_request("path is a directory"));
|
|
}
|
|
let file = std::fs::File::open(&full)
|
|
.map_err(|e| ApiError::from(anyhow!("read failed: {e}")))?;
|
|
// The resolved path: a symlink must not let the requested name
|
|
// dictate the type of another file's contents.
|
|
Ok((full.to_string_lossy().into_owned(), file))
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("file task failed: {e}")))??;
|
|
let presentation = local::files::presentation_for_path(&type_path);
|
|
crate::commands::file_serve::disk_response(
|
|
&type_path,
|
|
file,
|
|
presentation,
|
|
&method,
|
|
&headers,
|
|
"no-cache",
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
// --- project artifacts ----------------------------------------------------
|
|
|
|
/// Listing of the project's artifacts dir — the filesystem is the source of
|
|
/// truth; this scans it fresh on every call (and creates it if missing).
|
|
async fn list_artifacts(Path(id): Path<String>) -> ApiResult {
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let listing = local::files::list(&project)?;
|
|
Ok(Json(json!(listing)))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn manage_artifact_file(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<ManageFileReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let root = local::files::ensure_dir(&project)?;
|
|
let path = manage_local_file(&root, &req.path, req.action, req.new_name.as_deref(), false)?;
|
|
Ok(Json(json!({ "ok": true, "path": path })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ArtifactPathQuery {
|
|
path: String,
|
|
}
|
|
|
|
/// Delete a file or folder in the artifacts dir, by relative path.
|
|
async fn delete_artifact(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Query(q): Query<ArtifactPathQuery>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
blocking_api(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
local::files::delete_entry(&project, &q.path)?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Raw artifact bytes, by directory-relative path. `no-cache`: the same path can
|
|
/// be rewritten in place on disk.
|
|
async fn serve_artifact(
|
|
Path(id): Path<String>,
|
|
Query(q): Query<ArtifactPathQuery>,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
) -> std::result::Result<Response, ApiError> {
|
|
let (type_path, file) = tokio::task::spawn_blocking(move || {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let path = local::files::file_path(&project, &q.path).map_err(|_| not_found("file"))?;
|
|
let file = std::fs::File::open(&path).map_err(|_| not_found("file"))?;
|
|
let metadata = file
|
|
.metadata()
|
|
.map_err(|e| ApiError::from(anyhow!("stat failed: {e}")))?;
|
|
if !metadata.is_file() {
|
|
return Err(not_found("file"));
|
|
}
|
|
// `file_path` canonicalized: type the response by what it resolved to.
|
|
Ok((path.to_string_lossy().into_owned(), file))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("file task failed: {e}")))??;
|
|
let presentation = local::files::presentation_for_path(&type_path);
|
|
crate::commands::file_serve::disk_response(
|
|
&type_path,
|
|
file,
|
|
presentation,
|
|
&method,
|
|
&headers,
|
|
"no-cache",
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
/// Keep update checks and telemetry delivery running for long-lived dashboards.
|
|
fn spawn_background_tasks(check_updates: bool) {
|
|
if check_updates {
|
|
tokio::spawn(async {
|
|
loop {
|
|
updates::periodic_update_pass().await;
|
|
tokio::time::sleep(updates::PERIODIC_CHECK_INTERVAL).await;
|
|
}
|
|
});
|
|
}
|
|
tokio::spawn(async {
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
crate::telemetry::retry_outbox();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Requests and terminals in flight, which an automatic restart would cut off.
|
|
static ACTIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
/// An automatic restart is checking for idleness; new requests wait it out.
|
|
static DRAINING: AtomicBool = AtomicBool::new(false);
|
|
|
|
struct Active;
|
|
|
|
impl Active {
|
|
fn new() -> Self {
|
|
ACTIVE.fetch_add(1, Ordering::SeqCst);
|
|
Active
|
|
}
|
|
}
|
|
|
|
impl Drop for Active {
|
|
fn drop(&mut self) {
|
|
ACTIVE.fetch_sub(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn track_active(request: axum::extract::Request, next: Next) -> Response {
|
|
// The dashboard holds its event stream open for as long as it is open.
|
|
if request.uri().path() == "/api/events" {
|
|
return next.run(request).await;
|
|
}
|
|
// Count first, then check: paired with the drain's set-then-count, one side always sees the other.
|
|
let active = loop {
|
|
let active = Active::new();
|
|
if !DRAINING.load(Ordering::SeqCst) {
|
|
break active;
|
|
}
|
|
drop(active);
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
};
|
|
next.run(request).await.map(|body| {
|
|
axum::body::Body::new(ActiveBody {
|
|
body,
|
|
_active: active,
|
|
})
|
|
})
|
|
}
|
|
|
|
/// A response body that stays `Active` until hyper has sent or dropped it, so a
|
|
/// file still streaming after its handler returned isn't cut off by a restart.
|
|
struct ActiveBody {
|
|
body: axum::body::Body,
|
|
_active: Active,
|
|
}
|
|
|
|
impl axum::body::HttpBody for ActiveBody {
|
|
type Data = axum::body::Bytes;
|
|
type Error = axum::Error;
|
|
|
|
fn poll_frame(
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<Option<std::result::Result<hyper::body::Frame<Self::Data>, Self::Error>>>
|
|
{
|
|
std::pin::Pin::new(&mut self.body).poll_frame(cx)
|
|
}
|
|
|
|
fn is_end_stream(&self) -> bool {
|
|
self.body.is_end_stream()
|
|
}
|
|
|
|
fn size_hint(&self) -> hyper::body::SizeHint {
|
|
self.body.size_hint()
|
|
}
|
|
}
|
|
|
|
/// Relaunch into an update installed underneath this server once that interrupts nothing,
|
|
/// so a long-lived `orx up` stops launching runs and building sandboxes with old code.
|
|
fn spawn_restart_when_idle(state: AppState) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tokio::time::sleep(UPDATE_SAMPLE_INTERVAL).await;
|
|
let status = updates::status();
|
|
// The cache can claim an install the exec target doesn't have; never restart in a loop.
|
|
if !(status.auto_update
|
|
&& status.restart_required
|
|
&& updates::newer_exe_on_disk().await)
|
|
{
|
|
continue;
|
|
}
|
|
DRAINING.store(true, Ordering::SeqCst);
|
|
if ACTIVE.load(Ordering::SeqCst) == 0
|
|
&& !state.data_dir_move_in_progress.load(Ordering::SeqCst)
|
|
&& state.remote_sessions.list().await.iter().all(|session| {
|
|
matches!(
|
|
session.status,
|
|
RemoteSessionStatus::Disconnected
|
|
| RemoteSessionStatus::NeedsInstall
|
|
| RemoteSessionStatus::NeedsUpdate
|
|
)
|
|
})
|
|
&& state.chat.stop_admitting_if_idle().await
|
|
{
|
|
state.restart.notify_one();
|
|
return;
|
|
}
|
|
DRAINING.store(false, Ordering::SeqCst);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Startup summary of detected coding agents. Never blocks. It goes through
|
|
/// the same locked cache path as `/api/harnesses`, so the dashboard's first
|
|
/// call serves the preflight result instead of launching a second sweep.
|
|
fn spawn_agent_preflight(state: AppState) {
|
|
tokio::spawn(async move {
|
|
let payload = harnesses_payload(&state, &HarnessQuery::default()).await;
|
|
let line: Vec<String> = payload["harnesses"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|h| {
|
|
let name = h["name"].as_str().unwrap_or("agent");
|
|
if h["agentReady"].as_bool() == Some(true) {
|
|
match h["account"].as_str() {
|
|
Some(acct) => format!("{name} ✓ ({acct})"),
|
|
None => format!("{name} ✓"),
|
|
}
|
|
} else if h["installBroken"].as_bool() == Some(true) {
|
|
format!("{name} — installed but failed to run")
|
|
} else if h["catalogPending"].as_bool() == Some(true) {
|
|
format!("{name} — checking…")
|
|
} else if h["installed"].as_bool() == Some(true) {
|
|
format!(
|
|
"{name} — {}",
|
|
h["agentNote"].as_str().unwrap_or("not ready")
|
|
)
|
|
} else {
|
|
format!("{name} — not installed")
|
|
}
|
|
})
|
|
.collect();
|
|
eprintln!("orx up: agents: {}", line.join(" · "));
|
|
// The provisional pass can't vouch for readiness — this detached task
|
|
// waits out the catalog fill before deciding there's really nothing
|
|
// usable, so the warning reflects the settled answer.
|
|
let mut settled = payload;
|
|
for _ in 0..45 {
|
|
if !payload_is_provisional(&settled) {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
settled = harnesses_payload(&state, &HarnessQuery::default()).await;
|
|
}
|
|
let any_ready = settled["harnesses"]
|
|
.as_array()
|
|
.is_some_and(|all| all.iter().any(|h| h["agentReady"].as_bool() == Some(true)));
|
|
// Still provisional after 45s means the fill never converged — stay
|
|
// silent rather than guess "nothing ready" from clamped entries.
|
|
if !payload_is_provisional(&settled) && !any_ready {
|
|
eprintln!(
|
|
"orx up: warning: no coding agent ready — install Claude Code, Codex, OpenCode, Cursor or Antigravity, then connect a local model or sign in."
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// A signed-out harness is the only state that needs polling. Normal turns and
|
|
/// healthy idle sessions do no auth work; this loop merely notices a login the
|
|
/// user completed separately and wakes the UI immediately. The cache is left
|
|
/// alone: served payloads get the live auth snapshot overlaid on the way out,
|
|
/// the ready-claude gate re-detects a promoted login, and a wholesale clear
|
|
/// here used to discard in-flight catalog fills on every flap.
|
|
fn spawn_claude_auth_monitor(
|
|
chat: Arc<ChatHost>,
|
|
claude: Arc<local::claude::ClaudeHost>,
|
|
fill_in_flight: Arc<std::sync::atomic::AtomicBool>,
|
|
) {
|
|
tokio::spawn(async move {
|
|
let mut delay = Duration::from_secs(5);
|
|
let mut observed_generation = claude.auth_snapshot().generation;
|
|
loop {
|
|
tokio::time::sleep(delay).await;
|
|
let before = claude.auth_snapshot();
|
|
if before.generation != observed_generation {
|
|
observed_generation = before.generation;
|
|
if claude.claim_auth_announcement(before.generation) {
|
|
chat.emit_event(
|
|
"harness.auth",
|
|
json!({ "harness": "claude-code", "authState": before.state }),
|
|
);
|
|
}
|
|
}
|
|
if fill_in_flight.load(std::sync::atomic::Ordering::Acquire)
|
|
|| before.runtime_rejected
|
|
|| matches!(
|
|
before.state,
|
|
local::harness::HarnessAuthState::Ready
|
|
| local::harness::HarnessAuthState::Unsupported
|
|
) && !before.auth_check_failed
|
|
{
|
|
delay = Duration::from_secs(5);
|
|
continue;
|
|
}
|
|
let probe = local::harness::claude::current_auth_state().await;
|
|
let changed = claude.observe_auth_probe(&probe);
|
|
let after = claude.auth_snapshot();
|
|
if after.generation != observed_generation {
|
|
observed_generation = after.generation;
|
|
if claude.claim_auth_announcement(after.generation) {
|
|
chat.emit_event(
|
|
"harness.auth",
|
|
json!({ "harness": "claude-code", "authState": after.state }),
|
|
);
|
|
}
|
|
} else if changed {
|
|
chat.emit_event("harness.catalog", json!({}));
|
|
}
|
|
delay = if after.auth_check_failed {
|
|
(delay * 2).min(Duration::from_secs(60))
|
|
} else {
|
|
Duration::from_secs(5)
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- env var settings -------------------------------------------------------
|
|
|
|
/// Everything in `~/.openresearch/env`, values masked. `inProcessEnv` flags
|
|
/// keys that are also set in orx up's own environment (which wins at runtime).
|
|
fn env_settings_json() -> Value {
|
|
let vars: Vec<Value> = crate::config::list_synced_env()
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
json!({
|
|
"key": key,
|
|
"maskedValue": mask_token(value),
|
|
"inProcessEnv": std::env::var_os(key).is_some(),
|
|
})
|
|
})
|
|
.collect();
|
|
json!({ "vars": vars })
|
|
}
|
|
|
|
async fn env_settings() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| Ok(Json(env_settings_json())))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("env task failed: {e}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetEnvVarReq {
|
|
key: String,
|
|
value: String,
|
|
}
|
|
|
|
fn valid_env_key(key: &str) -> bool {
|
|
!key.is_empty()
|
|
&& !key.starts_with(|c: char| c.is_ascii_digit())
|
|
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
}
|
|
|
|
async fn set_env_var(Json(req): Json<SetEnvVarReq>) -> ApiResult {
|
|
let key = req.key.trim().to_string();
|
|
let value = req.value.trim().to_string();
|
|
if !valid_env_key(&key) {
|
|
return Err(bad_request(
|
|
"key must be letters, digits or _, not starting with a digit",
|
|
));
|
|
}
|
|
if crate::local::shell_env::IMPORTED.contains(&key.as_str()) {
|
|
return Err(bad_request(format!(
|
|
"{key} is reserved by OpenResearch. To change it, set {key} in your shell and restart OpenResearch."
|
|
)));
|
|
}
|
|
if value.is_empty() {
|
|
return Err(bad_request("value is required"));
|
|
}
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::config::write_synced_env_var(&key, &value)?;
|
|
Ok(Json(env_settings_json()))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("env task failed: {e}")))?
|
|
}
|
|
|
|
async fn delete_env_var(Path(key): Path<String>) -> ApiResult {
|
|
if !valid_env_key(&key) {
|
|
return Err(bad_request("invalid key"));
|
|
}
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::config::remove_synced_env_var(&key)?;
|
|
Ok(Json(env_settings_json()))
|
|
})
|
|
.await
|
|
.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)?;
|
|
|
|
let lock_path = std::path::PathBuf::from(&path);
|
|
let persistent_host = state.remote_instance_id.is_some();
|
|
let next_lock = tokio::task::spawn_blocking(move || {
|
|
DashboardLock::acquire(
|
|
&lock_path,
|
|
if persistent_host {
|
|
DashboardLockMode::Exclusive
|
|
} else {
|
|
DashboardLockMode::Shared
|
|
},
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("dashboard lock task failed: {e}")))?
|
|
.map_err(|error| ApiError(StatusCode::CONFLICT, error.to_string()))?;
|
|
|
|
state.chat.shutdown_harnesses().await;
|
|
tokio::task::spawn_blocking(move || crate::config::set_settings_data_dir(Some(path)))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("settings task failed: {e}")))??;
|
|
*state.dashboard_lock.lock().unwrap() = Some(next_lock);
|
|
state.chat.shutdown_harnesses().await;
|
|
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);
|
|
};
|
|
|
|
let data_dir_guard = state.data_dir_gate.clone().lock_owned().await;
|
|
let source_data_dir = crate::store::data_dir();
|
|
let move_token = uuid::Uuid::new_v4().to_string();
|
|
let (move_store, move_lock) = match Store::open().and_then(|store| {
|
|
let lock = store.acquire_data_dir_move_lock()?;
|
|
Ok((store, lock))
|
|
}) {
|
|
Ok(claim) => claim,
|
|
Err(_) => {
|
|
release(&state);
|
|
return ApiError(
|
|
StatusCode::CONFLICT,
|
|
"Another dashboard is moving the data directory.".into(),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
let move_claimed = move_store.claim_data_dir_move(&move_token).unwrap_or(false);
|
|
if !move_claimed {
|
|
release(&state);
|
|
return ApiError(
|
|
StatusCode::CONFLICT,
|
|
"Can't move while another dashboard has an active chat turn or storage move.".into(),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let release_move_claim = |token: &str| {
|
|
if let Ok(store) = Store::open() {
|
|
let _ = store.release_data_dir_move(token);
|
|
}
|
|
};
|
|
|
|
// 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);
|
|
let active_operations = state.project_lifecycle.operation_count();
|
|
if !busy.is_empty() || active_runs > 0 || active_operations > 0 {
|
|
release_move_claim(&move_token);
|
|
release(&state);
|
|
return ApiError(
|
|
StatusCode::CONFLICT,
|
|
format!(
|
|
"Can't move while work is in progress ({} active chat turn(s), \
|
|
{active_runs} active run(s), {active_operations} project operation(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_move_claim(&move_token);
|
|
release(&state);
|
|
return bad_request(e).into_response();
|
|
}
|
|
Err(e) => {
|
|
release_move_claim(&move_token);
|
|
release(&state);
|
|
return ApiError::from(anyhow!("validate task failed: {e}")).into_response();
|
|
}
|
|
}
|
|
|
|
let lock_target = std::path::PathBuf::from(&path);
|
|
let persistent_host = state.remote_instance_id.is_some();
|
|
let next_lock = match tokio::task::spawn_blocking(move || {
|
|
DashboardLock::acquire(
|
|
&lock_target,
|
|
if persistent_host {
|
|
DashboardLockMode::Exclusive
|
|
} else {
|
|
DashboardLockMode::Shared
|
|
},
|
|
)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(lock)) => lock,
|
|
Ok(Err(error)) => {
|
|
release_move_claim(&move_token);
|
|
release(&state);
|
|
return ApiError(StatusCode::CONFLICT, error.to_string()).into_response();
|
|
}
|
|
Err(error) => {
|
|
release_move_claim(&move_token);
|
|
release(&state);
|
|
return ApiError::from(anyhow!("dashboard lock task failed: {error}")).into_response();
|
|
}
|
|
};
|
|
|
|
let chat = state.chat.clone();
|
|
// Provider-native SQLite/session stores now live inside this directory.
|
|
// Close every idle harness child before the filesystem begins moving it.
|
|
chat.shutdown_harnesses().await;
|
|
|
|
// 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 flag = state.data_dir_move_in_progress.clone();
|
|
let dashboard_lock = state.dashboard_lock.clone();
|
|
let target = std::path::PathBuf::from(path);
|
|
tokio::spawn(async move {
|
|
let _data_dir_guard = data_dir_guard;
|
|
let _move_lock = move_lock;
|
|
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)) => {
|
|
*dashboard_lock.lock().unwrap() = Some(next_lock);
|
|
// Close any child spawned while a cross-filesystem copy ran.
|
|
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}") }),
|
|
),
|
|
}
|
|
// A cross-filesystem copy retains the source DB, while a rename removes
|
|
// it. Avoid reopening a removed source path and recreating it.
|
|
if source_data_dir.exists() {
|
|
if let Ok(store) = Store::open_at(source_data_dir) {
|
|
let _ = store.release_data_dir_move(&move_token);
|
|
}
|
|
}
|
|
if let Ok(store) = Store::open() {
|
|
let _ = store.release_data_dir_move(&move_token);
|
|
}
|
|
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(())
|
|
}
|
|
|
|
fn reject_if_stopping(state: &AppState) -> std::result::Result<(), ApiError> {
|
|
if state.stopping.load(Ordering::SeqCst) {
|
|
return Err(ApiError(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"The persistent OpenResearch host is stopping.".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// --- git settings -----------------------------------------------------------
|
|
|
|
fn git_out(args: &[&str]) -> Option<String> {
|
|
let out = std::process::Command::new("git").args(args).output().ok()?;
|
|
if !out.status.success() {
|
|
return None;
|
|
}
|
|
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
(!s.is_empty()).then_some(s)
|
|
}
|
|
|
|
fn git_settings_json(github_status: local::github::Status) -> Value {
|
|
json!({
|
|
"gitVersion": git_out(&["--version"]),
|
|
"userName": git_out(&["config", "--global", "user.name"]),
|
|
"userEmail": git_out(&["config", "--global", "user.email"]),
|
|
"ghInstalled": github_status.installed,
|
|
"githubAuthenticated": github_status.authenticated,
|
|
})
|
|
}
|
|
|
|
fn project_defaults_json(github_status: local::github::Status) -> Value {
|
|
json!({
|
|
"githubForNewProjects": crate::config::github_for_new_projects(),
|
|
"githubDefaultPromptSeen": crate::config::github_default_prompt_seen(),
|
|
"ghInstalled": github_status.installed,
|
|
"githubAuthenticated": github_status.authenticated,
|
|
})
|
|
}
|
|
|
|
async fn project_defaults() -> ApiResult {
|
|
Ok(Json(project_defaults_json(local::github::status().await)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SetProjectDefaultsReq {
|
|
github_for_new_projects: bool,
|
|
#[serde(default)]
|
|
github_default_prompt_seen: Option<bool>,
|
|
}
|
|
|
|
async fn set_project_defaults(Json(req): Json<SetProjectDefaultsReq>) -> ApiResult {
|
|
let github_status = local::github::status().await;
|
|
if req.github_for_new_projects && !github_status.authenticated {
|
|
return Err(bad_request(
|
|
"Connect GitHub before enabling it by default for new projects.",
|
|
));
|
|
}
|
|
crate::config::set_github_for_new_projects(req.github_for_new_projects)?;
|
|
if let Some(seen) = req.github_default_prompt_seen {
|
|
crate::config::set_github_default_prompt_seen(seen)?;
|
|
}
|
|
Ok(Json(project_defaults_json(github_status)))
|
|
}
|
|
|
|
async fn git_settings() -> ApiResult {
|
|
let github_status = local::github::status().await;
|
|
tokio::task::spawn_blocking(move || Ok(Json(git_settings_json(github_status))))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SetGitSettingsReq {
|
|
user_name: Option<String>,
|
|
user_email: Option<String>,
|
|
}
|
|
|
|
async fn set_git_settings(Json(req): Json<SetGitSettingsReq>) -> ApiResult {
|
|
let name = req.user_name.map(|s| s.trim().to_string());
|
|
let email = req.user_email.map(|s| s.trim().to_string());
|
|
if name.as_deref().is_none_or(str::is_empty) && email.as_deref().is_none_or(str::is_empty) {
|
|
return Err(bad_request(
|
|
"nothing to update: pass userName and/or userEmail",
|
|
));
|
|
}
|
|
let github_status = local::github::status().await;
|
|
tokio::task::spawn_blocking(move || {
|
|
for (key, value) in [("user.name", name), ("user.email", email)] {
|
|
if let Some(v) = value.filter(|v| !v.is_empty()) {
|
|
let ok = std::process::Command::new("git")
|
|
.args(["config", "--global", key, &v])
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
if !ok {
|
|
return Err(bad_request(format!("git config --global {key} failed")));
|
|
}
|
|
}
|
|
}
|
|
Ok(Json(git_settings_json(github_status)))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("git task failed: {e}")))?
|
|
}
|
|
|
|
// --- telemetry settings -----------------------------------------------------
|
|
|
|
/// Effective eligibility plus the saved preference controlled by the switch.
|
|
fn telemetry_settings_json() -> Value {
|
|
let preference_enabled = crate::telemetry::preference_enabled();
|
|
match crate::telemetry::effective_disabled_reason() {
|
|
None => {
|
|
json!({ "enabled": true, "preferenceEnabled": preference_enabled, "locked": false, "reason": null })
|
|
}
|
|
Some(r) => {
|
|
let reason = r.as_str();
|
|
let locked = !matches!(r, crate::telemetry::DisabledReason::Persisted);
|
|
json!({ "enabled": false, "preferenceEnabled": preference_enabled, "locked": locked, "reason": reason })
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn telemetry_settings() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| Ok(Json(telemetry_settings_json())))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("telemetry task failed: {e}")))?
|
|
}
|
|
|
|
/// A product event raised by the UI rather than by a command. Every field is
|
|
/// matched against a fixed allowlist in `telemetry`, so this local endpoint
|
|
/// cannot emit arbitrary telemetry.
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct UiEventReq {
|
|
name: String,
|
|
#[serde(default)]
|
|
choice: Option<String>,
|
|
#[serde(default)]
|
|
step: Option<String>,
|
|
#[serde(default)]
|
|
kind: Option<String>,
|
|
#[serde(default)]
|
|
experiment: Option<String>,
|
|
#[serde(default)]
|
|
slot: Option<u8>,
|
|
#[serde(default)]
|
|
surface: Option<String>,
|
|
#[serde(default)]
|
|
action: Option<String>,
|
|
}
|
|
|
|
async fn record_ui_event(Json(req): Json<UiEventReq>) -> ApiResult {
|
|
match req.name.as_str() {
|
|
"demo_welcome_choice" => {
|
|
if let Some(choice) = req.choice.as_deref() {
|
|
crate::telemetry::capture_demo_welcome_choice(choice);
|
|
}
|
|
}
|
|
"onboarding_step_viewed" => {
|
|
if let Some(step) = req.step.as_deref() {
|
|
crate::telemetry::capture_onboarding_step_viewed(step);
|
|
}
|
|
}
|
|
"demo_experiment_started" => {
|
|
if let (Some(kind), Some(experiment)) = (req.kind.as_deref(), req.experiment.as_deref())
|
|
{
|
|
crate::telemetry::capture_demo_experiment_started(kind, experiment);
|
|
}
|
|
}
|
|
"project_starter_clicked" => {
|
|
if let Some(slot) = req.slot {
|
|
crate::telemetry::capture_project_starter_clicked(slot);
|
|
}
|
|
}
|
|
"first_action" => {
|
|
if let (Some(surface), Some(action)) = (req.surface.as_deref(), req.action.as_deref()) {
|
|
crate::telemetry::capture_first_action(surface, action);
|
|
}
|
|
}
|
|
_ => return Ok(Json(json!({ "ok": false }))),
|
|
}
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetTelemetryReq {
|
|
enabled: bool,
|
|
}
|
|
|
|
async fn set_telemetry_settings(Json(req): Json<SetTelemetryReq>) -> ApiResult {
|
|
let enabled = req.enabled;
|
|
crate::telemetry::record_consent(enabled).await;
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::telemetry::set_persisted_disabled(!enabled)
|
|
.map_err(|e| ApiError::from(anyhow!("could not save telemetry setting: {e}")))?;
|
|
Ok(Json(telemetry_settings_json()))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("telemetry task failed: {e}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetLocaleReq {
|
|
locale: String,
|
|
}
|
|
|
|
async fn set_dashboard_locale(Json(SetLocaleReq { locale }): Json<SetLocaleReq>) -> ApiResult {
|
|
// Attached to every later batch, so a value the server rejects would drop them all.
|
|
let valid = (2..=16).contains(&locale.len())
|
|
&& locale
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-');
|
|
if !valid {
|
|
return Err(bad_request(format!("invalid locale: {locale:?}")));
|
|
}
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::telemetry::set_dashboard_locale(&locale)
|
|
.map_err(|e| ApiError::from(anyhow!("could not save the dashboard locale: {e}")))?;
|
|
Ok(Json(json!({ "locale": locale })))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("locale task failed: {e}")))?
|
|
}
|
|
|
|
// --- updates -----------------------------------------------------------------
|
|
|
|
async fn update_status() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| Ok(Json(json!(updates::status()))))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("update status task failed: {e}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetAutoUpdateReq {
|
|
enabled: bool,
|
|
}
|
|
|
|
async fn set_auto_update(Json(req): Json<SetAutoUpdateReq>) -> ApiResult {
|
|
let enabled = req.enabled;
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::config::set_auto_update_enabled(enabled)
|
|
.map_err(|e| ApiError::from(anyhow!("could not save the auto-update setting: {e}")))?;
|
|
Ok(Json(json!(updates::status())))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("auto-update task failed: {e}")))?
|
|
}
|
|
|
|
/// Apply an update now, for the user who doesn't want to wait for the next
|
|
/// periodic pass. Runs the same detached updater, so it can't race one already
|
|
/// in flight — the updater's file lock settles that.
|
|
async fn apply_update() -> ApiResult {
|
|
updates::apply_now().await?;
|
|
Ok(Json(json!(updates::status())))
|
|
}
|
|
|
|
/// Relaunch into the copy the updater already installed. Answers first, then
|
|
/// restarts, so the dashboard learns the request landed before the connection
|
|
/// drops; it reloads once the new server is up.
|
|
async fn restart_after_update(State(state): State<AppState>) -> ApiResult {
|
|
let status = tokio::task::spawn_blocking(updates::status)
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("update status task failed: {e}")))?;
|
|
let Some(version) = status.installed_version else {
|
|
return Err(ApiError(
|
|
StatusCode::CONFLICT,
|
|
"no newer orx is installed to restart into".into(),
|
|
));
|
|
};
|
|
let restart = state.restart.clone();
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
restart.notify_one();
|
|
});
|
|
Ok(Json(json!({ "restarting": true, "version": version })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct InstallCliReq {
|
|
/// Replace an `orx` that is already on PATH. The card only sends this after
|
|
/// showing the user what it would displace.
|
|
#[serde(default)]
|
|
force: bool,
|
|
}
|
|
|
|
/// Link the app's `orx` onto the user's PATH (Settings → Updates).
|
|
async fn install_cli(Json(req): Json<InstallCliReq>) -> ApiResult {
|
|
let installed =
|
|
tokio::task::spawn_blocking(move || crate::commands::install_cli::install(req.force))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("install-cli task failed: {e}")))??;
|
|
Ok(Json(json!({
|
|
"link": installed.link.to_string_lossy(),
|
|
"dir": installed.link.parent().unwrap_or(&installed.link).to_string_lossy(),
|
|
"target": installed.target.to_string_lossy(),
|
|
"onPath": installed.on_path,
|
|
"alreadyCurrent": installed.already_current,
|
|
})))
|
|
}
|
|
|
|
fn profile_settings_json() -> Value {
|
|
json!(crate::telemetry::load_profile())
|
|
}
|
|
|
|
async fn profile_settings() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| Ok(Json(profile_settings_json())))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("profile task failed: {e}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SetProfileReq {
|
|
#[serde(default)]
|
|
research_areas: Vec<String>,
|
|
#[serde(default)]
|
|
other_area: Option<String>,
|
|
#[serde(default)]
|
|
background: Option<String>,
|
|
#[serde(default)]
|
|
papers: Vec<crate::telemetry::ProfilePaper>,
|
|
}
|
|
|
|
fn profile_settings_update(
|
|
req: SetProfileReq,
|
|
current: crate::telemetry::ResearchProfile,
|
|
) -> std::result::Result<crate::telemetry::ResearchProfile, ApiError> {
|
|
if req.research_areas.is_empty() {
|
|
Ok(crate::telemetry::ResearchProfile {
|
|
research_areas: current.research_areas,
|
|
other_area: current.other_area,
|
|
background: req.background.filter(|value| !value.trim().is_empty()),
|
|
papers: req.papers,
|
|
})
|
|
} else {
|
|
normalize_research_profile(
|
|
req.research_areas,
|
|
req.other_area,
|
|
req.background,
|
|
req.papers,
|
|
)
|
|
}
|
|
}
|
|
|
|
async fn set_profile_settings(Json(req): Json<SetProfileReq>) -> ApiResult {
|
|
let profile = profile_settings_update(req, crate::telemetry::load_profile())?;
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::telemetry::set_profile(profile)
|
|
.map_err(|e| ApiError::from(anyhow!("could not save profile: {e}")))?;
|
|
Ok(Json(profile_settings_json()))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("profile task failed: {e}")))?
|
|
}
|
|
|
|
async fn ui_state() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| -> Result<Json<Value>> {
|
|
Ok(Json(json!(Store::open()?.ui_state()?)))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("UI state task failed: {error}")))?
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
async fn project_ui_state(Path(id): Path<String>) -> ApiResult {
|
|
tokio::task::spawn_blocking(move || -> ApiResult {
|
|
let store = Store::open()?;
|
|
store
|
|
.get_local_project(&id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
Ok(Json(json!(store.project_workspace_state(&id)?)))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("workspace task failed: {error}")))?
|
|
}
|
|
|
|
async fn set_project_ui_state(
|
|
Path(id): Path<String>,
|
|
Json(workspace): Json<WorkspaceState>,
|
|
) -> ApiResult {
|
|
workspace.validate().map_err(bad_request)?;
|
|
tokio::task::spawn_blocking(move || -> ApiResult {
|
|
if !Store::open()?.set_project_workspace_state(&id, &workspace)? {
|
|
return Err(not_found("project"));
|
|
}
|
|
Ok(Json(json!(workspace)))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("workspace task failed: {error}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SetUiStateReq {
|
|
#[serde(default)]
|
|
tour_completed: Option<bool>,
|
|
#[serde(default)]
|
|
preferred_agent: Option<StoredAgentSelectionReq>,
|
|
workspace: Option<GlobalWorkspaceState>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct StoredAgentSelectionReq {
|
|
harness: String,
|
|
model: Option<String>,
|
|
service_tier: Option<String>,
|
|
permission_mode: Option<String>,
|
|
reasoning_level: Option<String>,
|
|
}
|
|
|
|
async fn set_ui_state(Json(req): Json<SetUiStateReq>) -> ApiResult {
|
|
if let Some(workspace) = &req.workspace {
|
|
workspace.validate().map_err(bad_request)?;
|
|
}
|
|
tokio::task::spawn_blocking(move || -> Result<Json<Value>> {
|
|
let store = Store::open()?;
|
|
let selection = req
|
|
.preferred_agent
|
|
.map(|selection| {
|
|
if !local::harness::is_chat_harness(&selection.harness) {
|
|
return Err(anyhow!("unknown harness: {}", selection.harness));
|
|
}
|
|
let nonempty = |value: Option<String>| value.filter(|item| !item.trim().is_empty());
|
|
let permission_mode = nonempty(selection.permission_mode);
|
|
let service_tier = nonempty(selection.service_tier);
|
|
if permission_mode.as_deref().is_some_and(|mode| {
|
|
local::harness::permission_mode_for(&selection.harness, mode).is_none()
|
|
}) {
|
|
return Err(anyhow!("invalid permission mode for selected harness"));
|
|
}
|
|
if service_tier.as_deref().is_some_and(|tier| {
|
|
local::harness::service_tier_for(&selection.harness, tier).is_none()
|
|
}) {
|
|
return Err(anyhow!("invalid speed for selected harness"));
|
|
}
|
|
Ok(StoredAgentSelection {
|
|
harness: selection.harness.clone(),
|
|
model: nonempty(selection.model),
|
|
service_tier,
|
|
permission_mode: preferred_permission_mode(&selection.harness, permission_mode),
|
|
reasoning_level: nonempty(selection.reasoning_level),
|
|
})
|
|
})
|
|
.transpose()?;
|
|
if let Some(completed) = req.tour_completed {
|
|
store.set_tour_completed(completed)?;
|
|
}
|
|
if let Some(selection) = selection {
|
|
store.set_preferred_agent(&selection)?;
|
|
}
|
|
if let Some(workspace) = req.workspace {
|
|
store.set_global_workspace_state(&workspace)?;
|
|
}
|
|
Ok(Json(json!(store.ui_state()?)))
|
|
})
|
|
.await
|
|
.map_err(|error| ApiError::from(anyhow!("UI state task failed: {error}")))?
|
|
.map_err(bad_request)
|
|
}
|
|
|
|
/// The lit-source toggles as booleans (enabled = not in the disabled set).
|
|
fn lit_sources_json() -> Value {
|
|
let disabled = crate::config::disabled_lit_sources();
|
|
let enabled = |name: &str| !disabled.iter().any(|d| d == name);
|
|
json!({
|
|
"alphaxiv": enabled(crate::LitSource::Alphaxiv.as_str()),
|
|
"openalex": enabled(crate::LitSource::Openalex.as_str()),
|
|
"biorxiv": enabled(crate::LitSource::Biorxiv.as_str()),
|
|
"pubmed": enabled(crate::LitSource::Pubmed.as_str()),
|
|
})
|
|
}
|
|
|
|
async fn lit_sources_settings() -> ApiResult {
|
|
tokio::task::spawn_blocking(|| Ok(Json(lit_sources_json())))
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("lit-sources task failed: {e}")))?
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SetLitSourcesReq {
|
|
alphaxiv: bool,
|
|
openalex: bool,
|
|
biorxiv: bool,
|
|
pubmed: bool,
|
|
}
|
|
|
|
async fn set_lit_sources_settings(Json(req): Json<SetLitSourcesReq>) -> ApiResult {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut disabled = Vec::new();
|
|
for (enabled, source) in [
|
|
(req.alphaxiv, crate::LitSource::Alphaxiv),
|
|
(req.openalex, crate::LitSource::Openalex),
|
|
(req.biorxiv, crate::LitSource::Biorxiv),
|
|
(req.pubmed, crate::LitSource::Pubmed),
|
|
] {
|
|
if !enabled {
|
|
disabled.push(source.as_str().to_string());
|
|
}
|
|
}
|
|
crate::telemetry::set_disabled_lit_sources(disabled)
|
|
.map_err(|e| ApiError::from(anyhow!("could not save literature sources: {e}")))?;
|
|
Ok(Json(lit_sources_json()))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("lit-sources task failed: {e}")))?
|
|
}
|
|
|
|
// --- ssh hosts ----------------------------------------------------------------
|
|
|
|
#[derive(Clone, Copy, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub(crate) enum SshConnectBackend {
|
|
Ssh,
|
|
Slurm,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct SshConnectReq {
|
|
pub(crate) host: String,
|
|
pub(crate) backend: SshConnectBackend,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(tag = "type", rename_all = "lowercase")]
|
|
enum SshTerminalInput {
|
|
Resize { cols: u16, rows: u16 },
|
|
}
|
|
|
|
enum PtyEvent {
|
|
Output(Vec<u8>),
|
|
Eof,
|
|
Exit(std::result::Result<portable_pty::ExitStatus, String>),
|
|
}
|
|
|
|
struct PtySession {
|
|
master: Box<dyn MasterPty + Send>,
|
|
input: std::sync::mpsc::Sender<Vec<u8>>,
|
|
events: mpsc::Receiver<PtyEvent>,
|
|
kill: std::sync::mpsc::Sender<()>,
|
|
}
|
|
|
|
struct PtyChildGuard {
|
|
kill: std::sync::mpsc::Sender<()>,
|
|
running: bool,
|
|
}
|
|
|
|
impl Drop for PtyChildGuard {
|
|
fn drop(&mut self) {
|
|
if self.running {
|
|
let _ = self.kill.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn same_origin(headers: &HeaderMap) -> bool {
|
|
let Some(origin) = headers
|
|
.get(header::ORIGIN)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
return false;
|
|
};
|
|
let Some(host) = headers
|
|
.get(header::HOST)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
return false;
|
|
};
|
|
origin == format!("http://{host}") || origin == format!("https://{host}")
|
|
}
|
|
|
|
fn ssh_connect_failure(host: &str, error: String) -> SshHostTest {
|
|
SshHostTest {
|
|
host: host.to_string(),
|
|
reachable: false,
|
|
tools_found: false,
|
|
missing_tools: Vec::new(),
|
|
error: Some(error),
|
|
tested_at: now_ms(),
|
|
}
|
|
}
|
|
|
|
async fn send_ssh_connect_error(
|
|
socket: &mut WebSocket,
|
|
host: &str,
|
|
backend: SshConnectBackend,
|
|
error: String,
|
|
) {
|
|
if matches!(backend, SshConnectBackend::Ssh) {
|
|
record_ssh_host_test(&ssh_connect_failure(host, error.clone())).await;
|
|
}
|
|
let _ = socket
|
|
.send(Message::Text(
|
|
json!({ "type": "error", "error": error })
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn ssh_connect(
|
|
headers: HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
Query(req): Query<SshConnectReq>,
|
|
) -> Response {
|
|
let target = crate::jobs::ssh::SshTarget::alias(req.host.trim());
|
|
ssh_connect_to_target(headers, ws, req, target).await
|
|
}
|
|
|
|
pub(crate) async fn ssh_connect_to_target(
|
|
headers: HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
req: SshConnectReq,
|
|
target: crate::jobs::ssh::SshTarget,
|
|
) -> Response {
|
|
if !same_origin(&headers) {
|
|
return ApiError(StatusCode::FORBIDDEN, "SSH terminal origin rejected".into())
|
|
.into_response();
|
|
}
|
|
let host = req.host.trim().to_string();
|
|
if host.is_empty() {
|
|
return bad_request("host is required").into_response();
|
|
}
|
|
let active = Active::new();
|
|
ws.on_upgrade(move |socket| async move {
|
|
let _active = active;
|
|
ssh_connect_socket(socket, host, req.backend, target).await
|
|
})
|
|
}
|
|
|
|
const DEFAULT_PTY_SIZE: PtySize = PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
};
|
|
|
|
fn start_pty(program: &str, args: Vec<String>) -> Result<PtySession> {
|
|
start_pty_with_env(program, args, &[], DEFAULT_PTY_SIZE, None)
|
|
}
|
|
|
|
fn start_pty_with_env(
|
|
program: &str,
|
|
args: Vec<String>,
|
|
env: &[(&str, std::ffi::OsString)],
|
|
size: PtySize,
|
|
cwd: Option<&std::path::Path>,
|
|
) -> Result<PtySession> {
|
|
use std::io::{Read as _, Write as _};
|
|
|
|
let pair = native_pty_system().openpty(size)?;
|
|
let mut command = CommandBuilder::new(program);
|
|
command.args(args);
|
|
// App mode never puts the imported shell env into the process env, so a
|
|
// child sees launchd's PATH and config dirs unless they are exported here.
|
|
if let Some(path) = local::shell_env::search_path() {
|
|
command.env("PATH", path);
|
|
}
|
|
local::shell_env::export_to(|key, value| command.env(key, value));
|
|
for (key, value) in local::shell_env::host_gui_env() {
|
|
match value {
|
|
Some(value) => command.env(key, value),
|
|
None => command.env_remove(key),
|
|
}
|
|
}
|
|
command.env("TERM", "xterm-256color");
|
|
command.env_remove("NO_COLOR");
|
|
command.env_remove("FORCE_COLOR");
|
|
if let Some(cwd) = cwd {
|
|
command.cwd(cwd);
|
|
}
|
|
for (key, value) in env {
|
|
command.env(key, value);
|
|
}
|
|
let mut child = pair.slave.spawn_command(command)?;
|
|
drop(pair.slave);
|
|
|
|
let mut reader = pair.master.try_clone_reader()?;
|
|
let mut writer = pair.master.take_writer()?;
|
|
let (input_tx, input_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
|
let (kill_tx, kill_rx) = std::sync::mpsc::channel();
|
|
let (event_tx, event_rx) = mpsc::channel(64);
|
|
|
|
let output_tx = event_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let mut buf = [0; 8192];
|
|
loop {
|
|
match reader.read(&mut buf) {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
if output_tx
|
|
.blocking_send(PtyEvent::Output(buf[..n].to_vec()))
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
let _ = output_tx.blocking_send(PtyEvent::Eof);
|
|
});
|
|
std::thread::spawn(move || {
|
|
while let Ok(bytes) = input_rx.recv() {
|
|
if writer
|
|
.write_all(&bytes)
|
|
.and_then(|_| writer.flush())
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
std::thread::spawn(move || loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
let _ = event_tx.blocking_send(PtyEvent::Exit(Ok(status)));
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
let _ = event_tx.blocking_send(PtyEvent::Exit(Err(error.to_string())));
|
|
return;
|
|
}
|
|
Ok(None) => {}
|
|
}
|
|
match kill_rx.recv_timeout(Duration::from_millis(50)) {
|
|
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
|
let status = child
|
|
.kill()
|
|
.and_then(|_| child.wait())
|
|
.map_err(|error| error.to_string());
|
|
let _ = event_tx.blocking_send(PtyEvent::Exit(status));
|
|
return;
|
|
}
|
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
|
}
|
|
});
|
|
|
|
Ok(PtySession {
|
|
master: pair.master,
|
|
input: input_tx,
|
|
events: event_rx,
|
|
kill: kill_tx,
|
|
})
|
|
}
|
|
|
|
async fn ssh_connect_socket(
|
|
mut socket: WebSocket,
|
|
host: String,
|
|
backend: SshConnectBackend,
|
|
target: crate::jobs::ssh::SshTarget,
|
|
) {
|
|
let args = match crate::jobs::ssh::interactive_args(&target) {
|
|
Ok(args) => args,
|
|
Err(error) => {
|
|
send_ssh_connect_error(&mut socket, &host, backend, error.to_string()).await;
|
|
return;
|
|
}
|
|
};
|
|
let session = match tokio::task::spawn_blocking(move || start_pty("ssh", args)).await {
|
|
Ok(Ok(session)) => session,
|
|
Ok(Err(error)) => {
|
|
send_ssh_connect_error(&mut socket, &host, backend, error.to_string()).await;
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
send_ssh_connect_error(
|
|
&mut socket,
|
|
&host,
|
|
backend,
|
|
format!("SSH terminal task failed: {error}"),
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
let mut size = DEFAULT_PTY_SIZE;
|
|
let Some(status) = relay_pty(&mut socket, session, None, &mut size, None).await else {
|
|
return;
|
|
};
|
|
|
|
match status {
|
|
Ok(status) if status.success() => {}
|
|
Ok(status) => {
|
|
send_ssh_connect_error(
|
|
&mut socket,
|
|
&host,
|
|
backend,
|
|
format!("ssh {host} exited with code {}", status.exit_code()),
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
send_ssh_connect_error(&mut socket, &host, backend, error).await;
|
|
return;
|
|
}
|
|
}
|
|
|
|
let (backend_name, result, ssh_test) = match backend {
|
|
SshConnectBackend::Ssh => {
|
|
let test = probe_ssh_host_preflight(host.clone()).await;
|
|
let result = json!(&test);
|
|
("ssh", result, Some(test))
|
|
}
|
|
SshConnectBackend::Slurm => {
|
|
let result = crate::jobs::slurm::preflight(&host).await;
|
|
("slurm", slurm_preflight_value(&result), None)
|
|
}
|
|
};
|
|
if socket
|
|
.send(Message::Text(
|
|
json!({ "type": "complete", "backend": backend_name, "result": result })
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.is_ok()
|
|
{
|
|
if let Some(test) = ssh_test {
|
|
record_ssh_host_test(&test).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record a client resize message in `size`; false when it is not one.
|
|
fn apply_resize(size: &mut PtySize, text: &str) -> bool {
|
|
match serde_json::from_str(text) {
|
|
Ok(SshTerminalInput::Resize { cols, rows }) if cols > 0 && rows > 0 => {
|
|
size.rows = rows;
|
|
size.cols = cols;
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Relay one PTY session; `size` follows the client's resizes so a session
|
|
/// started afterwards can open at the terminal's real dimensions.
|
|
async fn relay_pty(
|
|
socket: &mut WebSocket,
|
|
session: PtySession,
|
|
mut output: Option<&mut String>,
|
|
size: &mut PtySize,
|
|
completed: Option<fn(&str) -> bool>,
|
|
) -> Option<std::result::Result<portable_pty::ExitStatus, String>> {
|
|
let PtySession {
|
|
master,
|
|
input,
|
|
mut events,
|
|
kill,
|
|
} = session;
|
|
let mut child = PtyChildGuard {
|
|
kill,
|
|
running: true,
|
|
};
|
|
|
|
let status = loop {
|
|
tokio::select! {
|
|
event = events.recv() => match event {
|
|
Some(PtyEvent::Output(bytes)) => {
|
|
if let Some(output) = output.as_deref_mut() { harness_setup::append_output(output, &bytes); }
|
|
if socket.send(Message::Binary(bytes.into())).await.is_err() {
|
|
return None;
|
|
}
|
|
if let (Some(completed), Some(output)) = (completed, output.as_deref()) {
|
|
if completed(output) {
|
|
return Some(Ok(portable_pty::ExitStatus::with_exit_code(0)));
|
|
}
|
|
}
|
|
}
|
|
Some(PtyEvent::Eof) => {} // EOF alone is not the child's exit status.
|
|
Some(PtyEvent::Exit(status)) => {
|
|
child.running = false;
|
|
break status;
|
|
}
|
|
None => break Err("Terminal ended without an exit status".into()),
|
|
},
|
|
message = socket.recv() => match message {
|
|
Some(Ok(Message::Binary(bytes))) => {
|
|
if input.send(bytes.to_vec()).is_err() {
|
|
break Err("Terminal input closed".into());
|
|
}
|
|
}
|
|
Some(Ok(Message::Text(text))) => {
|
|
if apply_resize(size, &text) {
|
|
let _ = master.resize(*size);
|
|
}
|
|
}
|
|
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => return None,
|
|
Some(Ok(_)) => {}
|
|
}
|
|
}
|
|
};
|
|
// The waiter and PTY reader run on separate threads. Drain the final bytes
|
|
// briefly so the last diagnostic reaches the terminal before completion.
|
|
while let Ok(Some(event)) =
|
|
tokio::time::timeout(Duration::from_millis(100), events.recv()).await
|
|
{
|
|
match event {
|
|
PtyEvent::Output(bytes) => {
|
|
if let Some(output) = output.as_deref_mut() {
|
|
harness_setup::append_output(output, &bytes);
|
|
}
|
|
if socket.send(Message::Binary(bytes.into())).await.is_err() {
|
|
return None;
|
|
}
|
|
}
|
|
PtyEvent::Eof | PtyEvent::Exit(_) => break,
|
|
}
|
|
}
|
|
|
|
Some(status)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ProjectTerminalReq {
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
async fn project_terminal(
|
|
headers: HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
Path(id): Path<String>,
|
|
Query(req): Query<ProjectTerminalReq>,
|
|
) -> Response {
|
|
if let Some(rejected) = reject_cross_origin(&headers) {
|
|
return rejected;
|
|
}
|
|
let active = Active::new();
|
|
ws.on_upgrade(move |mut socket| async move {
|
|
let _active = active;
|
|
let mut size = DEFAULT_PTY_SIZE;
|
|
let started = tokio::task::spawn_blocking(move || {
|
|
let root = project_terminal_root(&id, req.session_id.as_deref())?;
|
|
let (shell, args) = interactive_shell();
|
|
start_pty_with_env(&shell, args, &[], size, Some(&root))
|
|
})
|
|
.await
|
|
.map_err(anyhow::Error::from)
|
|
.and_then(|result| result);
|
|
let session = match started {
|
|
Ok(session) => session,
|
|
Err(error) => {
|
|
send_terminal_error(&mut socket, error).await;
|
|
return;
|
|
}
|
|
};
|
|
let Some(status) = relay_pty(&mut socket, session, None, &mut size, None).await else {
|
|
return;
|
|
};
|
|
match status {
|
|
Ok(status) => {
|
|
let message = json!({ "type": "exit", "code": status.exit_code() });
|
|
let _ = socket.send(Message::Text(message.to_string().into())).await;
|
|
}
|
|
Err(error) => send_terminal_error(&mut socket, anyhow!(error)).await,
|
|
}
|
|
})
|
|
}
|
|
|
|
/// The session worktree or, without a session, the project clone.
|
|
fn project_terminal_root(project_id: &str, session_id: Option<&str>) -> Result<std::path::PathBuf> {
|
|
let store = Store::open()?;
|
|
let project = store
|
|
.get_local_project(project_id)?
|
|
.ok_or_else(|| anyhow!("project not found"))?;
|
|
let session_id = session_id.map(str::trim).filter(|s| !s.is_empty());
|
|
let root = match session_id {
|
|
Some(session_id) => {
|
|
let session = store
|
|
.get_chat_session(session_id)?
|
|
.filter(|session| session.project_id == project.id)
|
|
.ok_or_else(|| anyhow!("chat session not found"))?;
|
|
session_checkout_root(&store, &project, &session.id)
|
|
}
|
|
None => resolve_checkout_root(&store, &project, None).map(|(root, _)| root),
|
|
};
|
|
root.map_err(|ApiError(_, message)| anyhow!(message))
|
|
}
|
|
|
|
/// The worktree the harness will create on its first turn, so a command run
|
|
/// before any message acts on the same checkout the agent sees.
|
|
fn session_checkout_root(
|
|
store: &Store,
|
|
project: &local::model::LocalProject,
|
|
session_id: &str,
|
|
) -> std::result::Result<std::path::PathBuf, ApiError> {
|
|
match local::git::ensure_session_worktree(project, session_id) {
|
|
Ok(dir) => Ok(crate::paths::canonicalize(&dir).unwrap_or(dir)),
|
|
Err(error) => {
|
|
eprintln!("orx up: session worktree unavailable, using the clone: {error}");
|
|
resolve_checkout_root(store, project, Some(session_id)).map(|(root, _)| root)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn openresearch_login(headers: HeaderMap, ws: WebSocketUpgrade) -> Response {
|
|
openresearch_terminal(headers, ws, vec!["login".into()]).await
|
|
}
|
|
|
|
async fn openresearch_ssh_key(headers: HeaderMap, ws: WebSocketUpgrade) -> Response {
|
|
openresearch_terminal(headers, ws, vec!["ssh-key".into(), "add".into()]).await
|
|
}
|
|
|
|
async fn openresearch_terminal(
|
|
headers: HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
args: Vec<String>,
|
|
) -> Response {
|
|
let program = crate::paths::spawnable_exe()
|
|
.map(|exe| exe.to_string_lossy().into_owned())
|
|
.map_err(anyhow::Error::from);
|
|
command_terminal(&headers, ws, program, args, false).await
|
|
}
|
|
|
|
/// Commands the settings page may run, keyed by the exact note text. The
|
|
/// loopback and origin guards are the security boundary (the session ends in
|
|
/// the user's shell anyway); this list only keeps the button honest.
|
|
const SETTINGS_COMMANDS: &[(&str, &[&str])] = &[
|
|
("gh auth login", &["gh", "auth", "login"]),
|
|
("hf auth login", &["hf", "auth", "login"]),
|
|
("claude auth status", &["claude", "auth", "status"]),
|
|
];
|
|
|
|
fn settings_command(command: &str) -> Option<&'static [&'static str]> {
|
|
SETTINGS_COMMANDS
|
|
.iter()
|
|
.find(|(text, _)| *text == command)
|
|
.map(|(_, argv)| *argv)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RunSettingsCommandReq {
|
|
command: String,
|
|
}
|
|
|
|
async fn run_settings_command(
|
|
headers: HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
Query(req): Query<RunSettingsCommandReq>,
|
|
) -> Response {
|
|
// Before the allowlist reply, so a cross-origin page learns nothing.
|
|
if let Some(rejected) = reject_cross_origin(&headers) {
|
|
return rejected;
|
|
}
|
|
let Some(argv) = settings_command(req.command.trim()) else {
|
|
return bad_request("command is not runnable from settings").into_response();
|
|
};
|
|
let program = local::shell_env::find_on_path(argv[0])
|
|
.map(|path| path.to_string_lossy().into_owned())
|
|
.ok_or_else(|| anyhow!("{} is not installed", argv[0]));
|
|
let args = argv[1..].iter().map(|arg| arg.to_string()).collect();
|
|
command_terminal(&headers, ws, program, args, true).await
|
|
}
|
|
|
|
/// The user's interactive login shell, so follow-up commands see the PATH a
|
|
/// fresh terminal would (an installer that just added `~/.local/bin`, say).
|
|
fn interactive_shell() -> (String, Vec<String>) {
|
|
if cfg!(windows) {
|
|
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
|
|
(shell, Vec::new())
|
|
} else {
|
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
|
|
(shell, vec!["-i".to_string(), "-l".to_string()])
|
|
}
|
|
}
|
|
|
|
fn reject_cross_origin(headers: &HeaderMap) -> Option<Response> {
|
|
(!same_origin(headers))
|
|
.then(|| ApiError(StatusCode::FORBIDDEN, "Terminal origin rejected".into()).into_response())
|
|
}
|
|
|
|
/// Run `program` in a PTY relayed over the websocket. With `shell_after`, a
|
|
/// command that ran is followed by the user's interactive shell in the same
|
|
/// terminal, at the size the client last reported.
|
|
async fn command_terminal(
|
|
headers: &HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
program: Result<String>,
|
|
args: Vec<String>,
|
|
shell_after: bool,
|
|
) -> Response {
|
|
if let Some(rejected) = reject_cross_origin(headers) {
|
|
return rejected;
|
|
}
|
|
let active = Active::new();
|
|
ws.on_upgrade(move |mut socket| async move {
|
|
let _active = active;
|
|
let mut size = DEFAULT_PTY_SIZE;
|
|
let started = match program {
|
|
Ok(program) => spawn_pty(program, args, Vec::new(), size).await,
|
|
Err(error) => Err(error),
|
|
};
|
|
let session = match started {
|
|
Ok(session) => session,
|
|
Err(error) => {
|
|
send_terminal_error(&mut socket, error).await;
|
|
return;
|
|
}
|
|
};
|
|
let Some(status) = relay_pty(&mut socket, session, None, &mut size, None).await else {
|
|
return;
|
|
};
|
|
let result = async {
|
|
let status = status.map_err(|error| anyhow!(error))?;
|
|
anyhow::ensure!(
|
|
status.success(),
|
|
"Command exited with code {}",
|
|
status.exit_code()
|
|
);
|
|
Ok::<_, anyhow::Error>(())
|
|
}
|
|
.await;
|
|
let message = match result {
|
|
Ok(()) => json!({ "type": "complete" }),
|
|
Err(error) => json!({ "type": "error", "error": error.to_string() }),
|
|
};
|
|
if socket
|
|
.send(Message::Text(message.to_string().into()))
|
|
.await
|
|
.is_err()
|
|
|| !shell_after
|
|
{
|
|
return;
|
|
}
|
|
continue_in_shell(&mut socket, &mut size, Vec::new()).await;
|
|
})
|
|
}
|
|
|
|
/// Hand the terminal to the user's interactive shell, with any env the command
|
|
/// before it needed (OpenCode's isolated store), so follow-ups land in the
|
|
/// same place.
|
|
async fn continue_in_shell(
|
|
socket: &mut WebSocket,
|
|
size: &mut PtySize,
|
|
env: Vec<(&'static str, std::ffi::OsString)>,
|
|
) {
|
|
let (shell, args) = interactive_shell();
|
|
match spawn_pty(shell, args, env, *size).await {
|
|
Ok(session) => {
|
|
relay_pty(socket, session, None, size, None).await;
|
|
}
|
|
Err(error) => send_terminal_error(socket, error).await,
|
|
}
|
|
}
|
|
|
|
async fn spawn_pty(
|
|
program: String,
|
|
args: Vec<String>,
|
|
env: Vec<(&'static str, std::ffi::OsString)>,
|
|
size: PtySize,
|
|
) -> Result<PtySession> {
|
|
tokio::task::spawn_blocking(move || start_pty_with_env(&program, args, &env, size, None))
|
|
.await?
|
|
}
|
|
|
|
async fn send_terminal_error(socket: &mut WebSocket, error: anyhow::Error) {
|
|
let _ = socket
|
|
.send(Message::Text(
|
|
json!({ "type": "error", "error": error.to_string() })
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await;
|
|
}
|
|
|
|
async fn remote_sessions(State(state): State<AppState>) -> Json<Value> {
|
|
Json(json!({ "sessions": state.remote_sessions.list().await }))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CreateRemoteSessionReq {
|
|
host: String,
|
|
#[serde(default)]
|
|
ui_preferences: crate::commands::up_remote::RemoteUiPreferences,
|
|
}
|
|
|
|
async fn create_remote_session(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CreateRemoteSessionReq>,
|
|
) -> std::result::Result<Response, ApiError> {
|
|
let host = req.host.trim().to_string();
|
|
if host.is_empty() {
|
|
return Err(bad_request("host is required"));
|
|
}
|
|
require_configured_ssh_host(&host)?;
|
|
let (created, session) = state
|
|
.remote_sessions
|
|
.create(host, req.ui_preferences, None)
|
|
.await?;
|
|
Ok((
|
|
if created {
|
|
StatusCode::ACCEPTED
|
|
} else {
|
|
StatusCode::OK
|
|
},
|
|
Json(json!(session)),
|
|
)
|
|
.into_response())
|
|
}
|
|
|
|
async fn remote_session(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
let session = state
|
|
.remote_sessions
|
|
.get(&id)
|
|
.await
|
|
.ok_or_else(|| not_found("remote session"))?;
|
|
Ok(Json(json!(session)))
|
|
}
|
|
|
|
async fn reconnect_remote_session(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> ApiResult {
|
|
Ok(Json(json!(state.remote_sessions.reconnect(&id).await?)))
|
|
}
|
|
|
|
async fn disconnect_remote_session(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> ApiResult {
|
|
Ok(Json(json!(state.remote_sessions.disconnect(&id).await?)))
|
|
}
|
|
|
|
async fn local_runtime() -> Json<Value> {
|
|
Json(json!({ "kind": "local", "version": env!("CARGO_PKG_VERSION") }))
|
|
}
|
|
|
|
async fn list_local_models() -> ApiResult {
|
|
Ok(Json(local::local_models::list()?))
|
|
}
|
|
|
|
async fn discover_local_models(Json(req): Json<local::local_models::Probe>) -> ApiResult {
|
|
let models = local::local_models::discover(&req)
|
|
.await
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
Ok(Json(json!({ "models": models })))
|
|
}
|
|
|
|
async fn check_local_model(Path(id): Path<String>) -> ApiResult {
|
|
let connection = local::local_models::read()?
|
|
.remove(&id)
|
|
.ok_or_else(|| not_found("local model connection"))?;
|
|
let models = local::local_models::discover(&local::local_models::Probe {
|
|
base_url: connection.base_url,
|
|
api_key: connection.api_key,
|
|
})
|
|
.await
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
Ok(Json(json!({ "models": models })))
|
|
}
|
|
|
|
async fn connect_local_model(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<local::local_models::Connect>,
|
|
) -> ApiResult {
|
|
let model = local::local_models::connect(req)
|
|
.await
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
*state.harnesses.lock().await = None;
|
|
Ok(Json(json!({ "model": model })))
|
|
}
|
|
|
|
async fn remove_local_model(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
local::local_models::remove(&id)?;
|
|
*state.harnesses.lock().await = None;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
// --- harnesses ---------------------------------------------------------------
|
|
|
|
const HARNESS_CACHE_TTL: Duration = Duration::from_secs(60);
|
|
|
|
/// Minimum age before a still-pending or promotion-blocked entry may re-arm a
|
|
/// catalog fill — bounds how often a non-converging state can buy a sweep.
|
|
const FILL_RETRY_FLOOR: Duration = Duration::from_secs(5);
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct HarnessQuery {
|
|
refresh: Option<u8>,
|
|
retry: Option<u8>,
|
|
}
|
|
|
|
fn overlay_claude_auth(payload: &mut Value, snapshot: &local::claude::AuthSnapshot) {
|
|
const FAILED_RECHECK_NOTE: &str = "Could not re-check Claude Code. The last verified configuration is still in use; re-check this harness.";
|
|
let Some(harnesses) = payload.get_mut("harnesses").and_then(Value::as_array_mut) else {
|
|
return;
|
|
};
|
|
let Some(claude) = harnesses
|
|
.iter_mut()
|
|
.find(|h| h.get("id").and_then(Value::as_str) == Some("claude-code"))
|
|
else {
|
|
return;
|
|
};
|
|
// Overlaying would replace the reinstall note with advice that runs the
|
|
// failing binary.
|
|
if entry_install_broken(claude) {
|
|
return;
|
|
}
|
|
// A provisional entry is never overlaid: the shared state would stamp an
|
|
// auth verdict (or a "sign in" note) the fill is about to settle, under a
|
|
// card that reads "checking" anyway.
|
|
if claude.get("catalogPending").and_then(Value::as_bool) == Some(true) {
|
|
return;
|
|
}
|
|
let entry_was_ready = claude.get("authState").and_then(Value::as_str) == Some("ready");
|
|
claude["authState"] = json!(snapshot.state);
|
|
claude["needsConfigRepair"] = json!(snapshot.credential_conflict);
|
|
claude["authCheckFailed"] = json!(snapshot.auth_check_failed);
|
|
claude["loginEligible"] = json!(
|
|
!snapshot.auth_check_failed
|
|
&& !snapshot.runtime_rejected
|
|
&& local::harness::claude::login_eligible_state(
|
|
snapshot.state,
|
|
snapshot.method,
|
|
snapshot.provider.as_deref(),
|
|
snapshot.credential_conflict,
|
|
)
|
|
);
|
|
if let Some(object) = claude.as_object_mut() {
|
|
match snapshot.method {
|
|
Some(method) => {
|
|
object.insert("authMethod".into(), json!(method));
|
|
}
|
|
None => {
|
|
object.remove("authMethod");
|
|
}
|
|
}
|
|
match &snapshot.provider {
|
|
Some(provider) => {
|
|
object.insert("authProvider".into(), json!(provider));
|
|
}
|
|
None => {
|
|
object.remove("authProvider");
|
|
}
|
|
}
|
|
if snapshot.state != local::harness::HarnessAuthState::Ready
|
|
|| snapshot.method != Some("oauth")
|
|
|| snapshot
|
|
.provider
|
|
.as_deref()
|
|
.is_some_and(|provider| provider != "firstParty")
|
|
|| !entry_was_ready
|
|
{
|
|
object.remove("account");
|
|
object.remove("org");
|
|
object.remove("plan");
|
|
}
|
|
}
|
|
if snapshot.state == local::harness::HarnessAuthState::Ready {
|
|
claude["authenticated"] = json!(true);
|
|
claude["agentReady"] = json!(true);
|
|
if snapshot.auth_check_failed {
|
|
claude["agentNote"] = json!(FAILED_RECHECK_NOTE);
|
|
} else if !entry_was_ready
|
|
|| claude.get("agentNote").and_then(Value::as_str) == Some(FAILED_RECHECK_NOTE)
|
|
{
|
|
if let Some(object) = claude.as_object_mut() {
|
|
object.remove("agentNote");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
claude["authenticated"] = json!(false);
|
|
claude["agentReady"] = json!(false);
|
|
claude["models"] = json!([]);
|
|
if snapshot.credential_conflict {
|
|
claude["agentNote"] = json!("Claude Code reports a subscription login while an Anthropic environment credential is set. Fix or unset that credential, then re-check this harness.");
|
|
return;
|
|
}
|
|
claude["agentNote"] = json!(if snapshot.runtime_rejected {
|
|
local::harness::claude::auth_recovery_note(snapshot.method, snapshot.provider.as_deref())
|
|
} else {
|
|
match snapshot.state {
|
|
local::harness::HarnessAuthState::NeedsLogin => {
|
|
if local::harness::claude::login_eligible_state(
|
|
snapshot.state,
|
|
snapshot.method,
|
|
snapshot.provider.as_deref(),
|
|
false,
|
|
) {
|
|
"Sign in with `claude auth login`, then re-check this harness."
|
|
} else {
|
|
"Check the configured Claude Code provider with `claude auth status`, then re-check this harness."
|
|
}
|
|
}
|
|
local::harness::HarnessAuthState::Unknown => {
|
|
"Open a terminal and run `claude auth status`, then re-check this harness."
|
|
}
|
|
local::harness::HarnessAuthState::Unsupported => {
|
|
"Update Claude Code to 2.1.211 or newer, then re-check this harness."
|
|
}
|
|
local::harness::HarnessAuthState::Ready => unreachable!(),
|
|
}
|
|
});
|
|
}
|
|
|
|
struct ClaudeCatalogRequest {
|
|
bin: std::path::PathBuf,
|
|
ultracode: bool,
|
|
method: Option<&'static str>,
|
|
provider: Option<String>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ClaudeCatalogQueue {
|
|
running: bool,
|
|
latest: Option<(std::time::Instant, ClaudeCatalogRequest)>,
|
|
}
|
|
|
|
fn claude_catalog_request(
|
|
harnesses: &[local::harness::HarnessInfo],
|
|
) -> Option<ClaudeCatalogRequest> {
|
|
let claude = harnesses
|
|
.iter()
|
|
.find(|h| h.id == "claude-code" && h.agent_ready)?;
|
|
if !local::harness::claude::external_provider(
|
|
claude.auth_method,
|
|
claude.auth_provider.as_deref(),
|
|
) {
|
|
return None;
|
|
}
|
|
Some(ClaudeCatalogRequest {
|
|
bin: claude.bin_path.as_ref()?.into(),
|
|
ultracode: claude.claude_ultracode,
|
|
method: claude.auth_method,
|
|
provider: claude.auth_provider.clone(),
|
|
})
|
|
}
|
|
|
|
fn enqueue_claude_catalog(
|
|
state: AppState,
|
|
cached_at: std::time::Instant,
|
|
request: ClaudeCatalogRequest,
|
|
) {
|
|
let mut queue = state.claude_catalog_queue.lock().unwrap();
|
|
queue.latest = Some((cached_at, request));
|
|
if queue.running {
|
|
return;
|
|
}
|
|
queue.running = true;
|
|
drop(queue);
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let next = {
|
|
let mut queue = state.claude_catalog_queue.lock().unwrap();
|
|
match queue.latest.take() {
|
|
Some(next) => next,
|
|
None => {
|
|
queue.running = false;
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
let (cached_at, request) = next;
|
|
let models =
|
|
local::harness::claude::external_model_catalog(request.bin, request.ultracode)
|
|
.await;
|
|
let empty = models.is_empty();
|
|
let mut cache = state.harnesses.lock().await;
|
|
let Some((at, payload)) = cache.as_mut() else {
|
|
continue;
|
|
};
|
|
if *at != cached_at {
|
|
continue;
|
|
}
|
|
let auth = state.claude.auth_snapshot();
|
|
if auth.state != local::harness::HarnessAuthState::Ready
|
|
|| auth.runtime_rejected
|
|
|| auth.method != request.method
|
|
|| auth.provider != request.provider
|
|
{
|
|
continue;
|
|
}
|
|
let Some(claude) = payload["harnesses"].as_array_mut().and_then(|all| {
|
|
all.iter_mut()
|
|
.find(|h| h["id"] == "claude-code" && h["agentReady"] == true)
|
|
}) else {
|
|
continue;
|
|
};
|
|
claude["models"] = json!(models);
|
|
if empty {
|
|
claude["agentNote"] = json!("Could not load Claude Code models. Re-check this harness or update Claude Code; the CLI default model is still available.");
|
|
} else if let Some(object) = claude.as_object_mut() {
|
|
object.remove("agentNote");
|
|
}
|
|
drop(cache);
|
|
state.chat.emit_event("harness.catalog", json!({}));
|
|
}
|
|
});
|
|
}
|
|
|
|
fn claude_entry(payload: &Value) -> Option<&Value> {
|
|
payload
|
|
.get("harnesses")?
|
|
.as_array()?
|
|
.iter()
|
|
.find(|h| h.get("id").and_then(Value::as_str) == Some("claude-code"))
|
|
}
|
|
|
|
fn entry_install_broken(entry: &Value) -> bool {
|
|
entry.get("installBroken").and_then(Value::as_bool) == Some(true)
|
|
}
|
|
|
|
/// A claude the cached entry must never be restored over: one that can't run,
|
|
/// or isn't there at all. Both would resurrect `agentReady` for a binary no
|
|
/// turn can spawn.
|
|
fn claude_unspawnable(payload: &Value) -> bool {
|
|
claude_entry(payload).is_none_or(|claude| {
|
|
entry_install_broken(claude)
|
|
|| claude.get("installed").and_then(Value::as_bool) != Some(true)
|
|
})
|
|
}
|
|
|
|
fn payload_has_ready_claude(payload: &Value) -> bool {
|
|
claude_entry(payload)
|
|
.and_then(|claude| claude.get("agentReady"))
|
|
.and_then(Value::as_bool)
|
|
== Some(true)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn claude_entry_pending(payload: &Value) -> bool {
|
|
claude_entry(payload)
|
|
.and_then(|claude| claude.get("catalogPending"))
|
|
.and_then(Value::as_bool)
|
|
== Some(true)
|
|
}
|
|
|
|
async fn harness_snapshot(Path(id): Path<String>) -> ApiResult {
|
|
let info = local::harness::detect_harness_snapshot(&id)
|
|
.await
|
|
.ok_or_else(|| not_found("harness"))?;
|
|
Ok(Json(json!(info)))
|
|
}
|
|
|
|
async fn list_harnesses(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<HarnessQuery>,
|
|
) -> Json<Value> {
|
|
Json(harnesses_payload(&state, &q).await)
|
|
}
|
|
|
|
/// The shared detection path behind `GET /api/harnesses` and the startup
|
|
/// preflight: the snapshot pass runs under the cache lock, so whoever arrives
|
|
/// first does the probing and everyone else in the TTL window reads the same
|
|
/// entry — no duplicate sweep at boot. `refresh=1` and the background fill
|
|
/// detect before taking the lock, so a slow probe never stalls a reader.
|
|
///
|
|
/// A plain request answers with the snapshot pass — install/auth/readiness,
|
|
/// without the model-catalog children that made cold calls take seconds — and
|
|
/// a background fill replaces the entry with the full catalog, then tells the
|
|
/// dashboard to re-read it. `refresh=1` still runs the full probe inline: an
|
|
/// explicit re-check wants the real answer, and the UI shows its own spinner.
|
|
async fn harnesses_payload(state: &AppState, q: &HarnessQuery) -> Value {
|
|
// `retry` only escalates to a full detect when it actually cleared a
|
|
// runtime rejection — a bare retry is an ordinary cached read.
|
|
let retried = q.retry == Some(1) && state.claude.clear_runtime_rejection();
|
|
if q.refresh == Some(1) || retried {
|
|
// An explicit re-check detects outside the cache lock: a full sweep
|
|
// takes seconds and must not stall every ordinary read queued behind
|
|
// it. Last writer wins — concurrent refreshes may duplicate a sweep.
|
|
state.claude.reserve_auth_check();
|
|
let harnesses = local::harness::detect_harnesses().await;
|
|
let catalog = claude_catalog_request(&harnesses);
|
|
let (mut payload, announce) = finish_harnesses_payload(state, harnesses, false).await;
|
|
let cached_at = std::time::Instant::now();
|
|
spawn_cursor_account_details(state, &mut payload, cached_at);
|
|
*state.harnesses.lock().await = Some((cached_at, payload.clone()));
|
|
if let Some(catalog) = catalog {
|
|
enqueue_claude_catalog(state.clone(), cached_at, catalog);
|
|
}
|
|
emit_auth_announcement(state, announce);
|
|
return payload;
|
|
}
|
|
let mut cache = state.harnesses.lock().await;
|
|
if let Some((at, payload)) = cache.as_ref() {
|
|
let auth = state.claude.auth_snapshot();
|
|
// Stale-while-revalidate serves the last answer while a fill
|
|
// refreshes it — re-snapshotting on every TTL tick would flap
|
|
// every card back to "checking". A provisional entry also asks
|
|
// for a fill: the single-flight flag makes that idempotent, and
|
|
// it re-arms a fill that lost the `snapshot_at` race so a
|
|
// provisional payload can't sit unanswered for a whole TTL. The
|
|
// same covers a live-verified login promotion (shared auth says
|
|
// Ready but the payload's claude isn't).
|
|
let promotable = auth.state == local::harness::HarnessAuthState::Ready
|
|
&& !payload_has_ready_claude(payload);
|
|
// Provisional/promotable re-arms carry a floor: a state that cannot
|
|
// converge (e.g. Ready auth over a broken install) must not buy a
|
|
// full sweep on every read.
|
|
let wants_fill = payload_is_provisional(payload) || promotable;
|
|
if at.elapsed() >= HARNESS_CACHE_TTL || (wants_fill && at.elapsed() >= FILL_RETRY_FLOOR) {
|
|
spawn_catalog_fill(state.clone(), *at, uuid::Uuid::new_v4());
|
|
}
|
|
let mut out = payload.clone();
|
|
overlay_claude_auth(&mut out, &auth);
|
|
return out;
|
|
}
|
|
seed_harnesses_locked(state, &mut cache).await
|
|
}
|
|
|
|
/// The spawn-free snapshot → provisional cache → background fill sequence,
|
|
/// shared by the first `/api/harnesses` request and the startup seed. The
|
|
/// boot call starts the fill's heavyweight probes during the user's first
|
|
/// page load instead of inside it — each spawn costs seconds on Windows, so
|
|
/// where the clock starts is most of the difference. Caller holds the
|
|
/// `harnesses` lock.
|
|
async fn seed_harnesses_locked(
|
|
state: &AppState,
|
|
cache: &mut Option<(std::time::Instant, Value)>,
|
|
) -> Value {
|
|
let started = std::time::Instant::now();
|
|
let harnesses = local::harness::detect_harnesses_snapshot().await;
|
|
let installed = harnesses.iter().filter(|h| h.installed).count();
|
|
// The snapshot row and the fill it seeds share one fillId so a boot's two
|
|
// passes join on it.
|
|
let fill_id = uuid::Uuid::new_v4();
|
|
let snapshot_ms = started.elapsed().as_millis() as u64;
|
|
let (mut payload, _) = finish_harnesses_payload(state, harnesses, true).await;
|
|
let cached_at = std::time::Instant::now();
|
|
spawn_cursor_account_details(state, &mut payload, cached_at);
|
|
*cache = Some((cached_at, payload.clone()));
|
|
// Off the lock-held request path and off a runtime worker: settings
|
|
// load + outbox persist are sync IO on the same filesystems this pass
|
|
// exists to stop blocking on.
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::telemetry::harness::capture_detect(
|
|
fill_id,
|
|
"snapshot",
|
|
snapshot_ms,
|
|
None,
|
|
installed,
|
|
0,
|
|
Vec::new(),
|
|
);
|
|
});
|
|
spawn_catalog_fill(state.clone(), cached_at, fill_id);
|
|
payload
|
|
}
|
|
|
|
/// The post-detection half of the harness answer: reconcile the result with
|
|
/// the ClaudeHost's tracked auth state, then overlay and report. Shared by
|
|
/// the snapshot and full passes, and by the background catalog fill.
|
|
///
|
|
/// `provisional` marks a discovery-only snapshot. Only full passes publish
|
|
/// live Claude auth observations and first-install telemetry.
|
|
/// The returned `Option` is a claimed auth announcement: the caller emits it
|
|
/// only after committing the payload, so a discarded pass never sends the
|
|
/// dashboard on a `refresh=1` sweep for results nobody will see.
|
|
async fn finish_harnesses_payload(
|
|
state: &AppState,
|
|
harnesses: Vec<local::harness::HarnessInfo>,
|
|
provisional: bool,
|
|
) -> (Value, Option<local::claude::AuthSnapshot>) {
|
|
if !provisional {
|
|
if let Some(probe) = harnesses
|
|
.iter()
|
|
.find(|h| h.id == "claude-code")
|
|
.and_then(|h| h.auth_observation.as_ref())
|
|
{
|
|
state.claude.observe_auth_probe(probe);
|
|
}
|
|
}
|
|
let mut payload = json!({ "harnesses": harnesses });
|
|
let mut snapshot = state.claude.auth_snapshot();
|
|
// A claude that broke mid-session leaves the snapshot `Ready` — `detect`
|
|
// skips the probe, and `observe_auth_state` won't downgrade on `Unknown` —
|
|
// so drop it here instead, or the resurrection below would restore
|
|
// `agentReady` for a binary that cannot start.
|
|
if snapshot.state == local::harness::HarnessAuthState::Ready && claude_unspawnable(&payload) {
|
|
state.claude.defer_auth_verification(snapshot.generation);
|
|
snapshot = state.claude.auth_snapshot();
|
|
}
|
|
// A provisional pass must not announce: `harness.auth` makes the dashboard
|
|
// call `refreshHarnesses(true)`, an inline full sweep — the stall this
|
|
// whole design exists to remove — fired by the snapshot's own adoption.
|
|
// A real pass hands its auth snapshot back unclaimed; the caller claims at
|
|
// emit time, after the payload commits, so a pass that loses the cache
|
|
// race never burns a generation it can't announce.
|
|
let announce = (!provisional).then_some(snapshot.clone());
|
|
overlay_claude_auth(&mut payload, &snapshot);
|
|
if !provisional {
|
|
crate::telemetry::harness::capture_initial(&payload);
|
|
}
|
|
(payload, announce)
|
|
}
|
|
|
|
/// Emit `harness.auth` for a generation this pass is entitled to announce.
|
|
/// The claim happens here — not in `finish_harnesses_payload` — so the
|
|
/// generation is only consumed when the event actually goes out.
|
|
fn emit_auth_announcement(state: &AppState, announce: Option<local::claude::AuthSnapshot>) {
|
|
if let Some(snap) = announce {
|
|
if state.claude.claim_auth_announcement(snap.generation) {
|
|
state.chat.emit_event(
|
|
"harness.auth",
|
|
json!({ "harness": "claude-code", "authState": snap.state }),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True while any entry still waits on the background catalog fill.
|
|
fn payload_is_provisional(payload: &Value) -> bool {
|
|
payload["harnesses"].as_array().is_some_and(|all| {
|
|
all.iter()
|
|
.any(|h| h["catalogPending"].as_bool() == Some(true))
|
|
})
|
|
}
|
|
|
|
/// Cursor's account lookup is a second child process deferred off the
|
|
/// detection answer; it patches the cache entry it was scheduled against.
|
|
fn spawn_cursor_account_details(
|
|
state: &AppState,
|
|
payload: &mut Value,
|
|
cached_at: std::time::Instant,
|
|
) {
|
|
// A provisional cursor entry keeps its lookup for the fill: spawning
|
|
// `<binPath> about` here would put a child process on the path that exists
|
|
// to avoid them — and the snapshot's binPath is only a first-candidate
|
|
// guess.
|
|
let cursor = payload["harnesses"].as_array_mut().and_then(|items| {
|
|
items.iter_mut().find(|h| {
|
|
h["id"] == "cursor"
|
|
&& h["authenticated"] == true
|
|
&& h["catalogPending"].as_bool() != Some(true)
|
|
})
|
|
});
|
|
if let Some(cursor) = cursor {
|
|
if let Some(bin) = cursor["binPath"].as_str().map(std::path::PathBuf::from) {
|
|
cursor["accountLoading"] = json!(true);
|
|
let cache = state.harnesses.clone();
|
|
tokio::spawn(async move {
|
|
let details = local::harness::cursor::account_details(&bin).await;
|
|
let mut cache = cache.lock().await;
|
|
let Some((at, payload)) = cache.as_mut() else {
|
|
return;
|
|
};
|
|
// A newer detection owns its own account lookup.
|
|
if *at != cached_at {
|
|
return;
|
|
}
|
|
let Some(cursor) = payload["harnesses"]
|
|
.as_array_mut()
|
|
.and_then(|items| items.iter_mut().find(|h| h["id"] == "cursor"))
|
|
else {
|
|
return;
|
|
};
|
|
cursor["accountLoading"] = json!(false);
|
|
if let Some(details) = details {
|
|
for (source, target) in [("userEmail", "account"), ("subscriptionTier", "plan")]
|
|
{
|
|
if let Some(value) =
|
|
details[source].as_str().filter(|value| !value.is_empty())
|
|
{
|
|
cursor[target] = json!(value);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Complete the catalog a snapshot answer deferred: the full sweep runs off
|
|
/// the request path, replaces the cache entry it was scheduled against, and
|
|
/// the `harness.catalog` event tells the dashboard to re-read it. `snapshot_at`
|
|
/// guards the swap — a refresh or retry that landed in between owns the cache.
|
|
/// One fill at a time: expired reads each ask for one, but a second sweep
|
|
/// would only lose the `snapshot_at` race and throw its probes away.
|
|
fn spawn_catalog_fill(state: AppState, snapshot_at: std::time::Instant, fill_id: uuid::Uuid) {
|
|
if state
|
|
.harness_fill_in_flight
|
|
.swap(true, std::sync::atomic::Ordering::SeqCst)
|
|
{
|
|
return;
|
|
}
|
|
state.claude.reserve_auth_check();
|
|
let probe_timings = local::harness::ProbeTimingSink::default();
|
|
tokio::spawn(local::harness::probe_timing_scope(
|
|
probe_timings.clone(),
|
|
async move {
|
|
let _fill = FillGuard(state.harness_fill_in_flight.clone());
|
|
let started = std::time::Instant::now();
|
|
// Commit each harness as its probes land: the onboarding gate is
|
|
// per-entry (`agentReady && !catalogPending`), so a ready agent must
|
|
// not wait for the slowest sibling's catalog child. The batch swap at
|
|
// the end still applies the cross-entry reconciliation
|
|
// (`finish_harnesses_payload`) to the complete set.
|
|
let mut stream = std::pin::pin!(local::harness::detect_harnesses_each());
|
|
let mut ordered = Vec::new();
|
|
// `first_ready_ms` answers the churn question directly: how far into
|
|
// the fill did a usable agent actually publish.
|
|
let mut first_ready_ms = None;
|
|
while let Some((index, info)) = futures::StreamExt::next(&mut stream).await {
|
|
// A progressive claude entry settles shared auth *now*: reads
|
|
// overlay that state onto every non-pending entry, so a Ready
|
|
// probe left unobserved until fill end would be stamped back to
|
|
// Unknown on each answer — the stall this loop exists to remove.
|
|
if info.id == "claude-code" {
|
|
if let Some(probe) = info.auth_observation.as_ref() {
|
|
state.claude.observe_auth_probe(probe);
|
|
}
|
|
}
|
|
let mut published = false;
|
|
{
|
|
let mut cache = state.harnesses.lock().await;
|
|
// Same guard as the final swap: a refresh or retry that
|
|
// landed in between owns the cache.
|
|
if let Some((at, payload)) = cache.as_mut() {
|
|
if *at == snapshot_at {
|
|
if let Some(slot) =
|
|
payload["harnesses"].as_array_mut().and_then(|all| {
|
|
all.iter_mut().find(|h| h["id"].as_str() == Some(info.id))
|
|
})
|
|
{
|
|
*slot = json!(info);
|
|
published = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// A superseded fill publishes nothing and must not announce: the
|
|
// pass that owns the cache emits its own catalog events.
|
|
if !published {
|
|
ordered.push((index, info));
|
|
continue;
|
|
}
|
|
state.chat.emit_event("harness.catalog", json!({}));
|
|
// For claude, "ready" means the overlay won't stamp it back down
|
|
// — its committed `agentReady` only stands once shared auth is
|
|
// Ready (the observation above).
|
|
if first_ready_ms.is_none()
|
|
&& info.agent_ready
|
|
&& (info.id != "claude-code"
|
|
|| state.claude.auth_snapshot().state
|
|
== local::harness::HarnessAuthState::Ready)
|
|
{
|
|
first_ready_ms = Some(started.elapsed().as_millis() as u64);
|
|
}
|
|
ordered.push((index, info));
|
|
}
|
|
ordered.sort_by_key(|(index, _)| *index);
|
|
let harnesses: Vec<local::harness::HarnessInfo> =
|
|
ordered.into_iter().map(|(_, info)| info).collect();
|
|
let catalog = claude_catalog_request(&harnesses);
|
|
// Finish before touching the cache: the Claude re-probe inside can
|
|
// cost a child process, and holding the lock across it stalls every
|
|
// reader the snapshot was meant to unblock.
|
|
let (mut payload, announce) = finish_harnesses_payload(&state, harnesses, false).await;
|
|
// The reconciled payload is authoritative — overlay applied — so
|
|
// readiness is counted from it, not the raw probes.
|
|
let installed = payload["harnesses"]
|
|
.as_array()
|
|
.map(|all| all.iter().filter(|h| h["installed"] == true).count())
|
|
.unwrap_or(0);
|
|
let ready = payload["harnesses"]
|
|
.as_array()
|
|
.map(|all| {
|
|
all.iter()
|
|
.filter(|h| h["agentReady"] == true && h["catalogPending"] != true)
|
|
.count()
|
|
})
|
|
.unwrap_or(0);
|
|
let mut catalog_at = None;
|
|
let committed = {
|
|
let mut cache = state.harnesses.lock().await;
|
|
// A refresh or retry that landed in between owns the cache. No
|
|
// event: clients holding a provisional payload already poll it at
|
|
// 1 Hz, and the discarded pass must not announce auth it never
|
|
// committed.
|
|
if !matches!(cache.as_ref(), Some((at, _)) if *at == snapshot_at) {
|
|
false
|
|
} else {
|
|
// Readiness can be *established* by the finish (a Ready
|
|
// shared-auth retry that ran no stream probe) — count it, but
|
|
// only once publication is confirmed: a superseded fill
|
|
// reports its probe timings with no invented readiness mark.
|
|
if first_ready_ms.is_none() && ready > 0 {
|
|
first_ready_ms = Some(started.elapsed().as_millis() as u64);
|
|
}
|
|
let filled_at = std::time::Instant::now();
|
|
spawn_cursor_account_details(&state, &mut payload, filled_at);
|
|
*cache = Some((filled_at, payload));
|
|
catalog_at = Some(filled_at);
|
|
true
|
|
}
|
|
};
|
|
// Emit regardless of the race outcome: a fill that lost it still ran
|
|
// real probes, and its timings are real data. Settings load +
|
|
// outbox persist are sync IO — keep them off the runtime workers.
|
|
let timings = std::mem::take(
|
|
&mut *probe_timings
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner()),
|
|
);
|
|
let duration_ms = started.elapsed().as_millis() as u64;
|
|
tokio::task::spawn_blocking(move || {
|
|
crate::telemetry::harness::capture_detect(
|
|
fill_id,
|
|
"full",
|
|
duration_ms,
|
|
first_ready_ms,
|
|
installed,
|
|
ready,
|
|
timings,
|
|
);
|
|
});
|
|
if !committed {
|
|
return;
|
|
}
|
|
if let (Some(catalog), Some(at)) = (catalog, catalog_at) {
|
|
enqueue_claude_catalog(state.clone(), at, catalog);
|
|
}
|
|
state.chat.emit_event("harness.catalog", json!({}));
|
|
emit_auth_announcement(&state, announce);
|
|
},
|
|
));
|
|
}
|
|
|
|
/// Clears the single-flight flag however the fill task exits once polled —
|
|
/// including the superseded-swap early return and a dropped future. (A task
|
|
/// dropped before its first poll leaks the flag, but that only happens at
|
|
/// runtime shutdown, where no fill will ever be wanted again.)
|
|
struct FillGuard(Arc<std::sync::atomic::AtomicBool>);
|
|
|
|
impl Drop for FillGuard {
|
|
fn drop(&mut self) {
|
|
self.0.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
// --- chat --------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SessionsQuery {
|
|
project_id: Option<String>,
|
|
/// `all` is the composer's `/resume` picker, which spans every project.
|
|
/// Spelled out so a dropped `projectId` cannot silently widen the scope.
|
|
scope: Option<String>,
|
|
}
|
|
|
|
async fn list_chat_sessions(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<SessionsQuery>,
|
|
) -> ApiResult {
|
|
let store = Store::open()?;
|
|
let sessions = match (q.project_id.as_deref(), q.scope.as_deref()) {
|
|
(Some(project_id), _) => store.list_chat_sessions_by_project(project_id)?,
|
|
(None, Some("all")) => store.list_all_chat_sessions()?,
|
|
(None, _) => return Err(bad_request("projectId or scope=all is required")),
|
|
};
|
|
let busy = state.chat.busy_sessions().await;
|
|
let sessions: Vec<Value> = sessions
|
|
.iter()
|
|
.map(|s| local::chat::session_json(s, busy.contains(&s.id)))
|
|
.collect();
|
|
Ok(Json(json!({ "sessions": sessions })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CreateChatSessionReq {
|
|
project_id: String,
|
|
harness: String,
|
|
model: Option<String>,
|
|
service_tier: Option<String>,
|
|
permission_mode: Option<String>,
|
|
#[serde(default)]
|
|
plan_mode: bool,
|
|
reasoning_level: Option<String>,
|
|
}
|
|
|
|
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)));
|
|
}
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&req.project_id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
let store = Store::open()?;
|
|
store
|
|
.get_local_project(&req.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let nonempty = |s: Option<String>| s.filter(|v| !v.trim().is_empty());
|
|
let permission_mode = nonempty(req.permission_mode);
|
|
let service_tier = nonempty(req.service_tier);
|
|
if permission_mode
|
|
.as_deref()
|
|
.is_some_and(|mode| local::harness::permission_mode_for(&req.harness, mode).is_none())
|
|
{
|
|
return Err(bad_request("invalid permission mode for selected harness"));
|
|
}
|
|
if service_tier
|
|
.as_deref()
|
|
.is_some_and(|tier| local::harness::service_tier_for(&req.harness, tier).is_none())
|
|
{
|
|
return Err(bad_request("invalid speed for selected harness"));
|
|
}
|
|
if req.plan_mode && !local::harness::supports_command_plan(&req.harness) {
|
|
return Err(bad_request(
|
|
"this harness activates Plan through permissions",
|
|
));
|
|
}
|
|
let session = StoredChatSession {
|
|
id: format!("chat_{}", uuid::Uuid::new_v4()),
|
|
project_id: req.project_id,
|
|
harness: req.harness,
|
|
native_session_id: None,
|
|
title: None,
|
|
title_source: None,
|
|
model: nonempty(req.model),
|
|
service_tier,
|
|
permission_mode,
|
|
plan_mode: req.plan_mode,
|
|
plan_reset_pending: false,
|
|
reasoning_level: nonempty(req.reasoning_level),
|
|
archived: false,
|
|
context_usage_json: None,
|
|
bootstrap_context: None,
|
|
goal: None,
|
|
active_leaf_id: None,
|
|
parent_session_id: None,
|
|
created_at: now_ms(),
|
|
updated_at: now_ms(),
|
|
};
|
|
store.create_chat_session(&session)?;
|
|
Ok(Json(
|
|
json!({ "session": local::chat::session_json(&session, false) }),
|
|
))
|
|
}
|
|
|
|
/// Chats the user had in an agent's own CLI, for the composer's `/resume`
|
|
/// picker. Reading them walks the agent's store, so it stays off the executor.
|
|
async fn list_native_chats() -> ApiResult {
|
|
let chats = tokio::task::spawn_blocking(|| {
|
|
let owned = Store::open()?.native_session_ids()?;
|
|
Ok::<_, crate::error::Error>(local::native_chats::list(
|
|
&owned,
|
|
local::native_chats::LISTING_LIMIT,
|
|
))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("native chat scan failed: {e}")))??;
|
|
let chats: Vec<Value> = chats
|
|
.iter()
|
|
.map(|chat| {
|
|
json!({
|
|
"harness": chat.harness,
|
|
"nativeId": chat.native_id,
|
|
"title": chat.title,
|
|
"cwd": chat.cwd,
|
|
"updatedAt": chat.updated_at,
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(Json(json!({ "chats": chats })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ImportNativeChatReq {
|
|
project_id: String,
|
|
harness: String,
|
|
native_id: String,
|
|
title: Option<String>,
|
|
}
|
|
|
|
/// Adopt one of those chats: a session bound to the agent's own id, with the
|
|
/// transcript backfilled so it does not open blank. Turns continue in the
|
|
/// agent's real home, since that is where the id resolves.
|
|
async fn import_native_chat(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<ImportNativeChatReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
if !local::native_chats::is_importable(&req.harness) {
|
|
return Err(bad_request("that agent's chats cannot be adopted"));
|
|
}
|
|
// As create does: a project whose delete is in flight takes no new sessions.
|
|
let _admission = state
|
|
.project_lifecycle
|
|
.admit(&req.project_id)
|
|
.ok_or_else(|| bad_request("project deletion is in progress"))?;
|
|
let session = tokio::task::spawn_blocking(move || {
|
|
local::chat::import_native_chat(
|
|
&Store::open()?,
|
|
&req.project_id,
|
|
&req.harness,
|
|
&req.native_id,
|
|
req.title,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("import failed: {e}")))??;
|
|
let session = state
|
|
.chat
|
|
.emit_session(Some(session))
|
|
.await
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
Ok(Json(
|
|
json!({ "session": local::chat::session_json(&session, false) }),
|
|
))
|
|
}
|
|
|
|
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 })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct UpdateChatSessionReq {
|
|
archived: Option<bool>,
|
|
title: Option<String>,
|
|
plan_mode: Option<bool>,
|
|
permission_mode: Option<String>,
|
|
/// Present-and-null clears the goal, which is why it is doubly wrapped.
|
|
#[serde(default, deserialize_with = "present_nullable_string")]
|
|
goal: Option<Option<String>>,
|
|
}
|
|
|
|
async fn update_chat_session(
|
|
State(state): State<AppState>,
|
|
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() {
|
|
return Err(bad_request("title cannot be empty"));
|
|
}
|
|
state
|
|
.chat
|
|
.set_title(&id, title)
|
|
.await?
|
|
.ok_or_else(|| not_found("chat session"))?
|
|
} else if let Some(archived) = req.archived {
|
|
state
|
|
.chat
|
|
.set_archived(&id, archived)
|
|
.await?
|
|
.ok_or_else(|| not_found("chat session"))?
|
|
} else if let Some(plan_mode) = req.plan_mode {
|
|
state
|
|
.chat
|
|
.set_plan_mode(&id, plan_mode)
|
|
.await?
|
|
.ok_or_else(|| not_found("chat session"))?
|
|
} else if let Some(goal) = req.goal {
|
|
let goal = goal
|
|
.map(|goal| goal.trim().to_string())
|
|
.filter(|goal| !goal.is_empty());
|
|
state
|
|
.chat
|
|
.set_goal(&id, goal.as_deref())
|
|
.await?
|
|
.ok_or_else(|| not_found("chat session"))?
|
|
} else if let Some(permission_mode) = req.permission_mode {
|
|
state
|
|
.chat
|
|
.set_permission_mode(&id, &permission_mode)
|
|
.await?
|
|
.ok_or_else(|| not_found("chat session"))?
|
|
} else {
|
|
return Err(bad_request("nothing to update"));
|
|
};
|
|
let busy = state.chat.is_busy(&id).await;
|
|
Ok(Json(
|
|
json!({ "session": local::chat::session_json(&session, busy) }),
|
|
))
|
|
}
|
|
|
|
async fn chat_messages(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
// Messages first: these are two connections, so a turn writing between them
|
|
// can only leave the leaf *ahead* of the list, which the client falls back on
|
|
// safely — the other order hides a reply that is already in the list.
|
|
let messages = local::chat::list_messages(&id)?;
|
|
let session = Store::open()?
|
|
.get_chat_session(&id)?
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
// The host restores its durable queue at startup; return the live snapshot
|
|
// so dispatch progress and cancellation are reflected immediately.
|
|
let queued = state.chat.queued_items(&id);
|
|
Ok(Json(json!({
|
|
"messages": messages,
|
|
"queued": queued,
|
|
"activeLeafId": session.active_leaf_id,
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SendChatReq {
|
|
text: String,
|
|
client_turn_id: Option<String>,
|
|
#[serde(default, deserialize_with = "present_nullable_string")]
|
|
model: Option<Option<String>>,
|
|
service_tier: Option<String>,
|
|
permission_mode: Option<String>,
|
|
plan_mode: Option<bool>,
|
|
reasoning_level: Option<String>,
|
|
#[serde(default)]
|
|
images: Vec<local::chat::ImageAttachment>,
|
|
#[serde(default)]
|
|
annotations: Vec<local::chat::TextAnnotation>,
|
|
/// `"steer"` hands the message to a turn already running; omitted (an
|
|
/// older client) keeps the parked-queue path.
|
|
mode: Option<SendMode>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum SendMode {
|
|
Steer,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ShellCommandReq {
|
|
command: String,
|
|
}
|
|
|
|
/// Runs a composer `!` command where the agent works and records it as a
|
|
/// user-side message. Refused while a turn runs: a message landing mid-stream
|
|
/// would sit between the turn's own messages and never reach the agent. (A
|
|
/// turn started during the command itself is not caught; the card still shows.)
|
|
async fn run_shell_command(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<ShellCommandReq>,
|
|
) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
let command = req.command.trim().to_string();
|
|
if command.is_empty() {
|
|
return Err(bad_request("command is required"));
|
|
}
|
|
if state.chat.is_busy(&id).await {
|
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
|
}
|
|
let session_id = id.clone();
|
|
let root = tokio::task::spawn_blocking(move || {
|
|
let store = Store::open()?;
|
|
let session = store
|
|
.get_chat_session(&session_id)?
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
let project = store
|
|
.get_local_project(&session.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
session_checkout_root(&store, &project, &session.id)
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("shell task failed: {e}")))??;
|
|
let message = state
|
|
.chat
|
|
.run_shell_command(&id, command, root)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(Json(json!({ "message": message })))
|
|
}
|
|
|
|
async fn compact_chat_session(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
if state.chat.is_busy(&id).await {
|
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
|
}
|
|
let probe = id.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
Store::open()?
|
|
.get_chat_session(&probe)?
|
|
.ok_or_else(|| not_found("chat session"))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("compact task failed: {e}")))??;
|
|
let message = state
|
|
.chat
|
|
.compact_session(&id)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
Ok(Json(json!({ "message": message })))
|
|
}
|
|
|
|
async fn send_chat_message(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<SendChatReq>,
|
|
) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
let store = Store::open()?;
|
|
let session = store
|
|
.get_chat_session(&id)?
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
let project = store
|
|
.get_local_project(&session.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let harness = session.harness;
|
|
let slash_skills = local::chat::builtin_slash_skill_names(&project, &req.text);
|
|
let text = req.text.trim().to_string();
|
|
let annotations = req
|
|
.annotations
|
|
.into_iter()
|
|
.filter(|annotation| !annotation.text.trim().is_empty())
|
|
.collect::<Vec<_>>();
|
|
if text.is_empty() && req.images.is_empty() && annotations.is_empty() {
|
|
return Err(bad_request("text is required"));
|
|
}
|
|
let overrides = local::chat::TurnOverrides {
|
|
clear_model: req.model == Some(None),
|
|
model: req.model.flatten(),
|
|
service_tier: req.service_tier,
|
|
permission_mode: req.permission_mode,
|
|
permission_revision: None,
|
|
plan_mode: req.plan_mode,
|
|
plan_revision: None,
|
|
reasoning_level: req.reasoning_level,
|
|
};
|
|
// The turn runs in the background; progress streams over /api/events.
|
|
let (response, capture_message) = if matches!(req.mode, Some(SendMode::Steer)) {
|
|
let result = state
|
|
.chat
|
|
.steer_message(
|
|
&id,
|
|
text,
|
|
overrides,
|
|
req.images,
|
|
annotations,
|
|
req.client_turn_id,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
if local::chat::is_client_turn_conflict(&error) {
|
|
ApiError(StatusCode::CONFLICT, error.to_string())
|
|
} else {
|
|
bad_request(error)
|
|
}
|
|
})?;
|
|
match result {
|
|
Some(turn) => {
|
|
let capture = !turn.existing;
|
|
(json!({ "ok": true, "turn": turn }), capture)
|
|
}
|
|
None => (json!({ "ok": true, "steered": true }), true),
|
|
}
|
|
} else {
|
|
let result = state
|
|
.chat
|
|
.send_message(
|
|
&id,
|
|
text,
|
|
overrides,
|
|
req.images,
|
|
annotations,
|
|
req.client_turn_id,
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
if local::chat::is_client_turn_conflict(&error) {
|
|
ApiError(StatusCode::CONFLICT, error.to_string())
|
|
} else {
|
|
bad_request(error)
|
|
}
|
|
})?;
|
|
let capture = !result.existing;
|
|
(json!({ "ok": true, "turn": result }), capture)
|
|
};
|
|
if capture_message {
|
|
crate::telemetry::capture_chat_message_sent(&harness);
|
|
for skill in slash_skills {
|
|
crate::telemetry::capture_skill_invoked(skill, "slash", Some(&harness));
|
|
}
|
|
}
|
|
Ok(Json(response))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RecoverChatReq {
|
|
action: String,
|
|
#[serde(default, deserialize_with = "present_nullable_string")]
|
|
model: Option<Option<String>>,
|
|
#[serde(default, deserialize_with = "present_nullable_string")]
|
|
service_tier: Option<Option<String>>,
|
|
#[serde(default, deserialize_with = "present_nullable_string")]
|
|
permission_mode: Option<Option<String>>,
|
|
plan_mode: Option<bool>,
|
|
#[serde(default, deserialize_with = "present_nullable_string")]
|
|
reasoning_level: Option<Option<String>>,
|
|
}
|
|
|
|
fn present_nullable_string<'de, D>(
|
|
deserializer: D,
|
|
) -> std::result::Result<Option<Option<String>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Option::<String>::deserialize(deserializer).map(Some)
|
|
}
|
|
|
|
async fn recover_chat_turn(
|
|
State(state): State<AppState>,
|
|
Path((id, turn_id)): Path<(String, String)>,
|
|
Json(req): Json<RecoverChatReq>,
|
|
) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
let result = state
|
|
.chat
|
|
.recover_turn(
|
|
&id,
|
|
&turn_id,
|
|
&req.action,
|
|
local::chat::RecoveryOverrides {
|
|
model: req.model,
|
|
service_tier: req.service_tier,
|
|
permission_mode: req.permission_mode,
|
|
plan_mode: req.plan_mode,
|
|
reasoning_level: req.reasoning_level,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|error| ApiError(StatusCode::CONFLICT, error.to_string()))?;
|
|
Ok(Json(json!({ "ok": true, "turn": result })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ForkChatReq {
|
|
/// The message being re-sampled: an assistant reply to retry, or a user
|
|
/// message to re-ask with `text`.
|
|
message_id: String,
|
|
/// Edited prompt. Absent re-sends the original message unchanged.
|
|
text: Option<String>,
|
|
}
|
|
|
|
async fn fork_chat_turn(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<ForkChatReq>,
|
|
) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
let edit_text = match req.text {
|
|
Some(text) if !text.trim().is_empty() => Some(text),
|
|
Some(_) => return Err(bad_request("text is required")),
|
|
None => None,
|
|
};
|
|
let invocation = if let Some(text) = edit_text.as_deref() {
|
|
let store = Store::open()?;
|
|
let session = store
|
|
.get_chat_session(&id)?
|
|
.ok_or_else(|| not_found("chat session"))?;
|
|
let project = store
|
|
.get_local_project(&session.project_id)?
|
|
.ok_or_else(|| not_found("project"))?;
|
|
let skills = local::chat::builtin_slash_skill_names(&project, text);
|
|
Some((session.harness, skills))
|
|
} else {
|
|
None
|
|
};
|
|
let kind = edit_text
|
|
.map(local::chat::ForkKind::Edit)
|
|
.unwrap_or(local::chat::ForkKind::Retry);
|
|
// A fork re-samples under the session's current settings, so it takes no
|
|
// overrides — the turn runs in the background and streams over /api/events.
|
|
let started = state
|
|
.chat
|
|
.fork_turn(&id, &req.message_id, kind)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
if started {
|
|
if let Some((harness, skills)) = invocation {
|
|
crate::telemetry::capture_chat_message_sent(&harness);
|
|
for skill in skills {
|
|
crate::telemetry::capture_skill_invoked(skill, "slash", Some(&harness));
|
|
}
|
|
}
|
|
}
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SelectBranchReq {
|
|
/// The fork to show. Its whole branch comes with it.
|
|
leaf_id: String,
|
|
}
|
|
|
|
async fn select_chat_branch(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<SelectBranchReq>,
|
|
) -> ApiResult {
|
|
reject_if_moving(&state)?;
|
|
state
|
|
.chat
|
|
.select_branch(&id, &req.leaf_id)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
/// Raw bytes of a chat attachment (image or PDF), by bare file name.
|
|
async fn chat_attachment(
|
|
Path(name): Path<String>,
|
|
method: Method,
|
|
headers: HeaderMap,
|
|
) -> std::result::Result<Response, ApiError> {
|
|
// Names are server-minted (att-<uuid>__<name>.<ext>); anything else is rejected.
|
|
if !name
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
|
|| name.contains("..")
|
|
{
|
|
return Err(bad_request("invalid attachment name"));
|
|
}
|
|
let (type_path, file) = tokio::task::spawn_blocking(move || {
|
|
let path = local::chat::attachments_dir()?.join(&name);
|
|
let file = std::fs::File::open(&path).map_err(|_| not_found("attachment"))?;
|
|
let metadata = file.metadata().map_err(|_| not_found("attachment"))?;
|
|
if !metadata.is_file() {
|
|
return Err(not_found("attachment"));
|
|
}
|
|
let resolved = crate::paths::canonicalize(&path).map_err(|_| not_found("attachment"))?;
|
|
Ok((resolved.to_string_lossy().into_owned(), file))
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::from(anyhow!("attachment task failed: {e}")))??;
|
|
crate::commands::file_serve::disk_response(
|
|
&type_path,
|
|
file,
|
|
local::files::presentation_for_path(&type_path),
|
|
&method,
|
|
&headers,
|
|
"max-age=31536000, immutable",
|
|
)
|
|
.await
|
|
.map_err(ApiError::from)
|
|
}
|
|
|
|
async fn interrupt_chat(State(state): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
|
state.chat.interrupt_by_user(&id).await?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
/// Cancel one message parked behind a running turn (the ✕ on a queued chip).
|
|
async fn cancel_queued_chat(
|
|
State(state): State<AppState>,
|
|
Path((id, item_id)): Path<(String, String)>,
|
|
) -> ApiResult {
|
|
let removed = state.chat.cancel_queued(&id, &item_id)?;
|
|
Ok(Json(json!({ "ok": true, "removed": removed })))
|
|
}
|
|
|
|
/// Retry one queued message after its safe delivery budget was exhausted.
|
|
async fn retry_queued_chat(
|
|
State(state): State<AppState>,
|
|
Path((id, item_id)): Path<(String, String)>,
|
|
) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
reject_if_moving(&state)?;
|
|
let retried = state.chat.retry_queued(&id, &item_id)?;
|
|
if !retried {
|
|
return Err(ApiError(
|
|
StatusCode::CONFLICT,
|
|
"queued message is no longer available for retry".into(),
|
|
));
|
|
}
|
|
Ok(Json(json!({ "ok": true, "retried": retried })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RespondReq {
|
|
prompt_id: String,
|
|
#[serde(default = "default_true")]
|
|
approve: bool,
|
|
#[serde(default)]
|
|
resume_mode: Option<String>,
|
|
#[serde(default)]
|
|
answers: Vec<String>,
|
|
#[serde(default)]
|
|
note: Option<String>,
|
|
#[serde(default)]
|
|
annotations: Vec<local::chat::TextAnnotation>,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Answer an interactive prompt (plan / permission / question) on a session.
|
|
async fn respond_chat(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<RespondReq>,
|
|
) -> ApiResult {
|
|
reject_if_stopping(&state)?;
|
|
state
|
|
.chat
|
|
.respond(local::chat::PromptAnswer {
|
|
session_id: id,
|
|
prompt_id: req.prompt_id,
|
|
approve: req.approve,
|
|
resume_mode: req.resume_mode,
|
|
answers: req.answers,
|
|
note: req.note,
|
|
annotations: req
|
|
.annotations
|
|
.into_iter()
|
|
.filter(|annotation| !annotation.text.trim().is_empty())
|
|
.collect(),
|
|
})
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
/// The `orx mcp-gate` bridge relaying one blocked tool call from a plan-mode
|
|
/// claude turn. The response body is the permission decision verbatim
|
|
/// (`{"behavior":"allow",…}` / `{"behavior":"deny",…}`) — the bridge
|
|
/// stringifies it into the MCP tool result unchanged. Deliberately long-held:
|
|
/// it returns when the user answers the card (or policy/timeout decides).
|
|
async fn bridge_permission(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<BridgePermissionReq>,
|
|
) -> ApiResult {
|
|
let decision = state
|
|
.chat
|
|
.request_permission(&req.session_id, &req.token, &req.tool_name, req.tool_input)
|
|
.await
|
|
.map_err(bad_request)?;
|
|
Ok(Json(serde_json::to_value(decision).map_err(bad_request)?))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BridgePermissionReq {
|
|
session_id: String,
|
|
token: String,
|
|
tool_name: String,
|
|
#[serde(default)]
|
|
tool_input: Value,
|
|
}
|
|
|
|
// --- agent ----------------------------------------------------------------
|
|
|
|
async fn agent_status(State(state): State<AppState>) -> Json<Value> {
|
|
let agents = state.agent.status().await;
|
|
Json(json!({ "running": !agents.is_empty(), "agents": agents }))
|
|
}
|
|
|
|
// --- /api/events SSE ------------------------------------------------------
|
|
|
|
async fn events(
|
|
State(state): State<AppState>,
|
|
) -> Sse<impl Stream<Item = std::result::Result<Event, Infallible>>> {
|
|
// Small buffer on purpose: run.log events can carry ~MB payloads, and a
|
|
// stalled client must backpressure the loop, not queue hundreds of MB.
|
|
let (tx, rx) = mpsc::channel::<Event>(16);
|
|
tokio::spawn(event_loop(tx.clone()));
|
|
// Chat events ride the same stream: chat.session / chat.message / chat.busy.
|
|
tokio::spawn(forward_chat_events(state.chat.subscribe(), tx));
|
|
let stream = futures::stream::unfold(rx, |mut rx| async move {
|
|
rx.recv().await.map(|ev| (Ok(ev), rx))
|
|
});
|
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
|
}
|
|
|
|
async fn forward_chat_events(
|
|
mut chat_rx: tokio::sync::broadcast::Receiver<(&'static str, Value)>,
|
|
tx: mpsc::Sender<Event>,
|
|
) {
|
|
loop {
|
|
let event = tokio::select! {
|
|
_ = tx.closed() => return,
|
|
event = chat_rx.recv() => event,
|
|
};
|
|
match event {
|
|
Ok((name, data)) => {
|
|
if tx.send(json_event(name, &data)).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
// The client must repair snapshots after missed edge-only events.
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
|
if tx
|
|
.send(json_event("resync.required", &json!({})))
|
|
.await
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Diff state for one SSE subscriber.
|
|
#[derive(Default)]
|
|
struct EventCursor {
|
|
projects: HashMap<String, i64>,
|
|
experiments: HashMap<String, i64>,
|
|
files: HashMap<String, u64>,
|
|
runs: HashMap<String, (String, i64)>,
|
|
log_offsets: HashMap<String, u64>,
|
|
/// Last update status sent. Unlike the rest of the cursor this isn't store
|
|
/// state — the updater is a separate process, so its progress reaches the UI
|
|
/// through the same diff the store changes do.
|
|
update: Option<updates::UpdateStatus>,
|
|
update_sampled_at: Option<std::time::Instant>,
|
|
}
|
|
|
|
/// How often the event loop re-reads update status. The 500ms store cadence is
|
|
/// there for run logs; nothing about an update needs that resolution.
|
|
const UPDATE_SAMPLE_INTERVAL: Duration = Duration::from_secs(10);
|
|
|
|
/// 500ms poll loop: diff the store + log files, push named events into the
|
|
/// channel. Ends when the subscriber disconnects (send fails). Same idiom as
|
|
/// serve.rs, extended with project/experiment diffs.
|
|
async fn event_loop(tx: mpsc::Sender<Event>) {
|
|
let mut cursor = EventCursor::default();
|
|
let mut first = true;
|
|
loop {
|
|
if !first {
|
|
// An idle store never sends, so a failed send can't be the only
|
|
// disconnect signal — watch the receiver side too or the loop
|
|
// (and its 2Hz store polling) leaks per closed EventSource.
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(Duration::from_millis(500)) => {}
|
|
_ = tx.closed() => return,
|
|
}
|
|
}
|
|
if tx.is_closed() {
|
|
return;
|
|
}
|
|
// Store hiccups (locked db) just skip a tick.
|
|
let batch = collect_events(&mut cursor, first).unwrap_or_default();
|
|
first = false;
|
|
for ev in batch {
|
|
if tx.send(ev).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn json_event(name: &str, data: &Value) -> Event {
|
|
Event::default().event(name).data(data.to_string())
|
|
}
|
|
|
|
/// One diff pass. On the first pass everything is "changed", so a fresh
|
|
/// subscriber gets a full snapshot and needs no separate baseline fetches.
|
|
fn collect_events(cursor: &mut EventCursor, first: bool) -> Result<Vec<Event>> {
|
|
let mut out = Vec::new();
|
|
|
|
// Sampled far below the 2Hz loop — the updater works on the scale of
|
|
// minutes, and this reads files. `cursor.update` is committed only once the
|
|
// batch is certain: every `?` below discards it (`event_loop` swallows the
|
|
// error), and a cursor that had already moved would never re-emit, leaving
|
|
// the restart banner permanently unshown for that subscriber.
|
|
let sampled_update = cursor
|
|
.update_sampled_at
|
|
.is_none_or(|at| at.elapsed() >= UPDATE_SAMPLE_INTERVAL)
|
|
.then(|| {
|
|
cursor.update_sampled_at = Some(std::time::Instant::now());
|
|
updates::status()
|
|
})
|
|
.filter(|update| cursor.update.as_ref() != Some(update));
|
|
if let Some(update) = &sampled_update {
|
|
out.push(json_event("update.status", &json!(update)));
|
|
}
|
|
|
|
let store = Store::open()?;
|
|
// Cap log bytes per tick so one pass never materializes a huge batch —
|
|
// remainders (whole-log replays included) stream out on later ticks.
|
|
let mut log_budget: u64 = 2_000_000;
|
|
|
|
for project in store.list_local_projects()? {
|
|
if cursor.projects.get(&project.id) != Some(&project.updated_at) {
|
|
cursor
|
|
.projects
|
|
.insert(project.id.clone(), project.updated_at);
|
|
out.push(json_event(
|
|
"project.updated",
|
|
&json!({ "project": project_json(&project) }),
|
|
));
|
|
}
|
|
push_experiment_events(&store, &project.id, cursor, &mut out)?;
|
|
// Artifacts appear live — anything written into the directory (by the
|
|
// agent or the user) pings the UI to refetch the listing.
|
|
let fp = local::files::fingerprint(&project);
|
|
if cursor.files.get(&project.id) != Some(&fp) {
|
|
cursor.files.insert(project.id.clone(), fp);
|
|
out.push(json_event(
|
|
"files.updated",
|
|
&json!({ "projectId": project.id }),
|
|
));
|
|
}
|
|
}
|
|
|
|
for run in store.list_runs(200)? {
|
|
let changed = match cursor.runs.get(&run.id) {
|
|
None => true,
|
|
Some((status, updated)) => *status != run.status || *updated != run.updated_at,
|
|
};
|
|
if changed {
|
|
cursor
|
|
.runs
|
|
.insert(run.id.clone(), (run.status.clone(), run.updated_at));
|
|
out.push(json_event(
|
|
"run.updated",
|
|
&json!({ "run": ApiRun::from(&run) }),
|
|
));
|
|
}
|
|
if first {
|
|
// Live runs replay their whole log through the stream (chunked per
|
|
// tick); terminal runs start at EOF — backfill is /api/runs/{id}/log.
|
|
let start = if is_terminal(&run.status) {
|
|
log_size(&run.id)
|
|
} else {
|
|
0
|
|
};
|
|
cursor.log_offsets.insert(run.id.clone(), start);
|
|
}
|
|
// Terminal runs were seeded at EOF above, so this is a no-op for them.
|
|
push_log_delta(&run, cursor, &mut out, &mut log_budget);
|
|
}
|
|
// Committed only now that the batch is certain to be returned.
|
|
if let Some(update) = sampled_update {
|
|
cursor.update = Some(update);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn push_experiment_events(
|
|
store: &Store,
|
|
project_id: &str,
|
|
cursor: &mut EventCursor,
|
|
out: &mut Vec<Event>,
|
|
) -> Result<()> {
|
|
for exp in store.list_experiments_by_project(project_id)? {
|
|
if cursor.experiments.get(&exp.id) != Some(&exp.updated_at) {
|
|
cursor.experiments.insert(exp.id.clone(), exp.updated_at);
|
|
out.push(json_event(
|
|
"experiment.updated",
|
|
&json!({ "experiment": exp }),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn push_log_delta(
|
|
run: &StoredRun,
|
|
cursor: &mut EventCursor,
|
|
out: &mut Vec<Event>,
|
|
budget: &mut u64,
|
|
) {
|
|
let offset = *cursor.log_offsets.entry(run.id.clone()).or_insert(0);
|
|
let size = log_size(&run.id);
|
|
if size <= offset || *budget == 0 {
|
|
return;
|
|
}
|
|
let chunk = read_log_from(&run.id, offset, *budget);
|
|
*budget -= chunk.len() as u64;
|
|
cursor
|
|
.log_offsets
|
|
.insert(run.id.clone(), offset + chunk.len() as u64);
|
|
// base64: chunk boundaries are arbitrary byte positions, and exact byte
|
|
// lengths are what lets the client dedup replays.
|
|
out.push(json_event(
|
|
"run.log",
|
|
&json!({
|
|
"runId": run.id,
|
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(&chunk),
|
|
"offset": offset,
|
|
}),
|
|
));
|
|
}
|
|
|
|
fn log_size(run_id: &str) -> u64 {
|
|
std::fs::metadata(log_path(run_id))
|
|
.map(|m| m.len())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn read_log_from(run_id: &str, offset: u64, max: u64) -> Vec<u8> {
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
let Ok(mut f) = std::fs::File::open(log_path(run_id)) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out = Vec::new();
|
|
if f.seek(SeekFrom::Start(offset)).is_ok() {
|
|
let _ = f.take(max).read_to_end(&mut out);
|
|
}
|
|
out
|
|
}
|
|
|
|
// --- embedded SPA ----------------------------------------------------------
|
|
|
|
/// ui/dist, embedded at release build time (debug builds read from disk).
|
|
#[derive(rust_embed::RustEmbed)]
|
|
#[folder = "ui/dist"]
|
|
struct UiDist;
|
|
|
|
const NOT_BUILT_PAGE: &str = "<!doctype html><html><head><title>orx up</title></head>\
|
|
<body style=\"font-family:system-ui;background:#111;color:#ddd;display:grid;place-items:center;height:100vh;margin:0\">\
|
|
<div><h1>UI not built</h1><p>Run <code>pnpm build</code> in <code>ui/</code>, then rebuild orx.</p>\
|
|
<p>The API is live at <code>/api/health</code>.</p></div></body></html>";
|
|
|
|
fn mime_for(path: &str) -> &'static str {
|
|
match path.rsplit('.').next() {
|
|
Some("html") => "text/html; charset=utf-8",
|
|
Some("js") => "text/javascript",
|
|
Some("css") => "text/css",
|
|
Some("svg") => "image/svg+xml",
|
|
Some("json") | Some("map") => "application/json",
|
|
Some("png") => "image/png",
|
|
Some("ico") => "image/x-icon",
|
|
Some("woff2") => "font/woff2",
|
|
Some("txt") => "text/plain; charset=utf-8",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
fn asset_response(path: &str, file: rust_embed::EmbeddedFile) -> Response {
|
|
// index.html must revalidate every load or browsers heuristically cache it
|
|
// and keep loading a stale (hashed) bundle; the hashed assets themselves
|
|
// are immutable by name. favicon.svg is likewise served under a fixed name.
|
|
let cache = if path == "index.html" || path == "favicon.svg" {
|
|
"no-cache"
|
|
} else {
|
|
"public, max-age=31536000, immutable"
|
|
};
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, mime_for(path)),
|
|
(header::CACHE_CONTROL, cache),
|
|
],
|
|
file.data.into_owned(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Every non-/api path: exact asset if it exists, index.html
|
|
/// otherwise (SPA client routing), friendly page when the UI isn't built.
|
|
pub(crate) async fn spa(uri: Uri) -> Response {
|
|
let path = uri.path().trim_start_matches('/');
|
|
if path.starts_with("api/") || path == "api" {
|
|
return not_found("route").into_response();
|
|
}
|
|
let candidate = if path.is_empty() { "index.html" } else { path };
|
|
if let Some(file) = UiDist::get(candidate) {
|
|
return asset_response(candidate, file);
|
|
}
|
|
match UiDist::get("index.html") {
|
|
Some(file) => asset_response("index.html", file),
|
|
None => Html(NOT_BUILT_PAGE).into_response(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn successful_recheck_clears_stale_claude_warning() {
|
|
let host = local::claude::ClaudeHost::new();
|
|
let ready = local::harness::claude::parse_auth_status(
|
|
Some(0),
|
|
br#"{"loggedIn":true,"authMethod":"third_party","apiProvider":"bedrock"}"#,
|
|
local::harness::claude::auth_barrier_sequence(),
|
|
);
|
|
host.observe_auth_probe(&ready);
|
|
let failed = local::harness::claude::parse_auth_status(
|
|
Some(2),
|
|
b"bad status",
|
|
local::harness::claude::auth_barrier_sequence(),
|
|
);
|
|
host.observe_auth_probe(&failed);
|
|
let mut payload =
|
|
json!({"harnesses": [{"id": "claude-code", "authState": "ready", "agentReady": true}]});
|
|
overlay_claude_auth(&mut payload, &host.auth_snapshot());
|
|
assert_eq!(payload["harnesses"][0]["authCheckFailed"], true);
|
|
assert!(payload["harnesses"][0]["agentNote"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("Could not re-check"));
|
|
|
|
let recovered = local::harness::claude::parse_auth_status(
|
|
Some(0),
|
|
br#"{"loggedIn":true,"authMethod":"third_party","apiProvider":"bedrock"}"#,
|
|
local::harness::claude::auth_barrier_sequence(),
|
|
);
|
|
host.observe_auth_probe(&recovered);
|
|
overlay_claude_auth(&mut payload, &host.auth_snapshot());
|
|
assert_eq!(payload["harnesses"][0]["authCheckFailed"], false);
|
|
assert!(payload["harnesses"][0].get("agentNote").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn send_model_distinguishes_cli_default_from_no_override() {
|
|
let omitted: SendChatReq = serde_json::from_value(json!({ "text": "hello" })).unwrap();
|
|
let default: SendChatReq =
|
|
serde_json::from_value(json!({ "text": "hello", "model": null })).unwrap();
|
|
assert_eq!(omitted.model, None);
|
|
assert_eq!(default.model, Some(None));
|
|
}
|
|
|
|
#[test]
|
|
fn harness_payload_predicates_read_the_wire_shape() {
|
|
let provisional = json!({
|
|
"harnesses": [
|
|
{"id": "claude-code", "agentReady": false, "catalogPending": true},
|
|
{"id": "codex", "agentReady": false},
|
|
]
|
|
});
|
|
assert!(payload_is_provisional(&provisional));
|
|
assert!(claude_entry_pending(&provisional));
|
|
assert!(!payload_has_ready_claude(&provisional));
|
|
|
|
let filled = json!({
|
|
"harnesses": [
|
|
{"id": "claude-code", "agentReady": true},
|
|
{"id": "codex", "agentReady": false},
|
|
]
|
|
});
|
|
assert!(!payload_is_provisional(&filled));
|
|
assert!(!claude_entry_pending(&filled));
|
|
assert!(payload_has_ready_claude(&filled));
|
|
|
|
// `catalogPending` is skip-serialized when false — its absence must
|
|
// read as settled, not pending.
|
|
let no_claude = json!({ "harnesses": [{"id": "codex", "agentReady": false}] });
|
|
assert!(!payload_is_provisional(&no_claude));
|
|
assert!(!claude_entry_pending(&no_claude));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn closed_dashboard_releases_idle_chat_receiver() {
|
|
let (sender, receiver) = tokio::sync::broadcast::channel(1);
|
|
let (tx, rx) = mpsc::channel(1);
|
|
drop(rx);
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
forward_chat_events(receiver, tx),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(sender.receiver_count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_streaming_response_stays_active_until_its_body_is_sent() {
|
|
let gate = Arc::new(tokio::sync::Notify::new());
|
|
let release = gate.clone();
|
|
let app = Router::new()
|
|
.route(
|
|
"/file",
|
|
get(move || async move {
|
|
axum::body::Body::from_stream(futures::stream::once(async move {
|
|
gate.notified().await;
|
|
Ok::<_, Infallible>("done")
|
|
}))
|
|
}),
|
|
)
|
|
.layer(middleware::from_fn(track_active));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("http://{}/file", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
|
|
|
let response = reqwest::get(url).await.unwrap();
|
|
assert_eq!(ACTIVE.load(Ordering::SeqCst), 1);
|
|
release.notify_one();
|
|
assert_eq!(response.text().await.unwrap(), "done");
|
|
for _ in 0..100 {
|
|
if ACTIVE.load(Ordering::SeqCst) == 0 {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
panic!("a sent response body stayed active");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lagged_chat_stream_requests_resync_and_continues() {
|
|
use axum::response::IntoResponse;
|
|
let (sender, receiver) = tokio::sync::broadcast::channel(1);
|
|
sender.send(("chat.busy", json!({"busy": true}))).unwrap();
|
|
sender.send(("chat.busy", json!({"busy": false}))).unwrap();
|
|
drop(sender);
|
|
let (tx, mut rx) = mpsc::channel(4);
|
|
forward_chat_events(receiver, tx).await;
|
|
let mut events = Vec::new();
|
|
while let Some(event) = rx.recv().await {
|
|
events.push(Ok::<_, Infallible>(event));
|
|
}
|
|
let response = Sse::new(futures::stream::iter(events)).into_response();
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let body = std::str::from_utf8(&bytes).unwrap();
|
|
assert!(body.starts_with("event: resync.required\ndata: {}\n\n"));
|
|
assert!(body.contains("event: chat.busy\ndata: {\"busy\":false}"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_requests_validate_metadata_and_allow_independent_preferences() {
|
|
let preferences: SetUiStateReq =
|
|
serde_json::from_value(json!({"tourCompleted":true})).unwrap();
|
|
assert!(preferences.workspace.is_none());
|
|
let workspace: SetUiStateReq = serde_json::from_value(json!({"workspace":{
|
|
"lastLocation":"/projects/project/tasks/new", "railOpen":true,
|
|
"panelWidth":500, "experimentsView":"tree"
|
|
}}))
|
|
.unwrap();
|
|
assert!(workspace.preferred_agent.is_none());
|
|
workspace.workspace.unwrap().validate().unwrap();
|
|
assert!(serde_json::from_value::<SetUiStateReq>(json!({"workspace":{
|
|
"lastLocation":null, "railOpen":true, "panelWidth":500,
|
|
"experimentsView":"grid"
|
|
}}))
|
|
.is_err());
|
|
let invalid: GlobalWorkspaceState = serde_json::from_value(json!({
|
|
"lastLocation":"/projects/project", "railOpen":true,
|
|
"panelWidth":500, "experimentsView":"tree"
|
|
}))
|
|
.unwrap();
|
|
assert!(invalid.validate().is_err());
|
|
let result = set_ui_state(Json(SetUiStateReq {
|
|
tour_completed: Some(true),
|
|
preferred_agent: None,
|
|
workspace: Some(invalid),
|
|
}))
|
|
.await;
|
|
assert_eq!(result.err().unwrap().0, StatusCode::BAD_REQUEST);
|
|
let unsupported: WorkspaceState = serde_json::from_value(json!({
|
|
"version":2, "lastLocation":null, "tasks":{}
|
|
}))
|
|
.unwrap();
|
|
let result = set_project_ui_state(Path("project".into()), Json(unsupported)).await;
|
|
assert_eq!(result.err().unwrap().0, StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_workspace_blocks_local_machine_actions_but_keeps_config_editing() {
|
|
for path in [
|
|
"/api/project-path/pick",
|
|
"/api/update",
|
|
"/api/update/apply",
|
|
"/api/update/restart",
|
|
"/api/settings/data-dir",
|
|
"/api/settings/data-dir/move",
|
|
"/api/settings/openresearch/login",
|
|
"/api/settings/openresearch/ssh-key",
|
|
"/api/settings/commands/run",
|
|
"/api/remote/sessions",
|
|
"/api/projects/p1/file/open",
|
|
"/api/projects/p1/file/reveal",
|
|
] {
|
|
assert!(remote_route_forbidden(path), "{path}");
|
|
}
|
|
assert!(!remote_route_forbidden("/api/settings/ssh/config"));
|
|
assert!(!remote_route_forbidden("/api/settings/ssh/master"));
|
|
assert!(!remote_route_forbidden("/api/settings/ssh/preflight"));
|
|
assert!(!remote_route_forbidden("/api/settings/ssh/connect"));
|
|
assert!(!remote_route_forbidden("/api/projects/p1/file"));
|
|
}
|
|
|
|
#[test]
|
|
fn remote_callback_token_is_limited_to_runs_and_the_spawn_preflight() {
|
|
assert!(is_remote_callback_route(&Method::POST, "/api/runs"));
|
|
assert!(is_remote_callback_route(
|
|
&Method::POST,
|
|
"/api/runs/run-1/cancel"
|
|
));
|
|
assert!(!is_remote_callback_route(&Method::GET, "/api/runs"));
|
|
assert!(!is_remote_callback_route(
|
|
&Method::POST,
|
|
"/api/runs/run-1/extra/cancel"
|
|
));
|
|
assert!(!is_remote_callback_route(
|
|
&Method::POST,
|
|
"/api/chat/sessions/s1/message"
|
|
));
|
|
assert!(is_remote_callback_route(
|
|
&Method::GET,
|
|
"/api/harnesses/codex/snapshot"
|
|
));
|
|
assert!(!is_remote_callback_route(
|
|
&Method::POST,
|
|
"/api/harnesses/codex/snapshot"
|
|
));
|
|
assert!(!is_remote_callback_route(&Method::GET, "/api/harnesses"));
|
|
assert!(!is_remote_callback_route(
|
|
&Method::GET,
|
|
"/api/harnesses/setup/commands"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_terminal_requires_the_dashboard_origin() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::HOST, "127.0.0.1:4791".parse().unwrap());
|
|
headers.insert(header::ORIGIN, "http://127.0.0.1:4791".parse().unwrap());
|
|
assert!(same_origin(&headers));
|
|
|
|
headers.insert(header::ORIGIN, "https://example.com".parse().unwrap());
|
|
assert!(!same_origin(&headers));
|
|
headers.remove(header::ORIGIN);
|
|
assert!(!same_origin(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_terminal_resize_message_deserializes() {
|
|
let input: SshTerminalInput =
|
|
serde_json::from_str(r#"{"type":"resize","cols":120,"rows":40}"#).unwrap();
|
|
let SshTerminalInput::Resize { cols, rows } = input;
|
|
assert_eq!((cols, rows), (120, 40));
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[tokio::test]
|
|
async fn windows_ssh_master_status_is_not_a_disconnection() {
|
|
let response = ssh_master_status(Query(SshPreflightReq {
|
|
host: "unused-host".into(),
|
|
container: None,
|
|
}))
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{}", error.1));
|
|
assert!(response.0["running"].is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_connection_failure_is_a_persistable_preflight_result() {
|
|
let test = ssh_connect_failure("cluster", "authentication failed".into());
|
|
assert_eq!(test.host, "cluster");
|
|
assert!(!test.reachable);
|
|
assert!(!test.tools_found);
|
|
assert_eq!(test.error.as_deref(), Some("authentication failed"));
|
|
assert!(test.tested_at > 0);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn pty_relays_input_output_resize_exit_and_cancel() {
|
|
let mut session = start_pty(
|
|
"sh",
|
|
vec![
|
|
"-c".into(),
|
|
"read value; printf 'reply:%s\\n' \"$value\"".into(),
|
|
],
|
|
)
|
|
.unwrap();
|
|
session
|
|
.master
|
|
.resize(PtySize {
|
|
rows: 40,
|
|
cols: 120,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.unwrap();
|
|
session.input.send(b"hello\n".to_vec()).unwrap();
|
|
|
|
let mut output = Vec::new();
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
while let Some(event) = session.events.recv().await {
|
|
match event {
|
|
PtyEvent::Output(bytes) => output.extend(bytes),
|
|
PtyEvent::Exit(Ok(status)) => {
|
|
assert!(status.success());
|
|
break;
|
|
}
|
|
PtyEvent::Exit(Err(error)) => panic!("PTY wait failed: {error}"),
|
|
PtyEvent::Eof => {}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("PTY did not exit");
|
|
assert!(String::from_utf8_lossy(&output).contains("reply:hello"));
|
|
|
|
let mut cancelled =
|
|
start_pty("sh", vec!["-c".into(), "trap '' HUP; sleep 30".into()]).unwrap();
|
|
cancelled.kill.send(()).unwrap();
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
while let Some(event) = cancelled.events.recv().await {
|
|
if matches!(event, PtyEvent::Exit(_)) {
|
|
return;
|
|
}
|
|
}
|
|
panic!("cancelled PTY ended without an exit event");
|
|
})
|
|
.await
|
|
.expect("cancelled PTY did not exit");
|
|
}
|
|
|
|
#[test]
|
|
fn client_resizes_update_the_tracked_size_only_when_valid() {
|
|
let mut size = DEFAULT_PTY_SIZE;
|
|
assert!(apply_resize(
|
|
&mut size,
|
|
r#"{"type":"resize","cols":111,"rows":33}"#
|
|
));
|
|
assert_eq!((size.cols, size.rows), (111, 33));
|
|
assert!(!apply_resize(
|
|
&mut size,
|
|
r#"{"type":"resize","cols":0,"rows":9}"#
|
|
));
|
|
assert!(!apply_resize(&mut size, "not json"));
|
|
assert_eq!((size.cols, size.rows), (111, 33));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_commands_are_an_exact_allowlist() {
|
|
assert_eq!(
|
|
settings_command("gh auth login"),
|
|
Some(&["gh", "auth", "login"][..])
|
|
);
|
|
assert_eq!(settings_command("gh auth login; rm -rf ~"), None);
|
|
assert_eq!(settings_command("gh"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn project_path_status_reports_an_unborn_repository_as_importable() {
|
|
let path =
|
|
std::env::temp_dir().join(format!("orx-project-path-status-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
let status = std::process::Command::new("git")
|
|
.current_dir(&path)
|
|
.args(["init", "-q", "-b", "main"])
|
|
.status()
|
|
.unwrap();
|
|
assert!(status.success());
|
|
|
|
let response = project_path_status(Query(ProjectPathStatusQ {
|
|
path: Some(path.to_string_lossy().into_owned()),
|
|
}))
|
|
.await;
|
|
let Json(body) = match response {
|
|
Ok(body) => body,
|
|
Err(error) => panic!("unexpected path status error: {}", error.1),
|
|
};
|
|
|
|
assert_eq!(body["gitState"], "unborn");
|
|
assert_eq!(body["initialized"], true);
|
|
let _ = std::fs::remove_dir_all(path);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_never_becomes_the_preferred_mode_for_new_sessions() {
|
|
assert_eq!(
|
|
preferred_permission_mode("claude-code", Some("plan".into())).as_deref(),
|
|
Some("auto")
|
|
);
|
|
assert_eq!(
|
|
preferred_permission_mode("codex", Some("plan".into())).as_deref(),
|
|
Some("approve-for-me")
|
|
);
|
|
assert_eq!(
|
|
preferred_permission_mode("opencode", Some("plan".into())).as_deref(),
|
|
Some("default")
|
|
);
|
|
}
|
|
|
|
fn expect_profile(
|
|
result: std::result::Result<crate::telemetry::ResearchProfile, ApiError>,
|
|
) -> crate::telemetry::ResearchProfile {
|
|
match result {
|
|
Ok(profile) => profile,
|
|
Err(error) => panic!("unexpected profile error: {}", error.1),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn research_profile_requires_an_area_and_other_description() {
|
|
let missing_area = normalize_research_profile(vec![], None, None, vec![]).unwrap_err();
|
|
assert_eq!(missing_area.1, "choose at least one research area");
|
|
|
|
let missing_other =
|
|
normalize_research_profile(vec!["Other".into()], Some(" ".into()), None, vec![])
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
missing_other.1,
|
|
"describe your research area when choosing Other"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn research_profile_preserves_disclosed_free_text() {
|
|
let profile = expect_profile(normalize_research_profile(
|
|
vec!["AI/ML".into(), "Other".into()],
|
|
Some(" AI for theorem proving ".into()),
|
|
Some(" I study RL.\nSecond line. ".into()),
|
|
vec![crate::telemetry::ProfilePaper {
|
|
paper_id: "1706.03762".into(),
|
|
title: Some("Attention Is All You Need".into()),
|
|
}],
|
|
));
|
|
|
|
assert_eq!(
|
|
profile.other_area.as_deref(),
|
|
Some(" AI for theorem proving ")
|
|
);
|
|
assert_eq!(
|
|
profile.background.as_deref(),
|
|
Some(" I study RL.\nSecond line. ")
|
|
);
|
|
assert_eq!(
|
|
profile.papers[0].title.as_deref(),
|
|
Some("Attention Is All You Need")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_profile_update_preserves_saved_research_areas() {
|
|
let current = crate::telemetry::ResearchProfile {
|
|
research_areas: vec!["Physics".into(), "Other".into()],
|
|
other_area: Some("Quantum information".into()),
|
|
..crate::telemetry::ResearchProfile::default()
|
|
};
|
|
let updated = expect_profile(profile_settings_update(
|
|
SetProfileReq {
|
|
research_areas: vec![],
|
|
other_area: None,
|
|
background: Some("Updated background".into()),
|
|
papers: vec![],
|
|
},
|
|
current,
|
|
));
|
|
|
|
assert_eq!(updated.research_areas, vec!["Physics", "Other"]);
|
|
assert_eq!(updated.other_area.as_deref(), Some("Quantum information"));
|
|
assert_eq!(updated.background.as_deref(), Some("Updated background"));
|
|
}
|
|
|
|
#[test]
|
|
fn api_run_exposes_cancel_intent() {
|
|
let run = StoredRun {
|
|
id: "run-1".into(),
|
|
experiment_id: "experiment-1".into(),
|
|
project_id: "project-1".into(),
|
|
status: "running".into(),
|
|
backend_json: "{}".into(),
|
|
command: String::new(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
ended_at: None,
|
|
exit_code: None,
|
|
commit_sha: None,
|
|
result_markdown: None,
|
|
cancel_requested: true,
|
|
chat_session_id: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(ApiRun::from(&run)).unwrap();
|
|
assert_eq!(value["cancelRequested"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_settings_registers_tinker_without_exposing_its_key() {
|
|
let settings = compute_settings_json(SshReadiness::NoUsableKey { pub_path: None });
|
|
let tinker = settings["targets"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|target| target["id"] == "tinker")
|
|
.unwrap();
|
|
assert!(tinker["configured"].is_boolean());
|
|
assert!(tinker.get("key").is_none());
|
|
assert!(tinker.get("apiKey").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn create_run_request_round_trips_agent_attribution_and_force() {
|
|
let request = CreateRunReq {
|
|
invocation_context: None,
|
|
telemetry_suppressed: true,
|
|
experiment_id: "experiment-1".into(),
|
|
backend: Some("local".into()),
|
|
flavor: None,
|
|
host: None,
|
|
container: None,
|
|
no_container: false,
|
|
manifest: None,
|
|
image: None,
|
|
timeout: None,
|
|
org: None,
|
|
provider: None,
|
|
disk: None,
|
|
force: true,
|
|
chat_session_id: Some("session-1".into()),
|
|
};
|
|
|
|
let value = serde_json::to_value(&request).unwrap();
|
|
assert_eq!(value["experimentId"], "experiment-1");
|
|
assert_eq!(value["chatSessionId"], "session-1");
|
|
assert_eq!(value["force"], true);
|
|
assert_eq!(
|
|
serde_json::from_value::<CreateRunReq>(value).unwrap(),
|
|
request
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn project_json_exposes_artifacts_dir_with_legacy_alias() {
|
|
let project = local::model::LocalProject {
|
|
id: "p1".into(),
|
|
name: "Demo".into(),
|
|
slug: "demo".into(),
|
|
github_owner: "o".into(),
|
|
github_repo: "r".into(),
|
|
github_sync_enabled: true,
|
|
baseline_branch: "main".into(),
|
|
repo_path: "/tmp/r".into(),
|
|
run_command: None,
|
|
paper_id: None,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
};
|
|
let json = project_json_with_artifacts_dir(
|
|
&project,
|
|
"/tmp/openresearch-test/files/demo".to_string(),
|
|
);
|
|
assert_eq!(json["artifactsDir"], json["filesDir"]);
|
|
assert!(json["artifactsDir"]
|
|
.as_str()
|
|
.unwrap()
|
|
.ends_with("files/demo"));
|
|
}
|
|
|
|
#[test]
|
|
fn project_file_text_never_lossily_decodes_binary_bytes() {
|
|
let (content, binary) = decode_project_file_text(b"plain text\n".to_vec(), false);
|
|
assert_eq!(content, "plain text\n");
|
|
assert!(!binary);
|
|
|
|
for bytes in [b"header\0payload".to_vec(), vec![0x89, b'P', b'N', b'G']] {
|
|
let (content, binary) = decode_project_file_text(bytes, false);
|
|
assert!(content.is_empty());
|
|
assert!(binary);
|
|
}
|
|
|
|
let mut split_utf8 = b"prefix".to_vec();
|
|
split_utf8.push(0xe2);
|
|
let (content, binary) = decode_project_file_text(split_utf8, true);
|
|
assert_eq!(content, "prefix");
|
|
assert!(!binary);
|
|
}
|
|
|
|
#[test]
|
|
fn project_file_versions_track_exact_bytes() {
|
|
let first = file_version(b"same-size-a");
|
|
let second = file_version(b"same-size-b");
|
|
assert_ne!(first, second);
|
|
|
|
let path = std::env::temp_dir().join(format!("orx-file-version-{}", uuid::Uuid::new_v4()));
|
|
std::fs::write(&path, b"same-size-a").unwrap();
|
|
assert_eq!(
|
|
file_version_on_disk(&path)
|
|
.map_err(|error| error.1)
|
|
.unwrap(),
|
|
first
|
|
);
|
|
std::fs::write(&path, b"same-size-b").unwrap();
|
|
assert_eq!(
|
|
file_version_on_disk(&path)
|
|
.map_err(|error| error.1)
|
|
.unwrap(),
|
|
second
|
|
);
|
|
std::fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn project_file_paths_reject_traversal() {
|
|
assert_eq!(
|
|
validated_project_file_path("./figures/chart.png")
|
|
.map_err(|error| error.1)
|
|
.unwrap()
|
|
.0,
|
|
"figures/chart.png"
|
|
);
|
|
for path in ["", "../secret", "/etc/passwd", "figures/../secret"] {
|
|
assert!(
|
|
validated_project_file_path(path).is_err(),
|
|
"accepted {path:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_file_actions_rename_duplicate_and_delete() {
|
|
let root = std::env::temp_dir().join(format!("orx-file-actions-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(root.join("reports")).unwrap();
|
|
std::fs::write(root.join("reports/result.md"), "result").unwrap();
|
|
|
|
let renamed = manage_local_file(
|
|
&root,
|
|
"reports/result.md",
|
|
FileAction::Rename,
|
|
Some("summary.md"),
|
|
true,
|
|
)
|
|
.map_err(|error| error.1)
|
|
.unwrap();
|
|
assert_eq!(renamed, "reports/summary.md");
|
|
|
|
let duplicated = manage_local_file(&root, &renamed, FileAction::Duplicate, None, true)
|
|
.map_err(|error| error.1)
|
|
.unwrap();
|
|
assert_eq!(duplicated, "reports/summary copy.md");
|
|
assert_eq!(
|
|
std::fs::read_to_string(root.join(&duplicated)).unwrap(),
|
|
"result"
|
|
);
|
|
|
|
manage_local_file(&root, &duplicated, FileAction::Delete, None, true)
|
|
.map_err(|error| error.1)
|
|
.unwrap();
|
|
assert!(!root.join(duplicated).exists());
|
|
assert!(manage_local_file(
|
|
&root,
|
|
&renamed,
|
|
FileAction::Rename,
|
|
Some("../escape.md"),
|
|
true,
|
|
)
|
|
.is_err());
|
|
std::fs::create_dir(root.join(".git")).unwrap();
|
|
std::fs::write(root.join(".git/config"), "private").unwrap();
|
|
assert!(manage_local_file(&root, ".git/config", FileAction::Delete, None, true,).is_err());
|
|
#[cfg(unix)]
|
|
{
|
|
let outside = std::env::temp_dir()
|
|
.join(format!("orx-file-actions-outside-{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir(&outside).unwrap();
|
|
std::os::unix::fs::symlink(&outside, root.join("jump")).unwrap();
|
|
std::os::unix::fs::symlink(root.join(&renamed), outside.join("back")).unwrap();
|
|
assert!(
|
|
manage_local_file(&root, "jump/back", FileAction::Delete, None, true,).is_err()
|
|
);
|
|
assert!(outside.join("back").exists());
|
|
std::os::unix::fs::symlink(root.join(&renamed), root.join("reports/summary-link"))
|
|
.unwrap();
|
|
assert!(manage_local_file(
|
|
&root,
|
|
&renamed,
|
|
FileAction::Rename,
|
|
Some("summary-link"),
|
|
true,
|
|
)
|
|
.is_err());
|
|
assert!(root.join(&renamed).exists());
|
|
assert!(std::fs::symlink_metadata(root.join("reports/summary-link"))
|
|
.unwrap()
|
|
.file_type()
|
|
.is_symlink());
|
|
assert!(manage_local_file(
|
|
&root,
|
|
"reports/summary-link",
|
|
FileAction::Duplicate,
|
|
None,
|
|
true,
|
|
)
|
|
.is_err());
|
|
std::os::unix::fs::symlink(root.join(".git"), root.join("git-link")).unwrap();
|
|
assert!(
|
|
manage_local_file(&root, "git-link/config", FileAction::Delete, None, true,)
|
|
.is_err()
|
|
);
|
|
let _ = std::fs::remove_dir_all(outside);
|
|
}
|
|
let case_renamed = manage_local_file(
|
|
&root,
|
|
&renamed,
|
|
FileAction::Rename,
|
|
Some("SUMMARY.md"),
|
|
true,
|
|
)
|
|
.map_err(|error| error.1)
|
|
.unwrap();
|
|
assert_eq!(case_renamed, "reports/SUMMARY.md");
|
|
assert!(root.join(case_renamed).exists());
|
|
assert!(std::fs::read_dir(root.join("reports"))
|
|
.unwrap()
|
|
.any(|entry| entry.unwrap().file_name() == "SUMMARY.md"));
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|
|
|
|
// ApiError has no Debug, so `.unwrap()` on the Err path won't compile; drop
|
|
// the error to its message string to make the Result assertion-friendly.
|
|
fn abs_path(path: &str) -> std::result::Result<(String, std::path::PathBuf), String> {
|
|
validated_absolute_file_path(path).map_err(|error| error.1)
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_file_paths_require_an_absolute_path() {
|
|
#[cfg(windows)]
|
|
let absolute = r"C:\Windows\System32\drivers\etc\hosts";
|
|
#[cfg(not(windows))]
|
|
let absolute = "/etc/hosts";
|
|
|
|
assert_eq!(
|
|
abs_path(&format!(" {absolute} ")),
|
|
Ok((absolute.to_string(), std::path::PathBuf::from(absolute))),
|
|
);
|
|
for path in ["", " ", "relative/path", "../secret", &"/x".repeat(3000)] {
|
|
assert!(abs_path(path).is_err(), "accepted {path:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_file_paths_expand_a_leading_tilde() {
|
|
let home = dirs::home_dir().expect("home dir");
|
|
// `~/x` and bare `~` resolve under home; the display stays as typed.
|
|
assert_eq!(
|
|
abs_path("~/.ssh/config"),
|
|
Ok(("~/.ssh/config".to_string(), home.join(".ssh/config"))),
|
|
);
|
|
assert_eq!(abs_path("~").map(|(_, p)| p), Ok(home));
|
|
// `~otheruser` isn't expanded, so it stays relative and is rejected.
|
|
assert!(abs_path("~otheruser/x").is_err());
|
|
}
|
|
|
|
fn no_key(path: Option<&str>) -> SshReadiness {
|
|
SshReadiness::NoUsableKey {
|
|
pub_path: path.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
/// The row used to hardcode `~/.ssh/id_ed25519.pub`, which is wrong on any
|
|
/// machine whose key is named something else.
|
|
#[test]
|
|
fn names_the_key_file_we_actually_found() {
|
|
let s = openresearch_summary(true, &no_key(Some("~/.ssh/work_ed25519.pub")));
|
|
assert!(s.contains("orx ssh-key add ~/.ssh/work_ed25519.pub"));
|
|
assert!(!s.contains("id_ed25519"), "no guessed default");
|
|
}
|
|
|
|
/// Nothing on disk to register, so `ssh-key add` alone would fail.
|
|
#[test]
|
|
fn tells_you_to_generate_one_when_there_is_no_key() {
|
|
let s = openresearch_summary(true, &no_key(None));
|
|
assert!(s.contains("ssh-keygen"));
|
|
}
|
|
|
|
#[test]
|
|
fn signed_out_beats_every_key_state() {
|
|
assert!(openresearch_summary(false, &SshReadiness::Ready).contains("orx login"));
|
|
assert!(openresearch_summary(false, &no_key(None)).contains("orx login"));
|
|
}
|
|
|
|
#[test]
|
|
fn unverified_says_why_it_could_not_check() {
|
|
let s = openresearch_summary(
|
|
true,
|
|
&SshReadiness::Unverified {
|
|
reason: "timed out".to_string(),
|
|
},
|
|
);
|
|
assert!(s.contains("timed out"), "surfaces the cause: {s}");
|
|
}
|
|
}
|