mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-11 09:59:34 +08:00
191 lines
6.7 KiB
Rust
191 lines
6.7 KiB
Rust
//! Local experiment creation — slug, branch, row. Shared by the CLI
|
|
//! (`orx create-experiment`) and the `orx up` HTTP API.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
use crate::error::{anyhow, Result};
|
|
use crate::store::{now_ms, Store};
|
|
|
|
use super::git;
|
|
use super::model::{LocalExperiment, LocalProject};
|
|
use super::slugify;
|
|
|
|
/// First free slug within the project: `base`, `base-2`, `base-3`, …
|
|
fn unique_slug(store: &Store, project_id: &str, base: &str) -> Result<String> {
|
|
let taken: HashSet<String> = store
|
|
.list_experiments_by_project(project_id)?
|
|
.into_iter()
|
|
.map(|e| e.slug)
|
|
.collect();
|
|
if !taken.contains(base) {
|
|
return Ok(base.to_string());
|
|
}
|
|
let mut n = 2;
|
|
loop {
|
|
let candidate = format!("{base}-{n}");
|
|
if !taken.contains(&candidate) {
|
|
return Ok(candidate);
|
|
}
|
|
n += 1;
|
|
}
|
|
}
|
|
|
|
/// The project's root experiment (parent NULL) — oldest first when several
|
|
/// exist. `None` on a fresh project: the tree starts empty and the first
|
|
/// no-parent create becomes the baseline. CLI/API creates without a parent
|
|
/// attach here once a root exists (pass `--baseline` to add another root).
|
|
pub fn project_root(store: &Store, project_id: &str) -> Result<Option<LocalExperiment>> {
|
|
// list is ordered created_at ASC, so `find` picks the oldest root.
|
|
Ok(store
|
|
.list_experiments_by_project(project_id)?
|
|
.into_iter()
|
|
.find(|e| e.parent_experiment_id.is_none()))
|
|
}
|
|
|
|
/// Warning for pre-orx/<slug>-baseline rows: roots created before baselines
|
|
/// got their own branch ride the project's base branch, so that branch is an
|
|
/// immutable experiment node. `None` for experiments created since.
|
|
pub fn legacy_root_warning(project: &LocalProject, experiment: &LocalExperiment) -> Option<String> {
|
|
(experiment.parent_experiment_id.is_none() && experiment.branch_name == project.baseline_branch)
|
|
.then(|| {
|
|
format!(
|
|
"warning: root experiment {} rides the project's base branch '{}' \
|
|
(created before baselines got their own orx/* branch). Treat '{}' \
|
|
as immutable — publish READMEs/notebooks elsewhere, or recreate the \
|
|
tree with `orx create-experiment --baseline`.",
|
|
experiment.id, project.baseline_branch, project.baseline_branch
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Create a local experiment. Every node gets its own `orx/<slug>` branch,
|
|
/// pushed to origin: a child forks off its parent's tip, a baseline/root off
|
|
/// the project's base branch. The base branch itself is never an experiment
|
|
/// node — it stays mutable (README, notebooks, publication surface) while
|
|
/// `orx/*` branches hold the experiment nodes' recorded code. Matches the
|
|
/// server path, which also branches baselines to `orx/<slug>`.
|
|
pub fn create_experiment(
|
|
store: &Store,
|
|
project: &LocalProject,
|
|
parent: Option<&LocalExperiment>,
|
|
slug: Option<&str>,
|
|
title: Option<String>,
|
|
description: Option<String>,
|
|
run_command: Option<String>,
|
|
) -> Result<LocalExperiment> {
|
|
if let Some(p) = parent {
|
|
if p.project_id != project.id {
|
|
return Err(anyhow!(
|
|
"Parent experiment {} belongs to a different project.",
|
|
p.id
|
|
));
|
|
}
|
|
}
|
|
let base = match slug {
|
|
Some(s) => slugify(s),
|
|
None => slugify(title.as_deref().unwrap_or("experiment")),
|
|
};
|
|
let slug = unique_slug(store, &project.id, &base)?;
|
|
|
|
let repo = git::ensure_clone(
|
|
&project.github_owner,
|
|
&project.github_repo,
|
|
&project.baseline_branch,
|
|
)?;
|
|
let fork_point = parent
|
|
.map(|p| p.branch_name.as_str())
|
|
.unwrap_or(&project.baseline_branch);
|
|
let branch_name = format!("orx/{slug}");
|
|
git::create_experiment_branch(Path::new(&repo), fork_point, &branch_name)?;
|
|
|
|
// Inherit: explicit > parent's command > project default > "".
|
|
let run_command = run_command
|
|
.filter(|c| !c.trim().is_empty())
|
|
.or_else(|| {
|
|
parent
|
|
.map(|p| p.run_command.clone())
|
|
.filter(|c| !c.trim().is_empty())
|
|
})
|
|
.or_else(|| project.run_command.clone())
|
|
.unwrap_or_default();
|
|
|
|
let now = now_ms();
|
|
let experiment = LocalExperiment {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
project_id: project.id.clone(),
|
|
parent_experiment_id: parent.map(|p| p.id.clone()),
|
|
slug,
|
|
branch_name,
|
|
title,
|
|
description,
|
|
run_command,
|
|
agent_status: "idle".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
chat_session_id: crate::local::chat::launching_chat_session(),
|
|
};
|
|
store.create_local_experiment(&experiment)?;
|
|
Ok(experiment)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn project(base: &str) -> LocalProject {
|
|
LocalProject {
|
|
id: "p1".into(),
|
|
name: "Demo".into(),
|
|
slug: "demo".into(),
|
|
github_owner: "o".into(),
|
|
github_repo: "r".into(),
|
|
baseline_branch: base.into(),
|
|
repo_path: "/tmp/r".into(),
|
|
run_command: None,
|
|
paper_id: None,
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
}
|
|
|
|
fn experiment(parent: Option<&str>, branch: &str) -> LocalExperiment {
|
|
LocalExperiment {
|
|
id: "e1".into(),
|
|
project_id: "p1".into(),
|
|
parent_experiment_id: parent.map(String::from),
|
|
slug: "baseline".into(),
|
|
branch_name: branch.into(),
|
|
title: None,
|
|
description: None,
|
|
run_command: String::new(),
|
|
agent_status: "idle".into(),
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
chat_session_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn warns_only_for_legacy_roots_on_the_base_branch() {
|
|
let p = project("main");
|
|
// Legacy root riding main: warn.
|
|
let w = legacy_root_warning(&p, &experiment(None, "main")).unwrap();
|
|
assert!(w.contains("rides the project's base branch 'main'"));
|
|
// Current-scheme root on its own branch: silent.
|
|
assert!(legacy_root_warning(&p, &experiment(None, "orx/baseline")).is_none());
|
|
// Child, even on the base branch name (not a root): silent.
|
|
assert!(legacy_root_warning(&p, &experiment(Some("root"), "main")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn project_is_available_as_an_experiment_slug() {
|
|
let root =
|
|
std::env::temp_dir().join(format!("orx-experiment-slug-test-{}", uuid::Uuid::new_v4()));
|
|
let store = Store::open_at(root.clone()).unwrap();
|
|
assert_eq!(unique_slug(&store, "p1", "project").unwrap(), "project");
|
|
drop(store);
|
|
std::fs::remove_dir_all(root).unwrap();
|
|
}
|
|
}
|