pub mod cancel;
pub mod create;
pub mod dto;
pub mod landed;
pub mod list;
pub mod merge;
pub mod reattach;
pub mod show;
pub mod spawn;
pub mod stalled;
pub mod supervisor_readiness;
pub mod supervisor_spawn;
pub mod wait;
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::{Subcommand, ValueEnum};
use octl_core::{
is_run_id_prefix, DiscussionId, IdValidationError, Kind, Lifecycle, NodeId, ProposalId, RunId,
RunPaths,
};
use crate::error::CliError;
use crate::output::OutputSpec;
#[derive(Debug, Clone, Copy, ValueEnum)]
#[clap(rename_all = "kebab-case")]
pub enum KindArg {
Code,
Spinoff,
Orchestrated,
Research,
TechnicalDecision,
MakeSkill,
FanOut,
Bugfix,
Orchestrate,
}
impl From<KindArg> for Kind {
fn from(k: KindArg) -> Self {
match k {
KindArg::Code => Kind::Code,
KindArg::Spinoff => Kind::Spinoff,
KindArg::Orchestrated => Kind::Orchestrated,
KindArg::Research => Kind::Research,
KindArg::TechnicalDecision => Kind::TechnicalDecision,
KindArg::MakeSkill => Kind::MakeSkill,
KindArg::FanOut => Kind::FanOut,
KindArg::Bugfix => Kind::Bugfix,
KindArg::Orchestrate => Kind::Orchestrate,
}
}
}
#[derive(Subcommand, Debug)]
pub enum RunAction {
Create {
#[arg(long, value_enum)]
kind: KindArg,
#[arg(long)]
title: String,
#[arg(long)]
source_repo: Option<String>,
#[arg(long)]
source_branch: Option<String>,
#[arg(long, conflicts_with = "prompt_file")]
task: Option<String>,
#[arg(long)]
prompt_file: Option<String>,
#[arg(long)]
layout: Option<String>,
#[arg(long)]
harness: Option<String>,
#[arg(long)]
no_hooks: bool,
#[arg(long)]
headless: bool,
#[arg(long)]
tmux_session: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=600), default_value_t = 90)]
agent_startup_timeout: u32,
#[arg(long, requires = "parent_node_id")]
parent_run_id: Option<String>,
#[arg(long, requires = "parent_run_id")]
parent_node_id: Option<String>,
#[arg(long)]
notify: Option<String>,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long, hide = true)]
skip_materialize: bool,
},
List {
#[arg(long)]
status: Option<String>,
#[arg(long)]
kind: Option<String>,
},
Show { run_id: String },
Cancel {
run_id: String,
#[arg(long)]
note: Option<String>,
},
Merge {
run_id: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
node_id: Option<String>,
#[arg(long)]
report_file: Option<std::path::PathBuf>,
#[arg(long, hide = true)]
confirm_interactive: bool,
#[arg(long)]
dry_run: bool,
},
Wait {
#[arg(required = true, num_args = 1..)]
run_id: Vec<String>,
#[arg(long, conflicts_with = "any")]
all: bool,
#[arg(long)]
any: bool,
#[arg(long, value_parser = wait::parse_duration, default_value = "6h")]
timeout: Option<Duration>,
#[arg(long)]
fail_on_error: bool,
#[arg(long)]
progress: bool,
#[arg(long, value_parser = wait::parse_duration)]
poll_interval: Option<Duration>,
},
Reattach {
run_id: String,
#[arg(long, hide = true)]
once: bool,
#[arg(long, hide = true)]
max_iter: Option<u32>,
},
}
pub fn dispatch(action: RunAction, spec: &OutputSpec, warnings: &[String]) -> Result<(), CliError> {
match action {
RunAction::Create {
kind,
title,
source_repo,
source_branch,
task,
prompt_file,
layout,
harness,
no_hooks,
headless,
tmux_session,
agent_startup_timeout,
parent_run_id,
parent_node_id,
notify,
idempotency_key,
dry_run,
skip_materialize,
} => create::run(create::Args {
skip_materialize,
kind: kind.into(),
title,
source_repo,
source_branch,
task,
prompt_file,
layout,
harness,
no_hooks,
headless,
tmux_session,
agent_startup_timeout,
parent_run_id,
parent_node_id,
notify,
idempotency_key,
dry_run,
spec,
warnings,
}),
RunAction::List { status, kind } => list::run(list::Args {
status,
kind,
spec,
warnings,
}),
RunAction::Show { run_id } => show::run(&run_id, spec, warnings),
RunAction::Cancel { run_id, note } => cancel::run(&run_id, note.as_deref(), spec, warnings),
RunAction::Merge {
run_id,
source,
node_id,
report_file,
confirm_interactive,
dry_run,
} => merge::run(merge::Args {
run_id,
source,
node_id,
report_file,
confirm_interactive,
dry_run,
spec,
warnings,
}),
RunAction::Wait {
run_id,
all: _,
any,
timeout,
fail_on_error,
progress,
poll_interval,
} => wait::run(wait::Args {
run_ids: run_id,
any,
timeout,
fail_on_error,
progress,
poll_interval,
spec,
warnings,
}),
RunAction::Reattach {
run_id,
once,
max_iter,
} => reattach::run(&run_id, once, max_iter, spec, warnings),
}
}
pub fn lifecycle_for(k: Kind) -> Lifecycle {
k.lifecycle()
}
#[derive(Debug)]
pub enum RunSelector {
Exact(RunId),
Prefix(RunIdPrefix),
}
#[derive(Debug)]
pub struct RunIdPrefix(String);
impl RunIdPrefix {
fn as_str(&self) -> &str {
&self.0
}
}
impl RunSelector {
pub fn parse(arg: &str) -> Result<Self, CliError> {
if arg.len() >= RunId::LEN {
return RunId::parse_str(arg).map(RunSelector::Exact).map_err(|e| {
CliError::user(
"invalid_run_id",
format!("run id {arg:?} is not a valid ULID: {e}"),
)
.with_invalid_value(arg)
});
}
if !is_run_id_prefix(arg) {
return Err(CliError::user(
"invalid_run_id",
format!(
"run id {arg:?} is not a valid ULID or run-id prefix: \
expected up to {} lowercase Crockford base32 characters (leading 0-7)",
RunId::LEN
),
)
.with_invalid_value(arg));
}
Ok(RunSelector::Prefix(RunIdPrefix(arg.to_string())))
}
pub fn resolve(self, root: &Path) -> Result<RunId, CliError> {
match self {
RunSelector::Exact(rid) => Ok(rid),
RunSelector::Prefix(prefix) => resolve_prefix(root, prefix.as_str()),
}
}
}
fn resolve_prefix(root: &Path, arg: &str) -> Result<RunId, CliError> {
let runs_dir = runs_root(root);
let entries = match std::fs::read_dir(&runs_dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(prefix_not_found(arg)),
Err(e) => {
return Err(CliError::system(
"io_error",
format!("read_dir {}: {}", runs_dir.display(), e),
))
}
};
let mut matches: Vec<RunId> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
CliError::system(
"io_error",
format!("read_dir {}: {}", runs_dir.display(), e),
)
})?;
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if name.starts_with(arg) {
if let Ok(rid) = RunId::parse_str(&name) {
matches.push(rid);
}
}
}
match matches.len() {
0 => Err(prefix_not_found(arg)),
1 => Ok(matches.pop().expect("len checked == 1")),
n => {
matches.sort();
Err(CliError::user(
"ambiguous_run_id",
format!(
"run id prefix {arg:?} matches {n} runs; use more characters to disambiguate"
),
)
.with_invalid_value(arg)
.with_expected(serde_json::Value::Array(
matches
.into_iter()
.map(|r| serde_json::Value::String(r.as_str().to_string()))
.collect(),
)))
}
}
}
fn prefix_not_found(arg: &str) -> CliError {
CliError::user(
"run_not_found",
format!("no run matching id prefix {arg:?}"),
)
.with_invalid_value(arg)
}
pub fn run_paths_exact(root: &Path, run_id: &RunId) -> Result<RunPaths, CliError> {
let dir = octl_core::run_dir(root, run_id);
RunPaths::from_validated(dir, run_id.clone()).map_err(from_core)
}
pub(crate) fn run_paths_from_cli_arg(root: &Path, run_id: &str) -> Result<RunPaths, CliError> {
let rid = RunSelector::parse(run_id)?.resolve(root)?;
run_paths_exact(root, &rid)
}
pub fn require_nonempty(value: &str, field: &str) -> Result<String, CliError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(CliError::user(
"invalid_value",
format!("--{field} must not be empty or whitespace-only"),
)
.with_invalid_value(value));
}
Ok(trimmed.to_string())
}
pub fn invalid_id(value: &str, err: &IdValidationError) -> CliError {
CliError::user("invalid_id", err.to_string())
.with_invalid_value(value)
.with_expected(serde_json::Value::String(err.expected().to_string()))
}
pub fn parse_run_id(value: &str) -> Result<RunId, CliError> {
RunId::parse_str(value).map_err(|e| invalid_id(value, &e))
}
pub fn parse_node_id(value: &str) -> Result<NodeId, CliError> {
NodeId::parse_str(value).map_err(|e| invalid_id(value, &e))
}
pub fn parse_discussion_id(value: &str) -> Result<DiscussionId, CliError> {
DiscussionId::parse_str(value).map_err(|e| invalid_id(value, &e))
}
pub fn parse_proposal_id(value: &str) -> Result<ProposalId, CliError> {
ProposalId::parse_str(value).map_err(|e| invalid_id(value, &e))
}
pub fn kind_kebab(k: Kind) -> &'static str {
match k {
Kind::Code => "code",
Kind::Spinoff => "spinoff",
Kind::Orchestrated => "orchestrated",
Kind::Research => "research",
Kind::TechnicalDecision => "technical-decision",
Kind::MakeSkill => "make-skill",
Kind::FanOut => "fan-out",
Kind::Bugfix => "bugfix",
Kind::Orchestrate => "orchestrate",
}
}
pub fn lifecycle_kebab(l: Lifecycle) -> &'static str {
match l {
Lifecycle::Autonomous => "autonomous",
Lifecycle::Interactive => "interactive",
}
}
pub fn status_kebab(s: octl_core::Status) -> &'static str {
use octl_core::Status::{Blocked, Cancelled, Done, Failed, Pending, Running};
match s {
Pending => "pending",
Running => "running",
Blocked => "blocked",
Done => "done",
Failed => "failed",
Cancelled => "cancelled",
}
}
pub fn runs_root(root: &Path) -> PathBuf {
root.join("runs")
}
pub fn from_core(err: octl_core::Error) -> CliError {
match err {
octl_core::Error::CorruptEventLog { .. }
| octl_core::Error::Json { .. }
| octl_core::Error::JsonBare(_) => CliError::user("corrupt_state", err.to_string()),
octl_core::Error::CorruptProjection {
ref expected_id,
ref body_id,
..
} => {
let (expected_id, body_id) = (expected_id.clone(), body_id.clone());
CliError::user("corrupt_state", err.to_string())
.with_invalid_value(body_id)
.with_expected(serde_json::Value::String(expected_id))
}
octl_core::Error::UnsupportedSchemaVersion {
found,
ref supported,
..
} => {
let supported = supported.clone();
CliError::user("corrupt_state", err.to_string())
.with_invalid_value(found.to_string())
.with_expected(serde_json::json!({ "supported_schema_versions": supported }))
}
octl_core::Error::SymlinkRunDir { ref path }
| octl_core::Error::SymlinkSubdir { ref path, .. }
| octl_core::Error::SymlinkStateFile { ref path, .. } => {
let path = path.display().to_string();
CliError::user("corrupt_run", err.to_string()).with_invalid_value(path)
}
octl_core::Error::EmptyIdempotencyKey => {
CliError::user("invalid_value", err.to_string()).with_invalid_value("")
}
other => CliError::system("io_error", other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_run_dir(root: &Path, id: &RunId) {
std::fs::create_dir_all(octl_core::run_dir(root, id)).unwrap();
}
#[test]
fn run_paths_exact_only_accepts_a_full_typed_id() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let full = octl_core::new_run_id();
let rid = parse_run_id(&full).unwrap();
make_run_dir(root, &rid);
let paths = run_paths_exact(root, &rid).unwrap();
assert_eq!(paths.run_id.as_str(), full);
let truncated = &full[..10];
let err = parse_run_id(truncated).unwrap_err();
assert_eq!(err.code, "invalid_id");
}
#[test]
fn run_selector_classifies_exact_vs_prefix() {
let full = octl_core::new_run_id();
assert!(matches!(
RunSelector::parse(&full).unwrap(),
RunSelector::Exact(_)
));
assert!(matches!(
RunSelector::parse(&full[..10]).unwrap(),
RunSelector::Prefix(_)
));
assert_eq!(
RunSelector::parse("not-a-ulid!").unwrap_err().code,
"invalid_run_id"
);
}
#[test]
fn cli_verb_entry_resolves_unambiguous_prefix() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let full = octl_core::new_run_id();
make_run_dir(root, &parse_run_id(&full).unwrap());
let paths = run_paths_from_cli_arg(root, &full[..10]).unwrap();
assert_eq!(paths.run_id.as_str(), full);
}
#[test]
fn truncated_id_resolves_on_cli_path_but_is_rejected_on_internal_path() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let full = octl_core::new_run_id();
make_run_dir(root, &parse_run_id(&full).unwrap());
let truncated = &full[..10];
assert_eq!(
run_paths_from_cli_arg(root, truncated)
.unwrap()
.run_id
.as_str(),
full
);
assert_eq!(parse_run_id(truncated).unwrap_err().code, "invalid_id");
}
}