Skip to main content

objectiveai_cli/db/
tasks.rs

1//! `schedules` + `tasks_runs` + `tasks_logs` — the `agents tasks
2//! {schedule, list, run}` storage tier.
3//!
4//! Per-schedule payload: an argv vector to invoke on each scheduled
5//! poll, the minimum interval between invocations in seconds, and a
6//! JSON snapshot of the caller's `AgentArguments` so the runner can
7//! re-install identity env vars at fire-time.
8//!
9//! Schedule rows are NEVER deleted. Versions are separate rows (`--
10//! overwrite` inserts `version = max+1`, shadowing the older rows);
11//! run history is one `tasks_runs` row per firing (written atomically
12//! by [`claim_pending`]'s claim query); each item a fired task emits
13//! is one `tasks_logs` row linked to its run.
14
15use objectiveai_sdk::cli::command::AgentArguments;
16use sqlx::Row as _;
17
18use super::{Error, Pool};
19
20/// Serializes every [`claim_pending`] claim transaction via
21/// `pg_advisory_xact_lock` — readiness reads `tasks_runs` while a
22/// concurrent claimer's inserts aren't yet visible, so row locks alone
23/// can't prevent a double-claim.
24const CLAIM_LOCK_KEY: i64 = 0x7461_736b_735f_7275; // "tasks_ru"
25
26/// SQL predicate: this `schedules` row (aliased `s`) is the newest
27/// version of its `(name, agent_instance_hierarchy)` — older versions
28/// are shadowed: never listed, never run.
29const LATEST_VERSION_PREDICATE: &str = "s.version = ( \
30    SELECT MAX(s2.version) FROM objectiveai.schedules s2 \
31    WHERE s2.name = s.name \
32      AND s2.agent_instance_hierarchy = s.agent_instance_hierarchy \
33)";
34
35/// One row from `schedules` as surfaced by `agents tasks list`.
36/// `command` is decoded from its JSON-string column.
37#[derive(Debug, Clone)]
38pub struct ListedSchedule {
39    pub id: i64,
40    pub name: String,
41    pub agent_instance_hierarchy: String,
42    pub command: Vec<String>,
43    pub description: String,
44    pub created_at: i64,
45    /// Unix seconds of this row's newest `tasks_runs` entry — derived,
46    /// not a column.
47    pub last_ran_at: Option<i64>,
48    pub interval_seconds: Option<u64>,
49    /// Version of this row: `1` on first insert, `max+1` per
50    /// `--overwrite`. Only the newest version of a `(name, aih)` lists.
51    pub version: i64,
52    /// The plugin that registered this schedule, if any. `Some` iff all
53    /// three `plugin_*` columns are set (the table CHECK enforces it).
54    pub plugin: Option<crate::plugin_path::PluginPath>,
55}
56
57/// Subset of a schedule row that `agents tasks run` needs to fire one
58/// task, plus the id of the `tasks_runs` row the claim minted for this
59/// firing (the log writer links every emitted item to it).
60#[derive(Debug, Clone)]
61pub struct RunRow {
62    pub run_id: i64,
63    pub name: String,
64    pub agent_instance_hierarchy: String,
65    pub version: i64,
66    pub command: Vec<String>,
67    pub agent_arguments: AgentArguments,
68    /// The plugin that registered this schedule, if any — re-installed
69    /// on the run ctx so the task fires on behalf of that plugin.
70    pub plugin: Option<crate::plugin_path::PluginPath>,
71}
72
73fn now_seconds() -> i64 {
74    use std::time::{SystemTime, UNIX_EPOCH};
75    SystemTime::now()
76        .duration_since(UNIX_EPOCH)
77        .map(|d| d.as_secs() as i64)
78        .unwrap_or(0)
79}
80
81/// Insert one schedule row and return its `(id, version)`.
82///
83/// `command` is JSON-serialised as a string array (the argv shape the
84/// runner will exec). `agent_arguments` is JSON-serialised verbatim —
85/// the runner re-installs each `Some(_)` field as the matching env var
86/// when the schedule fires.
87///
88/// Versions are separate rows, unique on `(name, aih, version)`:
89/// * `overwrite = false` inserts `version = 1`; if the schedule was
90///   ever created, version 1 already exists and the unique violation
91///   yields `Ok(None)` so the caller can raise a friendly "already
92///   exists" error.
93/// * `overwrite = true` inserts a NEW row with `version = max + 1`,
94///   shadowing the older rows (they never list or run again, but stay
95///   for per-version run history). The fresh row has no `tasks_runs`
96///   entries, so it is immediately pending. A concurrent overwrite can
97///   collide on the computed version; retry a bounded number of times.
98pub async fn insert_schedule(
99    pool: &Pool,
100    name: &str,
101    command: &[String],
102    description: &str,
103    agent_instance_hierarchy: &str,
104    interval_seconds: Option<u64>,
105    agent_arguments: &AgentArguments,
106    plugin: Option<&crate::plugin_path::PluginPath>,
107    overwrite: bool,
108) -> Result<Option<(i64, i64)>, Error> {
109    let command_json = serde_json::to_string(command)?;
110    let agent_arguments_json = serde_json::to_string(agent_arguments)?;
111    let interval_param: Option<i64> = interval_seconds.map(|s| s as i64);
112    let (plugin_owner, plugin_repository, plugin_version) = match plugin {
113        Some(p) => (
114            Some(p.owner.as_str()),
115            Some(p.repository.as_str()),
116            Some(p.version.as_str()),
117        ),
118        None => (None, None, None),
119    };
120
121    let columns = "(name, command, description, agent_instance_hierarchy, interval_seconds, \
122                    agent_arguments, plugin_owner, plugin_repository, plugin_version, created_at, \
123                    version)";
124    // Non-overwrite always claims version 1 — colliding with it means
125    // the schedule already exists (rows are never deleted). Overwrite
126    // computes max+1 in the INSERT itself.
127    let version_expr = if overwrite {
128        "(SELECT COALESCE(MAX(version), 0) + 1 FROM objectiveai.schedules \
129          WHERE name = $1 AND agent_instance_hierarchy = $4)"
130    } else {
131        "1"
132    };
133    let query = format!(
134        "INSERT INTO objectiveai.schedules {columns} \
135         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, {version_expr}) \
136         RETURNING id, version"
137    );
138
139    let mut attempts = 0;
140    loop {
141        attempts += 1;
142        let result = sqlx::query_as::<_, (i64, i64)>(&query)
143            .bind(name)
144            .bind(&command_json)
145            .bind(description)
146            .bind(agent_instance_hierarchy)
147            .bind(interval_param)
148            .bind(&agent_arguments_json)
149            .bind(plugin_owner)
150            .bind(plugin_repository)
151            .bind(plugin_version)
152            .bind(now_seconds())
153            .fetch_one(&**pool)
154            .await;
155
156        return match result {
157            Ok(pair) => Ok(Some(pair)),
158            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
159                if overwrite {
160                    if attempts < 3 {
161                        // Concurrent overwrite computed the same max+1
162                        // — re-run; MAX re-evaluates against the
163                        // winner.
164                        continue;
165                    }
166                    // Still colliding after retries — surface the db
167                    // error rather than a misleading "already exists"
168                    // (the caller DID pass --overwrite).
169                    Err(sqlx::Error::Database(e).into())
170                } else {
171                    // Collision on (name, aih, 1): the schedule already
172                    // exists — report absence so the handler can
173                    // surface a clean error.
174                    Ok(None)
175                }
176            }
177            Err(e) => Err(e.into()),
178        };
179    }
180}
181
182/// List `schedules` matching the supplied filters. Only the newest
183/// version of each `(name, aih)` is visible; readiness derives from
184/// each row's newest `tasks_runs` entry. Every filter is optional and
185/// composes additively — the SQL is one statement that gates each
186/// predicate on whether the corresponding bind is active (0 = inactive
187/// bool flag, `NULL` = unset after_id/count).
188///
189/// * `hierarchies`: exact-match AIH scope (no subtree descent).
190/// * `oneshot_only` / `interval_only`: kind filter (mutually exclusive
191///   at the CLI layer; both `false` = no kind filter).
192/// * `pending_only` / `exhausted_only`: readiness filter (same).
193/// * `after_id` / `count`: keyset pagination by ascending row id.
194///   `count = None` binds `NULL` to LIMIT for unlimited.
195pub async fn list_schedules(
196    pool: &Pool,
197    hierarchies: &[String],
198    oneshot_only: bool,
199    interval_only: bool,
200    pending_only: bool,
201    exhausted_only: bool,
202    after_id: Option<i64>,
203    count: Option<u64>,
204) -> Result<Vec<ListedSchedule>, Error> {
205    let count_param: Option<i64> = count.map(|c| c as i64);
206
207    // Exact-AIH scope (`= ANY($1)`, no subtree descent), only the
208    // newest version of each (name, aih) (older versions are
209    // shadowed), `last_ran_at` derived from the newest `tasks_runs`
210    // entry via LATERAL, the kind / readiness short-circuits
211    // (`($N = 0 OR …)`, $5 = now), and keyset pagination forward by
212    // ascending id (`id > COALESCE($7, 0)`). `LIMIT $8` is `NULL` when
213    // `count` is `None` → unlimited.
214    let query = format!(
215        "SELECT s.id, \
216                s.name, \
217                s.agent_instance_hierarchy, \
218                s.command, \
219                s.description, \
220                s.created_at, \
221                lr.last_ran_at, \
222                s.interval_seconds, \
223                s.plugin_owner, \
224                s.plugin_repository, \
225                s.plugin_version, \
226                s.version \
227         FROM objectiveai.schedules s \
228         LEFT JOIN LATERAL ( \
229             SELECT MAX(r.ran_at) AS last_ran_at \
230             FROM objectiveai.tasks_runs r WHERE r.schedule_id = s.id \
231         ) lr ON TRUE \
232         WHERE s.agent_instance_hierarchy = ANY($1) \
233             AND {LATEST_VERSION_PREDICATE} \
234             AND ($2 = 0 OR s.interval_seconds IS NULL) \
235             AND ($3 = 0 OR s.interval_seconds IS NOT NULL) \
236             AND ($4 = 0 OR ( \
237                 (s.interval_seconds IS NULL AND lr.last_ran_at IS NULL) \
238                 OR \
239                 (s.interval_seconds IS NOT NULL \
240                  AND (lr.last_ran_at IS NULL \
241                       OR ($5 - lr.last_ran_at) >= s.interval_seconds)) \
242             )) \
243             AND ($6 = 0 OR ( \
244                 (s.interval_seconds IS NULL AND lr.last_ran_at IS NOT NULL) \
245                 OR \
246                 (s.interval_seconds IS NOT NULL \
247                  AND lr.last_ran_at IS NOT NULL \
248                  AND ($5 - lr.last_ran_at) < s.interval_seconds) \
249             )) \
250             AND s.id > COALESCE($7, 0) \
251         ORDER BY s.id ASC \
252         LIMIT $8",
253    );
254    let rows = sqlx::query(&query)
255    .bind(hierarchies)
256    .bind(oneshot_only as i64)
257    .bind(interval_only as i64)
258    .bind(pending_only as i64)
259    .bind(now_seconds())
260    .bind(exhausted_only as i64)
261    .bind(after_id)
262    .bind(count_param)
263    .fetch_all(&**pool)
264    .await?;
265
266    let mut out = Vec::with_capacity(rows.len());
267    for row in rows {
268        let id: i64 = row.try_get(0)?;
269        let name: String = row.try_get(1)?;
270        let agent_instance_hierarchy: String = row.try_get(2)?;
271        let command_json: String = row.try_get(3)?;
272        let description: String = row.try_get(4)?;
273        let created_at: i64 = row.try_get(5)?;
274        let last_ran_at: Option<i64> = row.try_get(6)?;
275        let interval_seconds: Option<i64> = row.try_get(7)?;
276        let plugin_owner: Option<String> = row.try_get(8)?;
277        let plugin_repository: Option<String> = row.try_get(9)?;
278        let plugin_version: Option<String> = row.try_get(10)?;
279        let version: i64 = row.try_get(11)?;
280        let command: Vec<String> = serde_json::from_str(&command_json)?;
281        out.push(ListedSchedule {
282            id,
283            name,
284            agent_instance_hierarchy,
285            command,
286            description,
287            created_at,
288            last_ran_at,
289            interval_seconds: interval_seconds.map(|s| s as u64),
290            version,
291            plugin: crate::plugin_path::PluginPath::from_parts(
292                plugin_owner,
293                plugin_repository,
294                plugin_version,
295            ),
296        });
297    }
298    Ok(out)
299}
300
301/// Atomically claim every pending schedule in `parent`'s subtree (its
302/// own AIH or any descendant): ONE statement both selects the eligible
303/// rows and inserts their `tasks_runs` rows, so each returned `RunRow`
304/// carries the freshly-minted `run_id`. Schedules are never updated or
305/// deleted by a run — exhaustion is purely "has a run" (oneshots) /
306/// "newest run too recent" (recurring), and only the newest version of
307/// each (name, aih) is eligible.
308///
309/// The transaction opens with `pg_advisory_xact_lock` so concurrent
310/// `tasks run` callers fully serialize: the second claimer sees the
311/// first's `tasks_runs` rows and excludes those schedules. (Row locks
312/// can't do this — eligibility reads a DIFFERENT table than the one
313/// the claim writes, so a blocked reader would re-check a schedules
314/// row that never changed and double-claim.)
315pub async fn claim_pending(pool: &Pool, parent: &str) -> Result<Vec<RunRow>, Error> {
316    let now = now_seconds();
317
318    let mut tx = pool.begin().await?;
319
320    sqlx::query("SELECT pg_advisory_xact_lock($1)")
321        .bind(CLAIM_LOCK_KEY)
322        .execute(&mut *tx)
323        .await?;
324
325    let query = format!(
326        "WITH eligible AS ( \
327             SELECT s.id, s.name, s.agent_instance_hierarchy, s.version, \
328                    s.command, s.agent_arguments, \
329                    s.plugin_owner, s.plugin_repository, s.plugin_version \
330             FROM objectiveai.schedules s \
331             WHERE ( \
332                     s.agent_instance_hierarchy = $1 \
333                     OR s.agent_instance_hierarchy LIKE ($1 || '/%') \
334                 ) \
335                 AND {LATEST_VERSION_PREDICATE} \
336                 AND ( \
337                     (s.interval_seconds IS NULL \
338                      AND NOT EXISTS ( \
339                          SELECT 1 FROM objectiveai.tasks_runs r WHERE r.schedule_id = s.id \
340                      )) \
341                     OR \
342                     (s.interval_seconds IS NOT NULL \
343                      AND COALESCE( \
344                          $2 - (SELECT MAX(r.ran_at) FROM objectiveai.tasks_runs r \
345                                WHERE r.schedule_id = s.id) \
346                              >= s.interval_seconds, \
347                          TRUE)) \
348                 ) \
349         ), \
350         ins AS ( \
351             INSERT INTO objectiveai.tasks_runs (schedule_id, ran_at) \
352             SELECT id, $2 FROM eligible \
353             RETURNING id AS run_id, schedule_id \
354         ) \
355         SELECT ins.run_id, e.name, e.agent_instance_hierarchy, e.version, \
356                e.command, e.agent_arguments, \
357                e.plugin_owner, e.plugin_repository, e.plugin_version \
358         FROM eligible e \
359         JOIN ins ON ins.schedule_id = e.id \
360         ORDER BY e.id ASC",
361    );
362    let rows = sqlx::query(&query)
363        .bind(parent)
364        .bind(now)
365        .fetch_all(&mut *tx)
366        .await?;
367
368    tx.commit().await?;
369
370    let mut out = Vec::with_capacity(rows.len());
371    for row in rows {
372        let run_id: i64 = row.try_get(0)?;
373        let name: String = row.try_get(1)?;
374        let agent_instance_hierarchy: String = row.try_get(2)?;
375        let version: i64 = row.try_get(3)?;
376        let command_json: String = row.try_get(4)?;
377        let agent_arguments_json: String = row.try_get(5)?;
378        let plugin_owner: Option<String> = row.try_get(6)?;
379        let plugin_repository: Option<String> = row.try_get(7)?;
380        let plugin_version: Option<String> = row.try_get(8)?;
381        let command: Vec<String> = serde_json::from_str(&command_json)?;
382        let agent_arguments: AgentArguments = serde_json::from_str(&agent_arguments_json)?;
383        out.push(RunRow {
384            run_id,
385            name,
386            agent_instance_hierarchy,
387            version,
388            command,
389            agent_arguments,
390            plugin: crate::plugin_path::PluginPath::from_parts(
391                plugin_owner,
392                plugin_repository,
393                plugin_version,
394            ),
395        });
396    }
397    Ok(out)
398}
399
400/// Append one emitted item to a run's log. `value` is the serialized
401/// `tasks::run::ResponseItem` exactly as it crossed the wire.
402pub async fn insert_task_log(pool: &Pool, run_id: i64, value: &str) -> Result<(), Error> {
403    sqlx::query("INSERT INTO objectiveai.tasks_logs (run_id, value, created_at) VALUES ($1, $2, $3)")
404        .bind(run_id)
405        .bind(value)
406        .bind(now_seconds())
407        .execute(&**pool)
408        .await?;
409    Ok(())
410}