1use anyhow::Context;
15use redb::{ReadableTable, TableDefinition};
16use serde::{Deserialize, Serialize};
17
18use super::Db;
19use super::dynamic_workers;
20
21const SESSIONS: TableDefinition<(&str, &str, &str), &[u8]> = TableDefinition::new("sessions");
22
23pub const LEAD_LABEL: &str = "lead";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum SeatState {
36 Closed,
39 InTurn,
42 Idle,
44}
45
46impl SeatState {
47 pub fn is_open(self) -> bool {
49 matches!(self, Self::Idle | Self::InTurn)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct SessionRecord {
64 #[serde(default, skip_serializing)]
65 pub org: String,
66 #[serde(default, skip_serializing)]
67 pub project: String,
68 #[serde(default, skip_serializing)]
69 pub label: String,
70 #[serde(default)]
71 pub session_id: Option<String>,
72 #[serde(default)]
73 pub charter: Option<String>,
74 #[serde(default)]
75 pub kick: Option<String>,
76 #[serde(default)]
77 pub resume_kick: Option<String>,
78 #[serde(default)]
79 pub interactive: Option<bool>,
80 #[serde(default)]
84 pub seat_state: Option<SeatState>,
85 #[serde(default)]
92 pub is_git_repo: Option<bool>,
93 #[serde(default)]
99 pub mcp_families: Option<Vec<String>>,
100 #[serde(default)]
106 pub cwd: Option<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ProjectIdentity {
114 pub key: String,
115 pub org: String,
116 pub name: String,
117}
118
119pub fn get(
121 db: &Db,
122 org: &str,
123 project: &str,
124 label: &str,
125) -> anyhow::Result<Option<SessionRecord>> {
126 let txn = db.database().begin_read()?;
127 let table = match txn.open_table(SESSIONS) {
128 Ok(t) => t,
129 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
132 Err(e) => return Err(e.into()),
133 };
134 let Some(value) = table.get((org, project, label))? else {
135 return Ok(None);
136 };
137 decode(org, project, label, value.value()).map(Some)
138}
139
140pub fn put(db: &Db, record: &SessionRecord) -> anyhow::Result<()> {
142 let value = serde_json::to_vec(record).context("serialize session record")?;
143 let txn = db.database().begin_write()?;
144 {
145 let mut table = txn.open_table(SESSIONS)?;
146 table.insert(
147 (record.org.as_str(), record.project.as_str(), record.label.as_str()),
148 value.as_slice(),
149 )?;
150 }
151 txn.commit()?;
152 Ok(())
153}
154
155#[cfg(test)]
158pub(crate) fn raw_for_test(
159 db: &Db,
160 org: &str,
161 project: &str,
162 label: &str,
163) -> anyhow::Result<Vec<u8>> {
164 let txn = db.database().begin_read()?;
165 let table = txn.open_table(SESSIONS)?;
166 let value = table.get((org, project, label))?.expect("the row is there");
167 Ok(value.value().to_vec())
168}
169
170#[cfg(test)]
173pub(crate) fn put_raw_for_test(
174 db: &Db,
175 org: &str,
176 project: &str,
177 label: &str,
178 value: &[u8],
179) -> anyhow::Result<()> {
180 let txn = db.database().begin_write()?;
181 {
182 let mut table = txn.open_table(SESSIONS)?;
183 table.insert((org, project, label), value)?;
184 }
185 txn.commit()?;
186 Ok(())
187}
188
189pub struct SweepOutcome {
193 pub moved: usize,
195 pub unkeyable: usize,
198 pub rows: usize,
200}
201
202impl SweepOutcome {
203 pub fn drained(&self) -> bool {
208 self.moved == self.rows
209 }
210}
211
212pub fn migrate_from_dynamic_workers(
229 db: &Db,
230 projects: &[ProjectIdentity],
231) -> anyhow::Result<SweepOutcome> {
232 let rows = dynamic_workers::count(db)?;
237 let workers = dynamic_workers::list_all(db)?;
238 let mut moved = 0;
239 let mut unkeyable = 0;
240 for worker in &workers {
241 let Some(project) = projects.iter().find(|p| p.key == worker.project_key) else {
242 tracing::warn!(
243 target: "forge_workspace::store::sessions",
244 project_key = %worker.project_key,
245 label = %worker.label,
246 "no configured project for this worker's key; leaving its row in \
247 dynamic_workers",
248 );
249 unkeyable += 1;
250 continue;
251 };
252 let fields = SessionRecord {
253 org: project.org.clone(),
254 project: project.name.clone(),
255 label: worker.label.clone(),
256 session_id: None,
257 charter: Some(worker.charter.clone()),
258 kick: worker.kick.clone(),
259 resume_kick: worker.resume_kick.clone(),
260 interactive: Some(worker.interactive),
261 seat_state: None,
264 is_git_repo: None,
265 mcp_families: None,
267 cwd: None,
269 };
270 if !update(db, &fields)? {
275 put(db, &fields)?;
276 }
277 dynamic_workers::delete(db, &worker.project_key, &worker.label)?;
278 moved += 1;
279 }
280 if moved > 0 {
281 tracing::info!(
282 target: "forge_workspace::store::sessions",
283 rows = moved,
284 "moved persisted workers onto the sessions table and out of the retired one",
285 );
286 }
287 Ok(SweepOutcome { moved, unkeyable, rows })
288}
289
290pub fn list_for_project(db: &Db, org: &str, project: &str) -> anyhow::Result<Vec<SessionRecord>> {
293 Ok(list_all(db)?.into_iter().filter(|row| row.org == org && row.project == project).collect())
294}
295
296pub struct WorkerRowIndex {
299 pub org: String,
300 pub project: String,
301 pub label: String,
302 pub session_id: Option<String>,
303 pub is_git_repo: Option<bool>,
304 pub cwd: Option<String>,
309}
310
311#[derive(Deserialize)]
323struct RowStart {
324 session_id: Option<String>,
325 is_git_repo: Option<bool>,
326 #[serde(default)]
327 cwd: Option<String>,
328}
329
330pub fn worker_row_index(db: &Db) -> anyhow::Result<Vec<WorkerRowIndex>> {
338 let txn = db.database().begin_read()?;
339 let table = match txn.open_table(SESSIONS) {
340 Ok(t) => t,
341 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
342 Err(e) => return Err(e.into()),
343 };
344 let mut out = Vec::new();
345 for entry in table.iter()? {
346 let (key, value) = entry?;
347 let Ok(start) = serde_json::from_slice::<RowStart>(value.value()) else {
352 continue;
353 };
354 let (org, project, label) = key.value();
355 out.push(WorkerRowIndex {
356 org: org.to_owned(),
357 project: project.to_owned(),
358 label: label.to_owned(),
359 session_id: start.session_id.filter(|id| !id.is_empty()),
361 is_git_repo: start.is_git_repo,
362 cwd: start.cwd,
363 });
364 }
365 Ok(out)
366}
367
368pub fn list_all(db: &Db) -> anyhow::Result<Vec<SessionRecord>> {
370 let txn = db.database().begin_read()?;
371 let table = match txn.open_table(SESSIONS) {
372 Ok(t) => t,
373 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
376 Err(e) => return Err(e.into()),
377 };
378 let mut out = Vec::new();
379 for entry in table.iter()? {
380 let (key, value) = entry?;
381 let (row_org, row_project, row_label) = key.value();
382 match decode(row_org, row_project, row_label, value.value()) {
383 Ok(row) => out.push(row),
384 Err(error) => {
388 tracing::warn!(
389 target: "forge_workspace::store::sessions",
390 org = %row_org,
391 project = %row_project,
392 label = %row_label,
393 %error,
394 "skipping session row that failed to decode",
395 );
396 }
397 }
398 }
399 Ok(out)
400}
401
402pub fn update(db: &Db, fields: &SessionRecord) -> anyhow::Result<bool> {
407 let Some(mut row) = get(db, &fields.org, &fields.project, &fields.label)? else {
408 return Ok(false);
409 };
410 if fields.session_id.is_some() {
413 row.session_id.clone_from(&fields.session_id);
414 }
415 if fields.charter.is_some() {
416 row.charter.clone_from(&fields.charter);
417 }
418 if fields.kick.is_some() {
419 row.kick.clone_from(&fields.kick);
420 }
421 if fields.resume_kick.is_some() {
422 row.resume_kick.clone_from(&fields.resume_kick);
423 }
424 if fields.interactive.is_some() {
425 row.interactive = fields.interactive;
426 }
427 if fields.seat_state.is_some() {
428 row.seat_state = fields.seat_state;
429 }
430 if fields.is_git_repo.is_some() {
431 row.is_git_repo = fields.is_git_repo;
432 }
433 if let Some(families) = &fields.mcp_families {
436 row.mcp_families = if families.is_empty() { None } else { Some(families.clone()) };
437 }
438 put(db, &row)?;
441 Ok(true)
442}
443
444pub fn set_seat_state(
454 db: &Db,
455 org: &str,
456 project: &str,
457 label: &str,
458 state: SeatState,
459 reopen: bool,
460) -> anyhow::Result<bool> {
461 let Some(mut record) = get(db, org, project, label)? else { return Ok(false) };
462 if record.seat_state == Some(state) {
463 return Ok(true);
464 }
465 if !reopen && record.seat_state == Some(SeatState::Closed) {
466 return Ok(true);
467 }
468 record.seat_state = Some(state);
469 put(db, &record)?;
470 Ok(true)
471}
472
473pub fn restore_seat_state(
478 db: &Db,
479 org: &str,
480 project: &str,
481 label: &str,
482 state: Option<SeatState>,
483 expected: SeatState,
484) -> anyhow::Result<bool> {
485 let Some(mut record) = get(db, org, project, label)? else { return Ok(false) };
486 if record.seat_state != Some(expected) || record.seat_state == state {
487 return Ok(false);
488 }
489 record.seat_state = state;
490 put(db, &record)?;
491 Ok(true)
492}
493
494pub fn seat_state(
497 db: &Db,
498 org: &str,
499 project: &str,
500 label: &str,
501) -> anyhow::Result<Option<SeatState>> {
502 Ok(get(db, org, project, label)?.and_then(|row| row.seat_state))
503}
504
505pub fn seed_seat_state(db: &Db) -> anyhow::Result<usize> {
509 let mut seeded = 0;
510 for mut row in list_all(db)? {
511 if row.seat_state.is_some() {
512 continue;
513 }
514 row.seat_state = Some(SeatState::Idle);
515 put(db, &row)?;
516 seeded += 1;
517 }
518 Ok(seeded)
519}
520
521pub fn clear_session_id(db: &Db, org: &str, project: &str, label: &str) -> anyhow::Result<bool> {
523 let Some(mut record) = get(db, org, project, label)? else { return Ok(false) };
524 record.session_id = None;
525 put(db, &record)?;
526 Ok(true)
527}
528
529pub fn delete(db: &Db, org: &str, project: &str, label: &str) -> anyhow::Result<bool> {
533 let txn = db.database().begin_write()?;
534 let existed = {
535 let mut table = txn.open_table(SESSIONS)?;
536 table.remove((org, project, label))?.is_some()
537 };
538 txn.commit()?;
539 Ok(existed)
540}
541
542fn decode(org: &str, project: &str, label: &str, value: &[u8]) -> anyhow::Result<SessionRecord> {
543 let mut record: SessionRecord =
544 serde_json::from_slice(value).context("decode session record")?;
545 org.clone_into(&mut record.org);
548 project.clone_into(&mut record.project);
549 label.clone_into(&mut record.label);
550 if record.session_id.as_deref() == Some("") {
553 record.session_id = None;
554 }
555 Ok(record)
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use tempfile::tempdir;
562
563 fn worker(project: &str, label: &str, interactive: bool) -> dynamic_workers::DynamicWorker {
564 dynamic_workers::DynamicWorker {
565 project_key: project.to_owned(),
566 label: label.to_owned(),
567 charter: format!("charter for {label}"),
568 kick: Some(format!("kick for {label}")),
569 resume_kick: Some(format!("resume kick for {label}")),
570 interactive,
571 }
572 }
573
574 fn project(key: &str, org: &str, name: &str) -> ProjectIdentity {
575 ProjectIdentity { key: key.to_owned(), org: org.to_owned(), name: name.to_owned() }
576 }
577
578 fn record(org: &str, project: &str, label: &str, session_id: Option<&str>) -> SessionRecord {
579 SessionRecord {
580 org: org.to_owned(),
581 project: project.to_owned(),
582 label: label.to_owned(),
583 session_id: session_id.map(str::to_owned),
584 charter: None,
585 kick: None,
586 resume_kick: None,
587 interactive: None,
588 seat_state: None,
589 is_git_repo: None,
590 mcp_families: None,
591 cwd: None,
592 }
593 }
594
595 fn record_fields(
598 org: &str,
599 project: &str,
600 label: &str,
601 session_id: Option<&str>,
602 kick: Option<&str>,
603 ) -> SessionRecord {
604 SessionRecord { kick: kick.map(str::to_owned), ..record(org, project, label, session_id) }
605 }
606
607 #[test]
611 fn the_family_selection_round_trips_and_old_rows_stay_absent() {
612 let dir = tempdir().expect("tempdir");
613 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
614
615 let mut row = record("Personal", "forge", "steward", None);
616 row.mcp_families = Some(vec!["slack".to_owned(), "tasks".to_owned()]);
617 put(&db, &row).expect("write");
618
619 let read = get(&db, "Personal", "forge", "steward").expect("read").expect("row");
620 assert_eq!(
621 read.mcp_families,
622 Some(vec!["slack".to_owned(), "tasks".to_owned()]),
623 "the stored selection reads back whole"
624 );
625
626 let legacy = br#"{"session_id":null}"#;
627 let decoded =
628 decode("Personal", "forge", "old", legacy).expect("a legacy body still decodes");
629 assert_eq!(
630 decoded.mcp_families, None,
631 "an absent field stays absent, never an invented set"
632 );
633 }
634
635 #[test]
639 fn the_worker_cwd_round_trips_and_older_rows_stay_absent() {
640 let dir = tempdir().expect("tempdir");
641 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
642
643 let mut row = record("Personal", "forge", "nazar-hand", None);
644 row.cwd = Some("/Users/someone/Projects/nazar".to_owned());
645 put(&db, &row).expect("write");
646
647 let read = get(&db, "Personal", "forge", "nazar-hand").expect("read").expect("row");
648 assert_eq!(
649 read.cwd,
650 Some("/Users/someone/Projects/nazar".to_owned()),
651 "the stored tree reads back whole"
652 );
653
654 let legacy = br#"{"session_id":null}"#;
655 let decoded =
656 decode("Personal", "forge", "old", legacy).expect("a legacy body still decodes");
657 assert_eq!(decoded.cwd, None, "an absent tree stays absent, never an invented one");
658 }
659
660 #[test]
664 fn the_row_index_carries_the_recorded_cwd() {
665 let dir = tempdir().expect("tempdir");
666 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
667
668 let mut row = record("Personal", "forge", "nazar-hand", Some("id-1"));
669 row.cwd = Some("/Users/someone/Projects/nazar".to_owned());
670 row.is_git_repo = Some(true);
671 put(&db, &row).expect("write");
672
673 let index = worker_row_index(&db).expect("read");
674 let entry = index.iter().find(|entry| entry.label == "nazar-hand").expect("row");
675 assert_eq!(
676 entry.cwd.as_deref(),
677 Some("/Users/someone/Projects/nazar"),
678 "the launchpad composes the row's directory from this"
679 );
680 }
681
682 #[test]
686 fn the_seat_state_round_trips_and_old_rows_stay_absent() {
687 let dir = tempdir().expect("tempdir");
688 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
689
690 let mut row = record("Personal", "forge", "lead", Some("id-1"));
691 row.seat_state = Some(SeatState::InTurn);
692 put(&db, &row).expect("write");
693
694 let read = get(&db, "Personal", "forge", "lead").expect("read").expect("row");
695 assert_eq!(read.seat_state, Some(SeatState::InTurn), "the stored state reads back whole");
696 let body: serde_json::Value =
697 serde_json::from_slice(&raw_for_test(&db, "Personal", "forge", "lead").expect("raw"))
698 .expect("the body is json");
699 assert_eq!(
700 body.get("seat_state").and_then(serde_json::Value::as_str),
701 Some("in_turn"),
702 "the wire spelling is the state's own name: {body}",
703 );
704
705 let legacy = br#"{"session_id":null}"#;
706 let decoded =
707 decode("Personal", "forge", "old", legacy).expect("a legacy body still decodes");
708 assert_eq!(
709 decoded.seat_state, None,
710 "an absent state stays absent until the migration seeds it"
711 );
712 }
713
714 #[test]
718 fn setting_the_seat_state_keeps_the_rows_other_fields() {
719 let dir = tempdir().expect("tempdir");
720 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
721 let mut row = record("Personal", "forge", "steward", Some("id-1"));
722 row.charter = Some("charter".to_owned());
723 row.kick = Some("kick".to_owned());
724 put(&db, &row).expect("write");
725
726 assert!(
727 set_seat_state(&db, "Personal", "forge", "steward", SeatState::Closed, false)
728 .expect("set"),
729 "an existing row takes the state",
730 );
731 let read = get(&db, "Personal", "forge", "steward").expect("read").expect("row");
732 assert_eq!(read.seat_state, Some(SeatState::Closed));
733 assert_eq!(read.session_id.as_deref(), Some("id-1"), "the occupant survives");
734 assert_eq!(read.charter.as_deref(), Some("charter"), "and the charter");
735 assert_eq!(read.kick.as_deref(), Some("kick"), "and the kick");
736
737 assert!(
738 !set_seat_state(&db, "Personal", "forge", "ghost", SeatState::Idle, false)
739 .expect("set ghost"),
740 "a row that is not there is not created",
741 );
742 }
743
744 #[test]
749 fn a_closed_row_moves_only_for_an_explicit_reopen() {
750 let dir = tempdir().expect("tempdir");
751 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
752 let mut row = record("Personal", "forge", "lead", Some("id-1"));
753 row.seat_state = Some(SeatState::Closed);
754 put(&db, &row).expect("write");
755
756 for state in [SeatState::Idle, SeatState::InTurn, SeatState::Closed] {
757 assert!(
758 set_seat_state(&db, "Personal", "forge", "lead", state, false).expect("write"),
759 "the row is there to write",
760 );
761 assert_eq!(
762 get(&db, "Personal", "forge", "lead").expect("read").expect("row").seat_state,
763 Some(SeatState::Closed),
764 "a non-reopening write leaves {state:?} off a closed row",
765 );
766 }
767
768 assert!(
769 set_seat_state(&db, "Personal", "forge", "lead", SeatState::Idle, true)
770 .expect("reopen"),
771 "the row is there to reopen",
772 );
773 assert_eq!(
774 get(&db, "Personal", "forge", "lead").expect("read").expect("row").seat_state,
775 Some(SeatState::Idle),
776 "an opening door reopens it",
777 );
778 }
779
780 #[test]
785 fn the_seat_state_migration_seeds_old_rows_once() {
786 let dir = tempdir().expect("tempdir");
787 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
788 put_raw_for_test(&db, "Personal", "forge", "lead", br#"{"session_id":"id-1"}"#)
789 .expect("plant a row of the shape the previous build wrote");
790 let mut explicit = record("Personal", "forge", "steward", Some("id-2"));
791 explicit.seat_state = Some(SeatState::InTurn);
792 put(&db, &explicit).expect("write");
793
794 assert_eq!(
795 seed_seat_state(&db).expect("migrate"),
796 1,
797 "only the row without a state is seeded",
798 );
799 assert_eq!(
800 get(&db, "Personal", "forge", "lead").expect("read").expect("row").seat_state,
801 Some(SeatState::Idle),
802 "an old row reads as an open seat",
803 );
804 assert_eq!(
805 get(&db, "Personal", "forge", "steward").expect("read").expect("row").seat_state,
806 Some(SeatState::InTurn),
807 "an explicit state is left alone",
808 );
809 assert_eq!(seed_seat_state(&db).expect("second boot"), 0, "the migration is idempotent");
810 }
811
812 #[test]
817 fn the_first_open_copies_the_persisted_workers_over_once() {
818 let dir = tempdir().expect("tempdir");
819 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
820 dynamic_workers::insert_for_test(&db, &worker("proj-a", "steward", true))
821 .expect("seed steward");
822 dynamic_workers::insert_for_test(&db, &worker("proj-a", "quartermaster", false))
823 .expect("seed quartermaster");
824
825 let projects = [project("proj-a", "Personal", "forge")];
826 let outcome = migrate_from_dynamic_workers(&db, &projects).expect("migrate");
827 assert_eq!(outcome.moved, 2, "both persisted workers cross over");
828 assert!(outcome.drained(), "and nothing is left in the retired table");
829
830 let steward = get(&db, "Personal", "forge", "steward").expect("read").expect("steward row");
831 assert_eq!(
832 steward,
833 SessionRecord {
834 org: "Personal".to_owned(),
835 project: "forge".to_owned(),
836 label: "steward".to_owned(),
837 session_id: None,
838 charter: Some("charter for steward".to_owned()),
839 kick: Some("kick for steward".to_owned()),
840 resume_kick: Some("resume kick for steward".to_owned()),
841 interactive: Some(true),
842 seat_state: None,
843 is_git_repo: None,
846 mcp_families: None,
849 cwd: None,
851 },
852 "the row carries the worker's fields and no id",
853 );
854
855 let second = migrate_from_dynamic_workers(&db, &projects).expect("second boot");
856 assert_eq!(second.moved, 0, "a second boot copies nothing");
857 assert!(
858 second.drained(),
859 "and it is a clean drain: the first sweep took every row with it, so the table is \
860 empty and the caller may drop it",
861 );
862 assert!(
863 get(&db, "Personal", "forge", "quartermaster").expect("read").is_some(),
864 "and leaves the rows it copied where they were",
865 );
866 }
867
868 #[test]
873 fn a_worker_whose_project_is_no_longer_configured_is_left_behind() {
874 let dir = tempdir().expect("tempdir");
875 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
876 dynamic_workers::insert_for_test(&db, &worker("proj-gone", "steward", false))
877 .expect("seed");
878
879 let outcome = migrate_from_dynamic_workers(&db, &[project("proj-a", "Personal", "forge")])
880 .expect("migrate");
881 assert_eq!(outcome.moved, 0, "a worker with no configured project is not copied");
882 assert_eq!(outcome.unkeyable, 1, "and it is counted as left behind rather than dropped");
883 assert!(
884 !outcome.drained(),
885 "so the retired table is not safe to drop: its row exists nowhere else",
886 );
887 assert!(get(&db, "Personal", "forge", "steward").expect("read").is_none());
888 assert_eq!(
889 dynamic_workers::list_all(&db).expect("list workers").len(),
890 1,
891 "its row is still where it was",
892 );
893 }
894
895 #[test]
899 fn a_row_without_an_id_and_one_with_it_both_round_trip() {
900 let dir = tempdir().expect("tempdir");
901 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
902
903 put(&db, &record("Personal", "forge", "lead", Some("id-1"))).expect("put lead");
904 put(&db, &record("Personal", "forge", "steward", None)).expect("put worker");
905
906 assert_eq!(
907 get(&db, "Personal", "forge", "lead").expect("get").and_then(|row| row.session_id),
908 Some("id-1".to_owned()),
909 );
910 assert_eq!(
911 get(&db, "Personal", "forge", "steward").expect("get").map(|row| row.session_id),
912 Some(None)
913 );
914 assert_eq!(get(&db, "Personal", "forge", "ghost").expect("get"), None);
915 }
916
917 #[test]
921 fn list_update_and_delete_are_scoped_to_the_row() {
922 let dir = tempdir().expect("tempdir");
923 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
924 let mut steward = record("Personal", "forge", "steward", Some("id-1"));
925 steward.charter = Some("charter".to_owned());
926 put(&db, &steward).expect("put steward");
927 put(&db, &record("Personal", "forge", "lead", Some("id-2"))).expect("put lead");
928 put(&db, &record("Personal", "other", "steward", None)).expect("put elsewhere");
929
930 assert_eq!(
931 list_for_project(&db, "Personal", "forge").expect("list").len(),
932 2,
933 "a project's rows are scoped by org and project",
934 );
935 assert_eq!(list_all(&db).expect("list all").len(), 3, "and the whole table lists");
936
937 assert!(
938 update(&db, &record_fields("Personal", "forge", "steward", None, Some("new kick")))
939 .expect("update"),
940 "an existing row updates",
941 );
942 let updated = get(&db, "Personal", "forge", "steward").expect("get").expect("row");
943 assert_eq!(updated.kick.as_deref(), Some("new kick"), "the supplied field lands");
944 assert_eq!(
945 updated.session_id.as_deref(),
946 Some("id-1"),
947 "and an absent field keeps its stored value",
948 );
949 assert_eq!(updated.charter.as_deref(), Some("charter"), "including the charter");
950
951 assert!(
952 !update(&db, &record_fields("Personal", "forge", "ghost", None, Some("k")))
953 .expect("update ghost"),
954 "an absent row is not created",
955 );
956 assert!(get(&db, "Personal", "forge", "ghost").expect("get").is_none());
957
958 assert!(delete(&db, "Personal", "forge", "steward").expect("delete"));
959 assert!(get(&db, "Personal", "forge", "steward").expect("get").is_none());
960 assert!(
961 !delete(&db, "Personal", "forge", "steward").expect("delete again"),
962 "a second delete reports nothing removed",
963 );
964 assert!(
965 get(&db, "Personal", "other", "steward").expect("get").is_some(),
966 "deleting one project's row leaves another's alone",
967 );
968 }
969
970 #[test]
973 fn list_all_skips_an_undecodable_row() {
974 let dir = tempdir().expect("tempdir");
975 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
976 put(&db, &record("Personal", "forge", "lead", Some("id-1"))).expect("put");
977 put_raw_for_test(&db, "Personal", "forge", "corrupt", b"not a record")
978 .expect("plant the undecodable row");
979
980 let rows = list_all(&db).expect("list tolerates the corrupt blob");
981 assert_eq!(rows.len(), 1, "the good row survives a corrupt sibling");
982 assert_eq!(rows[0].label, "lead");
983 }
984
985 #[test]
990 fn a_table_holding_an_undecodable_entry_is_never_reported_as_drained() {
991 let dir = tempdir().expect("tempdir");
992 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
993 dynamic_workers::insert_for_test(&db, &worker("proj-a", "steward", false))
994 .expect("seed steward");
995 dynamic_workers::put_raw_for_test(&db, "proj-a", "unreadable", b"not a worker")
996 .expect("plant a retired row that will not decode");
997
998 let projects = [project("proj-a", "Personal", "forge")];
999 let outcome = migrate_from_dynamic_workers(&db, &projects).expect("migrate");
1000 assert_eq!(outcome.moved, 1, "the readable row crosses over");
1001 assert_eq!(outcome.rows, 2, "and the count is what the table holds, not what decoded");
1002 assert!(
1003 !outcome.drained(),
1004 "so the caller must not drop a table still holding an entry nothing else has",
1005 );
1006 }
1007
1008 #[test]
1017 fn worker_row_index_reports_what_the_wave_reads_without_the_row_body() {
1018 let dir = tempdir().expect("tempdir");
1019 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1020 put(&db, &record("Personal", "forge", "lead", Some("id-1"))).expect("put");
1021 let mut listed = record("Personal", "forge", "worker", Some("id-2"));
1022 listed.is_git_repo = Some(true);
1023 put(&db, &listed).expect("put");
1024 put(&db, &record("Personal", "forge", "blank", Some(""))).expect("put");
1025 put_raw_for_test(&db, "Personal", "forge", "corrupt", b"not a record")
1026 .expect("plant the undecodable row");
1027
1028 let rows = worker_row_index(&db).expect("the index reads the key and two fields");
1029 let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect();
1030 assert_eq!(
1031 labels,
1032 vec!["blank", "lead", "worker"],
1033 "an undecodable body is skipped, exactly as `list_all` skips it - offering it \
1034 would draw a worker the boot wave never starts",
1035 );
1036 let worker = rows.iter().find(|row| row.label == "worker").expect("the worker row");
1037 assert_eq!(worker.session_id.as_deref(), Some("id-2"), "the stored id is projected");
1038 assert_eq!(
1039 worker.is_git_repo,
1040 Some(true),
1041 "and so is the gitness the launchpad decides off",
1042 );
1043 let blank = rows.iter().find(|row| row.label == "blank").expect("the blank-id row");
1044 assert_eq!(
1045 blank.session_id, None,
1046 "a blank id is absence, not an id - a launchpad reading it as real would call \
1047 the row a resume",
1048 );
1049 }
1050
1051 #[test]
1056 fn update_merges_the_gitness_flag_and_leaves_an_absent_one_alone() {
1057 let dir = tempdir().expect("tempdir");
1058 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1059 put(&db, &record("Personal", "forge", "worker", Some("id"))).expect("put");
1060
1061 let mut supplied = record("Personal", "forge", "worker", None);
1062 supplied.is_git_repo = Some(true);
1063 assert!(update(&db, &supplied).expect("update"), "the row exists to merge onto");
1064 assert_eq!(
1065 get(&db, "Personal", "forge", "worker").expect("get").and_then(|row| row.is_git_repo),
1066 Some(true),
1067 "a supplied gitness merges onto the row",
1068 );
1069
1070 let blank = record("Personal", "forge", "worker", None);
1071 assert!(update(&db, &blank).expect("update"));
1072 assert_eq!(
1073 get(&db, "Personal", "forge", "worker").expect("get").and_then(|row| row.is_git_repo),
1074 Some(true),
1075 "and an absent one leaves the stored value alone",
1076 );
1077 }
1078
1079 #[test]
1082 fn an_empty_session_id_is_absent() {
1083 let dir = tempdir().expect("tempdir");
1084 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1085 put(&db, &record("Personal", "forge", "lead", Some(""))).expect("put");
1086 assert_eq!(
1087 get(&db, "Personal", "forge", "lead").expect("get").and_then(|row| row.session_id),
1088 None,
1089 );
1090 }
1091
1092 #[test]
1096 fn the_identity_is_the_key_and_is_not_repeated_in_the_body() {
1097 let dir = tempdir().expect("tempdir");
1098 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1099 let mut row = record("Personal", "forge", "steward", Some("id-1"));
1100 row.charter = Some("charter".to_owned());
1101 put(&db, &row).expect("put");
1102
1103 let raw = raw_for_test(&db, "Personal", "forge", "steward").expect("read the stored bytes");
1104 let body: serde_json::Value = serde_json::from_slice(&raw).expect("the body is json");
1105 for field in ["org", "project", "label"] {
1106 assert!(
1107 body.get(field).is_none(),
1108 "the key carries {field}, so the body must not restate it: {body}",
1109 );
1110 }
1111 assert_eq!(
1112 body.get("charter").and_then(serde_json::Value::as_str),
1113 Some("charter"),
1114 "the fields that belong to the body are still written there: {body}",
1115 );
1116 }
1117
1118 #[test]
1123 fn a_legacy_body_reads_back_under_its_key() {
1124 let dir = tempdir().expect("tempdir");
1125 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
1126 put_raw_for_test(
1127 &db,
1128 "Personal",
1129 "forge",
1130 "steward",
1131 br#"{"org":"Elsewhere","project":"elsewhere","label":"ghost","session_id":"id-1","charter":"c"}"#,
1132 )
1133 .expect("plant a row of the shape the previous build wrote");
1134
1135 let row = get(&db, "Personal", "forge", "steward")
1136 .expect("a legacy body decodes")
1137 .expect("the row is there");
1138 assert_eq!(row.org, "Personal", "the key names the row's org, not its stale body");
1139 assert_eq!(row.project, "forge", "the key names the row's project, not its stale body");
1140 assert_eq!(row.label, "steward", "and its label");
1141 assert_eq!(row.session_id.as_deref(), Some("id-1"), "the body's own fields still decode");
1142 assert_eq!(row.charter.as_deref(), Some("c"), "including the charter");
1143
1144 assert_eq!(
1145 list_for_project(&db, "Personal", "forge").expect("list").len(),
1146 1,
1147 "the row lists under the project its key names",
1148 );
1149 assert!(
1150 list_for_project(&db, "Personal", "elsewhere").expect("list").is_empty(),
1151 "and not under the one its stale body names",
1152 );
1153 let index = worker_row_index(&db).expect("the key-only reader reads it");
1154 assert_eq!(
1155 index
1156 .iter()
1157 .map(|row| (row.org.as_str(), row.project.as_str(), row.label.as_str()))
1158 .collect::<Vec<_>>(),
1159 vec![("Personal", "forge", "steward")],
1160 "the launcher's reader takes the identity from the key too",
1161 );
1162 }
1163}