Skip to main content

objectiveai_sdk/cli/command/tasks/schedule/
mod.rs

1//! `agents tasks schedule` — register a command + interval (or
2//! oneshot) in `tasks.sqlite`. Add-only leaf; the runner that
3//! actually fires schedules is follow-up work tracked by #216.
4//!
5//! Schedule per row:
6//! - `command`: argv vector to invoke on each scheduled poll.
7//! - `interval_seconds`: `Some(n)` for a recurring schedule with
8//!   `n` seconds as the floor between invocations; `None` for a
9//!   **oneshot** that the runner fires once on the next poll and
10//!   deletes the row. The CLI gates this via mutually-exclusive
11//!   `--interval <humantime>` / `--oneshot` flags.
12//! - The caller's full `AgentArguments` snapshot — captured by
13//!   the CLI handler so the runner can re-install identity env
14//!   vars at fire-time.
15
16use crate::cli::command::CommandRequest;
17
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
19#[schemars(rename = "cli.command.tasks.schedule.Request")]
20pub struct Request {
21    pub path_type: Path,
22    /// User-facing identifier. Unique per agent instance hierarchy —
23    /// a second `schedule` with the same `(name, aih)` fails the
24    /// `schedules` UNIQUE constraint unless `overwrite` is set.
25    /// `agents tasks run` tags every streamed output line with this
26    /// name so the caller can attribute output to its source schedule.
27    pub name: String,
28    /// argv to invoke on each scheduled poll.
29    pub command: Vec<String>,
30    /// Human-readable label. Required — surfaces on every
31    /// `agents tasks list` row, and the runner uses it in
32    /// observability output.
33    pub description: String,
34    /// Floor on wall-clock seconds between invocations. `None`
35    /// marks a **oneshot** schedule — the runner fires it once on
36    /// the next poll and deletes the row. `Some(n)` is a recurring
37    /// schedule with `n` seconds as the minimum gap between
38    /// invocations.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    #[schemars(extend("omitempty" = true))]
41    pub interval_seconds: Option<u64>,
42    /// Shadow an existing `(name, agent_instance_hierarchy)` schedule
43    /// instead of erroring on collision: a NEW row is inserted with
44    /// `version = max + 1`. Older versions never list or run again but
45    /// are kept so run history stays per-version; the new version has
46    /// no runs yet, so it fires fresh.
47    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
48    pub overwrite: bool,
49    pub jq: Option<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
53#[schemars(rename = "cli.command.tasks.schedule.Path")]
54pub enum Path {
55    #[serde(rename = "tasks/schedule")]
56    AgentsTasksSchedule,
57}
58
59impl CommandRequest for Request {
60    fn into_command(&self) -> Vec<String> {
61        let mut argv = vec![
62            "tasks".to_string(),
63            "schedule".to_string(),
64        ];
65        argv.push("--name".to_string());
66        argv.push(self.name.clone());
67        argv.push("--description".to_string());
68        argv.push(self.description.clone());
69        match self.interval_seconds {
70            Some(secs) => {
71                argv.push("--interval".to_string());
72                // Round-trip as humantime — `Duration::from_secs(N)`
73                // formats as e.g. `30s` / `1h30m` so the CLI
74                // re-parses cleanly.
75                argv.push(
76                    humantime::format_duration(std::time::Duration::from_secs(secs))
77                        .to_string(),
78                );
79            }
80            None => argv.push("--oneshot".to_string()),
81        }
82        if self.overwrite {
83            argv.push("--overwrite".to_string());
84        }
85        if let Some(jq) = &self.jq {
86            argv.push("--jq".to_string());
87            argv.push(jq.clone());
88        }
89        // `--` separator so command argv that itself contains flags
90        // round-trips cleanly through the trailing-var-arg parse.
91        argv.push("--".to_string());
92        argv.extend(self.command.iter().cloned());
93        argv
94    }
95}
96
97/// The created schedule's user-facing identity: its `--name`, the
98/// caller hierarchy it was registered under, and the version this call
99/// minted (`1` on first creation, `max + 1` per `--overwrite` — each
100/// version is its own row; older versions are shadowed but kept for
101/// per-version run history).
102#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
103#[schemars(rename = "cli.command.tasks.schedule.Response")]
104pub struct Response {
105    pub name: String,
106    pub agent_instance_hierarchy: String,
107    pub version: u64,
108}
109
110#[derive(clap::Args)]
111#[command(group(
112    clap::ArgGroup::new("schedule_kind")
113        .required(true)
114        .multiple(false)
115        .args(["interval", "oneshot"])
116))]
117pub struct Args {
118    /// Minimum interval between scheduled invocations. Humantime
119    /// format — `30s`, `5m`, `1h30m`, `2d`. Treated as a floor,
120    /// not a wall-clock deadline (#216). Mutually exclusive with
121    /// `--oneshot`.
122    #[arg(long)]
123    pub interval: Option<String>,
124    /// User-facing identifier. Globally unique. `agents tasks
125    /// run` tags every streamed output line with this name.
126    #[arg(long)]
127    pub name: String,
128    /// Human-readable label for this schedule. Required —
129    /// surfaces on every `agents tasks list` row.
130    #[arg(long)]
131    pub description: String,
132    /// Fire the command once on the next harness poll, then
133    /// delete the row. Mutually exclusive with `--interval`.
134    #[arg(long)]
135    pub oneshot: bool,
136    /// Shadow an existing `(name, agent-instance-hierarchy)` schedule
137    /// instead of erroring on collision: inserts a NEW version
138    /// (`max + 1`) that supersedes the old ones. Old versions keep
139    /// their run history but never list or run again.
140    #[arg(long)]
141    pub overwrite: bool,
142    /// jq filter applied to the JSON output.
143    #[arg(long)]
144    pub jq: Option<String>,
145    /// Command and arguments to run on each scheduled invocation.
146    /// Pass after `--` so flags meant for the inner command don't
147    /// collide with the leaf's own (`--interval` / `--oneshot` /
148    /// `--jq`).
149    #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1..)]
150    pub command: Vec<String>,
151}
152
153#[derive(clap::Args)]
154#[command(args_conflicts_with_subcommands = true)]
155pub struct Command {
156    #[command(flatten)]
157    pub args: Args,
158    #[command(subcommand)]
159    pub schema: Option<Schema>,
160}
161
162#[derive(clap::Subcommand)]
163pub enum Schema {
164    /// Emit the JSON Schema for this leaf's `Request` type and exit.
165    RequestSchema(request_schema::Args),
166    /// Emit the JSON Schema for this leaf's `Response` type and exit.
167    ResponseSchema(response_schema::Args),
168}
169
170impl TryFrom<Args> for Request {
171    type Error = crate::cli::command::FromArgsError;
172    fn try_from(args: Args) -> Result<Self, Self::Error> {
173        // The `schedule_kind` clap group guarantees exactly one
174        // of `--interval` / `--oneshot` is present.
175        let interval_seconds = match (args.interval, args.oneshot) {
176            (Some(interval), false) => {
177                let parsed =
178                    humantime::parse_duration(&interval).map_err(|source| {
179                        crate::cli::command::FromArgsError {
180                            field: "interval",
181                            source: source.to_string().into(),
182                        }
183                    })?;
184                Some(parsed.as_secs())
185            }
186            (None, true) => None,
187            _ => unreachable!(
188                "clap group `schedule_kind` enforces exactly one of `--interval` | `--oneshot`"
189            ),
190        };
191        if args.command.is_empty() {
192            return Err(crate::cli::command::FromArgsError {
193                field: "command",
194                source: "schedule requires at least one positional argument (the command)"
195                    .to_string()
196                    .into(),
197            });
198        }
199        Ok(Self {
200            path_type: Path::AgentsTasksSchedule,
201            name: args.name,
202            command: args.command,
203            description: args.description,
204            interval_seconds,
205            overwrite: args.overwrite,
206            jq: args.jq,
207        })
208    }
209}
210
211#[cfg(feature = "cli-executor")]
212pub async fn execute<E: crate::cli::command::CommandExecutor>(
213    executor: &E,
214    mut request: Request,
215    agent_arguments: Option<&crate::cli::command::AgentArguments>,
216) -> Result<Response, E::Error> {
217    request.jq = None;
218    executor.execute_one(request, agent_arguments).await
219}
220
221#[cfg(feature = "cli-executor")]
222pub async fn execute_jq<E: crate::cli::command::CommandExecutor>(
223    executor: &E,
224    mut request: Request,
225    jq: String,
226    agent_arguments: Option<&crate::cli::command::AgentArguments>,
227) -> Result<serde_json::Value, E::Error> {
228    request.jq = Some(jq);
229    executor.execute_one(request, agent_arguments).await
230}
231
232#[cfg(feature = "mcp")]
233impl crate::cli::command::CommandResponse for Response {
234    fn into_mcp(self) -> crate::cli::command::McpResponseItem {
235        crate::cli::command::McpResponseItem::JSONL(serde_json::to_value(self).unwrap())
236    }
237}
238
239pub mod request_schema;
240
241pub mod response_schema;