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    #[serde(flatten)]
50    pub base: crate::cli::command::RequestBase,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
54#[schemars(rename = "cli.command.tasks.schedule.Path")]
55pub enum Path {
56    #[serde(rename = "tasks/schedule")]
57    AgentsTasksSchedule,
58}
59
60impl CommandRequest for Request {
61    fn into_command(&self) -> Vec<String> {
62        let mut argv = vec![
63            "tasks".to_string(),
64            "schedule".to_string(),
65        ];
66        argv.push("--name".to_string());
67        argv.push(self.name.clone());
68        argv.push("--description".to_string());
69        argv.push(self.description.clone());
70        match self.interval_seconds {
71            Some(secs) => {
72                argv.push("--interval".to_string());
73                // Round-trip as humantime — `Duration::from_secs(N)`
74                // formats as e.g. `30s` / `1h30m` so the CLI
75                // re-parses cleanly.
76                argv.push(
77                    humantime::format_duration(std::time::Duration::from_secs(secs))
78                        .to_string(),
79                );
80            }
81            None => argv.push("--oneshot".to_string()),
82        }
83        if self.overwrite {
84            argv.push("--overwrite".to_string());
85        }
86        self.base.push_flags(&mut argv);
87        // `--` separator so command argv that itself contains flags
88        // round-trips cleanly through the trailing-var-arg parse.
89        argv.push("--".to_string());
90        argv.extend(self.command.iter().cloned());
91        argv
92    }
93
94    fn request_base(&self) -> &crate::cli::command::RequestBase {
95        &self.base
96    }
97
98    fn request_base_mut(&mut self) -> Option<&mut crate::cli::command::RequestBase> {
99        Some(&mut self.base)
100    }
101}
102
103/// The created schedule's user-facing identity: its `--name`, the
104/// caller hierarchy it was registered under, and the version this call
105/// minted (`1` on first creation, `max + 1` per `--overwrite` — each
106/// version is its own row; older versions are shadowed but kept for
107/// per-version run history).
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
109#[schemars(rename = "cli.command.tasks.schedule.Response")]
110pub struct Response {
111    pub name: String,
112    pub agent_instance_hierarchy: String,
113    pub version: u64,
114}
115
116#[derive(clap::Args)]
117#[command(group(
118    clap::ArgGroup::new("schedule_kind")
119        .required(true)
120        .multiple(false)
121        .args(["interval", "oneshot"])
122))]
123pub struct Args {
124    /// Minimum interval between scheduled invocations. Humantime
125    /// format — `30s`, `5m`, `1h30m`, `2d`. Treated as a floor,
126    /// not a wall-clock deadline (#216). Mutually exclusive with
127    /// `--oneshot`.
128    #[arg(long)]
129    pub interval: Option<String>,
130    /// User-facing identifier. Globally unique. `agents tasks
131    /// run` tags every streamed output line with this name.
132    #[arg(long)]
133    pub name: String,
134    /// Human-readable label for this schedule. Required —
135    /// surfaces on every `agents tasks list` row.
136    #[arg(long)]
137    pub description: String,
138    /// Fire the command once on the next harness poll, then
139    /// delete the row. Mutually exclusive with `--interval`.
140    #[arg(long)]
141    pub oneshot: bool,
142    /// Shadow an existing `(name, agent-instance-hierarchy)` schedule
143    /// instead of erroring on collision: inserts a NEW version
144    /// (`max + 1`) that supersedes the old ones. Old versions keep
145    /// their run history but never list or run again.
146    #[arg(long)]
147    pub overwrite: bool,
148    #[command(flatten)]
149    pub base: crate::cli::command::RequestBaseArgs,
150    /// Command and arguments to run on each scheduled invocation.
151    /// Pass after `--` so flags meant for the inner command don't
152    /// collide with the leaf's own (`--interval` / `--oneshot` /
153    /// `--jq`).
154    #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1..)]
155    pub command: Vec<String>,
156}
157
158#[derive(clap::Args)]
159#[command(args_conflicts_with_subcommands = true)]
160pub struct Command {
161    #[command(flatten)]
162    pub args: Args,
163    #[command(subcommand)]
164    pub schema: Option<Schema>,
165}
166
167#[derive(clap::Subcommand)]
168pub enum Schema {
169    /// Emit the JSON Schema for this leaf's `Request` type and exit.
170    RequestSchema(request_schema::Args),
171    /// Emit the JSON Schema for this leaf's `Response` type and exit.
172    ResponseSchema(response_schema::Args),
173}
174
175impl TryFrom<Args> for Request {
176    type Error = crate::cli::command::FromArgsError;
177    fn try_from(args: Args) -> Result<Self, Self::Error> {
178        // The `schedule_kind` clap group guarantees exactly one
179        // of `--interval` / `--oneshot` is present.
180        let interval_seconds = match (args.interval, args.oneshot) {
181            (Some(interval), false) => {
182                let parsed =
183                    humantime::parse_duration(&interval).map_err(|source| {
184                        crate::cli::command::FromArgsError {
185                            field: "interval",
186                            source: source.to_string().into(),
187                        }
188                    })?;
189                Some(parsed.as_secs())
190            }
191            (None, true) => None,
192            _ => unreachable!(
193                "clap group `schedule_kind` enforces exactly one of `--interval` | `--oneshot`"
194            ),
195        };
196        if args.command.is_empty() {
197            return Err(crate::cli::command::FromArgsError {
198                field: "command",
199                source: "schedule requires at least one positional argument (the command)"
200                    .to_string()
201                    .into(),
202            });
203        }
204        Ok(Self {
205            path_type: Path::AgentsTasksSchedule,
206            name: args.name,
207            command: args.command,
208            description: args.description,
209            interval_seconds,
210            overwrite: args.overwrite,
211            base: args.base.into(),
212        })
213    }
214}
215
216#[cfg(feature = "cli-executor")]
217pub async fn execute<E: crate::cli::command::CommandExecutor>(
218    executor: &E,
219    mut request: Request,
220    agent_arguments: Option<&crate::cli::command::AgentArguments>,
221) -> Result<Response, E::Error> {
222    request.base.clear_transform();
223    executor.execute_one(request, agent_arguments).await
224}
225
226#[cfg(feature = "cli-executor")]
227pub async fn execute_transform<E: crate::cli::command::CommandExecutor>(
228    executor: &E,
229    mut request: Request,
230    transform: crate::cli::command::Transform,
231    agent_arguments: Option<&crate::cli::command::AgentArguments>,
232) -> Result<serde_json::Value, E::Error> {
233    request.base.set_transform(transform);
234    executor.execute_one(request, agent_arguments).await
235}
236
237#[cfg(feature = "mcp")]
238impl crate::cli::command::CommandResponse for Response {
239    fn into_mcp(self) -> crate::cli::command::McpResponseItem {
240        crate::cli::command::McpResponseItem::JSONL(serde_json::to_value(self).unwrap())
241    }
242}
243
244pub mod request_schema;
245
246pub mod response_schema;