codex draft

This commit is contained in:
Ryan Ragona
2025-04-26 06:40:39 -07:00
parent b0ba65a936
commit 314d2216cb
8 changed files with 556 additions and 2 deletions
+191
View File
@@ -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<String>,
/// 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<String>,
}
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(())
}
}
+13
View File
@@ -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
}
+11
View File
@@ -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(())
}
+80
View File
@@ -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<Child> {
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<Child> {
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)
}
+91
View File
@@ -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<Paths> {
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<PathBuf> {
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<chrono::Utc>,
}
/// 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<Vec<SessionMeta>> {
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::<SessionMeta>(&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(())
}