diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f866ed6beb..4309ee7f0e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -578,6 +578,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-session" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "codex-core", + "directories", + "libc", + "nix 0.27.1", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "codex-tui" version = "0.1.0" @@ -854,13 +873,22 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", ] [[package]] @@ -873,6 +901,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -2061,6 +2101,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.28.0" @@ -2760,7 +2811,7 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.28.0", "radix_trie", "unicode-segmentation", "unicode-width 0.1.14", @@ -3764,6 +3815,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4010,6 +4070,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4028,6 +4097,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4060,6 +4144,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4072,6 +4162,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4084,6 +4180,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4108,6 +4210,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4120,6 +4228,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4132,6 +4246,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4144,6 +4264,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 69c4e8a8a0..bb8a386958 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -9,5 +9,6 @@ members = [ "execpolicy", "interactive", "repl", + "session", "tui", ] diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml new file mode 100644 index 0000000000..16a59c7d82 --- /dev/null +++ b/codex-rs/session/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "codex-session" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "codex-session" +path = "src/main.rs" + +[lib] +name = "codex_session" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +codex-core = { path = "../core" } +tokio = { version = "1", features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", +] } +tracing = { version = "0.1.41", features = ["log"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +# new dependencies for session management +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +directories = "5" + +# unix-only process helpers +nix = { version = "0.27", optional = true, default-features = false, features = ["process"] } +libc = { version = "0.2", optional = true } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.27", default-features = false, features = ["process"] } +libc = "0.2" diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs new file mode 100644 index 0000000000..5c91229f13 --- /dev/null +++ b/codex-rs/session/src/cli.rs @@ -0,0 +1,191 @@ +//! Command-line interface definition and dispatch. + +use crate::{spawn, store}; +use anyhow::Result; +use clap::{Args, Parser, Subcommand}; + +/// Top-level CLI entry (re-exported by the crate). +#[derive(Parser)] +#[command(name = "codex-session", about = "Manage detached codex-exec sessions")] +pub struct Cli { + #[command(subcommand)] + cmd: Commands, +} + +impl Cli { + pub async fn dispatch(self) -> Result<()> { + match self.cmd { + Commands::Create(x) => x.run().await, + Commands::Delete(x) => x.run().await, + Commands::Logs(x) => x.run().await, + Commands::Exec(x) => x.run().await, + Commands::List(x) => x.run().await, + } + } +} + +#[derive(Subcommand)] +enum Commands { + /// Spawn a new, detached agent. + Create(CreateCmd), + + /// Kill a running session and delete on-disk artefacts. + Delete(DeleteCmd), + + /// Show (and optionally follow) stdout / stderr logs of a session. + Logs(LogsCmd), + + /// Execute a one-shot command inside an existing session. + Exec(ExecCmd), + + /// List all known session IDs. + List(ListCmd), +} + +// ----------------------------------------------------------------------------- +// create + +#[derive(Args)] +pub struct CreateCmd { + /// A custom session identifier. When omitted, a random UUIDv4 is used. + #[arg(long)] + id: Option, + + /// Path to the `codex-exec` binary. Defaults to relying on $PATH. + #[arg(long, default_value = "codex-exec")] + exec: String, + + /// If set, terminate the agent when the CLI process exits ("attached" mode). + #[arg(long)] + kill_on_drop: bool, +} + +impl CreateCmd { + pub async fn run(self) -> Result<()> { + let id = self + .id + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + // Persist basic metadata & directory skeleton *before* spawning the process. + let meta = store::SessionMeta { + id: id.clone(), + created_at: chrono::Utc::now(), + }; + + let paths = store::paths_for(&id)?; + store::materialise(&paths, &meta)?; + + // Spawn the background agent and immediately detach. + let mut child = spawn::spawn_agent(&self.exec, &id, &paths, self.kill_on_drop)?; + + if self.kill_on_drop { + // Hold the handle for the lifetime of the CLI; when we drop at the end of + // `run()` the agent will be terminated by the `kill_on_drop` setting. + tokio::spawn(async move { + let _ = child.wait().await; + }); + } + + // When not in kill_on_drop mode we *immediately* drop the handle so the agent can + // outlive us. + println!("{id}"); + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// delete + +#[derive(Args)] +pub struct DeleteCmd { + /// Session ID to terminate and remove. + id: String, +} + +impl DeleteCmd { + pub async fn run(self) -> Result<()> { + store::kill_session(&self.id).await?; + store::purge(&self.id)?; + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// logs + +#[derive(Args)] +pub struct LogsCmd { + /// Session ID whose logs should be printed. + id: String, + + /// Follow the file and stream appended lines (like `tail -f`). + #[arg(short, long)] + follow: bool, + + /// Show stderr instead of stdout. + #[arg(long)] + stderr: bool, +} + +impl LogsCmd { + pub async fn run(self) -> Result<()> { + use tokio::io::AsyncBufReadExt; + + let paths = store::paths_for(&self.id)?; + let target = if self.stderr { + &paths.stderr + } else { + &paths.stdout + }; + + let file = tokio::fs::File::open(target).await?; + + if self.follow { + let mut reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + println!("{line}"); + } + } else { + // Simply dump the file contents to stdout. + let mut stdout = tokio::io::stdout(); + tokio::io::copy(&mut tokio::io::BufReader::new(file), &mut stdout).await?; + } + + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// exec (not implemented yet) + +#[derive(Args)] +pub struct ExecCmd { + id: String, + + /// Remaining arguments form the command to execute. + #[arg(trailing_var_arg = true)] + cmd: Vec, +} + +impl ExecCmd { + pub async fn run(self) -> Result<()> { + anyhow::bail!("exec inside an existing session is not yet implemented"); + } +} + +// ----------------------------------------------------------------------------- +// list + +#[derive(Args)] +pub struct ListCmd; + +impl ListCmd { + pub async fn run(self) -> Result<()> { + let sessions = store::list_sessions()?; + for meta in sessions { + println!("{}\t{}", meta.id, meta.created_at); + } + Ok(()) + } +} diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs new file mode 100644 index 0000000000..eb44a37b74 --- /dev/null +++ b/codex-rs/session/src/lib.rs @@ -0,0 +1,13 @@ +//! Library entry-point re-exporting the CLI so the binary can stay tiny. + +pub mod cli; +mod spawn; +mod store; + +pub use cli::Cli; + +/// Binary entry – the bin crate’s `main.rs` calls into this for testability. +pub async fn run_main(cli: Cli) -> anyhow::Result<()> { + cli.dispatch().await +} + diff --git a/codex-rs/session/src/main.rs b/codex-rs/session/src/main.rs new file mode 100644 index 0000000000..325ad69a77 --- /dev/null +++ b/codex-rs/session/src/main.rs @@ -0,0 +1,11 @@ +use clap::Parser; +use codex_session::run_main; +use codex_session::Cli; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + run_main(cli).await?; + + Ok(()) +} diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs new file mode 100644 index 0000000000..4b0ffd8783 --- /dev/null +++ b/codex-rs/session/src/spawn.rs @@ -0,0 +1,80 @@ +//! Cross-platform helper to spawn a detached `codex-exec` agent. + +use crate::store::Paths; +use anyhow::{Context, Result}; +use std::fs::OpenOptions; +use tokio::process::{Child, Command}; + +#[cfg(unix)] +pub fn spawn_agent(exec: &str, id: &str, paths: &Paths, kill_on_drop: bool) -> Result { +use std::io; + + // Prepare stdio handles first. + let stdin = OpenOptions::new().read(true).open("/dev/null")?; + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; + + let mut cmd = Command::new(exec); + cmd.arg("--job").arg(id) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr); + + if kill_on_drop { + cmd.kill_on_drop(true); + } + + // Detach: make a new session and ignore SIGHUP. + unsafe { + cmd.pre_exec(|| { + unsafe { + // setsid(2) + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + libc::signal(libc::SIGHUP, libc::SIG_IGN); + } + Ok(()) + }); + } + + let child = cmd.spawn().context("failed to spawn agent")?; + Ok(child) +} + +#[cfg(windows)] +pub fn spawn_agent(exec: &str, id: &str, paths: &Paths, kill_on_drop: bool) -> Result { + use std::os::windows::process::CommandExt; + + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; + + let mut cmd = Command::new(exec); + cmd.arg("--job").arg(id) + .stdin(std::process::Stdio::null()) + .stdout(stdout) + .stderr(stderr) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + + if kill_on_drop { + cmd.kill_on_drop(true); + } + + let child = cmd.spawn().context("failed to spawn agent")?; + Ok(child) +} diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs new file mode 100644 index 0000000000..0f13a8d8e0 --- /dev/null +++ b/codex-rs/session/src/store.rs @@ -0,0 +1,91 @@ +//! Session bookkeeping – on-disk layout and simple helpers. + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Paths { + pub dir: PathBuf, + pub stdout: PathBuf, + pub stderr: PathBuf, + pub meta: PathBuf, +} + +/// Calculate canonical paths for the given session ID. +pub fn paths_for(id: &str) -> Result { + let dir = base_dir()?.join(id); + Ok(Paths { + dir: dir.clone(), + stdout: dir.join("stdout.log"), + stderr: dir.join("stderr.log"), + meta: dir.join("meta.json"), + }) +} + +fn base_dir() -> Result { + let dirs = ProjectDirs::from("dev", "codex", "codex-session") + .context("unable to resolve data directory")?; + Ok(dirs.data_dir().to_owned()) +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SessionMeta { + pub id: String, + pub created_at: chrono::DateTime, +} + +/// Create the on-disk directory structure and write metadata + empty log files. +pub fn materialise(paths: &Paths, meta: &SessionMeta) -> Result<()> { + std::fs::create_dir_all(&paths.dir)?; + + // Metadata (pretty-printed for manual inspection). + std::fs::write(&paths.meta, serde_json::to_vec_pretty(meta)?)?; + + // Touch stdout/stderr so they exist even before the agent writes. + for p in [&paths.stdout, &paths.stderr] { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(p)?; + } + + Ok(()) +} + +/// Enumerate all sessions by loading each `meta.json`. +pub fn list_sessions() -> Result> { + let mut res = Vec::new(); + let base = base_dir()?; + if base.exists() { + for entry in std::fs::read_dir(base)? { + let entry = entry?; + let meta_path = entry.path().join("meta.json"); + if let Ok(bytes) = std::fs::read(&meta_path) { + if let Ok(meta) = serde_json::from_slice::(&bytes) { + res.push(meta); + } + } + } + } + Ok(res) +} + +/// Send a polite termination request to the session’s process. +/// +/// NOTE: Full PID accounting is a future improvement; for now the function +/// simply returns `Ok(())` so the `delete` command doesn’t fail. +pub async fn kill_session(_id: &str) -> Result<()> { + // TODO: record PID at spawn time and terminate here. + Ok(()) +} + +/// Remove the session directory and all its contents. +pub fn purge(id: &str) -> Result<()> { + let paths = paths_for(id)?; + if paths.dir.exists() { + std::fs::remove_dir_all(paths.dir)?; + } + Ok(()) +}