mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
codex draft
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user