1use objectiveai_sdk::cli::command::AgentArguments;
16use sqlx::Row as _;
17
18use super::{Error, Pool};
19
20const CLAIM_LOCK_KEY: i64 = 0x7461_736b_735f_7275; const 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#[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 pub last_ran_at: Option<i64>,
48 pub interval_seconds: Option<u64>,
49 pub version: i64,
52 pub plugin: Option<crate::plugin_path::PluginPath>,
55}
56
57#[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 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
81pub 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 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 continue;
165 }
166 Err(sqlx::Error::Database(e).into())
170 } else {
171 Ok(None)
175 }
176 }
177 Err(e) => Err(e.into()),
178 };
179 }
180}
181
182pub 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 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
301pub 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
400pub 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}