From c51b9ac8e89d8b9839d72d2a219934a8f0b0984f Mon Sep 17 00:00:00 2001 From: Daniel Kim Date: Fri, 17 Jul 2026 19:49:24 -0700 Subject: [PATCH 1/2] ControlPlane trait: hoist dual-mode command bodies behind two planes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (final) of the control-plane separation. Adds src/plane.rs — a #[async_trait(?Send)] ControlPlane trait with 14 verbs derived from the six dual-mode commands' paired arms — with ServerPlane (wraps client.rs, connects via require_credentials on the server arm only, with a sync ServerPlaceholder so pre-login guards keep firing before the login check) and LocalPlane (owns the Store; ?Send because rusqlite is !Sync — planes are driven inline under block_on and never spawned). The six commands collapse to resolve-once-then-verb; their local/server bodies move verbatim into the plane impls. Printing stays in the impl where the two planes differ (status blocks, launch recaps) and in the command where shared (runs table, logs footer). Domain type plane::Run (+RunLog/RunOrigin) merges the two run_failure_detail twins — output.rs no longer depends on client::Run. CLI-facing only: local::model (the up-API wire), the SQLite schema, and the client.rs DTOs are untouched, verified by byte-identical golden snapshots of the affected commands on a seeded isolated store. The plane lives outside src/local/ because local/mod.rs's invariant (nothing there calls client.rs) would otherwise break. up.rs and supervise.rs stay off the trait as the deliberate cloud-mirror bridges. --- src/commands/create_experiment.rs | 190 +--- src/commands/exp.rs | 1091 +---------------------- src/commands/logs.rs | 128 +-- src/commands/project.rs | 284 +----- src/commands/report.rs | 303 +------ src/commands/runs.rs | 125 +-- src/local/mod.rs | 14 - src/main.rs | 1 + src/output.rs | 22 - src/plane.rs | 481 ++++++++++ src/plane/local_plane.rs | 591 +++++++++++++ src/plane/server_plane.rs | 1364 +++++++++++++++++++++++++++++ 12 files changed, 2557 insertions(+), 2037 deletions(-) create mode 100644 src/plane.rs create mode 100644 src/plane/local_plane.rs create mode 100644 src/plane/server_plane.rs diff --git a/src/commands/create_experiment.rs b/src/commands/create_experiment.rs index 3307ccd..0b0a6d2 100644 --- a/src/commands/create_experiment.rs +++ b/src/commands/create_experiment.rs @@ -14,12 +14,8 @@ //! (`orx create-project` or the web), not here — so there is no `--repo` flag. //! The baseline is materialized on whatever repo the project is already bound to. -use crate::client::{ - create_baseline_experiment, create_child_experiment, CreateBaselineExperimentBody, - CreateChildBody, Experiment, -}; -use crate::error::{anyhow, require_credentials, Result}; -use crate::local::resolve::{resolve_project, ProjectRef}; +use crate::error::Result; +use crate::plane::{resolve_project, CreateExperimentSpec}; use crate::store::Store; const USAGE: &str = "Usage: orx create-experiment --title \"\" [--parent <experimentId>] [--description \"<text>\"] [--run-command \"<cmd>\"]"; @@ -33,177 +29,21 @@ pub async fn run(mut args: crate::CreateExperimentArgs) -> Result<()> { } }; - // Local project (orx up): create the row + branch locally, no api. + // Local project (orx up): create the row + branch locally, no api — the + // plane resolver decides which side owns the id. let store = Store::open()?; - match resolve_project(&store, &args.project_id)? { - ProjectRef::Local(project) => { - run_local( - &store, - &project, - title, - args.parent, - args.baseline, - args.description, - args.run_command, - )?; - // Key event, fired only on success. Coarse props only — no ids/names. - crate::telemetry::capture_experiment_started("create", true, None); - Ok(()) - } - ProjectRef::Server(_) => run_server(args, title).await, - } -} - -/// Server-mode create via the api. -async fn run_server(args: crate::CreateExperimentArgs, title: String) -> Result<()> { - // The server child-create API carries no run command field — refuse rather - // than silently drop it. (The baseline create below does accept one.) - if args.run_command.is_some() && args.parent.is_some() { - return Err(anyhow!( - "--run-command is supported for local projects and server baselines \ - only. For server child experiments, set it after creation with \ - `orx exp cmd <expId> --set '<cmd>'`." - )); - } - - let creds = require_credentials().await; - let description = args.description; - - let experiment: Experiment; - let kind: String; - if let Some(parent) = args.parent { - let envelope = create_child_experiment( - &creds, - &args.project_id, - &CreateChildBody { - title, - description, - parent_experiment_id: parent, - }, - ) - .await?; - experiment = envelope.experiment; - kind = "child".to_string(); - } else { - // Baseline on the project's already-bound GitHub repo. The server - // branches `orx/<slug>` off the branch picked at project creation - // (the repo's default unless one was chosen). - let envelope = create_baseline_experiment( - &creds, - &args.project_id, - &CreateBaselineExperimentBody { - title: Some(title), - description, - run_command: args.run_command, - }, - ) + let plane = resolve_project(store, &args.project_id)?; + let is_local = plane.is_local(); + plane + .create_experiment(CreateExperimentSpec { + title, + parent: args.parent, + baseline: args.baseline, + description: args.description, + run_command: args.run_command, + }) .await?; - experiment = envelope.experiment; - kind = "baseline".to_string(); - } - - println!("\u{2713} Created {} experiment", kind); - println!(" id: {}", experiment.id); - println!(" title: {}", experiment.title); - println!(" slug: {}", experiment.slug); - println!(" branch: {}", experiment.branch_name); - println!(); - println!("To edit it, check out the branch in your local clone of the project's repo:"); - println!( - " git fetch origin && git checkout {}", - experiment.branch_name - ); - println!(" # …edit, then…"); - println!( - " git commit -am \"<msg>\" && git push -u origin {}", - experiment.branch_name - ); // Key event, fired only on success. Coarse props only — no ids/names. - crate::telemetry::capture_experiment_started("create", false, None); - Ok(()) -} - -/// Local-mode create: every node gets a branch `orx/<slug>` pushed to origin -/// so jobs can clone it — children fork off the parent's tip, baselines off -/// the project's base branch (which itself is never an experiment node). No -/// parent = child of the project's oldest root when one exists; on an empty -/// project (or with `--baseline`) the new row becomes a baseline root. -/// Projects may hold multiple baselines. -fn run_local( - store: &Store, - project: &crate::local::model::LocalProject, - title: String, - parent: Option<String>, - baseline: bool, - description: Option<String>, - run_command: Option<String>, -) -> Result<()> { - let mut defaulted_to_root = false; - let parent_exp = match &parent { - Some(parent_id) => Some(store.get_local_experiment(parent_id)?.ok_or_else(|| { - anyhow!( - "Parent experiment {} not found in the local store. \ - See the dashboard, or omit --parent to branch off the project root.", - parent_id - ) - })?), - None if baseline => None, - None => { - let root = crate::local::experiments::project_root(store, &project.id)?; - defaulted_to_root = root.is_some(); - root - } - }; - let kind = if parent_exp.is_some() { - "child" - } else { - "baseline" - }; - - let experiment = crate::local::experiments::create_experiment( - store, - project, - parent_exp.as_ref(), - None, - Some(title), - description, - run_command, - )?; - - println!("\u{2713} Created local {} experiment", kind); - if defaulted_to_root { - let root = parent_exp.as_ref().unwrap(); - println!(" parent: {} (project root, defaulted)", root.id); - } - if let Some(warning) = parent_exp - .as_ref() - .and_then(|p| crate::local::experiments::legacy_root_warning(project, p)) - { - eprintln!(" {warning}"); - } - println!(" id: {}", experiment.id); - println!(" title: {}", experiment.display_name()); - println!(" slug: {}", experiment.slug); - println!(" branch: {}", experiment.branch_name); - if experiment.run_command.is_empty() { - println!( - " command: — (none inherited — set one with `orx project edit {} --run-command '<cmd>'`)", - project.id - ); - } else { - println!(" command: {}", experiment.run_command); - } - println!(); - println!("To edit it, check out the branch in the project's local clone:"); - println!(" cd {}", project.repo_path); - println!( - " git fetch origin && git checkout {}", - experiment.branch_name - ); - println!(" # …edit, then…"); - println!( - " git commit -am \"<msg>\" && git push -u origin {}", - experiment.branch_name - ); + crate::telemetry::capture_experiment_started("create", is_local, None); Ok(()) } diff --git a/src/commands/exp.rs b/src/commands/exp.rs index db4a7d3..7111e16 100644 --- a/src/commands/exp.rs +++ b/src/commands/exp.rs @@ -7,82 +7,57 @@ //! //! Unlike the project-scoped data commands, every verb here takes an //! *experiment* id (from `orx experiments <projectId>`). +//! +//! This module is now thin: it parses args and resolves the id to a +//! `ControlPlane`, then calls one verb. The per-plane bodies live in +//! `crate::plane::{server_plane, local_plane}`. Only the three job-launch helpers +//! (`hf_clone_script` / `default_hf_image` / `spawn_detached_supervise`) stay +//! here — every `src/local/*` backend imports them as `crate::commands::exp::*`. -use std::collections::HashMap; use std::time::{Duration, Instant}; -use tokio::io::AsyncReadExt; - -use crate::client::{ - cancel_experiment_run, create_external_run, find_project, get_experiment, list_runs, - start_experiment_run, update_experiment, RunTarget, UpdateExperimentBody, -}; -use crate::error::{anyhow, require_credentials, Result}; -use crate::jobs::{huggingface as hf, BackendDescriptor}; -use crate::local::model::LocalExperiment; -use crate::local::resolve::{resolve_experiment, resolve_project, ExperimentRef, ProjectRef}; -use crate::output::{format_duration, run_failure_detail}; -use crate::store::{now_ms, Store, StoredRun}; -use crate::{ExpCommand, ExpRunArgs}; +use crate::error::{anyhow, Result}; +use crate::plane::{resolve_experiment, resolve_project}; +use crate::store::Store; +use crate::ExpCommand; pub async fn run(args: crate::ExpArgs) -> Result<()> { // Local-mode detection first: an id in `local_experiments` takes the local // path, and credentials are only required on the server path (a local-only - // user may never have logged in). + // user may never have logged in). The plane resolver encodes that. let store = Store::open()?; match args.command { - ExpCommand::Status { exp_id } => match resolve_experiment(&store, &exp_id)? { - ExperimentRef::Local(exp) => local_status(&store, &exp), - ExperimentRef::Server(exp_id) => { - let creds = require_credentials().await; - status(&creds, &exp_id).await - } - }, - ExpCommand::Cmd { exp_id, set } => match resolve_experiment(&store, &exp_id)? { - ExperimentRef::Local(_) => Err(crate::local::unsupported("exp cmd")), - ExperimentRef::Server(exp_id) => { - let creds = require_credentials().await; - cmd(&creds, &exp_id, set).await - } - }, - ExpCommand::Desc { exp_id, set, stdin } => match resolve_experiment(&store, &exp_id)? { - ExperimentRef::Local(exp) => local_desc(&store, *exp, set, stdin).await, - ExperimentRef::Server(exp_id) => { - let creds = require_credentials().await; - desc(&creds, &exp_id, set, stdin).await - } - }, + ExpCommand::Status { exp_id } => { + resolve_experiment(store, &exp_id)? + .experiment_status() + .await + } + ExpCommand::Cmd { exp_id, set } => { + resolve_experiment(store, &exp_id)? + .set_experiment_command(set) + .await + } + ExpCommand::Desc { exp_id, set, stdin } => { + resolve_experiment(store, &exp_id)? + .experiment_desc(set, stdin) + .await + } ExpCommand::Run(run_args) => { let run_args = *run_args; - match resolve_experiment(&store, &run_args.exp_id)? { - ExperimentRef::Local(_) => local_launch(run_args).await, - ExperimentRef::Server(_) => { - let creds = require_credentials().await; - launch(&creds, run_args).await - } - } + resolve_experiment(store, &run_args.exp_id)? + .launch(run_args) + .await } - ExpCommand::Cancel { exp_id } => match resolve_experiment(&store, &exp_id)? { - ExperimentRef::Local(exp) => local_cancel(&store, &exp), - ExperimentRef::Server(exp_id) => { - let creds = require_credentials().await; - cancel(&creds, &exp_id).await - } - }, + ExpCommand::Cancel { exp_id } => resolve_experiment(store, &exp_id)?.cancel().await, ExpCommand::Wait { exp_id, project, timeout, interval, - } => wait(&store, exp_id, project, timeout, interval).await, + } => wait(store, exp_id, project, timeout, interval).await, } } -/// Terminal run states — the run is finished and won't change further. -fn is_terminal(status: &str) -> bool { - matches!(status, "done" | "failed" | "cancelled") -} - /// `orx exp wait …` — block on run state, for agents driving a research loop. /// /// Two modes, picked by argument: @@ -90,9 +65,10 @@ fn is_terminal(status: &str) -> bool { /// - `--project` — edge trigger: snapshot every run in the project and return when the first run *completes* — i.e. transitions into a terminal state (done/failed/cancelled). This is the "a slot just freed" signal a budget-saturation loop wants; run starts and queued→running transitions are intentionally ignored. /// /// Polls every `--interval` seconds (default 5), gives up after `--timeout` -/// seconds (default 1800) with a non-zero exit so callers can branch on it. +/// seconds (default 1800) with a non-zero exit so callers can branch on it. The +/// per-plane polling loops are `ControlPlane::{wait_experiment, wait_project}`. async fn wait( - store: &Store, + store: Store, exp_id: Option<String>, project: Option<String>, timeout: Option<u64>, @@ -107,752 +83,20 @@ async fn wait( "Specify what to wait on: `orx exp wait <expId>` (one run) or \ `orx exp wait --project <projectId>` (any run in a project)." )), - (Some(exp_id), None) => match resolve_experiment(store, &exp_id)? { - ExperimentRef::Local(_) => { - local_wait_experiment(store, &exp_id, interval, deadline).await - } - ExperimentRef::Server(exp_id) => { - let creds = require_credentials().await; - wait_experiment(&creds, &exp_id, interval, deadline).await - } - }, - (None, Some(project_id)) => match resolve_project(store, &project_id)? { - ProjectRef::Local(_) => { - local_wait_project(store, &project_id, interval, deadline).await - } - ProjectRef::Server(project_id) => { - let creds = require_credentials().await; - wait_project(&creds, &project_id, interval, deadline).await - } - }, - } -} - -/// Level trigger: poll one experiment's latest run until it's terminal. -async fn wait_experiment( - creds: &crate::config::Credentials, - exp_id: &str, - interval: Duration, - deadline: Instant, -) -> Result<()> { - let mut last_status: Option<String> = None; - loop { - let res = get_experiment(creds, exp_id).await?; - match res.latest_run { - None => { - if last_status.is_none() { - eprintln!("No run yet for this experiment — waiting for one to start…"); - last_status = Some(String::new()); - } - } - Some(r) => { - if last_status.as_deref() != Some(r.status.as_str()) { - eprintln!("{} {}", r.id, r.status); - last_status = Some(r.status.clone()); - } - if is_terminal(&r.status) { - println!("{} {}", r.id, r.status); - if let Some(detail) = run_failure_detail(&r) { - eprintln!("{detail}"); - } - return Ok(()); - } - } - } - sleep_until_or_timeout(interval, deadline).await?; - } -} - -/// Edge trigger: return as soon as any run in the project *completes* — i.e. -/// transitions into a terminal state (done/failed/cancelled) vs. the snapshot -/// taken on entry. Run starts, new queued runs, and queued→running transitions -/// are ignored: the useful project-wide signal is "a slot just freed", so a -/// budget-saturation loop can analyze the finished run and launch the next one. -/// -/// Note: runs already terminal at entry don't count (they're in the snapshot as -/// terminal). If *every* run is already terminal when this is called, there's -/// nothing left to complete — it returns immediately printing -/// `drained: no runs in flight` (exit 0), the termination signal for a budget -/// loop. Otherwise it returns on the first completion. -/// -/// This fires only on completions observed *within a single invocation*. A run -/// that finishes between two calls (while the caller is deciding/analyzing) is -/// already terminal in the next call's entry snapshot and won't fire. So the -/// caller must treat `exp wait --project` as a sleep-until-change signal and -/// re-list `orx runs` on every wake to find *all* newly-finished runs — don't -/// trust the printed line as the complete set. -async fn wait_project( - creds: &crate::config::Credentials, - project_id: &str, - interval: Duration, - deadline: Instant, -) -> Result<()> { - let snapshot: HashMap<String, String> = list_runs(creds, project_id) - .await? - .runs - .into_iter() - .map(|r| (r.id, r.status)) - .collect(); - let in_flight = snapshot.values().filter(|s| !is_terminal(s)).count(); - - // Fast path: nothing is in flight, so no run can *complete* while we watch — - // there's nothing to wait for. Rather than block until `--timeout` (the old - // behavior), return immediately with a distinct, machine-readable line so a - // budget loop can recognize "the batch is drained" and stop looping. This is - // the clean termination signal for `orx exp wait --project` in a loop. - if in_flight == 0 { - eprintln!( - "No runs in flight in this project ({} run(s), all terminal).", - snapshot.len() - ); - println!("drained: no runs in flight"); - return Ok(()); - } - - eprintln!( - "Watching {} run(s) in project ({} in flight) — returning on the first completion…", - snapshot.len(), - in_flight - ); - - loop { - sleep_until_or_timeout(interval, deadline).await?; - - let current = list_runs(creds, project_id).await?.runs; - // Each entry pairs the transition line with the run's failure detail (if - // it failed) so we can surface *why* on stderr after the machine line. - let mut completed: Vec<(String, Option<String>)> = Vec::new(); - for r in ¤t { - if !is_terminal(&r.status) { - continue; - } - // Fire only on a *new* terminal: a run that was non-terminal before, - // or a brand-new run that's already terminal. Skip runs that were - // already terminal in the entry snapshot. - let line = match snapshot.get(&r.id) { - Some(prev) if is_terminal(prev) => continue, - Some(prev) => format!("{} {} -> {}", r.id, prev, r.status), - None => format!("{} {} (new)", r.id, r.status), - }; - completed.push((line, run_failure_detail(r))); + (Some(exp_id), None) => { + resolve_experiment(store, &exp_id)? + .wait_experiment(interval, deadline) + .await } - if !completed.is_empty() { - for (line, detail) in &completed { - println!("{line}"); - if let Some(detail) = detail { - eprintln!("{detail}"); - } - } - return Ok(()); + (None, Some(project_id)) => { + resolve_project(store, &project_id)? + .wait_project(interval, deadline) + .await } } } -/// Sleep one interval, but fail with a timeout error if the deadline passed. -async fn sleep_until_or_timeout(interval: Duration, deadline: Instant) -> Result<()> { - if Instant::now() >= deadline { - return Err(anyhow!("Timed out waiting for a run state change.")); - } - let nap = interval.min(deadline.saturating_duration_since(Instant::now())); - tokio::time::sleep(nap).await; - if Instant::now() >= deadline { - return Err(anyhow!("Timed out waiting for a run state change.")); - } - Ok(()) -} - -/// `orx exp status <expId>` — the experiment row joined with its latest run, -/// plus everything needed to diff the run locally: the node's branch, the -/// parent's branch, the full commit SHA, and a ready-to-paste git recipe. -async fn status(creds: &crate::config::Credentials, exp_id: &str) -> Result<()> { - let res = get_experiment(creds, exp_id).await?; - let exp = res.experiment; - - // Parent branch (the diff base). Best-effort: a failed parent fetch - // degrades to printing the id alone, never fails the status command. - let parent_branch: Option<String> = match &exp.parent_experiment_id { - Some(parent_id) => get_experiment(creds, parent_id) - .await - .ok() - .map(|p| p.experiment.branch_name), - None => None, - }; - - println!("{} ({})", exp.title, exp.agent_status); - println!(" id: {}", exp.id); - println!(" branch: {}", exp.branch_name); - match (&exp.parent_experiment_id, &parent_branch) { - (Some(id), Some(branch)) => println!(" parent: {} (branch {})", id, branch), - (Some(id), None) => println!(" parent: {}", id), - (None, _) => println!(" parent: — (root experiment)"), - } - match &exp.sandbox_id { - Some(sb) => println!(" sandbox: {}", sb), - None => println!(" sandbox: — (none linked)"), - } - if exp.run_command.is_empty() { - println!( - " command: — (not set — `orx exp cmd {} --set \"…\"`)", - exp.id - ); - } else { - println!(" command: {}", exp.run_command); - } - - let mut full_sha: Option<String> = None; - match res.latest_run { - Some(r) => { - let commit = r - .commit_sha - .as_ref() - .map(|s| s.chars().take(7).collect::<String>()) - .unwrap_or_else(|| "—".to_string()); - println!( - " last run: {} ({}, commit {}, ran {}, updated {})", - r.id, - r.status, - commit, - format_duration(r.duration_seconds), - r.updated_at - ); - if let Some(detail) = run_failure_detail(&r) { - println!(" {detail}"); - } - if let Some(sha) = r.commit_sha { - println!(" commit: {}", sha); - full_sha = Some(sha); - } - } - None => println!(" last run: — (never run)"), - } - - // Local diff recipe — only when there's both a base (parent branch) and a - // head (run commit) to compare. Owner/repo lookup is best-effort too: on - // failure print placeholders the caller can fill from `orx projects`. - if let (Some(branch), Some(sha)) = (parent_branch, full_sha) { - let repo_path = match find_project(creds, &exp.project_id).await { - Ok(Some(p)) if !p.github_owner.is_empty() && !p.github_repo.is_empty() => { - format!("{}/{}", p.github_owner, p.github_repo) - } - _ => "<owner>/<repo>".to_string(), - }; - let dir = format!("~/.cache/openresearch/repos/{}", repo_path); - println!(); - println!("To see what this run changed vs. its parent, using your local clone (cloned on first use):"); - if repo_path == "<owner>/<repo>" { - println!(" # owner/repo from `orx projects`"); - } - println!( - " [ -d {} ] || git clone https://github.com/{} {}", - dir, repo_path, dir - ); - println!(" git -C {} fetch origin", dir); - println!(" git -C {} diff origin/{}...{}", dir, branch, sha); - } - - Ok(()) -} - -/// `orx exp cmd <expId> [--set <command>]` — view or set the run command. -async fn cmd(creds: &crate::config::Credentials, exp_id: &str, set: Option<String>) -> Result<()> { - match set { - Some(command) => { - let res = update_experiment( - creds, - exp_id, - &UpdateExperimentBody { - run_command: Some(command), - ..Default::default() - }, - ) - .await?; - println!("\u{2713} Run command set:"); - println!(" {}", res.experiment.run_command); - } - None => { - let res = get_experiment(creds, exp_id).await?; - if res.experiment.run_command.is_empty() { - println!( - "No run command set. Set one with `orx exp cmd {} --set \"…\"`.", - exp_id - ); - } else { - println!("{}", res.experiment.run_command); - } - } - } - Ok(()) -} - -/// `orx exp desc <expId> [--set <text> | --stdin]` — view or overwrite the -/// experiment's free-form description / notes (the existing `description` field). -/// Resolve the new description for `exp desc`, if this is a write. `--set` -/// and `--stdin` are mutually exclusive; either present means "overwrite". -async fn resolve_desc_input(set: Option<String>, stdin: bool) -> Result<Option<String>> { - match (set, stdin) { - (Some(_), true) => Err(anyhow!("Pass either --set or --stdin, not both.")), - (Some(text), false) => Ok(Some(text)), - (None, true) => { - let mut buf = String::new(); - tokio::io::stdin().read_to_string(&mut buf).await?; - Ok(Some(buf)) - } - (None, false) => Ok(None), - } -} - -async fn desc( - creds: &crate::config::Credentials, - exp_id: &str, - set: Option<String>, - stdin: bool, -) -> Result<()> { - let new_desc = resolve_desc_input(set, stdin).await?; - - match new_desc { - // Write path: overwrite the whole description. - Some(description) => { - update_experiment( - creds, - exp_id, - &UpdateExperimentBody { - description: Some(description), - ..Default::default() - }, - ) - .await?; - println!("\u{2713} Description saved."); - } - // Read path: print to stdout (pipe-friendly), or hint when empty. - None => { - let res = get_experiment(creds, exp_id).await?; - if res.experiment.description.is_empty() { - eprintln!( - "No description set. Add one with `orx exp desc {} --set \"…\"` \ - or pipe a file: `cat notes.md | orx exp desc {} --stdin`.", - exp_id, exp_id - ); - } else { - println!("{}", res.experiment.description); - } - } - } - Ok(()) -} - -/// `orx exp run <expId> …` — launch a run on a new instance or existing sandbox. -async fn launch(creds: &crate::config::Credentials, args: ExpRunArgs) -> Result<()> { - // External backends: orx submits and supervises the job itself; the api - // only mirrors. Everything below this branch is the managed path. - if args.manifest.is_some() && args.backend.as_deref() != Some("k8s") { - return Err(anyhow!("--manifest only applies with --backend k8s.")); - } - if args.host.is_some() && !matches!(args.backend.as_deref(), Some("ssh") | Some("slurm")) { - return Err(anyhow!("--host only applies with --backend ssh or slurm.")); - } - if args.org.is_some() { - return Err(anyhow!( - "--org only applies with --backend openresearch (local experiments); server \ - experiments bill the project's own org." - )); - } - match args.backend.as_deref() { - Some("hf") => return launch_hf(creds, args).await, - Some("modal") => return launch_modal(creds, args).await, - Some("k8s") => { - return Err(anyhow!( - "--backend k8s is supported for local experiments (`orx up`) only for now." - )); - } - Some("ssh") => { - return Err(anyhow!( - "--backend ssh is supported for local experiments (`orx up`) only for now." - )); - } - Some("slurm") => { - return Err(anyhow!( - "--backend slurm is supported for local experiments (`orx up`) only for now." - )); - } - Some("openresearch") => { - return Err(anyhow!( - "--backend openresearch is for local experiments (`orx up`) only. Server \ - experiments already run on OpenResearch compute — pass --gpu/--cpu/--sandbox." - )); - } - Some("local") => { - return Err(anyhow!( - "--backend local is supported for local experiments (`orx up`) only." - )); - } - Some(other) => { - return Err(anyhow!( - "Unknown --backend '{}'. Supported: hf (Hugging Face Jobs), \ - modal (Modal serverless GPUs), k8s/ssh/slurm/openresearch/local \ - (local experiments only).", - other - )); - } - None => {} - } - if args.flavor.is_some() || args.image.is_some() || args.timeout.is_some() { - return Err(anyhow!( - "--flavor/--image/--timeout only apply with an external --backend." - )); - } - if args.manifest.is_some() { - return Err(anyhow!("--manifest only applies with --backend k8s.")); - } - // Resolve the target: exactly one of --sandbox, --gpu, or --cpu. - let selectors = [ - args.sandbox.is_some(), - args.gpu.is_some(), - args.cpu.is_some(), - ]; - let chosen = selectors.iter().filter(|x| **x).count(); - if chosen > 1 { - return Err(anyhow!("Pass exactly one of --sandbox, --gpu, or --cpu.")); - } - if args.provider.is_some() && args.gpu.is_none() { - return Err(anyhow!( - "--provider only applies with --gpu (it selects among new GPU offers)." - )); - } - let target = if let Some(sandbox_id) = &args.sandbox { - RunTarget::Existing { - sandbox_id: sandbox_id.clone(), - } - } else if let Some(gpu) = &args.gpu { - RunTarget::New { - gpu: gpu.clone(), - gpu_count: args.count.unwrap_or(1), - disk_gb: args.disk.unwrap_or(100), - // Omitted = server default (RunPod). The server validates the name - // and 400s on an unknown provider, so no client-side check. - provider: args.provider.clone(), - } - } else if let Some(cpu_flavor) = &args.cpu { - RunTarget::NewCpu { - cpu_flavor: cpu_flavor.clone(), - vcpu_count: args.vcpus.unwrap_or(8), - } - } else { - return Err(anyhow!( - "Choose compute: --gpu <id> [--count N] [--disk GB], \ - --cpu <cpu5c|cpu5g|cpu5m> [--vcpus 2|8|32], or --sandbox <id>. \ - See `orx compute` for available GPUs." - )); - }; - - // Friendlier than the raw API "No run command set": tell them how to fix it. - let current = get_experiment(creds, &args.exp_id).await?; - if current.experiment.run_command.is_empty() { - return Err(anyhow!( - "No run command set for this experiment. Set one first with \ - `orx exp cmd {} --set \"…\"`.", - args.exp_id - )); - } - - // Coarse target label for analytics — NOT the sandbox id / gpu / flavor. - let target_kind = match &target { - RunTarget::Existing { .. } => "existing", - RunTarget::New { .. } => "gpu", - RunTarget::NewCpu { .. } => "cpu", - }; - start_experiment_run(creds, &args.exp_id, target, args.force).await?; - - // Key event, fired only on success. Server run (not local mode here). - crate::telemetry::capture_experiment_started("run", false, Some(target_kind)); - - println!("\u{2713} Run queued."); - println!( - " Follow it with `orx runs {}` and `orx logs <runId>`.", - current.experiment.project_id - ); - Ok(()) -} - -/// `orx exp run <expId> --backend hf --flavor <flavor>` — run the experiment -/// as a Hugging Face Job on the user's own HF account. -/// -/// Flow: register the mirror run with the api (which returns repo/branch/ -/// command), submit the job natively (clone the branch tip, run the command), -/// record the job handle everywhere, then detach `orx supervise <runId>` to -/// tail logs and mirror status. Returns immediately, like the managed path. -async fn launch_hf(creds: &crate::config::Credentials, args: ExpRunArgs) -> Result<()> { - if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() { - return Err(anyhow!( - "--backend hf runs on Hugging Face Jobs; drop --gpu/--cpu/--sandbox \ - and pass --flavor instead (e.g. --flavor a10g-small)." - )); - } - let flavor = args.flavor.clone().ok_or_else(|| { - anyhow!( - "--backend hf requires --flavor: t4-small, a10g-small/large, l4x1, \ - l40sx1, a100-large, h200, … (cpu-basic/cpu-upgrade for CPU). \ - Priced per minute on your Hugging Face account." - ) - })?; - // HF's own default is 30 minutes — a footgun for training runs, so default - // generously and let --timeout tighten it. - let timeout_seconds = match &args.timeout { - Some(t) => hf::parse_timeout(t)?, - None => 4 * 3600, - }; - let token = hf::resolve_token()?; - let namespace = hf::whoami(&token).await?; - - // Register first: the run must exist in the tree before compute starts, - // and the response carries the repo/branch/command orx needs to submit. - let mut descriptor = BackendDescriptor { - kind: "hf_job".to_string(), - namespace: Some(namespace.clone()), - job_id: None, - flavor: Some(flavor.clone()), - image: args.image.clone(), - url: None, - context: None, - manifest: None, - resources: None, - ssh_host: None, - ssh_port: None, - ssh_user: None, - timeout_secs: None, - }; - let created = - create_external_run(creds, &args.exp_id, serde_json::to_value(&descriptor)?).await?; - let run_id = created.run.id.clone(); - - let image = args - .image - .clone() - .unwrap_or_else(|| default_hf_image(&flavor)); - let script = hf_clone_script( - &created.branch_name, - &created.github_owner, - &created.github_repo, - &created.run_command, - ); - - let mut secrets = HashMap::new(); - secrets.insert("HF_TOKEN".to_string(), token.clone()); - // Clone credential precedence: explicit GITHUB_TOKEN (env, then the box's - // synced env file) overrides; otherwise the api's repo-scoped installation - // token flows automatically from the org's connected GitHub app — a - // private repo needs zero extra setup beyond having connected it. - let github_token = std::env::var("GITHUB_TOKEN") - .ok() - .filter(|t| !t.trim().is_empty()) - .or_else(|| crate::config::synced_env_var("GITHUB_TOKEN")) - .or_else(|| created.github_token.clone()); - if let Some(gh) = github_token { - secrets.insert("GITHUB_TOKEN".to_string(), gh); - } - let mut labels = HashMap::new(); - labels.insert("or_run".to_string(), run_id.clone()); - labels.insert("or_experiment".to_string(), args.exp_id.clone()); - labels.insert("or_project".to_string(), created.project_id.clone()); - - let job = hf::run_job( - &token, - &namespace, - &hf::JobSubmission { - command: vec!["bash".to_string(), "-c".to_string(), script], - docker_image: image.clone(), - flavor: flavor.clone(), - environment: HashMap::new(), - secrets, - timeout_seconds, - labels, - }, - ) - .await?; - - // Record the job handle: local store (the truth orx serve exposes), then - // the api mirror (display + reconciliation). Local write must not be lost - // even if the PATCH fails — supervise needs it to reattach. - descriptor.job_id = Some(job.id.clone()); - descriptor.url = Some(hf::job_url(&namespace, &job.id)); - descriptor.image = Some(image); - let store = Store::open()?; - store.upsert_run(&StoredRun { - id: run_id.clone(), - experiment_id: args.exp_id.clone(), - project_id: created.project_id.clone(), - status: "starting".to_string(), - backend_json: descriptor.to_json(), - command: created.run_command.clone(), - created_at: now_ms(), - updated_at: now_ms(), - ended_at: None, - exit_code: None, - commit_sha: None, - result_markdown: None, - cancel_requested: false, - })?; - if let Err(err) = crate::client::update_external_run( - creds, - &run_id, - serde_json::json!({ "backend": serde_json::to_value(&descriptor)? }), - ) - .await - { - eprintln!("warning: could not mirror the job handle to the api: {err}"); - } - - // Detach the supervisor: it tails logs, mirrors transitions, and uploads - // the final log. Survives this process exiting (new process group). - spawn_detached_supervise(&run_id)?; - - println!("\u{2713} Hugging Face job submitted."); - println!(" run {run_id}"); - println!(" job {}/{} ({flavor})", namespace, job.id); - println!(" watch {}", descriptor.url.as_deref().unwrap_or("")); - println!( - " Follow it with `orx exp wait {}` or `orx logs {run_id}`.", - args.exp_id - ); - // Key event, fired only on success. Managed run on the user's HF account. - crate::telemetry::capture_experiment_started("run", false, Some("hf")); - Ok(()) -} - -/// `orx exp run <expId> --backend modal --flavor <flavor>` — run the experiment -/// as a Modal Sandbox on the user's own Modal account. Same register → submit → -/// supervise flow as `launch_hf`, with the Modal Python launcher as transport. -async fn launch_modal(creds: &crate::config::Credentials, args: ExpRunArgs) -> Result<()> { - use crate::jobs::modal; - if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() { - return Err(anyhow!( - "--backend modal runs on Modal serverless GPUs; drop --gpu/--cpu/--sandbox \ - and pass --flavor instead (e.g. --flavor a10g, --flavor a100-80gb, --flavor cpu)." - )); - } - let flavor = args.flavor.clone().ok_or_else(|| { - anyhow!( - "--backend modal requires --flavor: a Modal GPU (t4, l4, a10g, a100, a100-80gb, \ - l40s, h100, h200, or e.g. h100:2) — or cpu / cpu-large for CPU-only. \ - Priced per second on your Modal account." - ) - })?; - let resources = modal::resolve_flavor(&flavor); - // Fail before registering the run with the api if Modal isn't set up. - modal::preflight().await?; - let timeout_seconds = match &args.timeout { - Some(t) => hf::parse_timeout(t)?, - None => 4 * 3600, - }; - const MODAL_APP: &str = "openresearch"; - - // Register first: the run must exist in the tree before compute starts, - // and the response carries the repo/branch/command orx needs to submit. - let mut descriptor = BackendDescriptor { - kind: "modal_job".to_string(), - namespace: Some(MODAL_APP.to_string()), - job_id: None, - flavor: Some(flavor.clone()), - image: args.image.clone(), - url: None, - context: None, - manifest: None, - resources: None, - ssh_host: None, - ssh_port: None, - ssh_user: None, - timeout_secs: None, - }; - let created = - create_external_run(creds, &args.exp_id, serde_json::to_value(&descriptor)?).await?; - let run_id = created.run.id.clone(); - - let image = args - .image - .clone() - .unwrap_or_else(|| modal::default_image(resources.gpu.is_some())); - let script = hf_clone_script( - &created.branch_name, - &created.github_owner, - &created.github_repo, - &created.run_command, - ); - - // Same clone-credential precedence as the HF path: explicit GITHUB_TOKEN - // (env, then the box's synced env file) overrides the api's repo-scoped - // installation token. - let mut env = HashMap::new(); - if let Ok(hf_token) = hf::resolve_token() { - env.insert("HF_TOKEN".to_string(), hf_token); - } - let github_token = std::env::var("GITHUB_TOKEN") - .ok() - .filter(|t| !t.trim().is_empty()) - .or_else(|| crate::config::synced_env_var("GITHUB_TOKEN")) - .or_else(|| created.github_token.clone()); - if let Some(gh) = github_token { - env.insert("GITHUB_TOKEN".to_string(), gh); - } - let mut tags = HashMap::new(); - tags.insert("or_run".to_string(), run_id.clone()); - tags.insert("or_experiment".to_string(), args.exp_id.clone()); - tags.insert("or_project".to_string(), created.project_id.clone()); - - let sandbox_id = modal::run_job(&modal::ModalJobSpec { - script, - image: image.clone(), - gpu: resources.gpu.clone(), - cpu: resources.cpu, - memory: resources.memory, - env, - timeout_seconds, - app: MODAL_APP.to_string(), - tags, - }) - .await?; - - // Record the sandbox handle: local store (the truth orx serve exposes), - // then the api mirror. The local write must not be lost even if PATCH fails. - descriptor.job_id = Some(sandbox_id.clone()); - descriptor.image = Some(image); - let store = Store::open()?; - store.upsert_run(&StoredRun { - id: run_id.clone(), - experiment_id: args.exp_id.clone(), - project_id: created.project_id.clone(), - status: "starting".to_string(), - backend_json: descriptor.to_json(), - command: created.run_command.clone(), - created_at: now_ms(), - updated_at: now_ms(), - ended_at: None, - exit_code: None, - commit_sha: None, - result_markdown: None, - cancel_requested: false, - })?; - if let Err(err) = crate::client::update_external_run( - creds, - &run_id, - serde_json::json!({ "backend": serde_json::to_value(&descriptor)? }), - ) - .await - { - eprintln!("warning: could not mirror the sandbox handle to the api: {err}"); - } - - spawn_detached_supervise(&run_id)?; - - println!("\u{2713} Modal sandbox submitted."); - println!(" run {run_id}"); - println!(" sandbox {sandbox_id} ({flavor})"); - println!( - " Follow it with `orx exp wait {}` or `orx logs {run_id}`.", - args.exp_id - ); - // Key event, fired only on success. Managed run on the user's Modal account. - crate::telemetry::capture_experiment_started("run", false, Some("modal")); - Ok(()) -} +// --- job-launch helpers shared with the src/local/* backends ----------------- /// Clone the experiment branch tip and run the fixed command. GITHUB_TOKEN /// (passed as a job secret when present locally) authenticates private @@ -899,252 +143,3 @@ pub(crate) fn spawn_detached_supervise(run_id: &str) -> Result<()> { .map_err(|e| anyhow!("Could not spawn `orx supervise {}`: {}", run_id, e))?; Ok(()) } - -/// `orx exp cancel <expId>` — terminate the in-flight run, if any. -async fn cancel(creds: &crate::config::Credentials, exp_id: &str) -> Result<()> { - cancel_experiment_run(creds, exp_id).await?; - println!("\u{2713} Run cancelled."); - Ok(()) -} - -// --- local mode (orx up) --- - -/// Local `exp status`: the store row plus its latest run. -fn local_status(store: &Store, exp: &LocalExperiment) -> Result<()> { - println!("{} ({}) [local]", exp.display_name(), exp.agent_status); - println!(" id: {}", exp.id); - println!(" branch: {}", exp.branch_name); - match &exp.parent_experiment_id { - Some(parent_id) => match store.get_local_experiment(parent_id)? { - Some(parent) => println!(" parent: {} (branch {})", parent_id, parent.branch_name), - None => println!(" parent: {}", parent_id), - }, - None => println!(" parent: — (root experiment)"), - } - if exp.run_command.is_empty() { - println!(" command: — (not set)"); - } else { - println!(" command: {}", exp.run_command); - } - - match store.latest_run_for_experiment(&exp.id)? { - Some(r) => { - let commit = r - .commit_sha - .as_deref() - .map(|s| s.chars().take(7).collect::<String>()) - .unwrap_or_else(|| "—".to_string()); - println!( - " last run: {} ({}, commit {}, ran {}, updated {})", - r.id, - r.status, - commit, - format_duration(crate::local::run_duration_secs(&r)), - crate::local::fmt_ago(r.updated_at) - ); - if let Some(detail) = crate::local::run_failure_detail(&r) { - println!(" {detail}"); - } - if let Some(sha) = &r.commit_sha { - println!(" commit: {}", sha); - } - } - None => println!(" last run: — (never run)"), - } - Ok(()) -} - -/// Local `exp desc`: read/write the description on the store row. -async fn local_desc( - store: &Store, - mut exp: LocalExperiment, - set: Option<String>, - stdin: bool, -) -> Result<()> { - match resolve_desc_input(set, stdin).await? { - Some(description) => { - exp.description = Some(description); - store.update_local_experiment(&exp)?; - println!("\u{2713} Description saved."); - } - None => match exp.description.as_deref().filter(|d| !d.trim().is_empty()) { - Some(d) => println!("{d}"), - None => eprintln!( - "No description set. Add one with `orx exp desc {} --set \"…\"` \ - or pipe a file: `cat notes.md | orx exp desc {} --stdin`.", - exp.id, exp.id - ), - }, - } - Ok(()) -} - -/// Local `exp run`: external backends only — HF Jobs, Modal, k8s, ssh, slurm, -/// or a detached process on this machine (`local`). -async fn local_launch(mut args: ExpRunArgs) -> Result<()> { - // Fill backend/flavor from the persisted default (Settings → Compute) - // BEFORE the flag validations below, so e.g. `--host box1` with a default - // of `ssh` is a valid launch, and before `backend_label` is captured, so - // telemetry records the resolved backend. - crate::local::apply_compute_default(&mut args.backend, &mut args.flavor); - if args.manifest.is_some() && args.backend.as_deref() != Some("k8s") { - return Err(anyhow!("--manifest only applies with --backend k8s.")); - } - if args.host.is_some() && !matches!(args.backend.as_deref(), Some("ssh") | Some("slurm")) { - return Err(anyhow!("--host only applies with --backend ssh or slurm.")); - } - if args.org.is_some() && args.backend.as_deref() != Some("openresearch") { - return Err(anyhow!("--org only applies with --backend openresearch.")); - } - // Coarse backend label for analytics; the backend name is already an enum, - // never user data. Recorded before the (borrowing) dispatch below. - let backend_label = args.backend.clone(); - let result = match args.backend.as_deref() { - Some("hf") => crate::local::hf::launch_local_hf(&args).await, - Some("modal") => crate::local::modal::launch_local_modal(&args).await, - Some("k8s") => crate::local::k8s::launch_local_k8s(&args).await, - Some("ssh") => crate::local::ssh::launch_local_ssh(&args).await, - Some("slurm") => crate::local::slurm::launch_local_slurm(&args).await, - Some("openresearch") => crate::local::openresearch::launch_local_openresearch(&args).await, - Some("local") => crate::local::localrun::launch_local_run(&args).await, - Some(other) => Err(anyhow!( - "Unknown --backend '{}'. Local experiments support: hf (Hugging Face Jobs), \ - modal (Modal serverless GPUs), k8s (your Kubernetes cluster), ssh (your own box), \ - slurm (your Slurm cluster), openresearch (an ephemeral OpenResearch box), \ - local (this machine).", - other - )), - None => Err(anyhow!( - "No --backend given and no default compute target is set. \ - Set a default in the dashboard (`orx up` → Settings → Compute → Make default), \ - or pass one per launch: \ - `--backend hf --flavor <flavor>` (e.g. --flavor a10g-small), \ - `--backend modal --flavor <flavor>` (e.g. --flavor a10g), \ - `--backend k8s` (runs the manifest committed on the branch — \ - default .orx/k8s.yaml, or --manifest <path>), \ - `--backend ssh --host <alias>` (an ~/.ssh/config alias), \ - `--backend slurm [--host <alias>] [--flavor h100:2]` (your Slurm cluster), \ - `--backend openresearch --flavor <shape>` (an ephemeral OpenResearch box, \ - e.g. --flavor h100_sxm or cpu5c; needs `orx login`), \ - or `--backend local` (a detached process on this machine)." - )), - }; - // Key event, fired only on a successful launch. Coarse backend only. - // `backend_label` is always `Some(<known backend>)` here — every arm that - // yields `Ok` matched a `Some("hf"|"modal"|...)`; `None`/unknown arms return - // `Err`. The `"unknown"` fallback is unreachable defense, never emitted. - if result.is_ok() { - let target = backend_label.as_deref().unwrap_or("unknown"); - crate::telemetry::capture_experiment_started("run", true, Some(target)); - } - result -} - -/// Local `exp cancel`: flag cancel intent on every in-flight run (concurrent -/// runs are possible via --force); the local supervisors cancel the HF jobs. -fn local_cancel(store: &Store, exp: &LocalExperiment) -> Result<()> { - let in_flight: Vec<_> = store - .list_runs_by_experiment(&exp.id)? - .into_iter() - .filter(|r| !is_terminal(&r.status)) - .collect(); - if in_flight.is_empty() { - return Err(anyhow!("No run in flight for this experiment.")); - } - for r in &in_flight { - store.set_cancel_requested(&r.id, true)?; - println!("\u{2713} Cancel requested for run {}.", r.id); - } - Ok(()) -} - -/// Local twin of `wait_experiment`: poll the store's latest run until terminal. -async fn local_wait_experiment( - store: &Store, - exp_id: &str, - interval: Duration, - deadline: Instant, -) -> Result<()> { - let mut last_status: Option<String> = None; - loop { - match store.latest_run_for_experiment(exp_id)? { - None => { - if last_status.is_none() { - eprintln!("No run yet for this experiment — waiting for one to start…"); - last_status = Some(String::new()); - } - } - Some(r) => { - if last_status.as_deref() != Some(r.status.as_str()) { - eprintln!("{} {}", r.id, r.status); - last_status = Some(r.status.clone()); - } - if is_terminal(&r.status) { - println!("{} {}", r.id, r.status); - if let Some(detail) = crate::local::run_failure_detail(&r) { - eprintln!("{detail}"); - } - return Ok(()); - } - } - } - sleep_until_or_timeout(interval, deadline).await?; - } -} - -/// Local twin of `wait_project`: same edge-trigger semantics over the store. -async fn local_wait_project( - store: &Store, - project_id: &str, - interval: Duration, - deadline: Instant, -) -> Result<()> { - let snapshot: HashMap<String, String> = store - .list_runs_by_project(project_id)? - .into_iter() - .map(|r| (r.id, r.status)) - .collect(); - let in_flight = snapshot.values().filter(|s| !is_terminal(s)).count(); - - if in_flight == 0 { - eprintln!( - "No runs in flight in this project ({} run(s), all terminal).", - snapshot.len() - ); - println!("drained: no runs in flight"); - return Ok(()); - } - - eprintln!( - "Watching {} run(s) in project ({} in flight) — returning on the first completion…", - snapshot.len(), - in_flight - ); - - loop { - sleep_until_or_timeout(interval, deadline).await?; - - let current = store.list_runs_by_project(project_id)?; - let mut completed: Vec<(String, Option<String>)> = Vec::new(); - for r in ¤t { - if !is_terminal(&r.status) { - continue; - } - let line = match snapshot.get(&r.id) { - Some(prev) if is_terminal(prev) => continue, - Some(prev) => format!("{} {} -> {}", r.id, prev, r.status), - None => format!("{} {} (new)", r.id, r.status), - }; - completed.push((line, crate::local::run_failure_detail(r))); - } - if !completed.is_empty() { - for (line, detail) in &completed { - println!("{line}"); - if let Some(detail) = detail { - eprintln!("{detail}"); - } - } - return Ok(()); - } - } -} diff --git a/src/commands/logs.rs b/src/commands/logs.rs index 76b13ed..453927d 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -1,10 +1,8 @@ -use std::io::{Read as _, Seek as _, Write}; +use std::io::Write; -use crate::client::read_run_log; -use crate::error::require_credentials; use crate::error::Result; -use crate::local::resolve::{resolve_run, RunRef}; -use crate::store::{log_path, Store}; +use crate::plane::{resolve_run, LogRequest}; +use crate::store::Store; /// Parses a string the way JS `Number(s)` does for our purposes and returns it /// only if it represents an integer (matching `Number.isInteger`). An empty or @@ -60,117 +58,33 @@ pub async fn run(args: crate::LogsArgs) -> Result<()> { }; // Local run (orx up): the log is a plain file beside the store — read it - // directly, no api / login needed. + // directly, no api / login needed. The plane resolver decides. let store = Store::open()?; - match resolve_run(&store, &args.run_id)? { - RunRef::Local(_) => run_local(&args.run_id, mode, max_bytes, start_byte, end_byte), - RunRef::Server(_) => run_server(&args.run_id, mode, max_bytes, start_byte, end_byte).await, + let plane = resolve_run(store, &args.run_id)?; + let log = plane + .read_log(LogRequest { + mode: mode.to_string(), + max_bytes, + start_byte, + end_byte, + }) + .await?; + + // A local run whose log file doesn't exist yet: one line, no body/footer. + if log.missing_local { + eprintln!("[local file] no log captured yet for this run."); + return Ok(()); } -} - -/// Server-mode log read via the api. -async fn run_server( - run_id: &str, - mode: &str, - max_bytes: Option<i64>, - start_byte: Option<i64>, - end_byte: Option<i64>, -) -> Result<()> { - let creds = require_credentials().await; - - let log = read_run_log(&creds, run_id, Some(mode), max_bytes, start_byte, end_byte).await?; // The log itself goes to stdout (pipe-friendly); metadata to stderr so it // doesn't pollute a `| grep` or a redirect. let mut stdout = std::io::stdout(); - stdout.write_all(log.content.as_bytes())?; - if !log.content.is_empty() && !log.content.ends_with('\n') { + stdout.write_all(&log.content)?; + if !log.content.is_empty() && !log.content.ends_with(b"\n") { stdout.write_all(b"\n")?; } stdout.flush()?; - let span = format!( - "bytes {}–{} of {}", - log.start_byte, log.end_byte, log.total_bytes - ); - let mut more: Vec<&str> = Vec::new(); - if log.truncated_before { - more.push("more above"); - } - if log.truncated_after { - more.push("more below"); - } - let more_str = if more.is_empty() { - String::new() - } else { - format!(" ({})", more.join(", ")) - }; - eprintln!("[{}] {}{}", log.source, span, more_str); - - Ok(()) -} - -/// Default byte window for local head/tail reads without `--bytes`. -const LOCAL_DEFAULT_BYTES: i64 = 64 * 1024; - -/// Local-mode log read: same head/tail/range semantics over the run's -/// `run-logs/<id>.log` file, same stdout/stderr split as the server path. -fn run_local( - run_id: &str, - mode: &str, - max_bytes: Option<i64>, - start_byte: Option<i64>, - end_byte: Option<i64>, -) -> Result<()> { - let path = log_path(run_id); - let total = match std::fs::metadata(&path) { - Ok(m) => m.len() as i64, - Err(_) => { - eprintln!("[local file] no log captured yet for this run."); - return Ok(()); - } - }; - - let max = max_bytes.unwrap_or(LOCAL_DEFAULT_BYTES).max(0); - let (start, end) = match mode { - "range" => ( - start_byte.unwrap_or(0).clamp(0, total), - end_byte.unwrap_or(total).clamp(0, total), - ), - "head" => (0, max.min(total)), - _ => ((total - max).max(0), total), - }; - - let mut content = Vec::new(); - if end > start { - let mut f = std::fs::File::open(&path)?; - f.seek(std::io::SeekFrom::Start(start as u64))?; - f.take((end - start) as u64).read_to_end(&mut content)?; - } - - let mut stdout = std::io::stdout(); - stdout.write_all(&content)?; - if !content.is_empty() && !content.ends_with(b"\n") { - stdout.write_all(b"\n")?; - } - stdout.flush()?; - - let mut more: Vec<&str> = Vec::new(); - if start > 0 { - more.push("more above"); - } - if end < total { - more.push("more below"); - } - let more_str = if more.is_empty() { - String::new() - } else { - format!(" ({})", more.join(", ")) - }; - eprintln!( - "[local file] bytes {}–{} of {}{}", - start, end, total, more_str - ); - + eprintln!("{}", log.footer()); Ok(()) } diff --git a/src/commands/project.rs b/src/commands/project.rs index 14049cd..446cf04 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -6,14 +6,8 @@ //! one — mirroring `orx experiments` (list) vs `orx exp` (operate). Project ids //! come from `orx projects`. -use tokio::io::AsyncReadExt; - -use crate::client::{ - get_project, list_experiments, list_reports, update_project, UpdateProjectBody, -}; -use crate::commands::experiments::print_tree; -use crate::error::{anyhow, require_credentials, Result}; -use crate::local::resolve::{resolve_project, ProjectRef}; +use crate::error::Result; +use crate::plane::{resolve_project, ProjectEdit}; use crate::ProjectCommand; pub async fn run(args: crate::ProjectArgs) -> Result<()> { @@ -21,274 +15,28 @@ pub async fn run(args: crate::ProjectArgs) -> Result<()> { ProjectCommand::View { project_id } | ProjectCommand::Edit { project_id, .. } => project_id, }; let store = crate::store::Store::open()?; - match resolve_project(&store, project_id)? { - ProjectRef::Local(project) => match args.command { - ProjectCommand::View { .. } => view_local(&store, &project), - ProjectCommand::Edit { - name, - description, - description_stdin, - public, - private, - run_command, - .. - } => { - if description.is_some() || description_stdin || public || private { - return Err(anyhow!( - "Local projects support --name and --run-command only." - )); - } - edit_local(&store, *project, name, run_command) - } - }, - ProjectRef::Server(_) => run_server(args).await, - } -} - -/// Server-mode `orx project` via the api. -async fn run_server(args: crate::ProjectArgs) -> Result<()> { - // The server project PATCH carries no run command field — refuse before - // even asking for credentials. - if let ProjectCommand::Edit { - run_command: Some(_), - .. - } = &args.command - { - return Err(anyhow!( - "--run-command is supported for local projects only. For server \ - projects, set it per experiment with `orx exp cmd <expId> --set '<cmd>'`." - )); - } - let creds = require_credentials().await; + let plane = resolve_project(store, project_id)?; match args.command { - ProjectCommand::View { project_id } => view(&creds, &project_id).await, + ProjectCommand::View { .. } => plane.view_project().await, ProjectCommand::Edit { - project_id, name, description, description_stdin, public, private, - run_command: _, + run_command, + .. } => { - edit( - &creds, - &project_id, - name, - description, - description_stdin, - public, - private, - ) - .await - } - } -} - -/// Local `orx project view`: the project row, its default run command, and a -/// flat experiment list (there is no local `orx experiments`). -fn view_local( - store: &crate::store::Store, - project: &crate::local::model::LocalProject, -) -> Result<()> { - println!("{} (local)", project.name); - println!(" id: {}", project.id); - println!( - " repo: {}/{} (baseline branch: {})", - project.github_owner, project.github_repo, project.baseline_branch - ); - println!(" clone: {}", project.repo_path); - match project - .run_command - .as_deref() - .filter(|c| !c.trim().is_empty()) - { - Some(cmd) => println!(" command: {}", cmd), - None => println!( - " command: — (not set — `orx project edit {} --run-command '<cmd>'`)", - project.id - ), - } - - let experiments = store.list_experiments_by_project(&project.id)?; - println!("\nExperiments"); - if experiments.is_empty() { - println!(" (none)"); - } else { - for e in &experiments { - let root = if e.parent_experiment_id.is_none() { - " [root]" - } else { - "" - }; - println!( - " {} {}{} ({})", - e.id, - e.display_name(), - root, - e.branch_name - ); - } - } - Ok(()) -} - -/// Local `orx project edit`: rename and/or set the default run command -/// (`--run-command ''` clears it). New experiments inherit the command. -fn edit_local( - store: &crate::store::Store, - mut project: crate::local::model::LocalProject, - name: Option<String>, - run_command: Option<String>, -) -> Result<()> { - if name.is_none() && run_command.is_none() { - return Err(anyhow!( - "Nothing to change. Pass at least one of --name or --run-command." - )); - } - if let Some(name) = name { - if name.trim().is_empty() { - return Err(anyhow!("--name cannot be empty.")); - } - project.name = name.trim().to_string(); - } - if let Some(cmd) = run_command { - project.run_command = Some(cmd).filter(|c| !c.trim().is_empty()); - } - store.update_local_project(&project)?; - - println!("\u{2713} Project updated."); - println!(" id: {}", project.id); - println!(" name: {}", project.name); - match project.run_command.as_deref() { - Some(cmd) => println!(" command: {}", cmd), - None => println!(" command: — (empty)"), - } - Ok(()) -} - -/// `orx project view <projectId>` — overview of a single project: its details, -/// experiment tree, and reports. Works for any public project, or any private -/// one in an org you belong to. -async fn view(creds: &crate::config::Credentials, project_id: &str) -> Result<()> { - let project = get_project(creds, project_id).await?.project; - - println!("{}", project.name); - println!(" id: {}", project.id); - if !project.github_owner.is_empty() { - println!(" repo: {}/{}", project.github_owner, project.github_repo); - } - println!( - " access: {}", - if project.is_public { - "public" - } else { - "private" - } - ); - if !project.description.is_empty() { - println!(" about: {}", project.description); - } - if let Some(q) = project - .example_question - .as_deref() - .filter(|q| !q.is_empty()) - { - println!(" ask: {}", q); - } - - let experiments = list_experiments(creds, project_id).await?.experiments; - println!("\nExperiments"); - if experiments.is_empty() { - println!(" (none)"); - } else { - print_tree(&experiments); - } - - let reports = list_reports(creds, project_id).await?.reports; - println!("\nReports"); - if reports.is_empty() { - println!(" (none)"); - } else { - for r in &reports { - println!(" {} {} ({})", r.id, r.title, r.created_at); - } - println!("\nRead one with: orx report show {} <reportId>", project_id); - } - Ok(()) -} - -/// `orx project edit <projectId> [--name …] [--description … | --description-stdin] [--public | --private]` -/// — overwrite a project's name, description, and/or visibility. -async fn edit( - creds: &crate::config::Credentials, - project_id: &str, - name: Option<String>, - description: Option<String>, - description_stdin: bool, - public: bool, - private: bool, -) -> Result<()> { - // `--description` and `--description-stdin` are mutually exclusive; either - // present means "overwrite the description". - let description = match (description, description_stdin) { - (Some(_), true) => { - return Err(anyhow!( - "Pass either --description or --description-stdin, not both." - )) - } - (Some(text), false) => Some(text), - (None, true) => { - let mut buf = String::new(); - tokio::io::stdin().read_to_string(&mut buf).await?; - Some(buf) - } - (None, false) => None, - }; - - // `--public` / `--private` map to the `isPublic` flag; clap's - // `conflicts_with` already rejects passing both. Neither flag leaves - // visibility untouched (`None`). - let is_public = match (public, private) { - (true, false) => Some(true), - (false, true) => Some(false), - _ => None, - }; - - if name.is_none() && description.is_none() && is_public.is_none() { - return Err(anyhow!( - "Nothing to change. Pass at least one of --name, --description \ - (or --description-stdin), --public, or --private." - )); - } - - let res = update_project( - creds, - project_id, - &UpdateProjectBody { - name, - description, - is_public, - }, - ) - .await?; - let project = res.project; - - println!("\u{2713} Project updated."); - println!(" id: {}", project.id); - println!(" name: {}", project.name); - println!( - " access: {}", - if project.is_public { - "public" - } else { - "private" + plane + .edit_project(ProjectEdit { + name, + description, + description_stdin, + public, + private, + run_command, + }) + .await } - ); - if project.description.is_empty() { - println!(" description: — (empty)"); - } else { - println!(" description: {}", project.description); } - Ok(()) } diff --git a/src/commands/report.rs b/src/commands/report.rs index 58767e5..a1f55c2 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -5,16 +5,14 @@ //! `images/` subfolder, exactly the shape the autoresearch agent writes. Upload //! creates the report row, then PUTs each file directly to storage using the //! presigned URLs the API returns (bytes never transit the API). +//! +//! Reports are a cloud-only feature: a local project has no report registry, so +//! its plane returns files-dir guidance instead of a registry op. The dispatch, +//! upload/list/show/download logic, and the guidance all live in the plane impls +//! (`ServerPlane` / `LocalPlane`); this command just resolves and forwards. -use std::path::{Path, PathBuf}; - -use crate::client::{ - create_report, download_report_file, get_report, list_reports, upload_to_presigned, - CreateReportBody, -}; -use crate::config::Credentials; -use crate::error::{anyhow, require_credentials, Result}; -use crate::local::resolve::{resolve_project, ProjectRef}; +use crate::error::Result; +use crate::plane::resolve_project; pub async fn run(args: crate::ReportArgs) -> Result<()> { let project_id = match &args.command { @@ -24,289 +22,6 @@ pub async fn run(args: crate::ReportArgs) -> Result<()> { | crate::ReportCommand::Download { project_id, .. } => project_id, }; let store = crate::store::Store::open()?; - match resolve_project(&store, project_id)? { - ProjectRef::Local(project) => local_guidance(&project), - ProjectRef::Server(_) => run_server(args).await, - } -} - -/// Server-mode `orx report` via the api. -async fn run_server(args: crate::ReportArgs) -> Result<()> { - match args.command { - crate::ReportCommand::Upload { - project_id, - folder, - title, - } => upload(&project_id, &folder, title).await, - crate::ReportCommand::List { project_id } => list(&project_id).await, - crate::ReportCommand::Show { project_id, report } => show(&project_id, &report).await, - crate::ReportCommand::Download { - project_id, - report, - dir, - } => download(&project_id, &report, &dir).await, - } -} - -/// Local projects have no report registry or upload step — the files dir on -/// disk is the whole feature. Point there instead of pretending to upload. -fn local_guidance(project: &crate::local::model::LocalProject) -> Result<()> { - let dir = crate::local::files::ensure_dir(project)?; - Err(anyhow!( - "`orx report` is cloud-only. Local projects have no upload step: write the report \ - folder (report.md + images/) straight into the project's files directory,\n {}\n\ - Everything in that directory shows up in the dashboard's Files tab.", - dir.display() - )) -} - -/// Resolve a report id-or-slug to its id, erroring clearly if it isn't found. -/// We always list first so a stale ref gives a helpful message, not a raw 404. -async fn resolve_report_id(creds: &Credentials, project_id: &str, report: &str) -> Result<String> { - let reports = list_reports(creds, project_id).await?.reports; - reports - .iter() - .find(|r| r.id == report || r.slug == report) - .map(|r| r.id.clone()) - .ok_or_else(|| { - anyhow!( - "No report {:?} in this project. List them with: orx report list {}", - report, - project_id - ) - }) -} - -/// `orx report show <projectId> <reportId|slug>` — print a report's markdown -/// body to stdout. Accepts a report id or its slug (resolved via the list). -async fn show(project_id: &str, report: &str) -> Result<()> { - let creds = require_credentials().await; - let report_id = resolve_report_id(&creds, project_id, report).await?; - - let detail = get_report(&creds, project_id, &report_id).await?; - if detail.markdown.is_empty() { - return Err(anyhow!( - "Report {:?} has no markdown body (report.md was never uploaded).", - detail.report.title - )); - } - print!("{}", detail.markdown); - if !detail.markdown.ends_with('\n') { - println!(); - } - Ok(()) -} - -/// `orx report download <projectId> <reportId|slug> <dir>` — write a report's -/// `report.md` (raw, frontmatter intact) plus every image it references into -/// `dir`, reconstructing the same folder shape `upload` consumes. This is what -/// lets a local publish step feed the report back into the alphaXiv ingest. -async fn download(project_id: &str, report: &str, dir: &str) -> Result<()> { - let creds = require_credentials().await; - let report_id = resolve_report_id(&creds, project_id, report).await?; - - let detail = get_report(&creds, project_id, &report_id).await?; - if detail.markdown.is_empty() { - return Err(anyhow!( - "Report {:?} has no markdown body (report.md was never uploaded).", - detail.report.title - )); - } - - let root = PathBuf::from(dir); - std::fs::create_dir_all(&root) - .map_err(|e| anyhow!("Could not create {}: {}", root.display(), e))?; - - // report.md, byte-for-byte (the markdown the API returns is the stored file, - // YAML frontmatter included — the ingest reads `repo`/`gpu`/`count` from it). - let md_path = root.join("report.md"); - std::fs::write(&md_path, detail.markdown.as_bytes()) - .map_err(|e| anyhow!("Could not write {}: {}", md_path.display(), e))?; - println!(" wrote report.md"); - - // Pull every report-relative file the markdown links to (images, mostly). - // There's no list-files endpoint, so the references in report.md are the - // manifest — which is exactly the set that has to exist for it to render. - let mut downloaded = 0usize; - for rel in report_relative_links(&detail.markdown) { - if !is_safe_report_path(&rel) { - continue; - } - let bytes = match download_report_file(&creds, project_id, &report_id, &rel).await { - Ok(b) => b, - // A broken link in the markdown shouldn't abort the whole download; - // surface it and keep going. - Err(e) => { - eprintln!(" ! skipped {} ({})", rel, e); - continue; - } - }; - let out = root.join(&rel); - if let Some(parent) = out.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| anyhow!("Could not create {}: {}", parent.display(), e))?; - } - std::fs::write(&out, &bytes) - .map_err(|e| anyhow!("Could not write {}: {}", out.display(), e))?; - println!(" wrote {}", rel); - downloaded += 1; - } - - println!( - "\u{2713} Downloaded report to {} (report.md + {} file{})", - root.display(), - downloaded, - if downloaded == 1 { "" } else { "s" } - ); - Ok(()) -} - -/// Extract the report-relative link/image targets from markdown — the `target` -/// in every `](target)` (covers `![alt](images/x.png)` and `[text](file)`). -/// Filters out absolute URLs, anchors, and absolute paths, leaving the local -/// files the report bundles. Deduplicated, order preserved. -fn report_relative_links(md: &str) -> Vec<String> { - let mut out: Vec<String> = Vec::new(); - let bytes = md.as_bytes(); - let mut i = 0; - while i + 1 < bytes.len() { - if bytes[i] == b']' && bytes[i + 1] == b'(' { - let start = i + 2; - if let Some(rel) = bytes[start..].iter().position(|&b| b == b')') { - let inner = &md[start..start + rel]; - // Drop an optional `"title"` after the URL: `(path "t")`. - let target = inner.split_whitespace().next().unwrap_or("").trim(); - if is_local_target(target) && !out.iter().any(|p| p == target) { - out.push(target.to_string()); - } - i = start + rel + 1; - continue; - } - } - i += 1; - } - out -} - -/// A link target that points at a file bundled in the report (not the web). -fn is_local_target(t: &str) -> bool { - !t.is_empty() - && !t.starts_with('#') - && !t.starts_with('/') - && !t.contains("://") - && !t.starts_with("//") - && !t.starts_with("mailto:") - && !t.starts_with("data:") -} - -/// Mirror of the server's `isSafeReportPath`: relative, no `..`/`.` segments, -/// no backslashes — so a malicious markdown link can't escape `dir`. -fn is_safe_report_path(p: &str) -> bool { - !p.starts_with('/') && !p.contains('\\') && !p.split('/').any(|seg| seg == ".." || seg == ".") -} - -async fn list(project_id: &str) -> Result<()> { - let creds = require_credentials().await; - let reports = list_reports(&creds, project_id).await?.reports; - if reports.is_empty() { - println!("No reports yet."); - return Ok(()); - } - for r in reports { - println!("{} {} ({})", r.id, r.title, r.created_at); - } - Ok(()) -} - -// Files surfaced by the OS that aren't part of a report. -const IGNORED: &[&str] = &[".DS_Store", "Thumbs.db"]; - -async fn upload(project_id: &str, folder: &str, title: Option<String>) -> Result<()> { - let creds = require_credentials().await; - - let root = PathBuf::from(folder); - if !root.is_dir() { - return Err(anyhow!("Not a directory: {}", folder)); - } - - // Collect every file under the folder as a report-relative POSIX path. - let mut rel_paths: Vec<String> = Vec::new(); - collect_files(&root, &root, &mut rel_paths)?; - rel_paths.retain(|p| { - let name = p.rsplit('/').next().unwrap_or(p); - !IGNORED.contains(&name) - }); - - if rel_paths.is_empty() { - return Err(anyhow!("No files found in {}", folder)); - } - if !rel_paths.iter().any(|p| p == "report.md") { - return Err(anyhow!( - "{} must contain a report.md at its top level", - folder - )); - } - - // Title defaults to the folder name. - let title = title.unwrap_or_else(|| { - root.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("report") - .to_string() - }); - - let result = create_report( - &creds, - project_id, - &CreateReportBody { - title: title.clone(), - slug: None, - files: rel_paths.clone(), - }, - ) - .await?; - - // Upload each file to its presigned URL. - for slot in &result.uploads { - let abs = root.join(&slot.path); - let bytes = - std::fs::read(&abs).map_err(|e| anyhow!("Could not read {}: {}", abs.display(), e))?; - upload_to_presigned(&slot.url, &slot.content_type, bytes).await?; - println!(" uploaded {}", slot.path); - } - - println!("\u{2713} Uploaded report"); - println!(" id: {}", result.report.id); - println!(" title: {}", result.report.title); - println!(" files: {}", result.uploads.len()); - Ok(()) -} - -/// Recursively collect files under `dir`, pushing each as a `/`-joined path -/// relative to `base`. -fn collect_files(base: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> { - for entry in - std::fs::read_dir(dir).map_err(|e| anyhow!("Could not read {}: {}", dir.display(), e))? - { - let entry = entry.map_err(|e| anyhow!("Could not read entry: {}", e))?; - let path = entry.path(); - let file_type = entry - .file_type() - .map_err(|e| anyhow!("Could not stat {}: {}", path.display(), e))?; - if file_type.is_dir() { - collect_files(base, &path, out)?; - } else if file_type.is_file() { - if let Ok(rel) = path.strip_prefix(base) { - let rel = rel - .components() - .map(|c| c.as_os_str().to_string_lossy()) - .collect::<Vec<_>>() - .join("/"); - if !rel.is_empty() { - out.push(rel); - } - } - } - } - Ok(()) + let plane = resolve_project(store, project_id)?; + plane.report(args.command).await } diff --git a/src/commands/runs.rs b/src/commands/runs.rs index f2fd2dc..7419c0b 100644 --- a/src/commands/runs.rs +++ b/src/commands/runs.rs @@ -1,50 +1,27 @@ //! Lists a project's runs as a table, newest first. -use std::collections::HashMap; - -use crate::client::{list_experiments, list_runs}; -use crate::error::{require_credentials, Result}; -use crate::local::resolve::{resolve_project, ProjectRef}; -use crate::output::{format_duration, print_table, run_failure_detail}; +use crate::error::Result; +use crate::output::{format_duration, print_table}; +use crate::plane::resolve_project; use crate::store::Store; /// Lists a project's runs as a table, newest first. pub async fn run(args: crate::RunsArgs) -> Result<()> { - // Local project (orx up): the store is the truth, no api / login needed. + // Local project (orx up): the store is the truth, no api / login needed — + // the plane resolver decides which side owns the id. let store = Store::open()?; - match resolve_project(&store, &args.project_id)? { - ProjectRef::Local(_) => run_local(&store, &args), - ProjectRef::Server(_) => run_server(&args).await, - } -} - -/// Server-mode listing from the api. -async fn run_server(args: &crate::RunsArgs) -> Result<()> { - let creds = require_credentials().await; + let plane = resolve_project(store, &args.project_id)?; + let listing = plane.list_runs().await?; - // Fetch experiments too, so we can label each run with its experiment title - // rather than a bare id. Both requests run concurrently (TS Promise.all). - let (runs_res, experiments_res) = tokio::join!( - list_runs(&creds, &args.project_id), - list_experiments(&creds, &args.project_id) - ); - let runs = runs_res?.runs; - let experiments = experiments_res?.experiments; - - let title_of: HashMap<String, String> = - experiments.into_iter().map(|e| (e.id, e.title)).collect(); - - let mut filtered: Vec<_> = match &args.experiment { - Some(exp_id) => runs + let filtered: Vec<_> = match &args.experiment { + Some(exp_id) => listing + .runs .into_iter() .filter(|r| &r.experiment_id == exp_id) .collect(), - None => runs, + None => listing.runs, }; - // Run ids are UUIDv7 — lexicographic sort is chronological. Newest first. - filtered.sort_by(|a, b| b.id.cmp(&a.id)); - if filtered.is_empty() { println!("No runs found."); return Ok(()); @@ -54,87 +31,17 @@ async fn run_server(args: &crate::RunsArgs) -> Result<()> { // (provider error on spin-up failures) doesn't fit a fixed-width column. let failures: Vec<(String, String)> = filtered .iter() - .filter_map(|r| run_failure_detail(r).map(|d| (r.id.clone(), d))) + .filter_map(|r| r.failure_detail().map(|d| (r.id.clone(), d))) .collect(); let rows: Vec<Vec<String>> = filtered - .into_iter() - .map(|r| { - vec![ - r.id, - r.status, - title_of - .get(&r.experiment_id) - .cloned() - .unwrap_or(r.experiment_id), - match &r.commit_sha { - Some(sha) => sha.chars().take(7).collect::<String>(), - None => "—".to_string(), - }, - format_duration(r.duration_seconds), - r.updated_at, - ] - }) - .collect(); - - print_table( - &[ - "ID", - "STATUS", - "EXPERIMENT", - "COMMIT", - "DURATION", - "UPDATED", - ], - &rows, - ); - - if !failures.is_empty() { - println!(); - for (id, detail) in &failures { - println!("{id} {detail}"); - } - } - - Ok(()) -} - -/// Local-mode listing from the store. Same table shape as the server path; -/// timestamps render as relative ("3m ago") since the store keeps unix millis. -fn run_local(store: &Store, args: &crate::RunsArgs) -> Result<()> { - let title_of: HashMap<String, String> = store - .list_experiments_by_project(&args.project_id)? - .into_iter() - .map(|e| (e.id.clone(), e.display_name().to_string())) - .collect(); - - // Already newest-first (store orders by created_at DESC). - let runs = store.list_runs_by_project(&args.project_id)?; - let filtered: Vec<_> = match &args.experiment { - Some(exp_id) => runs - .into_iter() - .filter(|r| &r.experiment_id == exp_id) - .collect(), - None => runs, - }; - - if filtered.is_empty() { - println!("No runs found."); - return Ok(()); - } - - let failures: Vec<(String, String)> = filtered .iter() - .filter_map(|r| crate::local::run_failure_detail(r).map(|d| (r.id.clone(), d))) - .collect(); - - let rows: Vec<Vec<String>> = filtered - .into_iter() .map(|r| { vec![ r.id.clone(), r.status.clone(), - title_of + listing + .titles .get(&r.experiment_id) .cloned() .unwrap_or_else(|| r.experiment_id.clone()), @@ -142,8 +49,8 @@ fn run_local(store: &Store, args: &crate::RunsArgs) -> Result<()> { Some(sha) => sha.chars().take(7).collect::<String>(), None => "—".to_string(), }, - format_duration(crate::local::run_duration_secs(&r)), - crate::local::fmt_ago(r.updated_at), + format_duration(r.duration_secs), + r.updated_display.clone(), ] }) .collect(); diff --git a/src/local/mod.rs b/src/local/mod.rs index a144fa0..149ec7a 100644 --- a/src/local/mod.rs +++ b/src/local/mod.rs @@ -176,20 +176,6 @@ pub fn run_duration_secs(run: &StoredRun) -> i64 { (run.ended_at.unwrap_or_else(now_ms) - run.created_at) / 1000 } -/// Local twin of `output::run_failure_detail` for store-backed runs. -pub fn run_failure_detail(run: &StoredRun) -> Option<String> { - if run.status != "failed" { - return None; - } - match run.result_markdown.as_deref().map(str::trim) { - Some(reason) if !reason.is_empty() => Some(format!("reason: {reason}")), - _ => Some(format!( - "reason: — (no message recorded — see `orx logs {}`)", - run.id - )), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 7735143..52c623a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod jobs; #[allow(dead_code)] mod local; mod output; +mod plane; mod remote; mod store; mod telemetry; diff --git a/src/output.rs b/src/output.rs index 17b0628..268ea7f 100644 --- a/src/output.rs +++ b/src/output.rs @@ -70,28 +70,6 @@ fn pad_end(s: &str, width: usize) -> String { } } -/// When a run ended in `failed`, return a human-readable explanation to print -/// beneath its status line — so an agent driving the CLI sees *why* a run died, -/// not just that it did. For compute spin-up failures this is the same provider -/// error the website shows as a toast (it's persisted to the run's -/// `result_markdown`). For runtime failures after the box came up no reason is -/// recorded, so we point at the logs instead. Returns `None` for non-failed runs. -pub fn run_failure_detail(run: &crate::client::Run) -> Option<String> { - if run.status != "failed" { - return None; - } - match run.result_markdown.as_deref().map(str::trim) { - Some(reason) if !reason.is_empty() => Some(format!("reason: {reason}")), - // No persisted reason: the run failed after the box was up, so the detail - // is in the logs (when we captured any). - _ if run.log_key.is_some() => Some(format!( - "reason: — (failed after startup; no message recorded — see `orx logs {}`)", - run.id - )), - _ => Some("reason: — (no message recorded)".to_string()), - } -} - /// Formats an arbitrary SQL cell value for display, matching the TS `cell()`: /// `null`/absent -> "", objects/arrays -> compact JSON, scalars -> their string. pub fn cell(value: &Value) -> String { diff --git a/src/plane.rs b/src/plane.rs new file mode 100644 index 0000000..f713e7e --- /dev/null +++ b/src/plane.rs @@ -0,0 +1,481 @@ +//! The control-plane abstraction — one `ControlPlane` trait with two +//! implementors, `ServerPlane` (the cloud api, `client.rs`) and `LocalPlane` +//! (the local store + `src/local`). The six dual-mode commands +//! (`exp`/`runs`/`logs`/`project`/`report`/`create_experiment`) resolve an id to +//! a `Box<dyn ControlPlane>` once and then call verbs, instead of each branching +//! on `resolve_project`/`resolve_run` and inlining a local body and a server body. +//! +//! ## Why this lives outside `src/local/` +//! +//! `local/mod.rs` documents a hard invariant: nothing under `src/local/` ever +//! calls `client.rs`. A `ServerPlane` is by definition a `client.rs` wrapper, so +//! it cannot live there. `local::resolve` stays the pure "is this id local?" +//! decision layer; `plane::resolve_*` builds on it and hands back the boxed plane +//! that owns the id. +//! +//! ## What the domain types deliberately exclude +//! +//! `plane::{Run, Experiment, Project}` are CLI-facing only: they carry exactly +//! the fields the six commands' render code reads, and nothing else. They are NOT +//! the `orx up` HTTP wire types — `local::model::*` (camelCase, consumed by the +//! UI) and the `client.rs` DTOs keep their own shapes and serialization. Each +//! plane maps its native type (`client::Run` / `store::StoredRun`, …) into the +//! domain type at its own boundary via plain conversions here. This confines the +//! type change to CLI table output, which is snapshot-verifiable, and keeps the +//! wire (UI) and SQLite schema untouched. + +use std::time::{Duration, Instant}; + +use async_trait::async_trait; + +use crate::client; +use crate::error::{anyhow, Result}; +use crate::store::{Store, StoredRun}; + +// --------------------------------------------------------------------------- +// Domain types — CLI-facing projections of the parallel wire/row shapes. +// --------------------------------------------------------------------------- + +/// Which plane a domain `Run` came from. Only affects the no-persisted-reason +/// wording in `failure_detail` (the two originals diverged there); everything +/// else about a `Run` is plane-agnostic. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RunOrigin { + /// From `client::Run`. `has_log` distinguishes "failed after startup" (a + /// captured log key exists) from a bare "no message recorded". + Server { has_log: bool }, + /// From `store::StoredRun`. No server log-key concept; the no-reason wording + /// always points at `orx logs`. + Local, +} + +/// A run, projected to just the fields CLI rendering reads. `duration_secs` and +/// `updated_display` are pre-rendered by the owning plane because the two planes +/// compute them differently (server: server-supplied seconds + an ISO string; +/// local: `now - created` and a relative "3m ago") — folding that into the +/// domain type keeps the command's table code plane-agnostic and byte-identical. +pub struct Run { + pub id: String, + pub experiment_id: String, + pub status: String, + pub commit_sha: Option<String>, + /// Seconds from creation to end (or to now while in-flight). + pub duration_secs: i64, + /// Ready-to-print "updated" cell (ISO string on server, "3m ago" locally). + pub updated_display: String, + /// Terminal detail source for `failure_detail`: the persisted reason. + pub result_markdown: Option<String>, + /// Which plane produced this run — selects the no-reason failure wording. + pub origin: RunOrigin, +} + +impl From<client::Run> for Run { + fn from(r: client::Run) -> Self { + Run { + origin: RunOrigin::Server { + has_log: r.log_key.is_some(), + }, + id: r.id, + experiment_id: r.experiment_id, + status: r.status, + commit_sha: r.commit_sha, + duration_secs: r.duration_seconds, + updated_display: r.updated_at, + result_markdown: r.result_markdown, + } + } +} + +impl From<&StoredRun> for Run { + fn from(r: &StoredRun) -> Self { + Run { + id: r.id.clone(), + experiment_id: r.experiment_id.clone(), + status: r.status.clone(), + commit_sha: r.commit_sha.clone(), + duration_secs: crate::local::run_duration_secs(r), + updated_display: crate::local::fmt_ago(r.updated_at), + result_markdown: r.result_markdown.clone(), + origin: RunOrigin::Local, + } + } +} + +impl Run { + /// When a run ended in `failed`, a human-readable explanation to print + /// beneath its status line — so an agent driving the CLI sees *why* a run + /// died, not just that it did. For compute spin-up failures this is the same + /// provider error the website shows as a toast (persisted to + /// `result_markdown`). Otherwise no reason is recorded, so we point at the + /// logs. Returns `None` for non-failed runs. + /// + /// This is the single merge of the former `output::run_failure_detail` + /// (`client::Run`) and `local::run_failure_detail` (`StoredRun`). They agreed + /// on the reason-present branch and diverged only when no reason was + /// persisted; `origin` reproduces each wording exactly. + pub fn failure_detail(&self) -> Option<String> { + if self.status != "failed" { + return None; + } + match self.result_markdown.as_deref().map(str::trim) { + Some(reason) if !reason.is_empty() => Some(format!("reason: {reason}")), + // No persisted reason: the server path split "failed after startup" + // (a captured log exists) from a bare "no message recorded"; the + // local path always points at `orx logs`. + _ => Some(match self.origin { + RunOrigin::Server { has_log: true } => format!( + "reason: — (failed after startup; no message recorded — see `orx logs {}`)", + self.id + ), + RunOrigin::Server { has_log: false } => { + "reason: — (no message recorded)".to_string() + } + RunOrigin::Local => format!( + "reason: — (no message recorded — see `orx logs {}`)", + self.id + ), + }), + } + } +} + +/// A rendered run log excerpt (server byte-range read or local file read), +/// projected to the fields `orx logs` prints. The command owns the stdout write +/// and the stderr footer; the plane supplies the bytes and the window metadata. +pub struct RunLog { + /// Log bytes for the requested window. + pub content: Vec<u8>, + pub start_byte: i64, + pub end_byte: i64, + pub total_bytes: i64, + /// Footer source label: the server's `source`, or "local file". + pub source: String, + pub truncated_before: bool, + pub truncated_after: bool, + /// Set only on the local path when the file does not exist yet — the command + /// prints the "no log captured yet" line instead of an empty read. + pub missing_local: bool, +} + +/// The two shapes of `orx logs` footer, kept byte-identical to the originals. +impl RunLog { + /// `"[<source>] bytes <s>–<e> of <total>[ (more above, more below)]"`. + pub fn footer(&self) -> String { + let mut more: Vec<&str> = Vec::new(); + if self.truncated_before { + more.push("more above"); + } + if self.truncated_after { + more.push("more below"); + } + let more_str = if more.is_empty() { + String::new() + } else { + format!(" ({})", more.join(", ")) + }; + format!( + "[{}] bytes {}–{} of {}{}", + self.source, self.start_byte, self.end_byte, self.total_bytes, more_str + ) + } +} + +/// The description write vs read distinction. Resolved *inside* each plane's +/// verb — not by the command — so ordering vs the login check matches the +/// pre-trait code: a logged-out `--stdin` user gets "Not logged in" without +/// stdin being consumed first (the server plane connects before resolving). +pub enum DescInput { + /// Overwrite the description with this text. + Set(String), + /// Read/print the current description. + Get, +} + +impl DescInput { + /// `--set` and `--stdin` are mutually exclusive; either present means + /// "overwrite". `--stdin` reads to EOF here, which is why call order + /// relative to `require_credentials` matters (see the enum doc). + pub async fn resolve(set: Option<String>, stdin: bool) -> Result<Self> { + use tokio::io::AsyncReadExt as _; + match (set, stdin) { + (Some(_), true) => Err(anyhow!("Pass either --set or --stdin, not both.")), + (Some(text), false) => Ok(DescInput::Set(text)), + (None, true) => { + let mut buf = String::new(); + tokio::io::stdin().read_to_string(&mut buf).await?; + Ok(DescInput::Set(buf)) + } + (None, false) => Ok(DescInput::Get), + } + } +} + +// --------------------------------------------------------------------------- +// The trait. +// --------------------------------------------------------------------------- + +/// One control plane (cloud api or local store). The verb set is derived +/// mechanically from what the six dual-mode commands call across their two arms. +/// +/// Each verb owns the behavior of one command arm, including its printing where +/// the two planes print differently (status lines, launch recaps, the logs +/// footer): merging that printing would risk drift, so it stays in the impl and +/// the command keeps only shared arg parsing / usage errors / shared table code. +/// Verbs that are server-only today (`set_experiment_command`, all `report_*`, +/// `create_child`) return the SAME `local::unsupported`/guidance error on +/// `LocalPlane` that the command returns today — byte-identical. +/// +/// `?Send`: `LocalPlane` owns a `rusqlite`-backed `Store` (which is `!Sync`), so +/// its verb futures can't be `Send`. That's fine — a plane is built and driven to +/// completion inline within one command's `async fn` (the `#[tokio::main]` future +/// is `block_on`, which needs no `Send`); a plane is never `tokio::spawn`ed or +/// moved across threads. Unlike the `Harness` trait (a `Sync` static registry), +/// nothing shares a `ControlPlane` between tasks. +#[async_trait(?Send)] +pub trait ControlPlane { + /// Whether this is the local store plane. Only for the create-experiment + /// telemetry flag (`capture_experiment_started(_, is_local, _)`), which the + /// command fires after the verb returns Ok. + fn is_local(&self) -> bool; + + // --- project ---------------------------------------------------------- + + /// `orx project view <id>` — print the project overview. + async fn view_project(&self) -> Result<()>; + + /// `orx project edit <id>` — local accepts name/run_command; server accepts + /// name/description/visibility. The command validates the per-plane flag + /// combination before calling (each plane rejects the flags it can't take). + async fn edit_project(&self, edit: ProjectEdit) -> Result<()>; + + // --- runs / logs ------------------------------------------------------ + + /// `orx runs <id>` — the project's runs plus an experiment-id→title map for + /// labeling. Newest first. The command renders the shared table. + async fn list_runs(&self) -> Result<RunListing>; + + /// `orx logs <runId>` — a log window for the resolved run. + async fn read_log(&self, req: LogRequest) -> Result<RunLog>; + + // --- experiment ------------------------------------------------------- + + /// `orx exp status <expId>` — print the experiment's status block. + async fn experiment_status(&self) -> Result<()>; + + /// `orx exp desc <expId>` — read or write the description. Takes the raw + /// flags so each plane resolves stdin at the point the pre-trait code did + /// (server: after the login check). + async fn experiment_desc(&self, set: Option<String>, stdin: bool) -> Result<()>; + + /// `orx exp cmd <expId> --set` — set the run command (server only; local + /// returns `unsupported("exp cmd")`). + async fn set_experiment_command(&self, command: Option<String>) -> Result<()>; + + /// `orx exp run <expId> …` — launch a run. The command passes the parsed + /// `ExpRunArgs`; each plane validates and dispatches its own backends. + async fn launch(&self, args: crate::ExpRunArgs) -> Result<()>; + + /// `orx exp cancel <expId>` — cancel the in-flight run(s). + async fn cancel(&self) -> Result<()>; + + /// `orx exp wait <expId>` — block on the experiment's latest run until + /// terminal (level trigger). + async fn wait_experiment(&self, interval: Duration, deadline: Instant) -> Result<()>; + + /// `orx exp wait --project <projectId>` — return on the first completion + /// (edge trigger). Keyed on a project id, so it's a plane built from the + /// project ref. + async fn wait_project(&self, interval: Duration, deadline: Instant) -> Result<()>; + + // --- create-experiment ------------------------------------------------ + + /// `orx create-experiment <projectId> …` — create a child or baseline node. + /// The command owns the USAGE guard and fires telemetry after Ok. + async fn create_experiment(&self, spec: CreateExperimentSpec) -> Result<()>; + + // --- reports (server only; local returns guidance) -------------------- + + /// `orx report <projectId> …` — dispatch the report subcommand. + async fn report(&self, cmd: crate::ReportCommand) -> Result<()>; +} + +/// The resolved fields for `orx project edit`. Local rejects the server-only +/// fields and vice-versa (the command pre-validates the combination). +pub struct ProjectEdit { + pub name: Option<String>, + pub description: Option<String>, + pub description_stdin: bool, + pub public: bool, + pub private: bool, + pub run_command: Option<String>, +} + +/// The runs listing for `orx runs`: the domain runs plus the experiment +/// id→display-title map used to label each row. +pub struct RunListing { + pub runs: Vec<Run>, + pub titles: std::collections::HashMap<String, String>, +} + +/// A parsed `orx logs` request (mode + byte window), resolved from args by the +/// command's shared parsing. +pub struct LogRequest { + pub mode: String, + pub max_bytes: Option<i64>, + pub start_byte: Option<i64>, + pub end_byte: Option<i64>, +} + +/// The resolved `orx create-experiment` inputs (title already required/parsed). +pub struct CreateExperimentSpec { + pub title: String, + pub parent: Option<String>, + pub baseline: bool, + pub description: Option<String>, + pub run_command: Option<String>, +} + +// --------------------------------------------------------------------------- +// Resolvers — build the boxed plane that owns an id. +// --------------------------------------------------------------------------- + +/// Resolve a project-keyed command to its plane. Local iff the id names a known +/// local project (`local::resolve::resolve_project`). +pub fn resolve_project(store: Store, project_id: &str) -> Result<Box<dyn ControlPlane>> { + use crate::local::resolve::{resolve_project, ProjectRef}; + Ok(match resolve_project(&store, project_id)? { + ProjectRef::Local(project) => Box::new(LocalPlane { + store, + project: Some(*project), + experiment: None, + id: project_id.to_string(), + }), + ProjectRef::Server(id) => Box::new(ServerPlaceholder { id }), + }) +} + +/// Resolve an experiment-keyed command to its plane. Local iff the id names a +/// known local experiment (`local::resolve::resolve_experiment`). +pub fn resolve_experiment(store: Store, exp_id: &str) -> Result<Box<dyn ControlPlane>> { + use crate::local::resolve::{resolve_experiment, ExperimentRef}; + Ok(match resolve_experiment(&store, exp_id)? { + ExperimentRef::Local(exp) => Box::new(LocalPlane { + store, + project: None, + experiment: Some(*exp), + id: exp_id.to_string(), + }), + ExperimentRef::Server(id) => Box::new(ServerPlaceholder { id }), + }) +} + +/// Resolve a run-keyed command to its plane. Local iff the run belongs to a +/// local experiment (`local::resolve::resolve_run`, which reuses `local_run`). +pub fn resolve_run(store: Store, run_id: &str) -> Result<Box<dyn ControlPlane>> { + use crate::local::resolve::{resolve_run, RunRef}; + Ok(match resolve_run(&store, run_id)? { + RunRef::Local(_) => Box::new(LocalPlane { + store, + project: None, + experiment: None, + id: run_id.to_string(), + }), + RunRef::Server(id) => Box::new(ServerPlaceholder { id }), + }) +} + +mod local_plane; +mod server_plane; + +use local_plane::LocalPlane; +use server_plane::ServerPlaceholder; + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{now_ms, StoredRun}; + + fn stored_run(status: &str, result_markdown: Option<&str>) -> StoredRun { + let now = now_ms(); + StoredRun { + id: "r1".to_string(), + experiment_id: "e1".to_string(), + project_id: "p1".to_string(), + status: status.to_string(), + backend_json: "{}".to_string(), + command: "echo hi".to_string(), + created_at: now, + updated_at: now, + ended_at: Some(now), + exit_code: None, + commit_sha: Some("abcdef1234567890".to_string()), + result_markdown: result_markdown.map(str::to_string), + cancel_requested: false, + } + } + + #[test] + fn stored_run_maps_to_local_domain_run() { + let run = Run::from(&stored_run("done", None)); + assert_eq!(run.id, "r1"); + assert_eq!(run.experiment_id, "e1"); + assert_eq!(run.status, "done"); + assert_eq!(run.commit_sha.as_deref(), Some("abcdef1234567890")); + assert!(matches!(run.origin, RunOrigin::Local)); + // The store keeps unix millis; the local mapping renders a relative + // "ago" string, not a raw timestamp. + assert!(run.updated_display.ends_with("ago")); + } + + #[test] + fn failure_detail_none_for_non_failed() { + assert!(Run::from(&stored_run("done", None)) + .failure_detail() + .is_none()); + } + + #[test] + fn failure_detail_reports_persisted_reason() { + let run = Run::from(&stored_run("failed", Some(" boom "))); + assert_eq!(run.failure_detail().as_deref(), Some("reason: boom")); + } + + #[test] + fn failure_detail_local_no_reason_points_at_logs() { + // A store run always has RunOrigin::Local: the no-reason wording must be + // the former `local::run_failure_detail` output, byte-for-byte. + let run = Run::from(&stored_run("failed", None)); + assert_eq!( + run.failure_detail().as_deref(), + Some("reason: — (no message recorded — see `orx logs r1`)") + ); + } + + #[test] + fn failure_detail_server_variants_match_the_old_output_fn() { + // Reproduce the two former `output::run_failure_detail` no-reason + // branches via RunOrigin::Server { has_log }. + let with_log = Run { + id: "r1".to_string(), + experiment_id: "e1".to_string(), + status: "failed".to_string(), + commit_sha: None, + duration_secs: 0, + updated_display: String::new(), + result_markdown: None, + origin: RunOrigin::Server { has_log: true }, + }; + assert_eq!( + with_log.failure_detail().as_deref(), + Some("reason: — (failed after startup; no message recorded — see `orx logs r1`)") + ); + let no_log = Run { + origin: RunOrigin::Server { has_log: false }, + ..with_log + }; + assert_eq!( + no_log.failure_detail().as_deref(), + Some("reason: — (no message recorded)") + ); + } +} diff --git a/src/plane/local_plane.rs b/src/plane/local_plane.rs new file mode 100644 index 0000000..5cb0de9 --- /dev/null +++ b/src/plane/local_plane.rs @@ -0,0 +1,591 @@ +//! `LocalPlane` — the local-store control plane (`store` + `src/local`). +//! +//! Holds the owning `Store` and, when the resolver already fetched it, the +//! resolved `LocalProject` / `LocalExperiment` row (so the verb needs no second +//! lookup). The verb bodies are the former `commands::{runs,logs,project,exp, +//! create_experiment,report}` local fns, moved here almost verbatim. +//! +//! Verbs that are server-only today return the SAME error the command returned: +//! `set_experiment_command` → `local::unsupported("exp cmd")`; `report` → the +//! files-dir guidance. Byte-identical. + +use std::collections::HashMap; +use std::io::{Read as _, Seek as _}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; + +use super::server_plane::sleep_until_or_timeout; +use super::{ + ControlPlane, CreateExperimentSpec, DescInput, LogRequest, ProjectEdit, Run, RunListing, RunLog, +}; +use crate::error::{anyhow, Result}; +use crate::local::model::{LocalExperiment, LocalProject}; +use crate::store::{log_path, Store}; +use crate::{ExpRunArgs, ReportCommand}; + +/// The local-store plane. `project`/`experiment` carry the row the resolver +/// already fetched (present for project-/experiment-keyed commands); `id` is the +/// resolved project/experiment/run id for the run-keyed and re-lookup paths. +pub struct LocalPlane { + pub(super) store: Store, + pub(super) project: Option<LocalProject>, + pub(super) experiment: Option<LocalExperiment>, + pub(super) id: String, +} + +impl LocalPlane { + /// The resolved project row, or an error if this plane wasn't built from a + /// project id. In practice unreachable — the resolvers set `project` for + /// every project-keyed command — but avoids an `unwrap`. + fn project(&self) -> Result<&LocalProject> { + self.project + .as_ref() + .ok_or_else(|| anyhow!("internal: local plane missing its project row")) + } + + /// The resolved experiment row, analogous to `project`. + fn experiment(&self) -> Result<&LocalExperiment> { + self.experiment + .as_ref() + .ok_or_else(|| anyhow!("internal: local plane missing its experiment row")) + } +} + +/// Default byte window for local head/tail reads without `--bytes`. +const LOCAL_DEFAULT_BYTES: i64 = 64 * 1024; + +#[async_trait(?Send)] +impl ControlPlane for LocalPlane { + fn is_local(&self) -> bool { + true + } + + // --- runs ------------------------------------------------------------- + + async fn list_runs(&self) -> Result<RunListing> { + let store = &self.store; + let project_id = &self.id; + let titles: HashMap<String, String> = store + .list_experiments_by_project(project_id)? + .into_iter() + .map(|e| (e.id.clone(), e.display_name().to_string())) + .collect(); + + // Already newest-first (store orders by created_at DESC). + let runs = store.list_runs_by_project(project_id)?; + let runs: Vec<Run> = runs.iter().map(Run::from).collect(); + Ok(RunListing { runs, titles }) + } + + // --- logs ------------------------------------------------------------- + + async fn read_log(&self, req: LogRequest) -> Result<RunLog> { + let run_id = &self.id; + let path = log_path(run_id); + let total = match std::fs::metadata(&path) { + Ok(m) => m.len() as i64, + Err(_) => { + return Ok(RunLog { + content: Vec::new(), + start_byte: 0, + end_byte: 0, + total_bytes: 0, + source: "local file".to_string(), + truncated_before: false, + truncated_after: false, + missing_local: true, + }); + } + }; + + let max = req.max_bytes.unwrap_or(LOCAL_DEFAULT_BYTES).max(0); + let (start, end) = match req.mode.as_str() { + "range" => ( + req.start_byte.unwrap_or(0).clamp(0, total), + req.end_byte.unwrap_or(total).clamp(0, total), + ), + "head" => (0, max.min(total)), + _ => ((total - max).max(0), total), + }; + + let mut content = Vec::new(); + if end > start { + let mut f = std::fs::File::open(&path)?; + f.seek(std::io::SeekFrom::Start(start as u64))?; + f.take((end - start) as u64).read_to_end(&mut content)?; + } + + Ok(RunLog { + content, + start_byte: start, + end_byte: end, + total_bytes: total, + source: "local file".to_string(), + truncated_before: start > 0, + truncated_after: end < total, + missing_local: false, + }) + } + + // --- project ---------------------------------------------------------- + + async fn view_project(&self) -> Result<()> { + let store = &self.store; + let project = self.project()?; + println!("{} (local)", project.name); + println!(" id: {}", project.id); + println!( + " repo: {}/{} (baseline branch: {})", + project.github_owner, project.github_repo, project.baseline_branch + ); + println!(" clone: {}", project.repo_path); + match project + .run_command + .as_deref() + .filter(|c| !c.trim().is_empty()) + { + Some(cmd) => println!(" command: {}", cmd), + None => println!( + " command: — (not set — `orx project edit {} --run-command '<cmd>'`)", + project.id + ), + } + + let experiments = store.list_experiments_by_project(&project.id)?; + println!("\nExperiments"); + if experiments.is_empty() { + println!(" (none)"); + } else { + for e in &experiments { + let root = if e.parent_experiment_id.is_none() { + " [root]" + } else { + "" + }; + println!( + " {} {}{} ({})", + e.id, + e.display_name(), + root, + e.branch_name + ); + } + } + Ok(()) + } + + async fn edit_project(&self, edit: ProjectEdit) -> Result<()> { + // Local projects support --name and --run-command only; the command + // validated the combination, but keep the guard for the direct path. + if edit.description.is_some() || edit.description_stdin || edit.public || edit.private { + return Err(anyhow!( + "Local projects support --name and --run-command only." + )); + } + let mut project = self.project()?.clone(); + let name = edit.name; + let run_command = edit.run_command; + if name.is_none() && run_command.is_none() { + return Err(anyhow!( + "Nothing to change. Pass at least one of --name or --run-command." + )); + } + if let Some(name) = name { + if name.trim().is_empty() { + return Err(anyhow!("--name cannot be empty.")); + } + project.name = name.trim().to_string(); + } + if let Some(cmd) = run_command { + project.run_command = Some(cmd).filter(|c| !c.trim().is_empty()); + } + self.store.update_local_project(&project)?; + + println!("\u{2713} Project updated."); + println!(" id: {}", project.id); + println!(" name: {}", project.name); + match project.run_command.as_deref() { + Some(cmd) => println!(" command: {}", cmd), + None => println!(" command: — (empty)"), + } + Ok(()) + } + + // --- experiment ------------------------------------------------------- + + async fn experiment_status(&self) -> Result<()> { + let store = &self.store; + let exp = self.experiment()?; + println!("{} ({}) [local]", exp.display_name(), exp.agent_status); + println!(" id: {}", exp.id); + println!(" branch: {}", exp.branch_name); + match &exp.parent_experiment_id { + Some(parent_id) => match store.get_local_experiment(parent_id)? { + Some(parent) => { + println!(" parent: {} (branch {})", parent_id, parent.branch_name) + } + None => println!(" parent: {}", parent_id), + }, + None => println!(" parent: — (root experiment)"), + } + if exp.run_command.is_empty() { + println!(" command: — (not set)"); + } else { + println!(" command: {}", exp.run_command); + } + + match store.latest_run_for_experiment(&exp.id)? { + Some(r) => { + let run = Run::from(&r); + let commit = run + .commit_sha + .as_deref() + .map(|s| s.chars().take(7).collect::<String>()) + .unwrap_or_else(|| "—".to_string()); + println!( + " last run: {} ({}, commit {}, ran {}, updated {})", + run.id, + run.status, + commit, + crate::output::format_duration(run.duration_secs), + run.updated_display + ); + if let Some(detail) = run.failure_detail() { + println!(" {detail}"); + } + if let Some(sha) = &run.commit_sha { + println!(" commit: {}", sha); + } + } + None => println!(" last run: — (never run)"), + } + Ok(()) + } + + async fn experiment_desc(&self, set: Option<String>, stdin: bool) -> Result<()> { + let input = DescInput::resolve(set, stdin).await?; + let mut exp = self.experiment()?.clone(); + match input { + DescInput::Set(description) => { + exp.description = Some(description); + self.store.update_local_experiment(&exp)?; + println!("\u{2713} Description saved."); + } + DescInput::Get => match exp.description.as_deref().filter(|d| !d.trim().is_empty()) { + Some(d) => println!("{d}"), + None => eprintln!( + "No description set. Add one with `orx exp desc {} --set \"…\"` \ + or pipe a file: `cat notes.md | orx exp desc {} --stdin`.", + exp.id, exp.id + ), + }, + } + Ok(()) + } + + async fn set_experiment_command(&self, _command: Option<String>) -> Result<()> { + Err(crate::local::unsupported("exp cmd")) + } + + async fn launch(&self, mut args: ExpRunArgs) -> Result<()> { + // Fill backend/flavor from the persisted default (Settings → Compute) + // BEFORE the flag validations below, so e.g. `--host box1` with a default + // of `ssh` is a valid launch, and before `backend_label` is captured, so + // telemetry records the resolved backend. + crate::local::apply_compute_default(&mut args.backend, &mut args.flavor); + if args.manifest.is_some() && args.backend.as_deref() != Some("k8s") { + return Err(anyhow!("--manifest only applies with --backend k8s.")); + } + if args.host.is_some() && !matches!(args.backend.as_deref(), Some("ssh") | Some("slurm")) { + return Err(anyhow!("--host only applies with --backend ssh or slurm.")); + } + if args.org.is_some() && args.backend.as_deref() != Some("openresearch") { + return Err(anyhow!("--org only applies with --backend openresearch.")); + } + // Coarse backend label for analytics; the backend name is already an + // enum, never user data. Recorded before the (borrowing) dispatch below. + let backend_label = args.backend.clone(); + let result = match args.backend.as_deref() { + Some("hf") => crate::local::hf::launch_local_hf(&args).await, + Some("modal") => crate::local::modal::launch_local_modal(&args).await, + Some("k8s") => crate::local::k8s::launch_local_k8s(&args).await, + Some("ssh") => crate::local::ssh::launch_local_ssh(&args).await, + Some("slurm") => crate::local::slurm::launch_local_slurm(&args).await, + Some("openresearch") => { + crate::local::openresearch::launch_local_openresearch(&args).await + } + Some("local") => crate::local::localrun::launch_local_run(&args).await, + Some(other) => Err(anyhow!( + "Unknown --backend '{}'. Local experiments support: hf (Hugging Face Jobs), \ + modal (Modal serverless GPUs), k8s (your Kubernetes cluster), ssh (your own box), \ + slurm (your Slurm cluster), openresearch (an ephemeral OpenResearch box), \ + local (this machine).", + other + )), + None => Err(anyhow!( + "No --backend given and no default compute target is set. \ + Set a default in the dashboard (`orx up` → Settings → Compute → Make default), \ + or pass one per launch: \ + `--backend hf --flavor <flavor>` (e.g. --flavor a10g-small), \ + `--backend modal --flavor <flavor>` (e.g. --flavor a10g), \ + `--backend k8s` (runs the manifest committed on the branch — \ + default .orx/k8s.yaml, or --manifest <path>), \ + `--backend ssh --host <alias>` (an ~/.ssh/config alias), \ + `--backend slurm [--host <alias>] [--flavor h100:2]` (your Slurm cluster), \ + `--backend openresearch --flavor <shape>` (an ephemeral OpenResearch box, \ + e.g. --flavor h100_sxm or cpu5c; needs `orx login`), \ + or `--backend local` (a detached process on this machine)." + )), + }; + // Key event, fired only on a successful launch. Coarse backend only. + // `backend_label` is always `Some(<known backend>)` here — every arm that + // yields `Ok` matched a `Some("hf"|"modal"|...)`; `None`/unknown arms + // return `Err`. The `"unknown"` fallback is unreachable defense. + if result.is_ok() { + let target = backend_label.as_deref().unwrap_or("unknown"); + crate::telemetry::capture_experiment_started("run", true, Some(target)); + } + result + } + + async fn cancel(&self) -> Result<()> { + let store = &self.store; + let exp = self.experiment()?; + let in_flight: Vec<_> = store + .list_runs_by_experiment(&exp.id)? + .into_iter() + .filter(|r| !crate::local::is_terminal(&r.status)) + .collect(); + if in_flight.is_empty() { + return Err(anyhow!("No run in flight for this experiment.")); + } + for r in &in_flight { + store.set_cancel_requested(&r.id, true)?; + println!("\u{2713} Cancel requested for run {}.", r.id); + } + Ok(()) + } + + async fn wait_experiment(&self, interval: Duration, deadline: Instant) -> Result<()> { + let store = &self.store; + let exp_id = &self.id; + let mut last_status: Option<String> = None; + loop { + match store.latest_run_for_experiment(exp_id)? { + None => { + if last_status.is_none() { + eprintln!("No run yet for this experiment — waiting for one to start…"); + last_status = Some(String::new()); + } + } + Some(r) => { + if last_status.as_deref() != Some(r.status.as_str()) { + eprintln!("{} {}", r.id, r.status); + last_status = Some(r.status.clone()); + } + if crate::local::is_terminal(&r.status) { + let run = Run::from(&r); + println!("{} {}", run.id, run.status); + if let Some(detail) = run.failure_detail() { + eprintln!("{detail}"); + } + return Ok(()); + } + } + } + sleep_until_or_timeout(interval, deadline).await?; + } + } + + async fn wait_project(&self, interval: Duration, deadline: Instant) -> Result<()> { + let store = &self.store; + let project_id = &self.id; + let snapshot: HashMap<String, String> = store + .list_runs_by_project(project_id)? + .into_iter() + .map(|r| (r.id, r.status)) + .collect(); + let in_flight = snapshot + .values() + .filter(|s| !crate::local::is_terminal(s)) + .count(); + + if in_flight == 0 { + eprintln!( + "No runs in flight in this project ({} run(s), all terminal).", + snapshot.len() + ); + println!("drained: no runs in flight"); + return Ok(()); + } + + eprintln!( + "Watching {} run(s) in project ({} in flight) — returning on the first completion…", + snapshot.len(), + in_flight + ); + + loop { + sleep_until_or_timeout(interval, deadline).await?; + + let current = store.list_runs_by_project(project_id)?; + let mut completed: Vec<(String, Option<String>)> = Vec::new(); + for r in ¤t { + if !crate::local::is_terminal(&r.status) { + continue; + } + let line = match snapshot.get(&r.id) { + Some(prev) if crate::local::is_terminal(prev) => continue, + Some(prev) => format!("{} {} -> {}", r.id, prev, r.status), + None => format!("{} {} (new)", r.id, r.status), + }; + completed.push((line, Run::from(r).failure_detail())); + } + if !completed.is_empty() { + for (line, detail) in &completed { + println!("{line}"); + if let Some(detail) = detail { + eprintln!("{detail}"); + } + } + return Ok(()); + } + } + } + + // --- create-experiment ------------------------------------------------ + + async fn create_experiment(&self, spec: CreateExperimentSpec) -> Result<()> { + let store = &self.store; + let project = self.project()?; + let CreateExperimentSpec { + title, + parent, + baseline, + description, + run_command, + } = spec; + + let mut defaulted_to_root = false; + let parent_exp = match &parent { + Some(parent_id) => Some(store.get_local_experiment(parent_id)?.ok_or_else(|| { + anyhow!( + "Parent experiment {} not found in the local store. \ + See the dashboard, or omit --parent to branch off the project root.", + parent_id + ) + })?), + None if baseline => None, + None => { + let root = crate::local::experiments::project_root(store, &project.id)?; + defaulted_to_root = root.is_some(); + root + } + }; + let kind = if parent_exp.is_some() { + "child" + } else { + "baseline" + }; + + let experiment = crate::local::experiments::create_experiment( + store, + project, + parent_exp.as_ref(), + None, + Some(title), + description, + run_command, + )?; + + println!("\u{2713} Created local {} experiment", kind); + if defaulted_to_root { + let root = parent_exp.as_ref().unwrap(); + println!(" parent: {} (project root, defaulted)", root.id); + } + if let Some(warning) = parent_exp + .as_ref() + .and_then(|p| crate::local::experiments::legacy_root_warning(project, p)) + { + eprintln!(" {warning}"); + } + println!(" id: {}", experiment.id); + println!(" title: {}", experiment.display_name()); + println!(" slug: {}", experiment.slug); + println!(" branch: {}", experiment.branch_name); + if experiment.run_command.is_empty() { + println!( + " command: — (none inherited — set one with `orx project edit {} --run-command '<cmd>'`)", + project.id + ); + } else { + println!(" command: {}", experiment.run_command); + } + println!(); + println!("To edit it, check out the branch in the project's local clone:"); + println!(" cd {}", project.repo_path); + println!( + " git fetch origin && git checkout {}", + experiment.branch_name + ); + println!(" # …edit, then…"); + println!( + " git commit -am \"<msg>\" && git push -u origin {}", + experiment.branch_name + ); + Ok(()) + } + + // --- reports (local has no report registry) --------------------------- + + async fn report(&self, _cmd: ReportCommand) -> Result<()> { + // Local projects have no report registry or upload step — the files dir + // on disk is the whole feature. Point there instead of pretending to + // upload. Byte-identical to the former `report::local_guidance`. + let project = self.project()?; + let dir = crate::local::files::ensure_dir(project)?; + Err(anyhow!( + "`orx report` is cloud-only. Local projects have no upload step: write the report \ + folder (report.md + images/) straight into the project's files directory,\n {}\n\ + Everything in that directory shows up in the dashboard's Files tab.", + dir.display() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh, throwaway store rooted at a unique temp dir (never mutates the + /// process-global `$ORX_DATA_DIR`). + fn temp_store() -> Store { + let dir = std::env::temp_dir().join(format!("orx-plane-{}", uuid::Uuid::new_v4())); + Store::open_at(dir).expect("open temp store") + } + + fn empty_local_plane() -> LocalPlane { + LocalPlane { + store: temp_store(), + project: None, + experiment: None, + id: "e1".to_string(), + } + } + + #[tokio::test] + async fn set_experiment_command_is_unsupported_locally() { + // The server-only verb must reproduce `local::unsupported("exp cmd")` + // byte-for-byte (the old `exp cmd` local arm's error). + let plane = empty_local_plane(); + let err = plane + .set_experiment_command(Some("echo hi".to_string())) + .await + .expect_err("exp cmd must be unsupported on a local experiment"); + assert_eq!( + err.to_string(), + crate::local::unsupported("exp cmd").to_string() + ); + } +} diff --git a/src/plane/server_plane.rs b/src/plane/server_plane.rs new file mode 100644 index 0000000..88d8a47 --- /dev/null +++ b/src/plane/server_plane.rs @@ -0,0 +1,1364 @@ +//! `ServerPlane` — the cloud-api control plane, wrapping `client.rs`. +//! +//! Constructed only on the server arm of a resolved command, so it fetches +//! credentials up front (`ServerPlane::connect` → `require_credentials`) exactly +//! as the old server bodies did on entry. The verb bodies below are the former +//! `commands::{runs,logs,project,exp,create_experiment,report}` server fns moved +//! here almost verbatim; only signatures/`self` were adapted. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use tokio::io::AsyncReadExt; + +use super::{ + ControlPlane, CreateExperimentSpec, DescInput, LogRequest, ProjectEdit, Run, RunListing, RunLog, +}; +use crate::client::{ + cancel_experiment_run, create_baseline_experiment, create_child_experiment, + create_external_run, create_report, download_report_file, find_project, get_experiment, + get_project, get_report, list_experiments, list_reports, list_runs, read_run_log, + start_experiment_run, update_experiment, update_project, upload_to_presigned, + CreateBaselineExperimentBody, CreateChildBody, CreateReportBody, RunTarget, + UpdateExperimentBody, UpdateProjectBody, +}; +use crate::commands::experiments::print_tree; +use crate::config::Credentials; +use crate::error::{anyhow, require_credentials, Result}; +use crate::jobs::{huggingface as hf, BackendDescriptor}; +use crate::output::format_duration; +use crate::store::{now_ms, Store, StoredRun}; +use crate::{ExpRunArgs, ReportCommand}; + +/// The cloud-api plane. `id` is the project/experiment/run id the command +/// resolved to `Server`; `creds` are fetched at construction. +pub struct ServerPlane { + id: String, + creds: Credentials, +} + +impl ServerPlane { + /// Fetch credentials and finish construction. Reached only on server arms — + /// same as the old code, so a local-only user who never logged in never hits + /// this. + async fn connect(id: String) -> ServerPlane { + let creds = require_credentials().await; + ServerPlane { id, creds } + } +} + +/// A not-yet-connected server plane. The resolvers are sync (and must run their +/// login-independent guards — e.g. the `--run-command`-on-server-child refusal — +/// BEFORE any `require_credentials`, matching the old arm ordering), so they box +/// this. The real `ServerPlane` is built by connecting (fetching credentials) on +/// the verb call, which is exactly where the old server bodies called +/// `require_credentials`. This keeps `require_credentials` off the local path and +/// off command entry, and preserves the guard-before-login order. +pub struct ServerPlaceholder { + pub(super) id: String, +} + +#[async_trait(?Send)] +impl ControlPlane for ServerPlaceholder { + fn is_local(&self) -> bool { + false + } + + async fn view_project(&self) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .view_project() + .await + } + async fn edit_project(&self, edit: ProjectEdit) -> Result<()> { + // The server project PATCH carries no run command field — refuse before + // even asking for credentials. + if edit.run_command.is_some() { + return Err(anyhow!( + "--run-command is supported for local projects only. For server \ + projects, set it per experiment with `orx exp cmd <expId> --set '<cmd>'`." + )); + } + ServerPlane::connect(self.id.clone()) + .await + .edit_project(edit) + .await + } + async fn list_runs(&self) -> Result<RunListing> { + ServerPlane::connect(self.id.clone()) + .await + .list_runs() + .await + } + async fn read_log(&self, req: LogRequest) -> Result<RunLog> { + ServerPlane::connect(self.id.clone()) + .await + .read_log(req) + .await + } + async fn experiment_status(&self) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .experiment_status() + .await + } + async fn experiment_desc(&self, set: Option<String>, stdin: bool) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .experiment_desc(set, stdin) + .await + } + async fn set_experiment_command(&self, command: Option<String>) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .set_experiment_command(command) + .await + } + async fn launch(&self, args: ExpRunArgs) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .launch(args) + .await + } + async fn cancel(&self) -> Result<()> { + ServerPlane::connect(self.id.clone()).await.cancel().await + } + async fn wait_experiment(&self, interval: Duration, deadline: Instant) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .wait_experiment(interval, deadline) + .await + } + async fn wait_project(&self, interval: Duration, deadline: Instant) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .wait_project(interval, deadline) + .await + } + async fn create_experiment(&self, spec: CreateExperimentSpec) -> Result<()> { + // The server child-create API carries no run command field — refuse + // rather than silently drop it. (The baseline create does accept one.) + // Refuse before asking for credentials, matching the old server body. + if spec.run_command.is_some() && spec.parent.is_some() { + return Err(anyhow!( + "--run-command is supported for local projects and server baselines \ + only. For server child experiments, set it after creation with \ + `orx exp cmd <expId> --set '<cmd>'`." + )); + } + ServerPlane::connect(self.id.clone()) + .await + .create_experiment(spec) + .await + } + async fn report(&self, cmd: ReportCommand) -> Result<()> { + ServerPlane::connect(self.id.clone()) + .await + .report(cmd) + .await + } +} + +#[async_trait(?Send)] +impl ControlPlane for ServerPlane { + fn is_local(&self) -> bool { + false + } + + // --- runs ------------------------------------------------------------- + + async fn list_runs(&self) -> Result<RunListing> { + let creds = &self.creds; + let project_id = &self.id; + + // Fetch experiments too, so we can label each run with its experiment + // title rather than a bare id. Both requests run concurrently. + let (runs_res, experiments_res) = tokio::join!( + list_runs(creds, project_id), + list_experiments(creds, project_id) + ); + let runs = runs_res?.runs; + let experiments = experiments_res?.experiments; + + let titles: HashMap<String, String> = + experiments.into_iter().map(|e| (e.id, e.title)).collect(); + + // Run ids are UUIDv7 — lexicographic sort is chronological. Newest first. + let mut runs: Vec<Run> = runs.into_iter().map(Run::from).collect(); + runs.sort_by(|a, b| b.id.cmp(&a.id)); + + Ok(RunListing { runs, titles }) + } + + // --- logs ------------------------------------------------------------- + + async fn read_log(&self, req: LogRequest) -> Result<RunLog> { + let log = read_run_log( + &self.creds, + &self.id, + Some(&req.mode), + req.max_bytes, + req.start_byte, + req.end_byte, + ) + .await?; + Ok(RunLog { + content: log.content.into_bytes(), + start_byte: log.start_byte, + end_byte: log.end_byte, + total_bytes: log.total_bytes, + source: log.source, + truncated_before: log.truncated_before, + truncated_after: log.truncated_after, + missing_local: false, + }) + } + + // --- project ---------------------------------------------------------- + + async fn view_project(&self) -> Result<()> { + let creds = &self.creds; + let project_id = &self.id; + let project = get_project(creds, project_id).await?.project; + + println!("{}", project.name); + println!(" id: {}", project.id); + if !project.github_owner.is_empty() { + println!(" repo: {}/{}", project.github_owner, project.github_repo); + } + println!( + " access: {}", + if project.is_public { + "public" + } else { + "private" + } + ); + if !project.description.is_empty() { + println!(" about: {}", project.description); + } + if let Some(q) = project + .example_question + .as_deref() + .filter(|q| !q.is_empty()) + { + println!(" ask: {}", q); + } + + let experiments = list_experiments(creds, project_id).await?.experiments; + println!("\nExperiments"); + if experiments.is_empty() { + println!(" (none)"); + } else { + print_tree(&experiments); + } + + let reports = list_reports(creds, project_id).await?.reports; + println!("\nReports"); + if reports.is_empty() { + println!(" (none)"); + } else { + for r in &reports { + println!(" {} {} ({})", r.id, r.title, r.created_at); + } + println!("\nRead one with: orx report show {} <reportId>", project_id); + } + Ok(()) + } + + async fn edit_project(&self, edit: ProjectEdit) -> Result<()> { + let creds = &self.creds; + let project_id = &self.id; + + // `--description` and `--description-stdin` are mutually exclusive; either + // present means "overwrite the description". + let description = match (edit.description, edit.description_stdin) { + (Some(_), true) => { + return Err(anyhow!( + "Pass either --description or --description-stdin, not both." + )) + } + (Some(text), false) => Some(text), + (None, true) => { + let mut buf = String::new(); + tokio::io::stdin().read_to_string(&mut buf).await?; + Some(buf) + } + (None, false) => None, + }; + + // `--public` / `--private` map to the `isPublic` flag; clap's + // `conflicts_with` already rejects passing both. Neither flag leaves + // visibility untouched (`None`). + let is_public = match (edit.public, edit.private) { + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }; + + if edit.name.is_none() && description.is_none() && is_public.is_none() { + return Err(anyhow!( + "Nothing to change. Pass at least one of --name, --description \ + (or --description-stdin), --public, or --private." + )); + } + + let res = update_project( + creds, + project_id, + &UpdateProjectBody { + name: edit.name, + description, + is_public, + }, + ) + .await?; + let project = res.project; + + println!("\u{2713} Project updated."); + println!(" id: {}", project.id); + println!(" name: {}", project.name); + println!( + " access: {}", + if project.is_public { + "public" + } else { + "private" + } + ); + if project.description.is_empty() { + println!(" description: — (empty)"); + } else { + println!(" description: {}", project.description); + } + Ok(()) + } + + // --- experiment ------------------------------------------------------- + + async fn experiment_status(&self) -> Result<()> { + let creds = &self.creds; + let exp_id = &self.id; + let res = get_experiment(creds, exp_id).await?; + let exp = res.experiment; + + // Parent branch (the diff base). Best-effort: a failed parent fetch + // degrades to printing the id alone, never fails the status command. + let parent_branch: Option<String> = match &exp.parent_experiment_id { + Some(parent_id) => get_experiment(creds, parent_id) + .await + .ok() + .map(|p| p.experiment.branch_name), + None => None, + }; + + println!("{} ({})", exp.title, exp.agent_status); + println!(" id: {}", exp.id); + println!(" branch: {}", exp.branch_name); + match (&exp.parent_experiment_id, &parent_branch) { + (Some(id), Some(branch)) => println!(" parent: {} (branch {})", id, branch), + (Some(id), None) => println!(" parent: {}", id), + (None, _) => println!(" parent: — (root experiment)"), + } + match &exp.sandbox_id { + Some(sb) => println!(" sandbox: {}", sb), + None => println!(" sandbox: — (none linked)"), + } + if exp.run_command.is_empty() { + println!( + " command: — (not set — `orx exp cmd {} --set \"…\"`)", + exp.id + ); + } else { + println!(" command: {}", exp.run_command); + } + + let mut full_sha: Option<String> = None; + match res.latest_run { + Some(r) => { + let run = Run::from(r); + let commit = run + .commit_sha + .as_ref() + .map(|s| s.chars().take(7).collect::<String>()) + .unwrap_or_else(|| "—".to_string()); + println!( + " last run: {} ({}, commit {}, ran {}, updated {})", + run.id, + run.status, + commit, + format_duration(run.duration_secs), + run.updated_display + ); + if let Some(detail) = run.failure_detail() { + println!(" {detail}"); + } + if let Some(sha) = run.commit_sha { + println!(" commit: {}", sha); + full_sha = Some(sha); + } + } + None => println!(" last run: — (never run)"), + } + + // Local diff recipe — only when there's both a base (parent branch) and + // a head (run commit) to compare. Owner/repo lookup is best-effort too: + // on failure print placeholders the caller can fill from `orx projects`. + if let (Some(branch), Some(sha)) = (parent_branch, full_sha) { + let repo_path = match find_project(creds, &exp.project_id).await { + Ok(Some(p)) if !p.github_owner.is_empty() && !p.github_repo.is_empty() => { + format!("{}/{}", p.github_owner, p.github_repo) + } + _ => "<owner>/<repo>".to_string(), + }; + let dir = format!("~/.cache/openresearch/repos/{}", repo_path); + println!(); + println!("To see what this run changed vs. its parent, using your local clone (cloned on first use):"); + if repo_path == "<owner>/<repo>" { + println!(" # owner/repo from `orx projects`"); + } + println!( + " [ -d {} ] || git clone https://github.com/{} {}", + dir, repo_path, dir + ); + println!(" git -C {} fetch origin", dir); + println!(" git -C {} diff origin/{}...{}", dir, branch, sha); + } + + Ok(()) + } + + async fn experiment_desc(&self, set: Option<String>, stdin: bool) -> Result<()> { + // Resolve after connect: `--stdin` must not be consumed before the + // login check (pre-trait ordering). + let input = DescInput::resolve(set, stdin).await?; + let creds = &self.creds; + let exp_id = &self.id; + match input { + // Write path: overwrite the whole description. + DescInput::Set(description) => { + update_experiment( + creds, + exp_id, + &UpdateExperimentBody { + description: Some(description), + ..Default::default() + }, + ) + .await?; + println!("\u{2713} Description saved."); + } + // Read path: print to stdout (pipe-friendly), or hint when empty. + DescInput::Get => { + let res = get_experiment(creds, exp_id).await?; + if res.experiment.description.is_empty() { + eprintln!( + "No description set. Add one with `orx exp desc {} --set \"…\"` \ + or pipe a file: `cat notes.md | orx exp desc {} --stdin`.", + exp_id, exp_id + ); + } else { + println!("{}", res.experiment.description); + } + } + } + Ok(()) + } + + async fn set_experiment_command(&self, command: Option<String>) -> Result<()> { + let creds = &self.creds; + let exp_id = &self.id; + match command { + Some(command) => { + let res = update_experiment( + creds, + exp_id, + &UpdateExperimentBody { + run_command: Some(command), + ..Default::default() + }, + ) + .await?; + println!("\u{2713} Run command set:"); + println!(" {}", res.experiment.run_command); + } + None => { + let res = get_experiment(creds, exp_id).await?; + if res.experiment.run_command.is_empty() { + println!( + "No run command set. Set one with `orx exp cmd {} --set \"…\"`.", + exp_id + ); + } else { + println!("{}", res.experiment.run_command); + } + } + } + Ok(()) + } + + async fn launch(&self, args: ExpRunArgs) -> Result<()> { + self.launch_impl(args).await + } + + async fn cancel(&self) -> Result<()> { + cancel_experiment_run(&self.creds, &self.id).await?; + println!("\u{2713} Run cancelled."); + Ok(()) + } + + async fn wait_experiment(&self, interval: Duration, deadline: Instant) -> Result<()> { + let creds = &self.creds; + let exp_id = &self.id; + let mut last_status: Option<String> = None; + loop { + let res = get_experiment(creds, exp_id).await?; + match res.latest_run { + None => { + if last_status.is_none() { + eprintln!("No run yet for this experiment — waiting for one to start…"); + last_status = Some(String::new()); + } + } + Some(r) => { + if last_status.as_deref() != Some(r.status.as_str()) { + eprintln!("{} {}", r.id, r.status); + last_status = Some(r.status.clone()); + } + if crate::local::is_terminal(&r.status) { + let run = Run::from(r); + println!("{} {}", run.id, run.status); + if let Some(detail) = run.failure_detail() { + eprintln!("{detail}"); + } + return Ok(()); + } + } + } + sleep_until_or_timeout(interval, deadline).await?; + } + } + + async fn wait_project(&self, interval: Duration, deadline: Instant) -> Result<()> { + let creds = &self.creds; + let project_id = &self.id; + let snapshot: HashMap<String, String> = list_runs(creds, project_id) + .await? + .runs + .into_iter() + .map(|r| (r.id, r.status)) + .collect(); + let in_flight = snapshot + .values() + .filter(|s| !crate::local::is_terminal(s)) + .count(); + + if in_flight == 0 { + eprintln!( + "No runs in flight in this project ({} run(s), all terminal).", + snapshot.len() + ); + println!("drained: no runs in flight"); + return Ok(()); + } + + eprintln!( + "Watching {} run(s) in project ({} in flight) — returning on the first completion…", + snapshot.len(), + in_flight + ); + + loop { + sleep_until_or_timeout(interval, deadline).await?; + + let current = list_runs(creds, project_id).await?.runs; + let mut completed: Vec<(String, Option<String>)> = Vec::new(); + for r in current { + if !crate::local::is_terminal(&r.status) { + continue; + } + let line = match snapshot.get(&r.id) { + Some(prev) if crate::local::is_terminal(prev) => continue, + Some(prev) => format!("{} {} -> {}", r.id, prev, r.status), + None => format!("{} {} (new)", r.id, r.status), + }; + completed.push((line, Run::from(r).failure_detail())); + } + if !completed.is_empty() { + for (line, detail) in &completed { + println!("{line}"); + if let Some(detail) = detail { + eprintln!("{detail}"); + } + } + return Ok(()); + } + } + } + + // --- create-experiment ------------------------------------------------ + + async fn create_experiment(&self, spec: CreateExperimentSpec) -> Result<()> { + let creds = &self.creds; + let project_id = &self.id; + let CreateExperimentSpec { + title, + parent, + description, + run_command, + baseline: _, + } = spec; + + let experiment: crate::client::Experiment; + let kind: String; + if let Some(parent) = parent { + let envelope = create_child_experiment( + creds, + project_id, + &CreateChildBody { + title, + description, + parent_experiment_id: parent, + }, + ) + .await?; + experiment = envelope.experiment; + kind = "child".to_string(); + } else { + // Baseline on the project's already-bound GitHub repo. The server + // branches `orx/<slug>` off the branch picked at project creation + // (the repo's default unless one was chosen). + let envelope = create_baseline_experiment( + creds, + project_id, + &CreateBaselineExperimentBody { + title: Some(title), + description, + run_command, + }, + ) + .await?; + experiment = envelope.experiment; + kind = "baseline".to_string(); + } + + println!("\u{2713} Created {} experiment", kind); + println!(" id: {}", experiment.id); + println!(" title: {}", experiment.title); + println!(" slug: {}", experiment.slug); + println!(" branch: {}", experiment.branch_name); + println!(); + println!("To edit it, check out the branch in your local clone of the project's repo:"); + println!( + " git fetch origin && git checkout {}", + experiment.branch_name + ); + println!(" # …edit, then…"); + println!( + " git commit -am \"<msg>\" && git push -u origin {}", + experiment.branch_name + ); + Ok(()) + } + + // --- reports ---------------------------------------------------------- + + async fn report(&self, cmd: ReportCommand) -> Result<()> { + // The variants carry the project id the command already resolved this + // plane from — `self.id` is that same id, so it is the single source + // here and the embedded copies are ignored. + match cmd { + ReportCommand::Upload { folder, title, .. } => { + self.report_upload(&self.id, &folder, title).await + } + ReportCommand::List { .. } => self.report_list(&self.id).await, + ReportCommand::Show { report, .. } => self.report_show(&self.id, &report).await, + ReportCommand::Download { report, dir, .. } => { + self.report_download(&self.id, &report, &dir).await + } + } + } +} + +impl ServerPlane { + /// The managed/external launch dispatcher — the former `exp::launch`. + async fn launch_impl(&self, args: ExpRunArgs) -> Result<()> { + let creds = &self.creds; + // External backends: orx submits and supervises the job itself; the api + // only mirrors. Everything below this branch is the managed path. + if args.manifest.is_some() && args.backend.as_deref() != Some("k8s") { + return Err(anyhow!("--manifest only applies with --backend k8s.")); + } + if args.host.is_some() && !matches!(args.backend.as_deref(), Some("ssh") | Some("slurm")) { + return Err(anyhow!("--host only applies with --backend ssh or slurm.")); + } + if args.org.is_some() { + return Err(anyhow!( + "--org only applies with --backend openresearch (local experiments); server \ + experiments bill the project's own org." + )); + } + match args.backend.as_deref() { + Some("hf") => return self.launch_hf(args).await, + Some("modal") => return self.launch_modal(args).await, + Some("k8s") => { + return Err(anyhow!( + "--backend k8s is supported for local experiments (`orx up`) only for now." + )); + } + Some("ssh") => { + return Err(anyhow!( + "--backend ssh is supported for local experiments (`orx up`) only for now." + )); + } + Some("slurm") => { + return Err(anyhow!( + "--backend slurm is supported for local experiments (`orx up`) only for now." + )); + } + Some("openresearch") => { + return Err(anyhow!( + "--backend openresearch is for local experiments (`orx up`) only. Server \ + experiments already run on OpenResearch compute — pass --gpu/--cpu/--sandbox." + )); + } + Some("local") => { + return Err(anyhow!( + "--backend local is supported for local experiments (`orx up`) only." + )); + } + Some(other) => { + return Err(anyhow!( + "Unknown --backend '{}'. Supported: hf (Hugging Face Jobs), \ + modal (Modal serverless GPUs), k8s/ssh/slurm/openresearch/local \ + (local experiments only).", + other + )); + } + None => {} + } + if args.flavor.is_some() || args.image.is_some() || args.timeout.is_some() { + return Err(anyhow!( + "--flavor/--image/--timeout only apply with an external --backend." + )); + } + if args.manifest.is_some() { + return Err(anyhow!("--manifest only applies with --backend k8s.")); + } + // Resolve the target: exactly one of --sandbox, --gpu, or --cpu. + let selectors = [ + args.sandbox.is_some(), + args.gpu.is_some(), + args.cpu.is_some(), + ]; + let chosen = selectors.iter().filter(|x| **x).count(); + if chosen > 1 { + return Err(anyhow!("Pass exactly one of --sandbox, --gpu, or --cpu.")); + } + if args.provider.is_some() && args.gpu.is_none() { + return Err(anyhow!( + "--provider only applies with --gpu (it selects among new GPU offers)." + )); + } + let target = if let Some(sandbox_id) = &args.sandbox { + RunTarget::Existing { + sandbox_id: sandbox_id.clone(), + } + } else if let Some(gpu) = &args.gpu { + RunTarget::New { + gpu: gpu.clone(), + gpu_count: args.count.unwrap_or(1), + disk_gb: args.disk.unwrap_or(100), + // Omitted = server default (RunPod). The server validates the + // name and 400s on an unknown provider, so no client-side check. + provider: args.provider.clone(), + } + } else if let Some(cpu_flavor) = &args.cpu { + RunTarget::NewCpu { + cpu_flavor: cpu_flavor.clone(), + vcpu_count: args.vcpus.unwrap_or(8), + } + } else { + return Err(anyhow!( + "Choose compute: --gpu <id> [--count N] [--disk GB], \ + --cpu <cpu5c|cpu5g|cpu5m> [--vcpus 2|8|32], or --sandbox <id>. \ + See `orx compute` for available GPUs." + )); + }; + + // Friendlier than the raw API "No run command set": tell them how to fix. + let current = get_experiment(creds, &args.exp_id).await?; + if current.experiment.run_command.is_empty() { + return Err(anyhow!( + "No run command set for this experiment. Set one first with \ + `orx exp cmd {} --set \"…\"`.", + args.exp_id + )); + } + + // Coarse target label for analytics — NOT the sandbox id / gpu / flavor. + let target_kind = match &target { + RunTarget::Existing { .. } => "existing", + RunTarget::New { .. } => "gpu", + RunTarget::NewCpu { .. } => "cpu", + }; + start_experiment_run(creds, &args.exp_id, target, args.force).await?; + + // Key event, fired only on success. Server run (not local mode here). + crate::telemetry::capture_experiment_started("run", false, Some(target_kind)); + + println!("\u{2713} Run queued."); + println!( + " Follow it with `orx runs {}` and `orx logs <runId>`.", + current.experiment.project_id + ); + Ok(()) + } + + /// `--backend hf` — run the experiment as a Hugging Face Job on the user's + /// own HF account. (Former `exp::launch_hf`.) + async fn launch_hf(&self, args: ExpRunArgs) -> Result<()> { + let creds = &self.creds; + if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() { + return Err(anyhow!( + "--backend hf runs on Hugging Face Jobs; drop --gpu/--cpu/--sandbox \ + and pass --flavor instead (e.g. --flavor a10g-small)." + )); + } + let flavor = args.flavor.clone().ok_or_else(|| { + anyhow!( + "--backend hf requires --flavor: t4-small, a10g-small/large, l4x1, \ + l40sx1, a100-large, h200, … (cpu-basic/cpu-upgrade for CPU). \ + Priced per minute on your Hugging Face account." + ) + })?; + // HF's own default is 30 minutes — a footgun for training runs, so + // default generously and let --timeout tighten it. + let timeout_seconds = match &args.timeout { + Some(t) => hf::parse_timeout(t)?, + None => 4 * 3600, + }; + let token = hf::resolve_token()?; + let namespace = hf::whoami(&token).await?; + + // Register first: the run must exist in the tree before compute starts, + // and the response carries the repo/branch/command orx needs to submit. + let mut descriptor = BackendDescriptor { + kind: "hf_job".to_string(), + namespace: Some(namespace.clone()), + job_id: None, + flavor: Some(flavor.clone()), + image: args.image.clone(), + url: None, + context: None, + manifest: None, + resources: None, + ssh_host: None, + ssh_port: None, + ssh_user: None, + timeout_secs: None, + }; + let created = + create_external_run(creds, &args.exp_id, serde_json::to_value(&descriptor)?).await?; + let run_id = created.run.id.clone(); + + let image = args + .image + .clone() + .unwrap_or_else(|| crate::commands::exp::default_hf_image(&flavor)); + let script = crate::commands::exp::hf_clone_script( + &created.branch_name, + &created.github_owner, + &created.github_repo, + &created.run_command, + ); + + let mut secrets = HashMap::new(); + secrets.insert("HF_TOKEN".to_string(), token.clone()); + // Clone credential precedence: explicit GITHUB_TOKEN (env, then the box's + // synced env file) overrides; otherwise the api's repo-scoped + // installation token flows automatically from the org's connected GitHub + // app — a private repo needs zero extra setup beyond having connected it. + let github_token = std::env::var("GITHUB_TOKEN") + .ok() + .filter(|t| !t.trim().is_empty()) + .or_else(|| crate::config::synced_env_var("GITHUB_TOKEN")) + .or_else(|| created.github_token.clone()); + if let Some(gh) = github_token { + secrets.insert("GITHUB_TOKEN".to_string(), gh); + } + let mut labels = HashMap::new(); + labels.insert("or_run".to_string(), run_id.clone()); + labels.insert("or_experiment".to_string(), args.exp_id.clone()); + labels.insert("or_project".to_string(), created.project_id.clone()); + + let job = hf::run_job( + &token, + &namespace, + &hf::JobSubmission { + command: vec!["bash".to_string(), "-c".to_string(), script], + docker_image: image.clone(), + flavor: flavor.clone(), + environment: HashMap::new(), + secrets, + timeout_seconds, + labels, + }, + ) + .await?; + + // Record the job handle: local store (the truth orx serve exposes), then + // the api mirror (display + reconciliation). Local write must not be lost + // even if the PATCH fails — supervise needs it to reattach. + descriptor.job_id = Some(job.id.clone()); + descriptor.url = Some(hf::job_url(&namespace, &job.id)); + descriptor.image = Some(image); + let store = Store::open()?; + store.upsert_run(&StoredRun { + id: run_id.clone(), + experiment_id: args.exp_id.clone(), + project_id: created.project_id.clone(), + status: "starting".to_string(), + backend_json: descriptor.to_json(), + command: created.run_command.clone(), + created_at: now_ms(), + updated_at: now_ms(), + ended_at: None, + exit_code: None, + commit_sha: None, + result_markdown: None, + cancel_requested: false, + })?; + if let Err(err) = crate::client::update_external_run( + creds, + &run_id, + serde_json::json!({ "backend": serde_json::to_value(&descriptor)? }), + ) + .await + { + eprintln!("warning: could not mirror the job handle to the api: {err}"); + } + + // Detach the supervisor: it tails logs, mirrors transitions, and uploads + // the final log. Survives this process exiting (new process group). + crate::commands::exp::spawn_detached_supervise(&run_id)?; + + println!("\u{2713} Hugging Face job submitted."); + println!(" run {run_id}"); + println!(" job {}/{} ({flavor})", namespace, job.id); + println!(" watch {}", descriptor.url.as_deref().unwrap_or("")); + println!( + " Follow it with `orx exp wait {}` or `orx logs {run_id}`.", + args.exp_id + ); + // Key event, fired only on success. Managed run on the user's HF account. + crate::telemetry::capture_experiment_started("run", false, Some("hf")); + Ok(()) + } + + /// `--backend modal` — run the experiment as a Modal Sandbox on the user's + /// own Modal account. (Former `exp::launch_modal`.) + async fn launch_modal(&self, args: ExpRunArgs) -> Result<()> { + use crate::jobs::modal; + let creds = &self.creds; + if args.sandbox.is_some() || args.gpu.is_some() || args.cpu.is_some() { + return Err(anyhow!( + "--backend modal runs on Modal serverless GPUs; drop --gpu/--cpu/--sandbox \ + and pass --flavor instead (e.g. --flavor a10g, --flavor a100-80gb, --flavor cpu)." + )); + } + let flavor = args.flavor.clone().ok_or_else(|| { + anyhow!( + "--backend modal requires --flavor: a Modal GPU (t4, l4, a10g, a100, a100-80gb, \ + l40s, h100, h200, or e.g. h100:2) — or cpu / cpu-large for CPU-only. \ + Priced per second on your Modal account." + ) + })?; + let resources = modal::resolve_flavor(&flavor); + // Fail before registering the run with the api if Modal isn't set up. + modal::preflight().await?; + let timeout_seconds = match &args.timeout { + Some(t) => hf::parse_timeout(t)?, + None => 4 * 3600, + }; + const MODAL_APP: &str = "openresearch"; + + // Register first: the run must exist in the tree before compute starts, + // and the response carries the repo/branch/command orx needs to submit. + let mut descriptor = BackendDescriptor { + kind: "modal_job".to_string(), + namespace: Some(MODAL_APP.to_string()), + job_id: None, + flavor: Some(flavor.clone()), + image: args.image.clone(), + url: None, + context: None, + manifest: None, + resources: None, + ssh_host: None, + ssh_port: None, + ssh_user: None, + timeout_secs: None, + }; + let created = + create_external_run(creds, &args.exp_id, serde_json::to_value(&descriptor)?).await?; + let run_id = created.run.id.clone(); + + let image = args + .image + .clone() + .unwrap_or_else(|| modal::default_image(resources.gpu.is_some())); + let script = crate::commands::exp::hf_clone_script( + &created.branch_name, + &created.github_owner, + &created.github_repo, + &created.run_command, + ); + + // Same clone-credential precedence as the HF path: explicit GITHUB_TOKEN + // (env, then the box's synced env file) overrides the api's repo-scoped + // installation token. + let mut env = HashMap::new(); + if let Ok(hf_token) = hf::resolve_token() { + env.insert("HF_TOKEN".to_string(), hf_token); + } + let github_token = std::env::var("GITHUB_TOKEN") + .ok() + .filter(|t| !t.trim().is_empty()) + .or_else(|| crate::config::synced_env_var("GITHUB_TOKEN")) + .or_else(|| created.github_token.clone()); + if let Some(gh) = github_token { + env.insert("GITHUB_TOKEN".to_string(), gh); + } + let mut tags = HashMap::new(); + tags.insert("or_run".to_string(), run_id.clone()); + tags.insert("or_experiment".to_string(), args.exp_id.clone()); + tags.insert("or_project".to_string(), created.project_id.clone()); + + let sandbox_id = modal::run_job(&modal::ModalJobSpec { + script, + image: image.clone(), + gpu: resources.gpu.clone(), + cpu: resources.cpu, + memory: resources.memory, + env, + timeout_seconds, + app: MODAL_APP.to_string(), + tags, + }) + .await?; + + // Record the sandbox handle: local store (the truth orx serve exposes), + // then the api mirror. The local write must not be lost even if PATCH + // fails. + descriptor.job_id = Some(sandbox_id.clone()); + descriptor.image = Some(image); + let store = Store::open()?; + store.upsert_run(&StoredRun { + id: run_id.clone(), + experiment_id: args.exp_id.clone(), + project_id: created.project_id.clone(), + status: "starting".to_string(), + backend_json: descriptor.to_json(), + command: created.run_command.clone(), + created_at: now_ms(), + updated_at: now_ms(), + ended_at: None, + exit_code: None, + commit_sha: None, + result_markdown: None, + cancel_requested: false, + })?; + if let Err(err) = crate::client::update_external_run( + creds, + &run_id, + serde_json::json!({ "backend": serde_json::to_value(&descriptor)? }), + ) + .await + { + eprintln!("warning: could not mirror the sandbox handle to the api: {err}"); + } + + crate::commands::exp::spawn_detached_supervise(&run_id)?; + + println!("\u{2713} Modal sandbox submitted."); + println!(" run {run_id}"); + println!(" sandbox {sandbox_id} ({flavor})"); + println!( + " Follow it with `orx exp wait {}` or `orx logs {run_id}`.", + args.exp_id + ); + // Key event, fired only on success. Managed run on the user's Modal + // account. + crate::telemetry::capture_experiment_started("run", false, Some("modal")); + Ok(()) + } + + // --- report subcommands (former commands::report server fns) ---------- + + async fn report_show(&self, project_id: &str, report: &str) -> Result<()> { + let creds = &self.creds; + let report_id = resolve_report_id(creds, project_id, report).await?; + + let detail = get_report(creds, project_id, &report_id).await?; + if detail.markdown.is_empty() { + return Err(anyhow!( + "Report {:?} has no markdown body (report.md was never uploaded).", + detail.report.title + )); + } + print!("{}", detail.markdown); + if !detail.markdown.ends_with('\n') { + println!(); + } + Ok(()) + } + + async fn report_download(&self, project_id: &str, report: &str, dir: &str) -> Result<()> { + let creds = &self.creds; + let report_id = resolve_report_id(creds, project_id, report).await?; + + let detail = get_report(creds, project_id, &report_id).await?; + if detail.markdown.is_empty() { + return Err(anyhow!( + "Report {:?} has no markdown body (report.md was never uploaded).", + detail.report.title + )); + } + + let root = PathBuf::from(dir); + std::fs::create_dir_all(&root) + .map_err(|e| anyhow!("Could not create {}: {}", root.display(), e))?; + + // report.md, byte-for-byte (the markdown the API returns is the stored + // file, YAML frontmatter included — the ingest reads `repo`/`gpu`/`count` + // from it). + let md_path = root.join("report.md"); + std::fs::write(&md_path, detail.markdown.as_bytes()) + .map_err(|e| anyhow!("Could not write {}: {}", md_path.display(), e))?; + println!(" wrote report.md"); + + // Pull every report-relative file the markdown links to (images, mostly). + // There's no list-files endpoint, so the references in report.md are the + // manifest — which is exactly the set that has to exist for it to render. + let mut downloaded = 0usize; + for rel in report_relative_links(&detail.markdown) { + if !is_safe_report_path(&rel) { + continue; + } + let bytes = match download_report_file(creds, project_id, &report_id, &rel).await { + Ok(b) => b, + // A broken link in the markdown shouldn't abort the whole + // download; surface it and keep going. + Err(e) => { + eprintln!(" ! skipped {} ({})", rel, e); + continue; + } + }; + let out = root.join(&rel); + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("Could not create {}: {}", parent.display(), e))?; + } + std::fs::write(&out, &bytes) + .map_err(|e| anyhow!("Could not write {}: {}", out.display(), e))?; + println!(" wrote {}", rel); + downloaded += 1; + } + + println!( + "\u{2713} Downloaded report to {} (report.md + {} file{})", + root.display(), + downloaded, + if downloaded == 1 { "" } else { "s" } + ); + Ok(()) + } + + async fn report_list(&self, project_id: &str) -> Result<()> { + let reports = list_reports(&self.creds, project_id).await?.reports; + if reports.is_empty() { + println!("No reports yet."); + return Ok(()); + } + for r in reports { + println!("{} {} ({})", r.id, r.title, r.created_at); + } + Ok(()) + } + + async fn report_upload( + &self, + project_id: &str, + folder: &str, + title: Option<String>, + ) -> Result<()> { + let creds = &self.creds; + + let root = PathBuf::from(folder); + if !root.is_dir() { + return Err(anyhow!("Not a directory: {}", folder)); + } + + // Collect every file under the folder as a report-relative POSIX path. + let mut rel_paths: Vec<String> = Vec::new(); + collect_files(&root, &root, &mut rel_paths)?; + rel_paths.retain(|p| { + let name = p.rsplit('/').next().unwrap_or(p); + !IGNORED.contains(&name) + }); + + if rel_paths.is_empty() { + return Err(anyhow!("No files found in {}", folder)); + } + if !rel_paths.iter().any(|p| p == "report.md") { + return Err(anyhow!( + "{} must contain a report.md at its top level", + folder + )); + } + + // Title defaults to the folder name. + let title = title.unwrap_or_else(|| { + root.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("report") + .to_string() + }); + + let result = create_report( + creds, + project_id, + &CreateReportBody { + title: title.clone(), + slug: None, + files: rel_paths.clone(), + }, + ) + .await?; + + // Upload each file to its presigned URL. + for slot in &result.uploads { + let abs = root.join(&slot.path); + let bytes = std::fs::read(&abs) + .map_err(|e| anyhow!("Could not read {}: {}", abs.display(), e))?; + upload_to_presigned(&slot.url, &slot.content_type, bytes).await?; + println!(" uploaded {}", slot.path); + } + + println!("\u{2713} Uploaded report"); + println!(" id: {}", result.report.id); + println!(" title: {}", result.report.title); + println!(" files: {}", result.uploads.len()); + Ok(()) + } +} + +/// Sleep one interval, but fail with a timeout error if the deadline passed. +/// (Former `exp::sleep_until_or_timeout` — shared by both planes' wait loops.) +pub(super) async fn sleep_until_or_timeout(interval: Duration, deadline: Instant) -> Result<()> { + if Instant::now() >= deadline { + return Err(anyhow!("Timed out waiting for a run state change.")); + } + let nap = interval.min(deadline.saturating_duration_since(Instant::now())); + tokio::time::sleep(nap).await; + if Instant::now() >= deadline { + return Err(anyhow!("Timed out waiting for a run state change.")); + } + Ok(()) +} + +// Files surfaced by the OS that aren't part of a report. +const IGNORED: &[&str] = &[".DS_Store", "Thumbs.db"]; + +/// Resolve a report id-or-slug to its id, erroring clearly if it isn't found. +/// We always list first so a stale ref gives a helpful message, not a raw 404. +async fn resolve_report_id(creds: &Credentials, project_id: &str, report: &str) -> Result<String> { + let reports = list_reports(creds, project_id).await?.reports; + reports + .iter() + .find(|r| r.id == report || r.slug == report) + .map(|r| r.id.clone()) + .ok_or_else(|| { + anyhow!( + "No report {:?} in this project. List them with: orx report list {}", + report, + project_id + ) + }) +} + +/// Extract the report-relative link/image targets from markdown — the `target` +/// in every `](target)` (covers `![alt](images/x.png)` and `[text](file)`). +/// Filters out absolute URLs, anchors, and absolute paths, leaving the local +/// files the report bundles. Deduplicated, order preserved. +fn report_relative_links(md: &str) -> Vec<String> { + let mut out: Vec<String> = Vec::new(); + let bytes = md.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b']' && bytes[i + 1] == b'(' { + let start = i + 2; + if let Some(rel) = bytes[start..].iter().position(|&b| b == b')') { + let inner = &md[start..start + rel]; + // Drop an optional `"title"` after the URL: `(path "t")`. + let target = inner.split_whitespace().next().unwrap_or("").trim(); + if is_local_target(target) && !out.iter().any(|p| p == target) { + out.push(target.to_string()); + } + i = start + rel + 1; + continue; + } + } + i += 1; + } + out +} + +/// A link target that points at a file bundled in the report (not the web). +fn is_local_target(t: &str) -> bool { + !t.is_empty() + && !t.starts_with('#') + && !t.starts_with('/') + && !t.contains("://") + && !t.starts_with("//") + && !t.starts_with("mailto:") + && !t.starts_with("data:") +} + +/// Mirror of the server's `isSafeReportPath`: relative, no `..`/`.` segments, +/// no backslashes — so a malicious markdown link can't escape `dir`. +fn is_safe_report_path(p: &str) -> bool { + !p.starts_with('/') && !p.contains('\\') && !p.split('/').any(|seg| seg == ".." || seg == ".") +} + +/// Recursively collect files under `dir`, pushing each as a `/`-joined path +/// relative to `base`. +fn collect_files(base: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> { + for entry in + std::fs::read_dir(dir).map_err(|e| anyhow!("Could not read {}: {}", dir.display(), e))? + { + let entry = entry.map_err(|e| anyhow!("Could not read entry: {}", e))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|e| anyhow!("Could not stat {}: {}", path.display(), e))?; + if file_type.is_dir() { + collect_files(base, &path, out)?; + } else if file_type.is_file() { + if let Ok(rel) = path.strip_prefix(base) { + let rel = rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::<Vec<_>>() + .join("/"); + if !rel.is_empty() { + out.push(rel); + } + } + } + } + Ok(()) +} From 2219792cd43d64c3f91075497a05ac8d1051dce4 Mon Sep 17 00:00:00 2001 From: Daniel Kim <sox8502@gmail.com> Date: Fri, 17 Jul 2026 20:22:10 -0700 Subject: [PATCH 2/2] Bump version to 0.1.63 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f40cc43..ab6a908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -928,7 +928,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openresearch-cli" -version = "0.1.62" +version = "0.1.63" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c06888f..d8524f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openresearch-cli" -version = "0.1.62" +version = "0.1.63" edition = "2021" description = "OpenResearch CLI (orx) — Rust port" repository = "https://github.com/alphaXiv/openresearch-cli"