mod assembly;
mod commands;
mod report;
mod serve;
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
pub mod exit {
pub const PASS: i32 = 0;
pub const FAIL: i32 = 1;
pub const UNKNOWN: i32 = 2;
pub const SUSPENDED: i32 = 3;
pub const NOT_IN_M0: i32 = 64;
pub const INTERNAL: i32 = 70;
}
pub struct Failure {
pub code: i32,
pub message: String,
}
impl Failure {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Failure {
code,
message: message.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum SuperviseArg {
Mutating,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum VisionArg {
Off,
Anthropic,
OpenaiCompat,
}
#[derive(Parser)]
#[command(
name = "pointlock",
version,
about = "Pointlock assembly-layer CLI (spine A.2: the seven commands)"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Lock(LockArgs),
Compile(CompileArgs),
Run(RunArgs),
Resume(ResumeArgs),
Inspect(InspectArgs),
Locate(LocateArgs),
Report(ReportArgs),
}
#[derive(clap::Args)]
struct LockArgs {
#[arg(long, default_value = assembly::FAKE_REGISTRATION)]
provider: String,
#[arg(long)]
out: PathBuf,
#[arg(long, value_name = "PATH")]
daemon_cmd: Option<PathBuf>,
#[arg(long = "daemon-env", value_name = "KEY=VALUE")]
daemon_env: Vec<String>,
#[arg(long)]
device: Option<String>,
}
#[derive(clap::Args)]
struct CompileArgs {
flow: Option<PathBuf>,
#[arg(long)]
emit_authoring_schema: bool,
#[arg(long)]
lockfile: Option<PathBuf>,
#[arg(long, default_value = assembly::FAKE_REGISTRATION)]
provider: String,
#[arg(long)]
out: PathBuf,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
#[derive(clap::Args)]
struct RunArgs {
flow_ir: PathBuf,
#[arg(long)]
store: PathBuf,
#[arg(long, default_value = assembly::FAKE_REGISTRATION)]
provider: String,
#[arg(long = "param", value_name = "KEY=VALUE")]
params: Vec<String>,
#[arg(long)]
device: Option<String>,
#[arg(long)]
lockfile: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
daemon_cmd: Option<PathBuf>,
#[arg(long = "daemon-env", value_name = "KEY=VALUE")]
daemon_env: Vec<String>,
#[arg(long)]
run_id: Option<String>,
#[arg(long, value_name = "TARGET")]
stop_at: Option<String>,
#[arg(long, value_name = "TARGET")]
stop_after: Option<String>,
#[arg(long, value_enum)]
supervise: Option<SuperviseArg>,
#[arg(long, value_enum, default_value_t = VisionArg::Off)]
vision: VisionArg,
#[arg(long)]
interactive: bool,
#[arg(long, value_name = "URL")]
webhook_url: Option<String>,
}
#[derive(clap::Args)]
struct ResumeArgs {
flow_ir: PathBuf,
#[arg(long)]
store: PathBuf,
#[arg(long)]
run: String,
#[arg(long, default_value = assembly::FAKE_REGISTRATION)]
provider: String,
#[arg(long)]
lockfile: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
daemon_cmd: Option<PathBuf>,
#[arg(long = "daemon-env", value_name = "KEY=VALUE")]
daemon_env: Vec<String>,
#[arg(long, value_name = "FLOW_IR")]
old_ir: Option<PathBuf>,
#[arg(long, value_enum)]
supervise: Option<SuperviseArg>,
#[arg(long, value_name = "TARGET")]
stop_at: Option<String>,
#[arg(long, value_name = "TARGET")]
stop_after: Option<String>,
#[arg(long, value_enum, default_value_t = VisionArg::Off)]
vision: VisionArg,
#[arg(long = "allow-mutating-reexec", value_name = "STEP_ID")]
allow_mutating_reexec: Vec<String>,
#[arg(long = "force-reexecute", value_name = "STEP_ID")]
force_reexecute: Vec<String>,
#[arg(long)]
interactive: bool,
#[arg(long, value_name = "URL")]
webhook_url: Option<String>,
#[arg(long)]
preview: bool,
#[arg(long)]
force_stale_writer: bool,
}
#[derive(clap::Args)]
struct InspectArgs {
#[arg(long)]
store: PathBuf,
#[arg(long)]
run: Option<String>,
#[arg(long)]
rebuild_checkpoint: bool,
#[arg(long)]
serve: bool,
#[arg(long)]
artifacts: Option<PathBuf>,
#[arg(long)]
ui: Option<PathBuf>,
#[arg(long)]
lockfile: Option<PathBuf>,
#[arg(long, default_value_t = 0)]
port: u16,
#[arg(long, value_enum, default_value_t = VisionArg::Off)]
vision: VisionArg,
#[arg(
long,
env = "POINTLOCK_SERVE_RESUME_TIMEOUT_SECS",
default_value_t = serve::DEFAULT_RESUME_TIMEOUT.as_secs(),
value_parser = clap::value_parser!(u64).range(1..)
)]
resume_timeout: u64,
#[arg(long, value_enum)]
supervise: Option<SuperviseArg>,
#[arg(long, value_name = "URL")]
webhook_url: Option<String>,
}
#[derive(clap::Args)]
struct LocateArgs {
#[arg(long)]
store: PathBuf,
#[arg(long)]
run: String,
#[arg(long)]
step: String,
#[arg(long)]
flow_ir: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
#[derive(clap::Args)]
struct ReportArgs {
#[arg(long)]
store: PathBuf,
#[arg(long)]
run: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
fn main() {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
use clap::error::ErrorKind;
let code = match err.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => exit::PASS,
_ => exit::NOT_IN_M0,
};
let _ = err.print();
std::process::exit(code);
}
};
let result = match cli.command {
Command::Lock(args) => commands::lock(&args),
Command::Compile(args) => match (args.emit_authoring_schema, &args.flow) {
(true, None) => commands::emit_authoring_schema(&args.out),
(true, Some(_)) => Err(Failure::new(
exit::NOT_IN_M0,
"--emit-authoring-schema takes no flow source; drop the positional argument"
.to_owned(),
)),
(false, Some(flow)) => commands::compile(
flow,
args.lockfile.as_deref(),
&args.provider,
&args.out,
args.format,
),
(false, None) => Err(Failure::new(
exit::NOT_IN_M0,
"a *.flow.yaml source is required (or use --emit-authoring-schema)".to_owned(),
)),
},
Command::Run(args) => commands::run(&args),
Command::Resume(args) => commands::resume(&args),
Command::Inspect(args) => {
if args.serve {
if args.run.is_some() || args.rebuild_checkpoint {
Err(Failure::new(
exit::NOT_IN_M0,
"--serve is a mode of its own; drop --run/--rebuild-checkpoint".to_owned(),
))
} else {
serve::serve(serve::ServeConfig {
store_dir: args.store,
artifacts_dir: args.artifacts,
port: args.port,
ui_dir: args.ui,
lockfile_path: args.lockfile,
vision: args.vision,
resume_timeout: std::time::Duration::from_secs(args.resume_timeout),
supervise: args.supervise,
webhook_url: args.webhook_url,
})
}
} else if args.artifacts.is_some()
|| args.port != 0
|| args.ui.is_some()
|| args.lockfile.is_some()
|| args.vision != VisionArg::Off
|| args.supervise.is_some()
|| args.webhook_url.is_some()
{
Err(Failure::new(
exit::NOT_IN_M0,
"--artifacts/--ui/--port/--lockfile/--vision/--supervise/--webhook-url only apply to --serve".to_owned(),
))
} else {
match args.run {
Some(run) => commands::inspect(&args.store, &run, args.rebuild_checkpoint),
None => Err(Failure::new(
exit::NOT_IN_M0,
"--run is required (or use --serve)".to_owned(),
)),
}
}
}
Command::Locate(args) => commands::locate(
&args.store,
&args.run,
&args.step,
args.flow_ir.as_deref(),
args.format,
),
Command::Report(args) => report::report(&args.store, &args.run, args.format),
};
match result {
Ok(code) => std::process::exit(code),
Err(failure) => {
eprintln!("pointlock: {}", failure.message);
std::process::exit(failure.code);
}
}
}
pub(crate) use {LockArgs as LockCliArgs, ResumeArgs as ResumeCliArgs, RunArgs as RunCliArgs};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resume_timeout_rejects_zero() {
let with = |value: &str| {
Cli::try_parse_from([
"pointlock",
"inspect",
"--store",
"s",
"--serve",
"--resume-timeout",
value,
])
};
let err = match with("0") {
Err(err) => err,
Ok(_) => panic!("0 must be rejected"),
};
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "{err}");
assert!(err.to_string().contains("resume-timeout"), "{err}");
assert!(with("1").is_ok(), "1 is the minimum");
}
}