scheduled prompts: edit existing schedule (closes #474)
This commit is contained in:
parent
0072946ea3
commit
9fee1a1e56
6 changed files with 428 additions and 0 deletions
|
|
@ -145,6 +145,22 @@ pub struct NewSchedule {
|
|||
pub source: ScheduleSource,
|
||||
}
|
||||
|
||||
/// Partial-update payload for `ScheduledPrompts::update` (#474).
|
||||
/// Every field is `Option<_>`; `None` keeps the existing value.
|
||||
/// The doubly-wrapped `Option<Option<u64>>` on `interval_seconds`
|
||||
/// is intentional: outer `None` = "don't touch", outer
|
||||
/// `Some(None)` = "set to NULL (toggle to one-shot)", outer
|
||||
/// `Some(Some(n))` = "set to n seconds." Same encoding as JSON
|
||||
/// "missing key" vs "explicit null" — the dashboard surface
|
||||
/// preserves the distinction.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdateSchedule {
|
||||
pub body: Option<String>,
|
||||
pub description: Option<Option<String>>,
|
||||
pub interval_seconds: Option<Option<u64>>,
|
||||
pub next_fire_at_unix: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct ScheduledPrompts {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
|
@ -331,6 +347,67 @@ impl ScheduledPrompts {
|
|||
Ok(skipped)
|
||||
}
|
||||
|
||||
/// Partial-update an existing schedule's mutable fields (#474).
|
||||
/// Every field is `Option<_>`; `None` means "leave the existing
|
||||
/// value alone", `Some(_)` means "set it to this." Refuses
|
||||
/// cancelled rows (no point editing a tombstone — operator can
|
||||
/// just submit a new schedule). Refuses `interval_seconds = Some(0)`
|
||||
/// — same rule as submit-time validation. Targets stay
|
||||
/// immutable: per-target last-result history is keyed on
|
||||
/// (schedule_id, target); changing the target set would orphan
|
||||
/// or duplicate history rows. Operator workaround for "I want
|
||||
/// different targets" is cancel-target + submit a new schedule.
|
||||
pub fn update(&self, id: i64, patch: UpdateSchedule) -> Result<()> {
|
||||
// Pre-flight: row must exist and not be cancelled.
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let cancelled: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT cancelled_at_unix FROM scheduled_prompts WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule {id} not found"))?;
|
||||
if cancelled.is_some() {
|
||||
bail!("schedule {id} is cancelled — submit a new one");
|
||||
}
|
||||
if let Some(Some(0)) = patch.interval_seconds {
|
||||
bail!("interval_seconds must be > 0 (use None for one-shot)");
|
||||
}
|
||||
// Single transaction so a partial failure can't leave a
|
||||
// half-updated row.
|
||||
let tx = conn.transaction()?;
|
||||
if let Some(body) = patch.body {
|
||||
tx.execute(
|
||||
"UPDATE scheduled_prompts SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(description) = patch.description {
|
||||
tx.execute(
|
||||
"UPDATE scheduled_prompts SET description = ?1 WHERE id = ?2",
|
||||
params![description, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(interval) = patch.interval_seconds {
|
||||
// `Some(Some(n))` = set to n. `Some(None)` = clear (one-shot).
|
||||
// `None` is filtered out by the outer `if let`.
|
||||
let value: Option<i64> = interval.map(i64::try_from).and_then(Result::ok);
|
||||
tx.execute(
|
||||
"UPDATE scheduled_prompts SET interval_seconds = ?1 WHERE id = ?2",
|
||||
params![value, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(next) = patch.next_fire_at_unix {
|
||||
tx.execute(
|
||||
"UPDATE scheduled_prompts SET next_fire_at_unix = ?1 WHERE id = ?2",
|
||||
params![next, id],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a one-shot row after its single fire. Cascades the
|
||||
/// target rows via the FK constraint. Idempotent on a missing
|
||||
/// id.
|
||||
|
|
@ -638,6 +715,137 @@ mod tests {
|
|||
assert_eq!(bob.last_result.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_partial_only_touches_set_fields() {
|
||||
let (_dir, db) = open();
|
||||
let id = db
|
||||
.submit(NewSchedule {
|
||||
owner: "operator".into(),
|
||||
targets: vec!["alice".into()],
|
||||
body: "old body".into(),
|
||||
first_fire_at_unix: 100,
|
||||
interval_seconds: Some(60),
|
||||
description: Some("old desc".into()),
|
||||
source: ScheduleSource::Operator,
|
||||
})
|
||||
.expect("submit");
|
||||
// Only change body — everything else must stay.
|
||||
db.update(
|
||||
id,
|
||||
UpdateSchedule {
|
||||
body: Some("new body".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("update body");
|
||||
let s = db.get(id).expect("get").expect("present");
|
||||
assert_eq!(s.body, "new body");
|
||||
assert_eq!(s.description.as_deref(), Some("old desc"));
|
||||
assert_eq!(s.interval_seconds, Some(60));
|
||||
assert_eq!(s.next_fire_at_unix, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interval_toggle_recurring_to_one_shot() {
|
||||
let (_dir, db) = open();
|
||||
let id = db
|
||||
.submit(NewSchedule {
|
||||
owner: "operator".into(),
|
||||
targets: vec!["alice".into()],
|
||||
body: "x".into(),
|
||||
first_fire_at_unix: 100,
|
||||
interval_seconds: Some(60),
|
||||
description: None,
|
||||
source: ScheduleSource::Operator,
|
||||
})
|
||||
.expect("submit");
|
||||
db.update(
|
||||
id,
|
||||
UpdateSchedule {
|
||||
interval_seconds: Some(None),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("update");
|
||||
let s = db.get(id).expect("get").expect("present");
|
||||
assert_eq!(s.interval_seconds, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_interval_toggle_one_shot_to_recurring() {
|
||||
let (_dir, db) = open();
|
||||
let id = submit_one_shot(&db, 100, &["alice"]);
|
||||
db.update(
|
||||
id,
|
||||
UpdateSchedule {
|
||||
interval_seconds: Some(Some(3600)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("update");
|
||||
let s = db.get(id).expect("get").expect("present");
|
||||
assert_eq!(s.interval_seconds, Some(3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_cancelled_row() {
|
||||
let (_dir, db) = open();
|
||||
let id = submit_one_shot(&db, 100, &["alice"]);
|
||||
db.cancel_all(id).expect("cancel");
|
||||
let err = db
|
||||
.update(
|
||||
id,
|
||||
UpdateSchedule {
|
||||
body: Some("nope".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("cancelled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_zero_interval() {
|
||||
let (_dir, db) = open();
|
||||
let id = submit_one_shot(&db, 100, &["alice"]);
|
||||
let err = db
|
||||
.update(
|
||||
id,
|
||||
UpdateSchedule {
|
||||
interval_seconds: Some(Some(0)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("interval_seconds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_clears_description() {
|
||||
let (_dir, db) = open();
|
||||
let id = db
|
||||
.submit(NewSchedule {
|
||||
owner: "operator".into(),
|
||||
targets: vec!["alice".into()],
|
||||
body: "x".into(),
|
||||
first_fire_at_unix: 100,
|
||||
interval_seconds: None,
|
||||
description: Some("first".into()),
|
||||
source: ScheduleSource::Operator,
|
||||
})
|
||||
.expect("submit");
|
||||
db.update(
|
||||
id,
|
||||
UpdateSchedule {
|
||||
description: Some(None),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("update");
|
||||
let s = db.get(id).expect("get").expect("present");
|
||||
assert!(s.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_source_round_trips() {
|
||||
let (_dir, db) = open();
|
||||
|
|
|
|||
Loading…
Reference in a new issue