Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions dev/omnidev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,16 @@ replaces the three-terminal local dev flow (`omnigent server`, `omnigent host`,

## Build & run

Requires the repo's usual dev prerequisites (`uv` for Python, `pnpm` for the
web UI) plus a Rust toolchain.
Requires the repo's usual dev prerequisites (`uv` for Python, Node 22+ and
either `pnpm` or Corepack for the web UI) plus a Rust toolchain.

```bash
cd dev/omnidev
cargo run # launches the TUI for the surrounding checkout
```

If `pnpm` is missing from `PATH`, omnidev automatically uses `corepack pnpm`.

Run it from anywhere inside the checkout — it walks up to the repo root
(the `.jj`/`.git` marker) and requires `omnigent/` and
`web/` to be present. Build a release binary with `cargo build --release`
Expand Down
12 changes: 6 additions & 6 deletions dev/omnidev/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ async fn run_supervisor(args: RunArgs) -> Result<()> {
profile,
)?);

let web = if args.no_vite {
None
} else {
Some(process::WebCommands::resolve(&pod)?)
};
let shared = Shared::new(&pod);
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<Cmd>();

Expand All @@ -272,12 +277,7 @@ async fn run_supervisor(args: RunArgs) -> Result<()> {
)?;

// Supervisor runs on the tokio runtime; the TUI drives it via cmd_tx.
let supervisor = Supervisor::new(
pod.clone(),
shared.clone(),
!args.no_vite,
args.trust_lan_origins,
);
let supervisor = Supervisor::new(pod.clone(), shared.clone(), web, args.trust_lan_origins);
let sup_handle = tokio::spawn(supervisor.run(cmd_rx));

// Run the TUI (owns the terminal) until the user quits.
Expand Down
162 changes: 162 additions & 0 deletions dev/omnidev/src/process.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
//! Concrete command specs for the three supervised processes.

use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

use anyhow::{bail, Result};

use crate::install::PYTHON_VERSION;
use crate::pod::Pod;

Expand All @@ -14,6 +18,57 @@ pub struct ProcSpec {
pub extra_env: Vec<(String, String)>,
}

/// Web commands resolved before the supervisor starts any children.
pub struct WebCommands {
pub vite: ProcSpec,
pub prepare: Option<ProcSpec>,
}

impl WebCommands {
pub fn resolve(pod: &Pod) -> Result<Self> {
Self::resolve_on_path(pod, std::env::var_os("PATH").as_deref())
}

fn resolve_on_path(pod: &Pod, path: Option<&OsStr>) -> Result<Self> {
let mut commands = Self {
vite: ProcSpec::vite(pod),
prepare: if pod.profile.as_ref().is_some_and(|p| p.prepare.is_none()) {
None
} else {
Some(ProcSpec::web_prepare(pod))
},
};
if pod.profile.is_some() {
return Ok(commands);
}

let available = |program: &str| {
path.is_some_and(|path| {
std::env::split_paths(path).any(|dir| {
std::fs::metadata(pod.web_dir().join(dir).join(program))
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
})
})
};
if available("pnpm") {
return Ok(commands);
}
if !available("corepack") {
bail!(
"Neither `pnpm` nor `corepack` is on PATH. The dev UI requires Node 22+ \
and pnpm. Install pnpm (`npm install -g pnpm`), or install Corepack \
(`npm install -g corepack`), then retry. Use --no-vite for backend-only development."
);
}
// Corepack can run the repository's pinned pnpm without installing global shims.
for spec in std::iter::once(&mut commands.vite).chain(commands.prepare.iter_mut()) {
spec.program = "corepack".into();
spec.args.insert(0, "pnpm".into());
}
Ok(commands)
}
}

impl ProcSpec {
fn from_profile(pod: &Pod, profile: &crate::profile::ProcessProfile) -> ProcSpec {
let expand = |value: &str| {
Expand Down Expand Up @@ -150,6 +205,98 @@ mod tests {
use crate::ports::Ports;
use crate::profile::{ProcessProfile, Profile};

fn web_pod(repo: PathBuf) -> Pod {
std::fs::create_dir_all(repo.join("web")).unwrap();
Pod {
dir: repo.join("pod"),
repo_root: repo,
ports: Ports {
server: 19191,
vite: 19292,
},
vite_host: "127.0.0.1".into(),
trusted_origins: Vec::new(),
profile: None,
}
}

fn fake_executable(dir: &std::path::Path, name: &str) {
let path = dir.join(name);
std::fs::write(&path, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n").unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}

#[test]
fn corepack_fallback_runs_install_and_vite_with_original_arguments() {
let repo = tempdir();
let bin = repo.join("bin");
std::fs::create_dir(&bin).unwrap();
fake_executable(&bin, "corepack");
let pod = web_pod(repo.clone());

let commands = WebCommands::resolve_on_path(&pod, Some(bin.as_os_str())).unwrap();
for (spec, expected) in [
(commands.prepare.unwrap(), "pnpm\ninstall\n"),
(
commands.vite,
"pnpm\nrun\ndev\n--host\n127.0.0.1\n--port\n19292\n--strictPort\n",
),
] {
assert_eq!(spec.program, "corepack");
let output = std::process::Command::new(bin.join(&spec.program))
.args(&spec.args)
.current_dir(&spec.cwd)
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
assert_eq!(spec.cwd, pod.web_dir());
}
std::fs::remove_dir_all(repo).unwrap();
}

#[test]
fn standalone_pnpm_is_preferred_over_corepack() {
let repo = tempdir();
fake_executable(&repo, "pnpm");
fake_executable(&repo, "corepack");
let pod = web_pod(repo.clone());
let commands = WebCommands::resolve_on_path(&pod, Some(repo.as_os_str())).unwrap();

assert_eq!(commands.vite.program, "pnpm");
assert_eq!(commands.vite.args[0], "run");
let prepare = commands.prepare.unwrap();
assert_eq!(prepare.program, "pnpm");
assert_eq!(prepare.args, ["install"]);
std::fs::remove_dir_all(repo).unwrap();
}

#[test]
fn missing_web_tools_report_setup_instructions() {
let repo = tempdir();
let pod = web_pod(repo.clone());
for path in [None, Some(repo.as_os_str())] {
let error = WebCommands::resolve_on_path(&pod, path).err().unwrap();
let message = error.to_string();
assert!(message.contains("Neither `pnpm` nor `corepack` is on PATH"));
assert!(message.contains("npm install -g pnpm"));
assert!(message.contains("--no-vite"));
}
std::fs::remove_dir_all(repo).unwrap();
}

#[test]
fn nonexecutable_pnpm_does_not_hide_corepack() {
let repo = tempdir();
let pod = web_pod(repo.clone());
std::fs::write(repo.join("pnpm"), "not executable").unwrap();
fake_executable(&repo, "corepack");

let commands = WebCommands::resolve_on_path(&pod, Some(repo.as_os_str())).unwrap();
assert_eq!(commands.vite.program, "corepack");
std::fs::remove_dir_all(repo).unwrap();
}

#[test]
fn vite_forwards_configured_host_and_port_but_backend_url_stays_loopback() {
let repo = tempdir();
Expand Down Expand Up @@ -278,6 +425,21 @@ mod tests {
);
assert_eq!(spec.cwd, repo.join("service"));
assert!(!pod.host_enabled());

let web = WebCommands::resolve_on_path(&pod, None).unwrap();
assert_eq!(web.vite.program, "server");
assert_eq!(web.vite.args, spec.args);
assert!(web.prepare.is_none());

pod.profile.as_mut().unwrap().prepare = Some(ProcessProfile {
command: vec!["custom-install".into(), "--offline".into()],
cwd: "ui".into(),
});
let web = WebCommands::resolve_on_path(&pod, None).unwrap();
let prepare = web.prepare.unwrap();
assert_eq!(prepare.program, "custom-install");
assert_eq!(prepare.args, ["--offline"]);
assert_eq!(prepare.cwd, repo.join("ui"));
}

fn tempdir() -> std::path::PathBuf {
Expand Down
53 changes: 25 additions & 28 deletions dev/omnidev/src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use tokio::time::{sleep, timeout};
use crate::browser;
use crate::omnigent_cmd;
use crate::pod::Pod;
use crate::process::ProcSpec;
use crate::process::{ProcSpec, WebCommands};
use crate::state::{ProcId, ProcStatus, Shared};

const CONVERSATION_PREFILL_VERSION: &str = "v1";
Expand Down Expand Up @@ -77,7 +77,7 @@ pub struct Supervisor {
pod: Arc<Pod>,
shared: Arc<Mutex<Shared>>,
env: Vec<(String, String)>,
vite_enabled: bool,
web: Option<WebCommands>,
/// Whether `--trust-lan-origins` was requested, so we can warn if it was
/// asked for but no LAN interface turned up any origins to trust.
trust_lan_origins: bool,
Expand All @@ -93,7 +93,7 @@ impl Supervisor {
pub fn new(
pod: Arc<Pod>,
shared: Arc<Mutex<Shared>>,
vite_enabled: bool,
web: Option<WebCommands>,
trust_lan_origins: bool,
) -> Supervisor {
let env = pod.env();
Expand All @@ -102,7 +102,7 @@ impl Supervisor {
pod,
shared,
env,
vite_enabled,
web,
trust_lan_origins,
slots: Default::default(),
expected_stops: HashSet::new(),
Expand Down Expand Up @@ -139,10 +139,14 @@ impl Supervisor {
}

self.start_backend().await;
if self.vite_enabled {
if let Some(web) = &self.web {
if web.vite.program == "corepack" && self.pod.profile.is_none() {
self.event("pnpm is not on PATH; using corepack pnpm for web commands");
}
self.prepare_vite().await;
self.spawn(ProcId::Vite);
self.open_ui_when_ready();
if self.spawn(ProcId::Vite) {
self.open_ui_when_ready();
}
}

loop {
Expand Down Expand Up @@ -336,7 +340,7 @@ impl Supervisor {
// backend, so treat it as a backend restart.
ProcId::Server | ProcId::Host => self.start_backend_restart().await,
ProcId::Vite => {
if self.vite_enabled {
if self.web.is_some() {
self.event("restarting vite");
self.stop(ProcId::Vite).await;
self.prepare_vite().await;
Expand All @@ -346,17 +350,13 @@ impl Supervisor {
}
}

fn spec(&self, id: ProcId) -> ProcSpec {
match id {
ProcId::Server => ProcSpec::server(&self.pod),
ProcId::Host => ProcSpec::host(&self.pod),
ProcId::Vite => ProcSpec::vite(&self.pod),
}
}

/// Spawn a child in its own process group and wire up output + exit monitor.
fn spawn(&mut self, id: ProcId) {
let spec = self.spec(id);
fn spawn(&mut self, id: ProcId) -> bool {
let spec = match id {
ProcId::Server => &ProcSpec::server(&self.pod),
ProcId::Host => &ProcSpec::host(&self.pod),
ProcId::Vite => &self.web.as_ref().expect("web is enabled").vite,
};
self.set_status(id, ProcStatus::Starting);

let mut cmd = Command::new(&spec.program);
Expand Down Expand Up @@ -385,7 +385,7 @@ impl Supervisor {
.unwrap()
.log_proc(id, format!("failed to spawn {}: {e}", spec.program));
self.set_status(id, ProcStatus::Crashed);
return;
return false;
}
};

Expand Down Expand Up @@ -422,6 +422,7 @@ impl Supervisor {
status,
});
});
true
}

/// Prepare web dependencies before Vite starts, but only when they are
Expand All @@ -430,13 +431,10 @@ impl Supervisor {
/// non-fatal: we still let Vite try, so a transient pnpm hiccup doesn't block
/// the whole session.
async fn prepare_vite(&self) {
if self
.pod
.profile
.as_ref()
.is_some_and(|profile| profile.prepare.is_none())
|| !self.pod.needs_web_prepare()
{
let Some(spec) = self.web.as_ref().and_then(|web| web.prepare.as_ref()) else {
return;
};
if !self.pod.needs_web_prepare() {
return;
}
self.set_status(ProcId::Vite, ProcStatus::Starting);
Expand All @@ -445,7 +443,6 @@ impl Supervisor {
"web deps missing or stale — preparing dependencies".into(),
);

let spec = ProcSpec::web_prepare(&self.pod);
let mut cmd = Command::new(&spec.program);
cmd.args(&spec.args)
.current_dir(&spec.cwd)
Expand Down Expand Up @@ -596,7 +593,7 @@ impl Supervisor {
}
}
ProcId::Vite => {
if self.vite_enabled {
if self.web.is_some() {
self.spawn(ProcId::Vite);
}
}
Expand Down
Loading
Loading