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" 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(()) +}