Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rawtree-cli"
version = "0.7.2"
version = "0.7.3"
edition = "2021"
repository = "https://github.com/rawtreedb/rawtree-cli"

Expand Down
38 changes: 22 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,16 @@ Workflows run saved SQL on demand or on a schedule and deliver results to sinks.

```sh
rtree workflow list
rtree workflow create --name errors --database analytics --interval-seconds 60 \
rtree workflow create --name errors --database analytics --schedule-cron "* * * * *" \
--sql "INSERT INTO error_counts SELECT count() FROM events WHERE level = 'error'"
rtree workflow create --name on-demand --database analytics --sql "SELECT 1"
rtree workflow create --name alerts --disabled --sql - \
rtree workflow create --name alerts --schedule-cron "* * * * *" --schedule-enabled false --sql - \
--sink '{"type":"http","settings":{"url":"https://example.com/hook","headers":{"Authorization":"Bearer ..."}}}' \
< alerts.sql
rtree workflow get <id>
rtree workflow update <id> --interval-seconds 300
rtree workflow update <id> --interval-seconds null
rtree workflow update <id> --disable
rtree workflow update <id> --schedule-cron "*/5 * * * *"
rtree workflow update <id> --schedule-cron null
rtree workflow update <id> --schedule-enabled false
rtree workflow update <id> --clear-sinks
rtree workflow delete <id>

Expand All @@ -333,17 +333,23 @@ the environment, or the saved defaults). API keys must have admin permission.
The CLI sends `--database` and `--sql` inside the API's `query` object. On `update`,
each flag changes only that query field; the other field stays unchanged.

Creating a workflow without `--interval-seconds` makes it manual-only. The CLI
explicitly sends `interval_seconds: null`, so the API's default interval is not used.
Use `--interval-seconds null` to remove an existing schedule, matching the API's
`interval_seconds: null`. The same value is accepted on `create`.
An interval restores recurring execution, subject to the enabled state; use
`--enable` as well if the workflow is paused. Omitting `--interval-seconds` on `update`
preserves the current mode.
`--disable` pauses scheduled runs while retaining the interval. Manual runs remain
available in either mode, including while paused. Text output shows status as
`active`, `paused`, or `manual`, the next scheduled run when available, and HTTP
sink URLs.
Creating a workflow without `--schedule-cron` makes it manual-only. The CLI explicitly
sends `schedule: null`, preserving its manual-only default. Use `--schedule-cron null` to
remove an existing schedule. The obsolete `--interval-seconds` flag is rejected.

`--schedule-cron "*/5 * * * *"` runs every five minutes in UTC. Five-field expressions use
minute, hour, day-of-month, month, and day-of-week; six fields include leading
seconds. The API validates the expression. Requests nest cron and pause state in
`schedule`, for example `{"schedule":{"cron":"*/5 * * * *","enabled":false}}`.

On `update`, omitting `--schedule-cron` preserves the schedule. `--schedule-enabled false` pauses it and
`--schedule-enabled true` resumes it without changing the cron expression. Adding cron to a
manual-only workflow enables scheduling by default; changing an existing cron
preserves its pause state unless you also pass `--schedule-enabled true|false`.
`--schedule-enabled` accepts an explicit boolean and requires a cron expression on `create`. `--schedule-cron null` cannot be
combined with pause/resume flags. Manual runs remain available while paused.
Text output shows `active`, `paused`, or `manual`, the UTC cron expression, and
the next scheduled run when available. JSON output preserves the API response.

Sinks are JSON objects passed with `--sink`, up to five. Each has a `type` and a
nested `settings` object: `{"database","table"}` for `table` sinks and
Expand Down
40 changes: 19 additions & 21 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,12 +490,12 @@ pub enum WorkflowCommand {
/// SQL to run: a SELECT or INSERT INTO ... SELECT. Use "-" to read from stdin.
#[arg(long)]
sql: String,
/// Seconds between automatic runs (1–86400); null or omitted means manual-only
#[arg(long, value_name = "SECONDS|null")]
interval_seconds: Option<WorkflowIntervalArg>,
/// Create the workflow paused
#[arg(long)]
disabled: bool,
/// UTC cron expression (5 fields, or 6 with seconds); null or omitted means manual-only
#[arg(long, value_name = "EXPRESSION|null")]
schedule_cron: Option<WorkflowCronArg>,
/// Enable or pause the schedule (true or false); requires a cron expression
#[arg(long, action = clap::ArgAction::Set, value_name = "BOOL")]
schedule_enabled: Option<bool>,
/// Sink as a JSON object; repeat for up to five sinks
#[arg(long = "sink", value_name = "JSON")]
sinks: Vec<String>,
Expand All @@ -514,15 +514,12 @@ pub enum WorkflowCommand {
/// New SQL. Use "-" to read from stdin.
#[arg(long)]
sql: Option<String>,
/// Seconds between automatic runs (1–86400); null removes the schedule
#[arg(long, value_name = "SECONDS|null")]
interval_seconds: Option<WorkflowIntervalArg>,
/// Resume scheduled runs
#[arg(long, conflicts_with = "disable")]
enable: bool,
/// Pause scheduled runs
#[arg(long)]
disable: bool,
/// UTC cron expression (5 fields, or 6 with seconds); null removes the schedule
#[arg(long, value_name = "EXPRESSION|null")]
schedule_cron: Option<WorkflowCronArg>,
/// Enable or pause scheduled runs (true or false); omit to preserve the state
#[arg(long, action = clap::ArgAction::Set, value_name = "BOOL")]
schedule_enabled: Option<bool>,
/// Sink as a JSON object; replaces all sinks. Repeat for up to five.
#[arg(long = "sink", value_name = "JSON", conflicts_with = "clear_sinks")]
sinks: Vec<String>,
Expand Down Expand Up @@ -583,20 +580,21 @@ pub enum WorkflowCommand {
},
}

#[derive(Clone, Copy, Debug)]
pub struct WorkflowIntervalArg(pub Option<u32>);
#[derive(Clone, Debug)]
pub struct WorkflowCronArg(pub Option<String>);

impl FromStr for WorkflowIntervalArg {
impl FromStr for WorkflowCronArg {
type Err = String;

fn from_str(value: &str) -> Result<Self, Self::Err> {
if value == "null" {
return Ok(Self(None));
}
match value.parse::<u32>() {
Ok(seconds) if (1..=86400).contains(&seconds) => Ok(Self(Some(seconds))),
_ => Err("expected a whole number from 1 to 86400, or null".into()),
let value = value.trim();
if value.is_empty() {
return Err("expected a UTC cron expression or null".into());
}
Ok(Self(Some(value.to_string())))
}
}

Expand Down
75 changes: 64 additions & 11 deletions src/cli/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,9 +560,10 @@ fn workflow_create_collects_repeated_sinks() {
"alerts",
"--sql",
"SELECT 1",
"--interval-seconds",
"60",
"--disabled",
"--schedule-cron",
"* * * * *",
"--schedule-enabled",
"false",
"--sink",
r#"{"type":"table","settings":{"database":"a","table":"b"}}"#,
"--sink",
Expand All @@ -573,15 +574,18 @@ fn workflow_create_collects_repeated_sinks() {
Command::Workflow {
action:
WorkflowCommand::Create {
interval_seconds,
disabled,
schedule_cron,
schedule_enabled,
sinks,
database,
..
},
} => {
assert_eq!(interval_seconds.map(|interval| interval.0), Some(Some(60)));
assert!(disabled);
assert_eq!(
schedule_cron.map(|cron| cron.0),
Some(Some("* * * * *".into()))
);
assert_eq!(schedule_enabled, Some(false));
assert_eq!(sinks.len(), 2);
assert!(database.is_none());
}
Expand All @@ -592,7 +596,7 @@ fn workflow_create_collects_repeated_sinks() {
#[test]
fn workflow_update_rejects_conflicting_flags() {
for args in [
vec!["--enable", "--disable"],
vec!["--schedule-enabled", "true", "--schedule-enabled", "false"],
vec!["--clear-sinks", "--sink", "{}"],
] {
let mut command = vec!["rtree", "workflow", "update", "wf-1"];
Expand All @@ -602,7 +606,7 @@ fn workflow_update_rejects_conflicting_flags() {
}

#[test]
fn workflow_interval_and_time_ranges_are_validated() {
fn workflow_cron_and_time_ranges_are_validated() {
assert!(Cli::try_parse_from([
"rtree",
"workflow",
Expand All @@ -611,8 +615,8 @@ fn workflow_interval_and_time_ranges_are_validated() {
"a",
"--sql",
"SELECT 1",
"--interval-seconds",
"86401",
"--schedule-cron",
"",
])
.is_err());
assert!(Cli::try_parse_from([
Expand All @@ -635,3 +639,52 @@ fn workflow_interval_and_time_ranges_are_validated() {
} if range.since.as_deref() == Some("2h")
));
}

#[test]
fn workflow_schedule_enabled_requires_an_explicit_boolean() {
for base in [
vec![
"rtree",
"workflow",
"create",
"--name",
"alerts",
"--sql",
"SELECT 1",
"--schedule-cron",
"* * * * *",
],
vec!["rtree", "workflow", "update", "wf-1"],
] {
for (text, expected) in [("true", true), ("false", false)] {
let mut args = base.clone();
args.extend(["--schedule-enabled", text]);
let parsed = Cli::try_parse_from(args).unwrap();
match parsed.command {
Command::Workflow {
action:
WorkflowCommand::Create {
schedule_enabled, ..
}
| WorkflowCommand::Update {
schedule_enabled, ..
},
} => assert_eq!(schedule_enabled, Some(expected)),
_ => panic!("expected workflow command"),
}
}
for flags in [
vec!["--schedule-enabled"],
vec!["--schedule-enabled", "yes"],
vec!["--schedule-enabled", "1"],
vec!["--cron", "* * * * *"],
vec!["--enable"],
vec!["--disable"],
vec!["--disabled"],
] {
let mut args = base.clone();
args.extend(flags);
assert!(Cli::try_parse_from(args).is_err());
}
}
}
Loading