1use super::{
68 Assignee, DegradationEntry, RunId, RunListFilter, RunRecord, RunStatus, RunStore,
69 RunStoreError, StepEntry, TaskId, VacateOutcome,
70};
71use async_trait::async_trait;
72use rusqlite::{params, OptionalExtension};
73use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
74use std::collections::BTreeMap;
75use std::path::Path;
76
77const SCHEMA_SQL: &str = "\
78CREATE TABLE IF NOT EXISTS runs (\
79 id TEXT PRIMARY KEY, \
80 task_id TEXT NOT NULL, \
81 status TEXT NOT NULL, \
82 step_entries_json TEXT NOT NULL, \
83 degradations_json TEXT NOT NULL DEFAULT '[]', \
84 operator_sid TEXT, \
85 current_json TEXT, \
86 next_generation INTEGER NOT NULL DEFAULT 0, \
87 result_ref_json TEXT, \
88 input_json TEXT, \
89 created_at INTEGER NOT NULL, \
90 updated_at INTEGER NOT NULL\
91);\
92CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);\
93";
94
95fn migrate_add_column_if_missing(
101 conn: &rusqlite::Connection,
102 column: &str,
103 decl: &str,
104) -> rusqlite::Result<()> {
105 let mut stmt = conn.prepare("PRAGMA table_info(runs)")?;
106 let has_column = stmt
107 .query_map([], |row| row.get::<_, String>(1))?
108 .collect::<Result<Vec<String>, _>>()?
109 .iter()
110 .any(|name| name == column);
111 if !has_column {
112 conn.execute_batch(&format!("ALTER TABLE runs ADD COLUMN {column} {decl};"))?;
113 }
114 Ok(())
115}
116
117pub struct SqliteRunStore {
125 isle: AsyncIsle,
126}
127
128impl SqliteRunStore {
129 pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), RunStoreError> {
132 let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), |conn| {
133 conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
138 conn.execute_batch(SCHEMA_SQL)?;
139 migrate_add_column_if_missing(conn, "degradations_json", "TEXT NOT NULL DEFAULT '[]'")?;
140 migrate_add_column_if_missing(conn, "input_json", "TEXT")?;
141 migrate_add_column_if_missing(conn, "current_json", "TEXT")?;
142 migrate_add_column_if_missing(conn, "next_generation", "INTEGER NOT NULL DEFAULT 0")
143 })
144 .await
145 .map_err(map_isle_err)?;
146 Ok((Self { isle }, driver))
147 }
148
149 pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), RunStoreError> {
151 let (isle, driver) = AsyncIsle::open_in_memory(|conn| {
152 conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
153 conn.execute_batch(SCHEMA_SQL)?;
154 migrate_add_column_if_missing(conn, "degradations_json", "TEXT NOT NULL DEFAULT '[]'")?;
155 migrate_add_column_if_missing(conn, "input_json", "TEXT")?;
156 migrate_add_column_if_missing(conn, "current_json", "TEXT")?;
157 migrate_add_column_if_missing(conn, "next_generation", "INTEGER NOT NULL DEFAULT 0")
158 })
159 .await
160 .map_err(map_isle_err)?;
161 Ok((Self { isle }, driver))
162 }
163
164 async fn record_assign(
177 &self,
178 id: &RunId,
179 slot: &str,
180 op: String,
181 desc: String,
182 ) -> Result<Option<(u64, Option<Assignee>)>, RunStoreError> {
183 let id_str = id.to_string();
184 let slot = slot.to_string();
185 let updated_at = crate::types::now_unix() as i64;
186
187 self.isle
188 .call(move |conn| {
189 let tx =
198 conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
199 let Some((current_json, generation)) = read_assignment_columns(&tx, &id_str)?
200 else {
201 return Ok(None);
202 };
203 let mut current = decode_current(current_json)?;
204 let generation = generation as u64 + 1;
209 let previous = current.insert(
213 slot.clone(),
214 Assignee {
215 op,
216 desc,
217 gen: generation,
218 },
219 );
220 write_assignment_columns(&tx, &id_str, ¤t, generation, updated_at)?;
221 tx.commit()?;
222 Ok(Some((generation, previous)))
223 })
224 .await
225 .map_err(map_isle_err)
226 }
227
228 async fn record_conditional_vacate(
243 &self,
244 id: &RunId,
245 slot: &str,
246 expected_gen: u64,
247 ) -> Result<Option<VacateOutcome>, RunStoreError> {
248 let id_str = id.to_string();
249 let slot = slot.to_string();
250 let updated_at = crate::types::now_unix() as i64;
251
252 self.isle
253 .call(move |conn| {
254 let tx =
255 conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
256 let Some((current_json, generation)) = read_assignment_columns(&tx, &id_str)?
257 else {
258 return Ok(None);
259 };
260 let mut current = decode_current(current_json)?;
261 match current.get(&slot) {
262 Some(held) if held.gen == expected_gen => {}
263 other => {
264 return Ok(Some(VacateOutcome::Stale {
268 current: other.cloned(),
269 }));
270 }
271 }
272 let Some(released) = current.remove(&slot) else {
273 return Ok(Some(VacateOutcome::Stale { current: None }));
274 };
275 let generation = generation as u64 + 1;
278 write_assignment_columns(&tx, &id_str, ¤t, generation, updated_at)?;
279 tx.commit()?;
280 Ok(Some(VacateOutcome::Released {
281 generation,
282 released,
283 }))
284 })
285 .await
286 .map_err(map_isle_err)
287 }
288}
289
290fn read_assignment_columns(
293 tx: &rusqlite::Transaction<'_>,
294 id_str: &str,
295) -> rusqlite::Result<Option<(Option<String>, i64)>> {
296 tx.query_row(
297 "SELECT current_json, next_generation FROM runs WHERE id = ?1",
298 params![id_str],
299 |row| Ok((row.get(0)?, row.get(1)?)),
300 )
301 .optional()
302}
303
304fn decode_current(current_json: Option<String>) -> rusqlite::Result<BTreeMap<String, Assignee>> {
307 match current_json {
308 Some(text) => serde_json::from_str(&text)
309 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))),
310 None => Ok(BTreeMap::new()),
311 }
312}
313
314fn write_assignment_columns(
318 tx: &rusqlite::Transaction<'_>,
319 id_str: &str,
320 current: &BTreeMap<String, Assignee>,
321 generation: u64,
322 updated_at: i64,
323) -> rusqlite::Result<()> {
324 let next_json = if current.is_empty() {
325 None
326 } else {
327 Some(
328 serde_json::to_string(current)
329 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
330 )
331 };
332 tx.execute(
333 "UPDATE runs SET current_json = ?1, next_generation = ?2, updated_at = ?3 WHERE id = ?4",
334 params![next_json, generation as i64, updated_at, id_str],
335 )?;
336 Ok(())
337}
338
339fn map_isle_err(e: IsleError) -> RunStoreError {
340 RunStoreError::Other(format!("sqlite: {e}"))
341}
342
343type RunRow = (
350 String,
351 String,
352 String,
353 String,
354 String,
355 Option<String>,
356 Option<String>,
357 i64,
358 Option<String>,
359 Option<String>,
360 i64,
361 i64,
362);
363
364const RUN_SELECT_COLUMNS: &str = "id, task_id, status, step_entries_json, degradations_json, \
365 operator_sid, current_json, next_generation, result_ref_json, input_json, created_at, \
366 updated_at";
367
368fn read_run_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RunRow> {
373 Ok((
374 row.get::<_, String>(0)?,
375 row.get::<_, String>(1)?,
376 row.get::<_, String>(2)?,
377 row.get::<_, String>(3)?,
378 row.get::<_, String>(4)?,
379 row.get::<_, Option<String>>(5)?,
380 row.get::<_, Option<String>>(6)?,
381 row.get::<_, i64>(7)?,
382 row.get::<_, Option<String>>(8)?,
383 row.get::<_, Option<String>>(9)?,
384 row.get::<_, i64>(10)?,
385 row.get::<_, i64>(11)?,
386 ))
387}
388
389fn row_to_record(row: RunRow) -> Result<RunRecord, RunStoreError> {
390 let (
391 id,
392 task_id,
393 status_json,
394 step_entries_json,
395 degradations_json,
396 operator_sid,
397 current_json,
398 next_generation,
399 result_ref_json,
400 input_json,
401 created_at,
402 updated_at,
403 ) = row;
404 let status: RunStatus = serde_json::from_str(&status_json)
405 .map_err(|e| RunStoreError::Other(format!("decode status: {e}")))?;
406 let step_entries: Vec<StepEntry> = serde_json::from_str(&step_entries_json)
407 .map_err(|e| RunStoreError::Other(format!("decode step_entries: {e}")))?;
408 let degradations: Vec<DegradationEntry> = serde_json::from_str(°radations_json)
409 .map_err(|e| RunStoreError::Other(format!("decode degradations: {e}")))?;
410 let current: BTreeMap<String, Assignee> = match current_json {
417 Some(text) => serde_json::from_str(&text)
418 .map_err(|e| RunStoreError::Other(format!("decode current: {e}")))?,
419 None => BTreeMap::new(),
420 };
421 let result_ref: Option<serde_json::Value> = match result_ref_json {
422 Some(text) => Some(
423 serde_json::from_str(&text)
424 .map_err(|e| RunStoreError::Other(format!("decode result_ref: {e}")))?,
425 ),
426 None => None,
427 };
428 let id = RunId::parse(id).map_err(|e| RunStoreError::Other(format!("decode id: {e}")))?;
432 let task_id =
433 TaskId::parse(task_id).map_err(|e| RunStoreError::Other(format!("decode task_id: {e}")))?;
434 Ok(RunRecord {
435 id,
436 task_id,
437 status,
438 step_entries,
439 degradations,
440 operator_sid,
441 current,
442 next_generation: next_generation as u64,
443 result_ref,
444 input_json,
445 created_at: created_at as u64,
446 updated_at: updated_at as u64,
447 })
448}
449
450#[async_trait]
451impl RunStore for SqliteRunStore {
452 fn name(&self) -> &str {
453 "sqlite"
454 }
455
456 async fn create(&self, record: RunRecord) -> Result<(), RunStoreError> {
457 record.validate_assignment_generations()?;
460 let id = record.id.to_string();
461 let id_for_conflict = record.id.clone();
462 let task_id = record.task_id.to_string();
463 let status_json = serde_json::to_string(&record.status)
464 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
465 let step_entries_json = serde_json::to_string(&record.step_entries)
466 .map_err(|e| RunStoreError::Other(format!("encode step_entries: {e}")))?;
467 let degradations_json = serde_json::to_string(&record.degradations)
468 .map_err(|e| RunStoreError::Other(format!("encode degradations: {e}")))?;
469 let operator_sid = record.operator_sid.clone();
470 let current_json = if record.current.is_empty() {
475 None
476 } else {
477 Some(
478 serde_json::to_string(&record.current)
479 .map_err(|e| RunStoreError::Other(format!("encode current: {e}")))?,
480 )
481 };
482 let next_generation = record.next_generation as i64;
483 let result_ref_json = record
484 .result_ref
485 .as_ref()
486 .map(serde_json::to_string)
487 .transpose()
488 .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
489 let input_json = record.input_json.clone();
490 let created_at = record.created_at as i64;
491 let updated_at = record.updated_at as i64;
492
493 self.isle
494 .call(move |conn| {
495 let tx =
500 conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
501 let exists: i64 = tx.query_row(
502 "SELECT COUNT(*) FROM runs WHERE id = ?1",
503 params![id],
504 |row| row.get(0),
505 )?;
506 if exists > 0 {
507 return Err(rusqlite::Error::SqliteFailure(
508 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
509 Some(format!("__mlua_swarm_duplicate:{id}")),
510 ));
511 }
512 tx.execute(
513 "INSERT INTO runs (id, task_id, status, step_entries_json, \
514 degradations_json, operator_sid, current_json, next_generation, \
515 result_ref_json, input_json, created_at, updated_at) \
516 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
517 params![
518 id,
519 task_id,
520 status_json,
521 step_entries_json,
522 degradations_json,
523 operator_sid,
524 current_json,
525 next_generation,
526 result_ref_json,
527 input_json,
528 created_at,
529 updated_at,
530 ],
531 )?;
532 tx.commit()?;
533 Ok(())
534 })
535 .await
536 .map_err(|e| match &e {
537 IsleError::Sqlite(rusqlite::Error::SqliteFailure(_, Some(msg)))
538 if msg.starts_with("__mlua_swarm_duplicate:") =>
539 {
540 RunStoreError::Duplicate(id_for_conflict.clone())
541 }
542 _ => map_isle_err(e),
543 })
544 }
545
546 async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError> {
547 let id_str = id.to_string();
548 let id_for_notfound = id.clone();
549 let row = self
550 .isle
551 .call(move |conn| {
552 conn.query_row(
553 &format!("SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE id = ?1"),
554 params![id_str],
555 read_run_row,
556 )
557 .optional()
558 })
559 .await
560 .map_err(map_isle_err)?;
561 match row {
562 Some(row) => row_to_record(row),
563 None => Err(RunStoreError::NotFound(id_for_notfound)),
564 }
565 }
566
567 async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError> {
568 let task_id_str = task_id.to_string();
569 let rows = self
570 .isle
571 .call(move |conn| {
572 let mut stmt = conn.prepare(&format!(
573 "SELECT {RUN_SELECT_COLUMNS} FROM runs \
574 WHERE task_id = ?1 ORDER BY created_at ASC"
575 ))?;
576 let iter = stmt.query_map(params![task_id_str], read_run_row)?;
577 let mut out = Vec::new();
578 for r in iter {
579 out.push(r?);
580 }
581 Ok(out)
582 })
583 .await
584 .map_err(map_isle_err)?;
585 rows.into_iter().map(row_to_record).collect()
586 }
587
588 async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError> {
589 let id_str = id.to_string();
590 let id_for_notfound = id.clone();
591 let updated_at = crate::types::now_unix() as i64;
592
593 let updated = self
594 .isle
595 .call(move |conn| {
596 let tx =
599 conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
600 let existing: Option<String> = tx
601 .query_row(
602 "SELECT step_entries_json FROM runs WHERE id = ?1",
603 params![id_str],
604 |row| row.get(0),
605 )
606 .optional()?;
607 let Some(existing_json) = existing else {
608 return Ok(false);
609 };
610 let mut entries: Vec<StepEntry> = serde_json::from_str(&existing_json)
611 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
612 entries.push(entry);
613 let new_json = serde_json::to_string(&entries)
614 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
615 tx.execute(
616 "UPDATE runs SET step_entries_json = ?1, updated_at = ?2 WHERE id = ?3",
617 params![new_json, updated_at, id_str],
618 )?;
619 tx.commit()?;
620 Ok(true)
621 })
622 .await
623 .map_err(map_isle_err)?;
624
625 if updated {
626 Ok(())
627 } else {
628 Err(RunStoreError::NotFound(id_for_notfound))
629 }
630 }
631
632 async fn append_degradation(
633 &self,
634 id: &RunId,
635 entry: DegradationEntry,
636 ) -> Result<(), RunStoreError> {
637 let id_str = id.to_string();
638 let id_for_notfound = id.clone();
639 let updated_at = crate::types::now_unix() as i64;
640
641 let updated = self
642 .isle
643 .call(move |conn| {
644 let tx =
647 conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
648 let existing: Option<String> = tx
649 .query_row(
650 "SELECT degradations_json FROM runs WHERE id = ?1",
651 params![id_str],
652 |row| row.get(0),
653 )
654 .optional()?;
655 let Some(existing_json) = existing else {
656 return Ok(false);
657 };
658 let mut entries: Vec<DegradationEntry> = serde_json::from_str(&existing_json)
659 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
660 entries.push(entry);
661 let new_json = serde_json::to_string(&entries)
662 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
663 tx.execute(
664 "UPDATE runs SET degradations_json = ?1, updated_at = ?2 WHERE id = ?3",
665 params![new_json, updated_at, id_str],
666 )?;
667 tx.commit()?;
668 Ok(true)
669 })
670 .await
671 .map_err(map_isle_err)?;
672
673 if updated {
674 Ok(())
675 } else {
676 Err(RunStoreError::NotFound(id_for_notfound))
677 }
678 }
679
680 async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError> {
681 let id_str = id.to_string();
682 let id_for_notfound = id.clone();
683 let status_json = serde_json::to_string(&status)
684 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
685 let updated_at = crate::types::now_unix() as i64;
686 let n = self
687 .isle
688 .call(move |conn| {
689 conn.execute(
690 "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3",
691 params![status_json, updated_at, id_str],
692 )
693 })
694 .await
695 .map_err(map_isle_err)?;
696 if n == 0 {
697 Err(RunStoreError::NotFound(id_for_notfound))
698 } else {
699 Ok(())
700 }
701 }
702
703 async fn try_transition(
704 &self,
705 id: &RunId,
706 from: RunStatus,
707 to: RunStatus,
708 ) -> Result<bool, RunStoreError> {
709 let id_str = id.to_string();
710 let from_json = serde_json::to_string(&from)
711 .map_err(|e| RunStoreError::Other(format!("encode from status: {e}")))?;
712 let to_json = serde_json::to_string(&to)
713 .map_err(|e| RunStoreError::Other(format!("encode to status: {e}")))?;
714 let updated_at = crate::types::now_unix() as i64;
715 let n = self
721 .isle
722 .call(move |conn| {
723 conn.execute(
724 "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3 AND status = ?4",
725 params![to_json, updated_at, id_str, from_json],
726 )
727 })
728 .await
729 .map_err(map_isle_err)?;
730 Ok(n == 1)
731 }
732
733 async fn acquire_assignee(
734 &self,
735 id: &RunId,
736 slot: &str,
737 op: &str,
738 desc: &str,
739 ) -> Result<(u64, Option<Assignee>), RunStoreError> {
740 if slot.is_empty() {
744 return Err(RunStoreError::AssigneeSlotRequired);
745 }
746 if desc.trim().is_empty() {
747 return Err(RunStoreError::AssigneeDescRequired);
748 }
749 self.record_assign(id, slot, op.to_string(), desc.to_string())
751 .await?
752 .ok_or_else(|| RunStoreError::NotFound(id.clone()))
753 }
754
755 async fn vacate_assignee(
756 &self,
757 id: &RunId,
758 slot: &str,
759 expected_gen: u64,
760 ) -> Result<VacateOutcome, RunStoreError> {
761 if slot.is_empty() {
762 return Err(RunStoreError::AssigneeSlotRequired);
763 }
764 self.record_conditional_vacate(id, slot, expected_gen)
765 .await?
766 .ok_or_else(|| RunStoreError::NotFound(id.clone()))
767 }
768
769 async fn set_result(
770 &self,
771 id: &RunId,
772 result_ref: serde_json::Value,
773 ) -> Result<(), RunStoreError> {
774 let id_str = id.to_string();
775 let id_for_notfound = id.clone();
776 let result_ref_json = serde_json::to_string(&result_ref)
777 .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
778 let updated_at = crate::types::now_unix() as i64;
779 let n = self
780 .isle
781 .call(move |conn| {
782 conn.execute(
783 "UPDATE runs SET result_ref_json = ?1, updated_at = ?2 WHERE id = ?3",
784 params![result_ref_json, updated_at, id_str],
785 )
786 })
787 .await
788 .map_err(map_isle_err)?;
789 if n == 0 {
790 Err(RunStoreError::NotFound(id_for_notfound))
791 } else {
792 Ok(())
793 }
794 }
795
796 async fn set_input_json(&self, id: &RunId, input_json: String) -> Result<(), RunStoreError> {
797 let id_str = id.to_string();
798 let id_for_notfound = id.clone();
799 let updated_at = crate::types::now_unix() as i64;
800 let n = self
801 .isle
802 .call(move |conn| {
803 conn.execute(
804 "UPDATE runs SET input_json = ?1, updated_at = ?2 WHERE id = ?3",
805 params![input_json, updated_at, id_str],
806 )
807 })
808 .await
809 .map_err(map_isle_err)?;
810 if n == 0 {
811 Err(RunStoreError::NotFound(id_for_notfound))
812 } else {
813 Ok(())
814 }
815 }
816
817 async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError> {
818 let status_json = serde_json::to_string(&RunStatus::Running)
819 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
820 let rows = self
821 .isle
822 .call(move |conn| {
823 let mut stmt = conn.prepare(&format!(
824 "SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE status = ?1"
825 ))?;
826 let iter = stmt.query_map(params![status_json], read_run_row)?;
827 let mut out = Vec::new();
828 for r in iter {
829 out.push(r?);
830 }
831 Ok(out)
832 })
833 .await
834 .map_err(map_isle_err)?;
835 rows.into_iter().map(row_to_record).collect()
836 }
837
838 async fn list(&self, filter: &RunListFilter) -> Result<Vec<RunRecord>, RunStoreError> {
839 let task_id = filter.task_id.as_ref().map(|t| t.to_string());
840 let status_json = filter
841 .status
842 .map(|s| serde_json::to_string(&s))
843 .transpose()
844 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
845 let limit = filter.limit.map(|l| l as i64).unwrap_or(-1);
846 let offset = filter.offset.map(|o| o as i64).unwrap_or(0);
847 let rows = self
848 .isle
849 .call(move |conn| {
850 let mut stmt = conn.prepare(&format!(
854 "SELECT {RUN_SELECT_COLUMNS} FROM runs \
855 WHERE (?1 IS NULL OR task_id = ?1) \
856 AND (?2 IS NULL OR status = ?2) \
857 ORDER BY created_at DESC, rowid DESC \
858 LIMIT ?3 OFFSET ?4"
859 ))?;
860 let iter =
861 stmt.query_map(params![task_id, status_json, limit, offset], read_run_row)?;
862 let mut out = Vec::new();
863 for r in iter {
864 out.push(r?);
865 }
866 Ok(out)
867 })
868 .await
869 .map_err(map_isle_err)?;
870 rows.into_iter().map(row_to_record).collect()
871 }
872
873 async fn delete(&self, id: &RunId) -> Result<(), RunStoreError> {
874 let id_str = id.to_string();
875 let id_for_notfound = id.clone();
876 let n = self
877 .isle
878 .call(move |conn| conn.execute("DELETE FROM runs WHERE id = ?1", params![id_str]))
879 .await
880 .map_err(map_isle_err)?;
881 if n == 0 {
882 Err(RunStoreError::NotFound(id_for_notfound))
883 } else {
884 Ok(())
885 }
886 }
887}
888
889#[cfg(test)]
894mod tests {
895 use super::*;
896 use serde_json::json;
897
898 fn mk(id: &str, task_id: &str, created_at: u64) -> RunRecord {
899 RunRecord {
900 id: RunId::parse(id).unwrap(),
901 task_id: TaskId::parse(task_id).unwrap(),
902 status: RunStatus::Pending,
903 step_entries: vec![],
904 degradations: vec![],
905 operator_sid: None,
906 current: Default::default(),
907 next_generation: 0,
908 result_ref: None,
909 input_json: None,
910 created_at,
911 updated_at: created_at,
912 }
913 }
914
915 fn mk_degradation(tool: &str, at: u64) -> DegradationEntry {
916 DegradationEntry {
917 tool: tool.to_string(),
918 error: "boom".to_string(),
919 fallback: "cached-default".to_string(),
920 note: None,
921 step_ref: Some("worker".to_string()),
922 attempt: Some(1),
923 at,
924 }
925 }
926
927 #[tokio::test]
928 async fn create_then_get() {
929 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
930 s.create(mk("R-1", "T-1", 100)).await.unwrap();
931 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
932 assert_eq!(got.task_id, TaskId::parse("T-1").unwrap());
933 assert_eq!(got.status, RunStatus::Pending);
934 assert!(got.step_entries.is_empty());
935 assert_eq!(got.result_ref, None);
936 drop(s);
937 driver.shutdown().await.unwrap();
938 }
939
940 #[tokio::test]
944 async fn create_rejects_a_holder_generation_above_the_counter() {
945 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
946 let mut record = mk("R-1", "T-1", 100);
947 record.current.insert(
948 SLOT_A.to_string(),
949 Assignee {
950 op: "S-seeded".to_string(),
951 desc: "seeded straight into the record".to_string(),
952 gen: 99,
953 },
954 );
955
956 let err = s.create(record).await.unwrap_err();
957 assert!(
958 matches!(
959 err,
960 RunStoreError::AssigneeGenerationAhead {
961 gen: 99,
962 next_generation: 0,
963 ..
964 }
965 ),
966 "got: {err:?}"
967 );
968 assert!(
969 matches!(
970 s.get(&RunId::parse("R-1").unwrap()).await.unwrap_err(),
971 RunStoreError::NotFound(_)
972 ),
973 "a refused create must leave no row behind"
974 );
975 drop(s);
976 driver.shutdown().await.unwrap();
977 }
978
979 #[tokio::test]
980 async fn duplicate_create_rejected() {
981 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
982 s.create(mk("R-1", "T-1", 100)).await.unwrap();
983 let err = s.create(mk("R-1", "T-1", 200)).await.unwrap_err();
984 assert!(matches!(err, RunStoreError::Duplicate(_)), "got: {err:?}");
985 drop(s);
986 driver.shutdown().await.unwrap();
987 }
988
989 #[tokio::test]
990 async fn get_missing_returns_not_found() {
991 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
992 let err = s.get(&RunId::parse("R-nope").unwrap()).await.unwrap_err();
993 assert!(matches!(err, RunStoreError::NotFound(_)));
994 drop(s);
995 driver.shutdown().await.unwrap();
996 }
997
998 #[tokio::test]
999 async fn list_by_task_filters_and_orders_ascending() {
1000 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1001 s.create(mk("R-1", "T-1", 300)).await.unwrap();
1002 s.create(mk("R-2", "T-2", 50)).await.unwrap();
1003 s.create(mk("R-3", "T-1", 100)).await.unwrap();
1004 let list = s
1005 .list_by_task(&TaskId::parse("T-1").unwrap())
1006 .await
1007 .unwrap();
1008 let ids: Vec<_> = list.iter().map(|r| r.id.to_string()).collect();
1009 assert_eq!(ids, vec!["R-3", "R-1"]);
1010 drop(s);
1011 driver.shutdown().await.unwrap();
1012 }
1013
1014 #[tokio::test]
1015 async fn append_step_entry_accumulates_in_order() {
1016 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1017 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1018 s.append_step_entry(
1019 &RunId::parse("R-1").unwrap(),
1020 StepEntry::basic(
1021 crate::types::StepId::parse("ST-1").unwrap(),
1022 Some("step-a".into()),
1023 Some("dispatched".into()),
1024 None,
1025 101,
1026 ),
1027 )
1028 .await
1029 .unwrap();
1030 s.append_step_entry(
1031 &RunId::parse("R-1").unwrap(),
1032 StepEntry::basic(
1033 crate::types::StepId::parse("ST-2").unwrap(),
1034 Some("step-b".into()),
1035 Some("passed".into()),
1036 None,
1037 102,
1038 ),
1039 )
1040 .await
1041 .unwrap();
1042 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1043 assert_eq!(got.step_entries.len(), 2);
1044 assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
1045 assert_eq!(got.step_entries[1].step_ref, Some("step-b".into()));
1046 drop(s);
1047 driver.shutdown().await.unwrap();
1048 }
1049
1050 #[tokio::test]
1051 async fn append_step_entry_unknown_run_fails() {
1052 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1053 let err = s
1054 .append_step_entry(
1055 &RunId::parse("R-nope").unwrap(),
1056 StepEntry::basic(
1057 crate::types::StepId::parse("ST-1").unwrap(),
1058 None,
1059 None,
1060 None,
1061 1,
1062 ),
1063 )
1064 .await
1065 .unwrap_err();
1066 assert!(matches!(err, RunStoreError::NotFound(_)));
1067 drop(s);
1068 driver.shutdown().await.unwrap();
1069 }
1070
1071 #[tokio::test]
1072 async fn append_degradation_accumulates_in_order() {
1073 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1074 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1075 s.append_degradation(
1076 &RunId::parse("R-1").unwrap(),
1077 mk_degradation("web_search", 101),
1078 )
1079 .await
1080 .unwrap();
1081 s.append_degradation(
1082 &RunId::parse("R-1").unwrap(),
1083 mk_degradation("code_exec", 102),
1084 )
1085 .await
1086 .unwrap();
1087 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1088 assert_eq!(got.degradations.len(), 2);
1089 assert_eq!(got.degradations[0].tool, "web_search");
1090 assert_eq!(got.degradations[1].tool, "code_exec");
1091 drop(s);
1092 driver.shutdown().await.unwrap();
1093 }
1094
1095 #[tokio::test]
1096 async fn append_degradation_unknown_run_fails() {
1097 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1098 let err = s
1099 .append_degradation(
1100 &RunId::parse("R-nope").unwrap(),
1101 mk_degradation("web_search", 1),
1102 )
1103 .await
1104 .unwrap_err();
1105 assert!(matches!(err, RunStoreError::NotFound(_)));
1106 drop(s);
1107 driver.shutdown().await.unwrap();
1108 }
1109
1110 #[tokio::test]
1111 async fn append_degradation_bumps_updated_at() {
1112 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1113 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1114 s.append_degradation(
1115 &RunId::parse("R-1").unwrap(),
1116 mk_degradation("web_search", 200),
1117 )
1118 .await
1119 .unwrap();
1120 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1121 assert!(got.updated_at > 100);
1122 drop(s);
1123 driver.shutdown().await.unwrap();
1124 }
1125
1126 #[tokio::test]
1127 async fn update_status_persists() {
1128 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1129 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1130 s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Done)
1131 .await
1132 .unwrap();
1133 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1134 assert_eq!(got.status, RunStatus::Done);
1135 drop(s);
1136 driver.shutdown().await.unwrap();
1137 }
1138
1139 #[tokio::test]
1140 async fn set_result_persists() {
1141 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1142 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1143 s.set_result(&RunId::parse("R-1").unwrap(), json!({"ok": true}))
1144 .await
1145 .unwrap();
1146 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1147 assert_eq!(got.result_ref, Some(json!({"ok": true})));
1148 drop(s);
1149 driver.shutdown().await.unwrap();
1150 }
1151
1152 #[tokio::test]
1153 async fn persists_across_reopen() {
1154 let dir = tempfile::tempdir().unwrap();
1155 let path = dir.path().join("runs.db");
1156
1157 {
1158 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1159 s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
1160 s.append_step_entry(
1161 &RunId::parse("R-keep").unwrap(),
1162 StepEntry::basic(
1163 crate::types::StepId::parse("ST-1").unwrap(),
1164 Some("step-a".into()),
1165 Some("dispatched".into()),
1166 None,
1167 43,
1168 ),
1169 )
1170 .await
1171 .unwrap();
1172 drop(s);
1173 driver.shutdown().await.unwrap();
1174 }
1175
1176 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1177 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
1178 assert_eq!(got.task_id, TaskId::parse("T-keep").unwrap());
1179 assert_eq!(got.step_entries.len(), 1);
1180 assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
1181 drop(s);
1182 driver.shutdown().await.unwrap();
1183 }
1184
1185 #[tokio::test]
1186 async fn list_running_filters_by_status() {
1187 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1188 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1189 s.create(mk("R-2", "T-2", 200)).await.unwrap();
1190 s.create(mk("R-3", "T-3", 300)).await.unwrap();
1191 s.update_status(&RunId::parse("R-2").unwrap(), RunStatus::Running)
1192 .await
1193 .unwrap();
1194 s.update_status(&RunId::parse("R-3").unwrap(), RunStatus::Done)
1195 .await
1196 .unwrap();
1197 let running = s.list_running().await.unwrap();
1198 assert_eq!(running.len(), 1);
1199 assert_eq!(running[0].id, RunId::parse("R-2").unwrap());
1200 assert_eq!(running[0].status, RunStatus::Running);
1201 drop(s);
1202 driver.shutdown().await.unwrap();
1203 }
1204
1205 #[tokio::test]
1206 async fn try_transition_is_atomic_compare_and_set() {
1207 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1208 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1209 s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Interrupted)
1210 .await
1211 .unwrap();
1212
1213 let first = s
1214 .try_transition(
1215 &RunId::parse("R-1").unwrap(),
1216 RunStatus::Interrupted,
1217 RunStatus::Running,
1218 )
1219 .await
1220 .unwrap();
1221 assert!(first, "first CAS must flip Interrupted -> Running");
1222 assert_eq!(
1223 s.get(&RunId::parse("R-1").unwrap()).await.unwrap().status,
1224 RunStatus::Running
1225 );
1226
1227 let second = s
1228 .try_transition(
1229 &RunId::parse("R-1").unwrap(),
1230 RunStatus::Interrupted,
1231 RunStatus::Running,
1232 )
1233 .await
1234 .unwrap();
1235 assert!(
1236 !second,
1237 "a racing second CAS must not flip a now-Running row"
1238 );
1239
1240 let absent = s
1241 .try_transition(
1242 &RunId::parse("R-nope").unwrap(),
1243 RunStatus::Interrupted,
1244 RunStatus::Running,
1245 )
1246 .await
1247 .unwrap();
1248 assert!(!absent, "an absent Run must report false, not error");
1249 drop(s);
1250 driver.shutdown().await.unwrap();
1251 }
1252
1253 const SLOT_A: &str = "phase-a-op";
1259 const SLOT_B: &str = "phase-b-op";
1260
1261 #[tokio::test]
1264 async fn launch_starts_vacant_at_generation_zero() {
1265 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1266 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1267 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1268 assert!(got.current.is_empty(), "no slot is held at launch");
1269 assert_eq!(got.next_generation, 0);
1270 drop(s);
1271 driver.shutdown().await.unwrap();
1272 }
1273
1274 #[tokio::test]
1277 async fn acquire_advances_generation_even_for_the_same_op() {
1278 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1279 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1280 let id = RunId::parse("R-1").unwrap();
1281
1282 let (gen, previous) = s
1283 .acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1284 .await
1285 .unwrap();
1286 assert_eq!(gen, 1, "the first Assign stamps generation 1");
1287 assert_eq!(previous, None);
1288
1289 let (gen, previous) = s
1290 .acquire_assignee(&id, SLOT_A, "S-a1", "same holder, new event")
1291 .await
1292 .unwrap();
1293 assert_eq!(gen, 2, "A4: a repeat Assign for the same op still bumps");
1294 assert_eq!(previous.expect("displaced holder").gen, 1);
1295
1296 let got = s.get(&id).await.unwrap();
1297 assert_eq!(got.next_generation, 2);
1298 assert_eq!(
1299 got.current.len(),
1300 1,
1301 "A1: re-assigning a slot leaves it with exactly one holder"
1302 );
1303 let holder = &got.current[SLOT_A];
1304 assert_eq!(holder.gen, 2);
1305 assert_eq!(holder.desc, "same holder, new event");
1306 drop(s);
1307 driver.shutdown().await.unwrap();
1308 }
1309
1310 #[tokio::test]
1313 async fn assigning_one_slot_leaves_the_others_intact() {
1314 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1315 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1316 let id = RunId::parse("R-1").unwrap();
1317
1318 s.acquire_assignee(&id, SLOT_A, "S-a1", "holds phase a")
1319 .await
1320 .unwrap();
1321 let got = s.get(&id).await.unwrap();
1322 assert!(
1323 !got.current.contains_key(SLOT_B),
1324 "an unassigned slot has no entry — that absence IS its Vacant"
1325 );
1326
1327 s.acquire_assignee(&id, SLOT_B, "S-b2", "holds phase b")
1328 .await
1329 .unwrap();
1330 let got = s.get(&id).await.unwrap();
1331 assert_eq!(got.current[SLOT_A].op, "S-a1", "the first seat survived");
1332 assert_eq!(got.current[SLOT_B].op, "S-b2");
1333 drop(s);
1334 driver.shutdown().await.unwrap();
1335 }
1336
1337 #[tokio::test]
1340 async fn the_generation_counter_is_shared_across_slots() {
1341 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1342 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1343 let id = RunId::parse("R-1").unwrap();
1344
1345 let (first, _) = s
1346 .acquire_assignee(&id, SLOT_A, "S-a1", "holds phase a")
1347 .await
1348 .unwrap();
1349 let (second, _) = s
1350 .acquire_assignee(&id, SLOT_B, "S-b2", "holds phase b")
1351 .await
1352 .unwrap();
1353 let (third, _) = s
1354 .acquire_assignee(&id, SLOT_A, "S-a3", "takes over phase a")
1355 .await
1356 .unwrap();
1357
1358 assert_eq!(
1359 (first, second, third),
1360 (1, 2, 3),
1361 "a second slot does not start its own counter at 1"
1362 );
1363
1364 let got = s.get(&id).await.unwrap();
1365 assert_eq!(got.next_generation, 3);
1366 assert_eq!(got.current[SLOT_A].gen, 3);
1367 assert_eq!(got.current[SLOT_B].gen, 2);
1368 drop(s);
1369 driver.shutdown().await.unwrap();
1370 }
1371
1372 #[tokio::test]
1375 async fn vacate_advances_generation_and_clears_the_holder() {
1376 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1377 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1378 let id = RunId::parse("R-1").unwrap();
1379
1380 s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1381 .await
1382 .unwrap();
1383 let outcome = s.vacate_assignee(&id, SLOT_A, 1).await.unwrap();
1384 assert_eq!(
1385 outcome,
1386 VacateOutcome::Released {
1387 generation: 2,
1388 released: Assignee {
1389 op: "S-a1".into(),
1390 desc: "first hold".into(),
1391 gen: 1,
1392 },
1393 },
1394 "A4: a Vacant that happens is an event and advances G"
1395 );
1396
1397 let got = s.get(&id).await.unwrap();
1398 assert!(
1399 !got.current.contains_key(SLOT_A),
1400 "R2: the Run stays, the holder does not"
1401 );
1402 assert_eq!(got.next_generation, 2);
1403
1404 let (gen, previous) = s
1405 .acquire_assignee(&id, SLOT_A, "S-b2", "after release")
1406 .await
1407 .unwrap();
1408 assert_eq!(gen, 3, "the next Assign continues from the bumped counter");
1409 assert_eq!(
1410 previous, None,
1411 "nothing was displaced — the slot was Vacant"
1412 );
1413 drop(s);
1414 driver.shutdown().await.unwrap();
1415 }
1416
1417 #[tokio::test]
1420 async fn vacate_releases_only_the_named_slot() {
1421 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1422 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1423 let id = RunId::parse("R-1").unwrap();
1424
1425 s.acquire_assignee(&id, SLOT_A, "S-a1", "holds phase a")
1426 .await
1427 .unwrap();
1428 s.acquire_assignee(&id, SLOT_B, "S-b2", "holds phase b")
1429 .await
1430 .unwrap();
1431
1432 let outcome = s.vacate_assignee(&id, SLOT_A, 1).await.unwrap();
1433 assert!(
1434 matches!(&outcome, VacateOutcome::Released { released, .. } if released.op == "S-a1"),
1435 "got: {outcome:?}"
1436 );
1437
1438 let got = s.get(&id).await.unwrap();
1439 assert!(!got.current.contains_key(SLOT_A));
1440 assert_eq!(
1441 got.current[SLOT_B].op, "S-b2",
1442 "vacating one seat must not empty another"
1443 );
1444 drop(s);
1445 driver.shutdown().await.unwrap();
1446 }
1447
1448 #[tokio::test]
1452 async fn a_stale_release_does_not_disturb_the_current_holder() {
1453 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1454 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1455 let id = RunId::parse("R-1").unwrap();
1456
1457 let (observed_gen, _) = s
1459 .acquire_assignee(&id, SLOT_A, "S-away", "the holder that went quiet")
1460 .await
1461 .unwrap();
1462 s.acquire_assignee(&id, SLOT_A, "S-fresh", "took the seat mid-decision")
1464 .await
1465 .unwrap();
1466
1467 let outcome = s.vacate_assignee(&id, SLOT_A, observed_gen).await.unwrap();
1468 assert_eq!(
1469 outcome,
1470 VacateOutcome::Stale {
1471 current: Some(Assignee {
1472 op: "S-fresh".into(),
1473 desc: "took the seat mid-decision".into(),
1474 gen: 2,
1475 }),
1476 },
1477 "the stale reader is told who holds the seat now, and nothing is released"
1478 );
1479
1480 let got = s.get(&id).await.unwrap();
1481 assert_eq!(
1482 got.current[SLOT_A].op, "S-fresh",
1483 "the newer holder stands — A8 already decided this contest"
1484 );
1485 assert_eq!(
1486 got.next_generation, 2,
1487 "the refused release burned no generation"
1488 );
1489 drop(s);
1490 driver.shutdown().await.unwrap();
1491 }
1492
1493 #[tokio::test]
1496 async fn vacate_on_a_vacant_run_is_stale_and_writes_nothing() {
1497 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1498 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1499 let id = RunId::parse("R-1").unwrap();
1500
1501 let outcome = s.vacate_assignee(&id, SLOT_A, 1).await.unwrap();
1502 assert_eq!(outcome, VacateOutcome::Stale { current: None });
1503 assert_eq!(
1504 s.get(&id).await.unwrap().next_generation,
1505 0,
1506 "a release that did not release is not an assignment event"
1507 );
1508 drop(s);
1509 driver.shutdown().await.unwrap();
1510 }
1511
1512 #[tokio::test]
1515 async fn acquire_never_rewrites_the_incumbent_assignee() {
1516 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1517 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1518 let id = RunId::parse("R-1").unwrap();
1519
1520 s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1521 .await
1522 .unwrap();
1523 let held_before = s.get(&id).await.unwrap().current[SLOT_A].clone();
1524 assert_eq!(held_before.gen, 1);
1525
1526 let (_, displaced) = s
1527 .acquire_assignee(&id, SLOT_A, "S-b2", "takeover")
1528 .await
1529 .unwrap();
1530
1531 assert_eq!(
1532 held_before.gen, 1,
1533 "A3: gen is immutable for the lifetime of an instance"
1534 );
1535 assert_eq!(
1536 displaced.expect("displaced holder"),
1537 held_before,
1538 "Q3: the displaced instance is returned as-is, not mutated"
1539 );
1540 drop(s);
1541 driver.shutdown().await.unwrap();
1542 }
1543
1544 #[tokio::test]
1547 async fn acquire_displaces_a_live_holder() {
1548 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1549 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1550 let id = RunId::parse("R-1").unwrap();
1551
1552 s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1553 .await
1554 .unwrap();
1555 let (gen, displaced) = s
1556 .acquire_assignee(&id, SLOT_A, "S-b2", "takeover")
1557 .await
1558 .unwrap();
1559
1560 assert_eq!(gen, 2);
1561 assert_eq!(displaced.expect("displaced holder").op, "S-a1");
1562 let got = s.get(&id).await.unwrap();
1563 assert_eq!(got.current[SLOT_A].op, "S-b2");
1564 assert_eq!(got.current.len(), 1, "A1: still one holder for that slot");
1565 drop(s);
1566 driver.shutdown().await.unwrap();
1567 }
1568
1569 #[tokio::test]
1572 async fn acquire_rejects_a_missing_desc_without_side_effects() {
1573 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1574 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1575 let id = RunId::parse("R-1").unwrap();
1576 s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1577 .await
1578 .unwrap();
1579
1580 for blank in ["", " "] {
1581 let err = s
1582 .acquire_assignee(&id, SLOT_A, "S-b2", blank)
1583 .await
1584 .unwrap_err();
1585 assert!(
1586 matches!(err, RunStoreError::AssigneeDescRequired),
1587 "got: {err:?}"
1588 );
1589 }
1590
1591 let err = s
1592 .acquire_assignee(&id, "", "S-b2", "no slot named")
1593 .await
1594 .unwrap_err();
1595 assert!(
1596 matches!(err, RunStoreError::AssigneeSlotRequired),
1597 "got: {err:?}"
1598 );
1599 let err = s.vacate_assignee(&id, "", 1).await.unwrap_err();
1600 assert!(
1601 matches!(err, RunStoreError::AssigneeSlotRequired),
1602 "got: {err:?}"
1603 );
1604
1605 let got = s.get(&id).await.unwrap();
1606 assert_eq!(got.next_generation, 1, "a refused event is not an event");
1607 assert_eq!(got.current[SLOT_A].op, "S-a1");
1608 drop(s);
1609 driver.shutdown().await.unwrap();
1610 }
1611
1612 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1616 async fn concurrent_acquires_hand_out_distinct_generations() {
1617 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1618 let s = std::sync::Arc::new(s);
1619 s.create(mk("R-1", "T-1", 100)).await.unwrap();
1620
1621 let mut handles = Vec::new();
1622 for i in 0..8u32 {
1623 let s = s.clone();
1624 let slot = if i % 2 == 0 { SLOT_A } else { SLOT_B };
1625 handles.push(tokio::spawn(async move {
1626 s.acquire_assignee(
1627 &RunId::parse("R-1").unwrap(),
1628 slot,
1629 &format!("S-{i}"),
1630 "concurrent hold",
1631 )
1632 .await
1633 .unwrap()
1634 .0
1635 }));
1636 }
1637 let mut generations = Vec::new();
1638 for h in handles {
1639 generations.push(h.await.unwrap());
1640 }
1641 generations.sort_unstable();
1642 assert_eq!(generations, (1..=8).collect::<Vec<u64>>());
1643 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1644 assert_eq!(got.next_generation, 8);
1645 assert_eq!(
1646 got.current.len(),
1647 2,
1648 "both slots ended up held — no writer clobbered the other's entry"
1649 );
1650 drop(s);
1651 driver.shutdown().await.unwrap();
1652 }
1653
1654 #[tokio::test]
1655 async fn assignment_on_an_unknown_run_fails() {
1656 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1657 let missing = RunId::parse("R-nope").unwrap();
1658 let err = s
1659 .acquire_assignee(&missing, SLOT_A, "S-a1", "hold")
1660 .await
1661 .unwrap_err();
1662 assert!(matches!(err, RunStoreError::NotFound(_)), "got: {err:?}");
1663 let err = s.vacate_assignee(&missing, SLOT_A, 1).await.unwrap_err();
1664 assert!(matches!(err, RunStoreError::NotFound(_)), "got: {err:?}");
1665 drop(s);
1666 driver.shutdown().await.unwrap();
1667 }
1668
1669 #[tokio::test]
1672 async fn assignment_survives_reopen() {
1673 let dir = tempfile::tempdir().unwrap();
1674 let path = dir.path().join("runs.db");
1675
1676 {
1677 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1678 s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
1679 let id = RunId::parse("R-keep").unwrap();
1680 s.acquire_assignee(&id, SLOT_A, "main-ai", "held at restart")
1681 .await
1682 .unwrap();
1683 s.acquire_assignee(&id, SLOT_B, "S-b2", "also held at restart")
1684 .await
1685 .unwrap();
1686 drop(s);
1687 driver.shutdown().await.unwrap();
1688 }
1689
1690 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1691 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
1692 let holder = &got.current[SLOT_A];
1693 assert_eq!(holder.op, "main-ai");
1694 assert_eq!(holder.desc, "held at restart");
1695 assert_eq!(holder.gen, 1);
1696 assert_eq!(
1697 got.current[SLOT_B].gen, 2,
1698 "the second seat survives with its own stamp"
1699 );
1700 assert_eq!(got.next_generation, 2);
1701 drop(s);
1702 driver.shutdown().await.unwrap();
1703 }
1704
1705 #[tokio::test]
1714 async fn a_pre_slot_current_json_fails_loud_rather_than_reading_vacant() {
1715 let dir = tempfile::tempdir().unwrap();
1716 let path = dir.path().join("runs.db");
1717
1718 {
1719 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1720 s.create(mk("R-legacy", "T-legacy", 7)).await.unwrap();
1721 drop(s);
1722 driver.shutdown().await.unwrap();
1723 }
1724 {
1725 let conn = rusqlite::Connection::open(&path).unwrap();
1726 conn.execute(
1727 "UPDATE runs SET current_json = ?1, next_generation = 1 WHERE id = 'R-legacy'",
1728 params![r#"{"op":"main-ai","desc":"held","gen":1}"#],
1729 )
1730 .unwrap();
1731 }
1732
1733 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1734 let err = s.get(&RunId::parse("R-legacy").unwrap()).await.unwrap_err();
1735 assert!(
1736 matches!(&err, RunStoreError::Other(msg) if msg.contains("decode current")),
1737 "got: {err:?}"
1738 );
1739 drop(s);
1740 driver.shutdown().await.unwrap();
1741 }
1742
1743 #[tokio::test]
1747 async fn legacy_db_without_assignment_columns_migrates() {
1748 let dir = tempfile::tempdir().unwrap();
1749 let path = dir.path().join("runs.db");
1750
1751 {
1753 let conn = rusqlite::Connection::open(&path).unwrap();
1754 conn.execute_batch(
1755 "CREATE TABLE runs (\
1756 id TEXT PRIMARY KEY, \
1757 task_id TEXT NOT NULL, \
1758 status TEXT NOT NULL, \
1759 step_entries_json TEXT NOT NULL, \
1760 degradations_json TEXT NOT NULL DEFAULT '[]', \
1761 operator_sid TEXT, \
1762 result_ref_json TEXT, \
1763 input_json TEXT, \
1764 created_at INTEGER NOT NULL, \
1765 updated_at INTEGER NOT NULL\
1766 );",
1767 )
1768 .unwrap();
1769 conn.execute(
1770 "INSERT INTO runs (id, task_id, status, step_entries_json, degradations_json, \
1771 operator_sid, result_ref_json, input_json, created_at, updated_at) \
1772 VALUES ('R-old', 'T-old', '\"pending\"', '[]', '[]', NULL, NULL, NULL, 7, 7)",
1773 [],
1774 )
1775 .unwrap();
1776 }
1777
1778 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1779 let got = s.get(&RunId::parse("R-old").unwrap()).await.unwrap();
1780 assert!(
1781 got.current.is_empty(),
1782 "a pre-existing row reads back with no slot held"
1783 );
1784 assert_eq!(
1785 got.next_generation, 0,
1786 "and starts at the launch value of G"
1787 );
1788 assert_eq!(got.created_at, 7);
1789
1790 let (gen, _) = s
1792 .acquire_assignee(
1793 &RunId::parse("R-old").unwrap(),
1794 SLOT_A,
1795 "S-a1",
1796 "after migration",
1797 )
1798 .await
1799 .unwrap();
1800 assert_eq!(gen, 1);
1801 drop(s);
1802 driver.shutdown().await.unwrap();
1803 }
1804
1805 #[tokio::test]
1806 async fn input_json_roundtrips_across_reopen() {
1807 let dir = tempfile::tempdir().unwrap();
1808 let path = dir.path().join("runs.db");
1809 let snapshot = r#"{"blueprint":"snapshot","init_ctx":{}}"#;
1810
1811 {
1812 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1813 let mut rec = mk("R-keep", "T-keep", 42);
1814 rec.input_json = Some(snapshot.to_string());
1815 s.create(rec).await.unwrap();
1816 drop(s);
1817 driver.shutdown().await.unwrap();
1818 }
1819
1820 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1821 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
1822 assert_eq!(got.input_json.as_deref(), Some(snapshot));
1823 drop(s);
1824 driver.shutdown().await.unwrap();
1825 }
1826}