use std::collections::BTreeMap;
use kernel::layout::CityLayout;
use kernel::{EventDraft, EventKind, Payload, RunId, TimeMs};
use super::*;
use crate::JsonlLedger;
fn draft(where_at: Option<&str>, kind: EventKind) -> EventDraft {
EventDraft {
run: RunId::CITY,
t: TimeMs::new(1),
who: "city".to_owned(),
addr: where_at.map(|raw| Address::parse(raw).unwrap()),
kind,
data: Payload::empty(),
ig: false,
}
}
fn city(root: &Path) -> PathBuf {
CityLayout::new(root).ledger()
}
fn slice_of(root: &Path, room: &str) -> PathBuf {
session_slice(&CityLayout::new(root), &Address::parse(room).unwrap())
}
fn slices(root: &Path) -> BTreeMap<String, Vec<u8>> {
let sessions = slice_of(root, "webapp/room")
.parent()
.unwrap()
.to_path_buf();
let mut out = BTreeMap::new();
if !sessions.exists() {
return out;
}
let mut stack = vec![sessions.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
continue;
}
let rel = path
.strip_prefix(&sessions)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
out.insert(rel, std::fs::read(&path).unwrap());
}
}
out
}
fn two_rooms_and_one_city_record(ledger_dir: &Path) {
let (mut ledger, _) = JsonlLedger::open(ledger_dir, TimeMs::new(0)).unwrap();
ledger
.append_all(vec![
draft(Some("webapp/api-rewrite"), EventKind::RunStarted),
draft(Some("webapp/backend/db-migration"), EventKind::ToolCalled),
draft(None, EventKind::CityInitialized),
])
.unwrap();
ledger
.append_all(vec![draft(
Some("webapp/api-rewrite"),
EventKind::RunFrozen,
)])
.unwrap();
}
#[test]
fn deleting_the_sessions_directory_and_replaying_the_ledger_restores_the_bytes() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
two_rooms_and_one_city_record(&ledger_dir);
let before = slices(tmp.path());
let mut names: Vec<&String> = before.keys().collect();
names.sort();
assert_eq!(
names,
vec!["api-rewrite.jsonl", "backend/db-migration.jsonl"],
"one file per room, and none for a city record"
);
std::fs::remove_dir_all(slice_of(tmp.path(), "webapp/room").parent().unwrap()).unwrap();
let mut rebuilt = Sessions::for_ledger(&ledger_dir).unwrap();
for line in crate::read_raw_lines_at(&ledger_dir).unwrap() {
rebuilt
.absorb(&EventRecord::parse_line(&line).unwrap())
.unwrap();
}
assert_eq!(
slices(tmp.path()),
before,
"a deleted projection is laid down again byte for byte"
);
}
#[test]
fn a_slice_of_another_shape_is_laid_down_again() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
two_rooms_and_one_city_record(&ledger_dir);
let (mut ledger, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
ledger
.append_all(vec![draft(
Some("webapp/api-rewrite"),
EventKind::ModelCalled,
)])
.unwrap();
drop(ledger);
let expected = crate::read_raw_lines_at(&ledger_dir)
.unwrap()
.into_iter()
.filter(|line| {
EventRecord::parse_line(line).is_ok_and(|record| {
record
.addr()
.is_some_and(|addr| addr.as_str() == "webapp/api-rewrite")
})
})
.count();
let path = slice_of(tmp.path(), "webapp/api-rewrite");
let text = std::fs::read_to_string(&path).unwrap();
let body: Vec<&str> = text.lines().skip(1).collect();
std::fs::write(&path, format!("older-format 0\n{}\n", body.join("\n"))).unwrap();
let mut rebuilt = Sessions::for_ledger(&ledger_dir).unwrap();
let line = crate::read_raw_lines_at(&ledger_dir)
.unwrap()
.pop()
.unwrap();
rebuilt
.absorb(&EventRecord::parse_line(&line).unwrap())
.unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert!(
after.starts_with(&format!("{SLICE_MAGIC} 0\n")),
"the rebuilt slice carries this build's header"
);
assert_eq!(
after.lines().count(),
expected.saturating_add(1),
"every record the Ledger holds for the address is back"
);
}
#[test]
fn a_slice_that_missed_the_tail_catches_up_before_it_is_appended_to() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
let (mut ledger, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
ledger
.append_all(vec![draft(
Some("webapp/api-rewrite"),
EventKind::RunStarted,
)])
.unwrap();
ledger
.append_all(vec![draft(
Some("webapp/api-rewrite"),
EventKind::ToolCalled,
)])
.unwrap();
drop(ledger);
let path = slice_of(tmp.path(), "webapp/api-rewrite");
let text = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = text.lines().collect();
std::fs::write(&path, format!("{}\n", lines[..lines.len() - 1].join("\n"))).unwrap();
let (mut reopened, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
reopened
.append_all(vec![draft(
Some("webapp/api-rewrite"),
EventKind::RunFrozen,
)])
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(
text.lines().count(),
4,
"the missed line and the new one are both there"
);
}
#[test]
fn a_refused_projection_never_fails_the_ledger() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
let slice = slice_of(tmp.path(), "webapp/room");
let blocked = slice.parent().unwrap();
std::fs::create_dir_all(blocked.parent().unwrap()).unwrap();
std::fs::write(blocked, b"a file where the directory belongs").unwrap();
let (mut ledger, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
let written = ledger.append_all(vec![draft(Some("webapp/room"), EventKind::RunStarted)]);
assert!(written.is_ok(), "the Ledger's contract holds anyway");
let line = crate::read_raw_lines_at(&ledger_dir).unwrap().remove(0);
let record = EventRecord::parse_line(&line).unwrap();
let mut sessions = Sessions::for_ledger(&ledger_dir).unwrap();
let refused = sessions.absorb(&record);
assert!(
refused.is_err(),
"a projection that could not be written says so"
);
assert!(blocked.is_file(), "the blocked path is left as it was");
}
#[test]
fn the_city_s_own_record_gets_no_slice() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
let name = tmp
.path()
.file_name()
.and_then(|name| name.to_str())
.expect("a temporary directory has a name")
.to_owned();
let (mut ledger, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
ledger
.append_all(vec![
draft(Some(&name), EventKind::CityInitialized),
draft(Some("webapp/room"), EventKind::RunStarted),
])
.unwrap();
drop(ledger);
let filed = slices(tmp.path());
assert_eq!(
filed.keys().collect::<Vec<_>>(),
vec![&"room.jsonl".to_owned()],
"the room's slice is the only one laid down"
);
assert!(
!tmp.path().join(&name).exists(),
"no directory named after the city stands inside it"
);
}
#[test]
fn a_session_slice_lives_under_the_sessions_of_its_first_segment() {
let root = Path::new("/city");
let sessions = root.join("webapp").join(RESERVED_PREFIX).join(SESSIONS_DIR);
for (room, file) in [
("webapp/api-rewrite", sessions.join("api-rewrite.jsonl")),
(
"webapp/backend/db-migration",
sessions.join("backend").join("db-migration.jsonl"),
),
("webapp", sessions.join("webapp.jsonl")),
] {
assert_eq!(slice_of(root, room), file, "{room}");
}
}
#[test]
fn a_session_slice_is_out_of_every_write_domain() {
let spelled = slice_of(Path::new("/city"), "lab/room1")
.to_string_lossy()
.replace('\\', "/");
let as_address = spelled.trim_start_matches("/city/").to_owned();
assert!(
Address::parse(&as_address).unwrap().is_reserved(),
"{spelled} is not in a reserved subtree"
);
}
#[test]
fn a_checkpoint_can_tell_a_session_slice_from_a_promise() {
for spelled in [
"lab/.sprawling/sessions/room1.jsonl",
"lab/room1/.sprawling/sessions/room1.jsonl",
"lab/.SPRAWLING/sessions/x",
] {
assert!(
is_session_projection(Path::new(spelled)),
"{spelled} is a session slice"
);
}
for spelled in [
"lab/.sprawling/CONFIG.toml",
"lab/.sprawling/skills/one/SKILL.md",
"lab/room1/JOB.md",
"sessions/room1.jsonl",
"lab/room1/sessions/notes.md",
"lab/.sprawling/library/sessions.md",
] {
assert!(
!is_session_projection(Path::new(spelled)),
"{spelled} is a person's or a promise, not a slice"
);
}
}
#[test]
fn a_new_room_is_filed_without_reading_the_ledger() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
let (mut ledger, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
ledger
.append_all(vec![
draft(Some("webapp/api-rewrite"), EventKind::RunStarted),
draft(Some("webapp/backend/db-migration"), EventKind::RunStarted),
])
.unwrap();
drop(ledger);
let records: Vec<EventRecord> = crate::read_raw_lines_at(&ledger_dir)
.unwrap()
.iter()
.map(|line| EventRecord::parse_line(line).unwrap())
.collect();
std::fs::remove_dir_all(slice_of(tmp.path(), "webapp/room").parent().unwrap()).unwrap();
let mut sessions = Sessions::for_ledger(&ledger_dir).unwrap();
sessions.absorb(&records[0]).unwrap();
std::fs::remove_dir_all(&ledger_dir).unwrap();
let filed = sessions.absorb(&records[1]);
assert!(
filed.is_ok(),
"a new room read the Ledger to be filed: {filed:?}"
);
let slice = std::fs::read(slice_of(tmp.path(), "webapp/backend/db-migration")).unwrap();
let mut expected = format!("{SLICE_MAGIC} {}\n", records[1].seq().value()).into_bytes();
expected.extend_from_slice(&records[1].canonical_line().unwrap());
expected.push(b'\n');
assert_eq!(slice, expected);
}
#[test]
fn a_ledger_that_handed_off_its_slices_files_nothing_itself() {
let tmp = tempfile::tempdir().unwrap();
let ledger_dir = city(tmp.path());
let (mut ledger, _) = JsonlLedger::open(&ledger_dir, TimeMs::new(0)).unwrap();
let handed = ledger.hand_off_session_slices();
let handed_twice = ledger.hand_off_session_slices();
ledger
.append_all(vec![draft(
Some("webapp/api-rewrite"),
EventKind::RunStarted,
)])
.unwrap();
let filed_by_the_writer = slices(tmp.path()).len();
if let Some(mut slices_kept) = handed {
for line in ledger.read_raw_lines().unwrap() {
slices_kept
.absorb(&EventRecord::parse_line(&line).unwrap())
.unwrap();
}
}
assert_eq!(
(
handed_twice.is_none(),
filed_by_the_writer,
slices(tmp.path()).into_keys().collect::<Vec<_>>()
),
(true, 0, vec!["api-rewrite.jsonl".to_owned()])
);
}