use serde::{Deserialize, Serialize};
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum WorkflowError {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("control-plane request: {0}")]
Http(#[from] reqwest::Error),
#[error("reading steps file: {0}")]
Io(#[from] std::io::Error),
#[error("steps file is not valid JSON: {0}")]
Json(#[from] serde_json::Error),
}
type Result<T> = std::result::Result<T, WorkflowError>;
#[derive(Debug, clap::Args)]
pub struct WorkflowArgs {
#[command(subcommand)]
command: WorkflowCommand,
}
#[derive(Debug, clap::Subcommand)]
enum WorkflowCommand {
Define {
name: String,
#[arg(long)]
file: std::path::PathBuf,
#[arg(long)]
server: Option<String>,
},
Ls {
#[arg(long)]
server: Option<String>,
},
Get {
name: String,
#[arg(long)]
server: Option<String>,
},
Run {
name: String,
#[arg(long)]
data: Option<String>,
#[arg(long)]
server: Option<String>,
},
RunStatus {
name: String,
id: String,
#[arg(long)]
server: Option<String>,
},
Rm {
name: String,
#[arg(long)]
server: Option<String>,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct WorkflowView {
name: String,
#[serde(default)]
steps: Vec<StepView>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct StepView {
id: String,
function: String,
#[serde(default)]
depends_on: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct RunView {
id: String,
status: String,
#[serde(default)]
steps: std::collections::BTreeMap<String, StepRunView>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct StepRunView {
status: String,
#[serde(default)]
attempts: u32,
}
#[derive(Debug, Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum WorkflowOutput {
Defined { name: String },
List { workflows: Vec<WorkflowView> },
Show { workflow: WorkflowView },
RunStarted { id: String, status: String },
RunStatus { run: RunView },
Removed { name: String },
}
impl crate::output::Rendered for WorkflowOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Defined { name } => writeln!(w, "defined workflow {name}"),
Self::List { workflows } => {
if workflows.is_empty() {
return writeln!(w, "no workflows");
}
for wf in workflows {
writeln!(w, "{} ({} steps)", wf.name, wf.steps.len())?;
}
Ok(())
}
Self::Show { workflow } => {
writeln!(w, "{}", workflow.name)?;
for s in &workflow.steps {
if s.depends_on.is_empty() {
writeln!(w, " {} -> {}", s.id, s.function)?;
} else {
writeln!(
w,
" {} -> {} (after {})",
s.id,
s.function,
s.depends_on.join(", ")
)?;
}
}
Ok(())
}
Self::RunStarted { id, status } => {
writeln!(w, "started run {id} [{status}]")
}
Self::RunStatus { run } => {
writeln!(w, "{} [{}]", run.id, run.status)?;
for (step_id, sr) in &run.steps {
writeln!(w, " {step_id}: {} (attempts={})", sr.status, sr.attempts)?;
}
Ok(())
}
Self::Removed { name } => writeln!(w, "removed workflow {name}"),
}
}
}
pub async fn run(args: WorkflowArgs, config: &ProjectConfig) -> Result<WorkflowOutput> {
let out = match args.command {
WorkflowCommand::Define { name, file, server } => {
let (server, http) = client::connect(server, config)?;
let raw = read_file(&file).await?;
let value: serde_json::Value = serde_json::from_slice(&raw)?;
let body = match value {
serde_json::Value::Array(_) => serde_json::json!({ "steps": value }),
other => other,
};
http.put(format!("{server}/api/workflows/{name}"))
.json(&body)
.send()
.await?
.error_for_status()?;
WorkflowOutput::Defined { name }
}
WorkflowCommand::Ls { server } => {
let (server, http) = client::connect(server, config)?;
let list: Vec<WorkflowView> = http
.get(format!("{server}/api/workflows"))
.send()
.await?
.error_for_status()?
.json()
.await?;
WorkflowOutput::List { workflows: list }
}
WorkflowCommand::Get { name, server } => {
let (server, http) = client::connect(server, config)?;
let w: WorkflowView = http
.get(format!("{server}/api/workflows/{name}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
WorkflowOutput::Show { workflow: w }
}
WorkflowCommand::Run { name, data, server } => {
let (server, http) = client::connect(server, config)?;
let mut req = http.post(format!("{server}/api/workflows/{name}/runs"));
if let Some(input) = data {
req = req.body(input.into_bytes());
}
let run: RunView = req.send().await?.error_for_status()?.json().await?;
WorkflowOutput::RunStarted {
id: run.id,
status: run.status,
}
}
WorkflowCommand::RunStatus { name, id, server } => {
let (server, http) = client::connect(server, config)?;
let run: RunView = http
.get(format!("{server}/api/workflows/{name}/runs/{id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
WorkflowOutput::RunStatus { run }
}
WorkflowCommand::Rm { name, server } => {
let (server, http) = client::connect(server, config)?;
http.delete(format!("{server}/api/workflows/{name}"))
.send()
.await?
.error_for_status()?;
WorkflowOutput::Removed { name }
}
};
Ok(out)
}
async fn read_file(path: &std::path::Path) -> Result<Vec<u8>> {
if path.as_os_str() == "-" {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
tokio::io::stdin().read_to_end(&mut buf).await?;
Ok(buf)
} else {
Ok(tokio::fs::read(path).await?)
}
}