authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 04:01:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
logcffb5ce4bca95f8c242b5513d981a708206cee41
treecb09fcef5f6794e742e6945aec25c7e37da23252
parent7d2feb6f84ba766193b4041b9d6ee375a354e98e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: open sections through a replica-backed session for the app

The application edits through notebook::session. Section::open binds a section file to a replica under a cache directory named by the document identity and publishes in the background through a file remote (read_file, commit_file and the new confirm_file_snapshot); save(space, before, after, author) reports Queued, Unchanged or Stale, the last when the stored page no longer matches the model the edit started from. The recovery contract is queue(), conflicts(), remote_page(space), review(id, after) and export_recovery(path). The canvas editor gains CanvasEditor::page(), rebuilding the stored model from its state so an unedited import round-trips exactly and an edited one feeds the page writer; import retains only what it used to overwrite. snowbound opens a page with --section FILE TITLE [--cache DIR], saves every change through the session, shows saving, saved, conflict or offline in the window title, reloads a page changed underneath the editor, and resolves a conflict with ⌘⇧K (keep mine) or ⌘⇧T (take theirs). tools/session_acceptance.py is the executable acceptance scenario: the session_client example edits pages on the mounted Samba share across two launches and a OneNote clone reopens the result; the first run passed on four pages (evidence/m4/session-acceptance). Assisted-by: claude-fable-5.1

12 files changed, 1036 insertions(+), 53 deletions(-)

Cargo.lock+1
......@@ -2613,6 +2613,7 @@ dependencies = [
26132613 "accesskit_winit",
26142614 "arboard",
26152615 "canvas",
2616 "notebook",
26162617 "objc2 0.5.2",
26172618 "objc2-app-kit 0.2.2",
26182619 "objc2-foundation 0.2.2",
crates/canvas/src/editor.rs+263-6
......@@ -9,7 +9,7 @@ use crate::{
99};
1010use onestore::ExGuid;
1111use onestore::page::text::{EditError, Paragraph};
12use onestore::page::{Definition, Outline, PageParagraph};
12use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title};
1313use parley::{
1414 Affinity, BoundingBox,
1515 editing::{Cursor, Selection as ParagraphSelection},
......@@ -102,6 +102,7 @@ pub struct CanvasEditor {
102102 date: Option<PageDate>,
103103 outlines: Vec<TextOutline>,
104104 definitions: BTreeMap<ExGuid, Definition>,
105 header: PageHeader,
105106 active: Focus,
106107 undo: Vec<History>,
107108 redo: Vec<History>,
......@@ -109,6 +110,15 @@ pub struct CanvasEditor {
109110 preferred_x: Option<f32>,
110111}
111112
113/// Imported page state the editable content does not carry.
114#[derive(Default)]
115struct PageHeader {
116 title: String,
117 created: Option<u64>,
118 margin_origin: [f32; 2],
119 areas: Vec<page::TitleArea>,
120}
121
112122enum Focus {
113123 Outline(usize),
114124 Draft {
......@@ -643,6 +653,7 @@ impl CanvasEditor {
643653 definitions: BTreeMap::new(),
644654 objects: Vec::new(),
645655 date: None,
656 header: PageHeader::default(),
646657 active,
647658 undo: Vec::new(),
648659 redo: Vec::new(),
......@@ -651,11 +662,13 @@ impl CanvasEditor {
651662 })
652663 }
653664
654 pub fn from_page(
655 mut page: onestore::page::Page,
656 engine: &mut TextEngine,
657 ) -> Result<Self, EditorError> {
658 let (objects, mut outlines, date) = page::build(&mut page, engine, true)?;
665 pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> {
666 let page::Import {
667 objects,
668 mut outlines,
669 date,
670 areas,
671 } = page::build(&mut page, engine, true)?;
659672 let needs_caret = outlines.is_empty();
660673 if needs_caret {
661674 let x = objects
......@@ -680,6 +693,12 @@ impl CanvasEditor {
680693 };
681694 }
682695 editor.objects = objects;
696 editor.header = PageHeader {
697 title: page.title,
698 created: page.created,
699 margin_origin: page.margin_origin,
700 areas,
701 };
683702 let mut ids = BTreeSet::new();
684703 if !editor.object_layouts().all(|(id, _)| ids.insert(id)) {
685704 return Err(EditError::InvalidStructure.into());
......@@ -687,6 +706,74 @@ impl CanvasEditor {
687706 Ok(editor)
688707 }
689708
709 /// Rebuilds the stored page, restoring the title areas and read-only objects import split up.
710 pub fn page(&self) -> Result<Page, EditorError> {
711 let mut objects: Vec<PageObject> = Vec::new();
712 for content in &self.objects {
713 let mut outline = match content {
714 page::Content::Editable(id) => {
715 match self.outlines.iter().find(|outline| outline.id == *id) {
716 Some(outline) => outline.snapshot(),
717 None => continue,
718 }
719 }
720 page::Content::Outline { source, .. } => source.clone(),
721 page::Content::Date { .. } => self
722 .date
723 .as_ref()
724 .ok_or(EditError::InvalidStructure)?
725 .source()
726 .clone(),
727 page::Content::Image(image) => {
728 objects.push(PageObject::Image(image.clone()));
729 continue;
730 }
731 page::Content::ReadOnly(object) => {
732 objects.push(object.source.clone());
733 continue;
734 }
735 };
736 let area = self
737 .header
738 .areas
739 .iter()
740 .find_map(|area| Some((area, *area.origins.get(&outline.id)?)));
741 let Some((area, origin)) = area else {
742 objects.push(PageObject::Outline(outline));
743 continue;
744 };
745 [outline.layout.x, outline.layout.y] = origin;
746 match objects.last_mut() {
747 Some(PageObject::Title(title)) if title.id == area.id => {
748 title.outlines.push(outline)
749 }
750 _ => objects.push(PageObject::Title(Title {
751 id: area.id,
752 date: area.date,
753 layout: area.layout.clone(),
754 outlines: vec![outline],
755 })),
756 }
757 }
758 objects.extend(
759 self.outlines
760 .iter()
761 .filter(|outline| !self.has_page_outline(outline.id))
762 .map(|outline| PageObject::Outline(outline.snapshot())),
763 );
764 Ok(Page {
765 title: self.header.title.clone(),
766 created: self
767 .date
768 .as_ref()
769 .map(PageDate::timestamp)
770 .or(self.header.created),
771 margin_origin: self.header.margin_origin,
772 objects,
773 definitions: self.definitions.clone(),
774 })
775 }
776
690777 /// Whether this outline occupies a slot in the imported page's paint order.
691778 pub fn has_page_outline(&self, id: ExGuid) -> bool {
692779 self.objects
......@@ -896,6 +983,7 @@ impl CanvasEditor {
896983 definitions,
897984 objects: Vec::new(),
898985 date,
986 header: PageHeader::default(),
899987 active: Focus::Outline(0),
900988 undo: Vec::new(),
901989 redo: Vec::new(),
......@@ -7108,4 +7196,173 @@ mod tests {
71087196 }
71097197 assert!(!editor.redo(&mut engine).unwrap());
71107198 }
7199
7200 const CORPUS: [&[u8]; 3] = [
7201 include_bytes!("../../../corpus/canvas/baseline-anchors.one"),
7202 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"),
7203 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"),
7204 ];
7205
7206 fn corpus_pages(section: &[u8]) -> Vec<(ExGuid, Page)> {
7207 use onestore::{RevisionIndex, Store, document::Document};
7208 let store = Store::parse(section).unwrap();
7209 let index = RevisionIndex::parse(&store).unwrap();
7210 let document = Document::parse(&index).unwrap();
7211 let mut spaces = document
7212 .pages()
7213 .unwrap()
7214 .into_iter()
7215 .map(|(space, _)| space)
7216 .collect::<Vec<_>>();
7217 spaces.dedup();
7218 spaces
7219 .into_iter()
7220 .map(|space| (space, Page::from_space(&document, space).unwrap()))
7221 .collect()
7222 }
7223
7224 fn corpus_page(section: &[u8], title: &str) -> (ExGuid, Page) {
7225 corpus_pages(section)
7226 .into_iter()
7227 .find(|(_, page)| page.title == title)
7228 .unwrap()
7229 }
7230
7231 fn body_text(page: &Page, id: ExGuid) -> Option<String> {
7232 page.objects.iter().find_map(|object| match object {
7233 PageObject::Outline(outline) if outline.id == id => Some(
7234 outline
7235 .paragraphs
7236 .iter()
7237 .map(|paragraph| paragraph.text().unwrap().text.text())
7238 .collect(),
7239 ),
7240 _ => None,
7241 })
7242 }
7243
7244 #[test]
7245 fn an_unedited_page_rebuilds_into_the_model_it_was_imported_from() {
7246 let mut pages = 0;
7247 for section in CORPUS {
7248 for (_, page) in corpus_pages(section) {
7249 let mut engine = TextEngine::default();
7250 let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
7251 assert_eq!(editor.page().unwrap(), page);
7252 pages += 1;
7253 }
7254 }
7255 assert_eq!(pages, 30);
7256 }
7257
7258 #[test]
7259 fn typing_moving_resizing_adding_and_deleting_reach_the_rebuilt_page() {
7260 let mut engine = TextEngine::default();
7261 let (_, source) = corpus_page(CORPUS[0], "Baseline anchors");
7262 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();
7263 let removed = editor
7264 .outlines
7265 .iter()
7266 .find(|outline| {
7267 !outline.title
7268 && outline.document.nodes().len() == 1
7269 && outline.document.validate_flat().is_ok()
7270 })
7271 .unwrap()
7272 .id;
7273 let bodies = editor
7274 .outlines
7275 .iter()
7276 .filter(|outline| !outline.title && outline.id != removed)
7277 .map(|outline| outline.id)
7278 .collect::<Vec<_>>();
7279 editor.focus_outline(bodies[0]).unwrap();
7280 editor.insert(&mut engine, "typed").unwrap();
7281 editor.move_outline(bodies[1], [123.0, 456.0]).unwrap();
7282 editor.focus_outline(bodies[2]).unwrap();
7283 editor.resize(&mut engine, 200.0).unwrap();
7284 editor.focus_outline(removed).unwrap();
7285 editor.select_all().unwrap();
7286 assert!(editor.delete(&mut engine, false).unwrap());
7287 let added = editor
7288 .create_outline(&mut engine, [24.0, 600.0], 300.0)
7289 .unwrap();
7290 editor.insert(&mut engine, "added").unwrap();
7291
7292 let page = editor.page().unwrap();
7293 assert_eq!(page.title, source.title);
7294 assert_eq!(page.created, source.created);
7295 assert_eq!(page.margin_origin, source.margin_origin);
7296 assert!(body_text(&page, bodies[0]).unwrap().starts_with("typed"));
7297 let moved = page
7298 .objects
7299 .iter()
7300 .find_map(|object| match object {
7301 PageObject::Outline(outline) if outline.id == bodies[1] => Some(&outline.layout),
7302 _ => None,
7303 })
7304 .unwrap();
7305 assert_eq!([moved.x, moved.y], [Some(123.0), Some(456.0)]);
7306 let resized = page
7307 .objects
7308 .iter()
7309 .find_map(|object| match object {
7310 PageObject::Outline(outline) if outline.id == bodies[2] => Some(&outline.layout),
7311 _ => None,
7312 })
7313 .unwrap();
7314 assert_eq!(resized.max_width, Some(200.0));
7315 assert_eq!(resized.width_set_by_user, Some(true));
7316 assert!(body_text(&page, removed).is_none());
7317 assert_eq!(body_text(&page, added).as_deref(), Some("added"));
7318 assert_eq!(page.objects.last().unwrap().id(), added);
7319 assert_eq!(
7320 page.objects.iter().map(PageObject::id).collect::<Vec<_>>(),
7321 source
7322 .objects
7323 .iter()
7324 .map(PageObject::id)
7325 .filter(|id| *id != removed)
7326 .chain([added])
7327 .collect::<Vec<_>>()
7328 );
7329 assert_eq!(
7330 page.objects
7331 .iter()
7332 .find(|object| matches!(object, PageObject::Title(_))),
7333 source
7334 .objects
7335 .iter()
7336 .find(|object| matches!(object, PageObject::Title(_)))
7337 );
7338
7339 let mut engine = TextEngine::default();
7340 let reimported = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
7341 assert_eq!(reimported.page().unwrap(), page);
7342 }
7343
7344 #[test]
7345 fn an_edited_page_writes_back_through_the_page_writer() {
7346 let mut engine = TextEngine::default();
7347 let (space, source) = corpus_page(CORPUS[2], "Split middle");
7348 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();
7349 let body = editor
7350 .outlines
7351 .iter()
7352 .find(|outline| !outline.title)
7353 .unwrap()
7354 .id;
7355 editor.focus_outline(body).unwrap();
7356 editor.insert(&mut engine, "Edited ").unwrap();
7357 let page = editor.page().unwrap();
7358 let written = onestore::PreparedEdit::page(CORPUS[2], space, &page, "Author").unwrap();
7359 let reread = corpus_pages(written.as_bytes())
7360 .into_iter()
7361 .find_map(|(candidate, page)| (candidate == space).then_some(page))
7362 .unwrap();
7363 let edited = body_text(&page, body).unwrap();
7364 assert!(edited.starts_with("Edited "));
7365 assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str()));
7366 assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str()));
7367 }
71117368}
crates/canvas/src/editor/page.rs+41-16
......@@ -6,6 +6,22 @@ use crate::{
66};
77use onestore::page::text::Paragraph;
88use onestore::page::{Image, Outline, Page, PageObject};
9use std::collections::BTreeMap;
10
11/// A title object's own state, plus the child origins `build` replaces with page coordinates.
12pub(crate) struct TitleArea {
13 pub id: onestore::ExGuid,
14 pub date: Option<onestore::ExGuid>,
15 pub layout: onestore::document::Layout,
16 pub origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>,
17}
18
19pub(crate) struct Import {
20 pub objects: Vec<Content>,
21 pub outlines: Vec<TextOutline>,
22 pub date: Option<PageDate>,
23 pub areas: Vec<TitleArea>,
24}
925
1026pub(crate) enum Content {
1127 Date {
......@@ -25,6 +41,8 @@ pub struct ReadOnlyObject {
2541 pub source: PageObject,
2642 pub message: &'static str,
2743 pub(crate) label: TextLayout,
44 /// Title coordinates are stored relative to the page margin; nothing else is offset.
45 offset: [f32; 2],
2846}
2947
3048impl Content {
......@@ -51,7 +69,7 @@ impl Content {
5169
5270impl ReadOnlyObject {
5371 fn new(
54 mut source: PageObject,
72 source: PageObject,
5573 margin: [f32; 2],
5674 message: &'static str,
5775 engine: &mut TextEngine,
......@@ -61,15 +79,9 @@ impl ReadOnlyObject {
6179 } else {
6280 [0.0; 2]
6381 };
64 let layout = source.layout_mut();
82 let layout = source.layout();
6583 let x = layout.x.unwrap_or(0.0) + offset[0];
6684 let y = layout.y.unwrap_or(0.0) + offset[1];
67 if offset[0] != 0.0 {
68 layout.x = Some(x);
69 }
70 if offset[1] != 0.0 {
71 layout.y = Some(y);
72 }
7385 let width = layout.max_width.unwrap_or(160.0);
7486 let height = layout.max_height.unwrap_or(42.0);
7587 if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 {
......@@ -97,12 +109,13 @@ impl ReadOnlyObject {
97109 source,
98110 message,
99111 label,
112 offset,
100113 }))
101114 }
102115 pub fn rect(&self) -> [f32; 4] {
103116 let layout = self.source.layout();
104 let x = layout.x.unwrap_or(0.0);
105 let y = layout.y.unwrap_or(0.0);
117 let x = layout.x.unwrap_or(0.0) + self.offset[0];
118 let y = layout.y.unwrap_or(0.0) + self.offset[1];
106119 [
107120 x,
108121 y,
......@@ -115,20 +128,17 @@ impl ReadOnlyObject {
115128 }
116129}
117130
118#[expect(
119 clippy::type_complexity,
120 reason = "the three owned parts of an imported page"
121)]
122131pub(crate) fn build(
123132 page: &mut Page,
124133 engine: &mut TextEngine,
125134 editable: bool,
126) -> Result<(Vec<Content>, Vec<TextOutline>, Option<PageDate>), EditorError> {
135) -> Result<Import, EditorError> {
127136 if page.margin_origin.iter().any(|v| !v.is_finite()) {
128137 return Err(EditorError::InvalidGeometry);
129138 }
130139 let mut objects = Vec::new();
131140 let mut outlines = Vec::new();
141 let mut areas = Vec::new();
132142 let mut date = None;
133143 for object in std::mem::take(&mut page.objects) {
134144 match &object {
......@@ -189,6 +199,16 @@ pub(crate) fn build(
189199 }
190200 Err(error) => return Err(error.into()),
191201 };
202 areas.push(TitleArea {
203 id: title.id,
204 date: title.date,
205 layout: title.layout.clone(),
206 origins: title
207 .outlines
208 .iter()
209 .map(|outline| (outline.id, [outline.layout.x, outline.layout.y]))
210 .collect(),
211 });
192212 let mut anchor = if editable {
193213 title
194214 .outlines
......@@ -309,5 +329,10 @@ pub(crate) fn build(
309329 )?)),
310330 }
311331 }
312 Ok((objects, outlines, date))
332 Ok(Import {
333 objects,
334 outlines,
335 date,
336 areas,
337 })
313338}
crates/canvas/src/gpu/page.rs+3-2
......@@ -78,8 +78,9 @@ impl From<EditorError> for SceneError {
7878
7979impl PageScene {
8080 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {
81 let (objects, _, _) =
82 crate::editor::page::build(&mut page, engine, false).map_err(SceneError::from)?;
81 let objects = crate::editor::page::build(&mut page, engine, false)
82 .map_err(SceneError::from)?
83 .objects;
8384 let images = Self::decode_images(&objects)?;
8485 Ok(Self {
8586 reference: Some(objects),
crates/notebook/examples/session_client.rs created+195
......@@ -0,0 +1,195 @@
1//! Drives a section the way the application does: open it through a session, save
2//! page-model edits, wait for their publication and report the page texts.
3//! `session_client SECTION CACHE_DIR LABEL` edits the first body paragraph of every
4//! page whose title starts with "Move" and adds one outline per page; each launch
5//! reports the pending queue it found and the receipts it obtained.
6
7use notebook::{
8 EditStatus,
9 session::{Event, Save, Section},
10};
11use onestore::{
12 ExGuid,
13 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id},
14};
15use std::{
16 sync::mpsc,
17 time::{Duration, Instant},
18};
19
20fn body_text(page: &Page) -> Option<ExGuid> {
21 page.objects.iter().find_map(|object| match object {
22 PageObject::Outline(outline) => outline
23 .paragraphs
24 .iter()
25 .find_map(|p| p.text().map(|t| t.id)),
26 _ => None,
27 })
28}
29
30fn texts(page: &Page) -> Vec<String> {
31 let mut out = Vec::new();
32 for object in &page.objects {
33 let outlines: Vec<&Outline> = match object {
34 PageObject::Outline(outline) => vec![outline],
35 PageObject::Title(title) => title.outlines.iter().collect(),
36 _ => Vec::new(),
37 };
38 for outline in outlines {
39 for paragraph in &outline.paragraphs {
40 if let Some(text) = paragraph.text() {
41 out.push(text.text.text().to_owned());
42 }
43 }
44 }
45 }
46 out
47}
48
49fn edit(page: &mut Page, label: &str) {
50 let text = body_text(page).expect("a body paragraph");
51 for object in &mut page.objects {
52 let PageObject::Outline(outline) = object else {
53 continue;
54 };
55 if let Some(paragraph) = outline
56 .paragraphs
57 .iter_mut()
58 .find(|p| p.text().is_some_and(|t| t.id == text))
59 {
60 let target = paragraph.text_mut().unwrap();
61 let format = target.text.format_at(0).unwrap().clone();
62 target
63 .text
64 .apply(onestore::page::text::Edit {
65 range: 0..0,
66 replacement: onestore::page::Paragraph::new(format!("{label} "), format),
67 })
68 .unwrap();
69 }
70 }
71 let template = page
72 .objects
73 .iter()
74 .find_map(|object| match object {
75 PageObject::Outline(outline) => outline
76 .paragraphs
77 .iter()
78 .find(|p| p.text().is_some())
79 .cloned(),
80 _ => None,
81 })
82 .unwrap();
83 let mut paragraph: PageParagraph = template.clone();
84 paragraph.id = new_id().unwrap();
85 paragraph.parent = None;
86 paragraph.level = 1;
87 paragraph.lists.clear();
88 paragraph.tags.clear();
89 paragraph.style = None;
90 paragraph.collapsed = false;
91 paragraph.content = ParagraphContent::Text(TextObject {
92 id: new_id().unwrap(),
93 date_field: None,
94 text: onestore::page::Paragraph::new(
95 format!("{label} outline 🦀 é"),
96 template.text().unwrap().text.format_at(0).unwrap().clone(),
97 ),
98 tags: Vec::new(),
99 });
100 let outline = Outline {
101 id: new_id().unwrap(),
102 title: false,
103 min_width: None,
104 layout: onestore::document::Layout {
105 x: Some(72.0),
106 y: Some(520.0),
107 ..Default::default()
108 },
109 indents: Vec::new(),
110 paragraphs: vec![paragraph],
111 unsupported: Vec::new(),
112 };
113 let at = page
114 .objects
115 .iter()
116 .position(|o| matches!(o, PageObject::Title(_)))
117 .unwrap_or(page.objects.len());
118 page.objects.insert(at, PageObject::Outline(outline));
119}
120
121fn main() -> Result<(), Box<dyn std::error::Error>> {
122 let args: Vec<String> = std::env::args().collect();
123 let [_, file, cache, label] = args.as_slice() else {
124 return Err("Usage: session_client SECTION CACHE_DIR LABEL".into());
125 };
126 let (notify, notified) = mpsc::channel();
127 let section = Section::open(file, cache, move || {
128 let _ = notify.send(());
129 })?;
130 let pending = section.pending()?;
131 println!(
132 "{}",
133 serde_json::json!({"event": "opened", "pending": pending.len(), "file": section.file()})
134 );
135 let pages = section.pages()?;
136 let mut queued = Vec::new();
137 for (space, title) in pages.iter().filter(|(_, title)| title.starts_with("Move")) {
138 let before = section.page(*space)?;
139 let mut after = before.clone();
140 edit(&mut after, label);
141 match section.save(*space, &before, &after, "session client")? {
142 Save::Queued(id) => queued.push((id, *space, title.clone(), texts(&after))),
143 other => return Err(format!("{title}: {other:?}").into()),
144 }
145 }
146 let deadline = Instant::now() + Duration::from_secs(120);
147 let mut receipts = Vec::new();
148 while queued
149 .iter()
150 .any(|(id, ..)| !receipts.iter().any(|(n, _)| n == id))
151 {
152 if Instant::now() > deadline {
153 return Err("Publication timed out".into());
154 }
155 let _ = notified.recv_timeout(Duration::from_millis(200));
156 for event in section.events() {
157 match event {
158 Event::Attempt {
159 id,
160 status: EditStatus::Published { revision },
161 } => receipts.push((id, revision)),
162 Event::Attempt { id, status } => {
163 println!(
164 "{}",
165 serde_json::json!({"event": "attempt", "id": id, "status": format!("{status:?}")})
166 );
167 }
168 Event::Unreachable(error) => {
169 println!(
170 "{}",
171 serde_json::json!({"event": "unreachable", "error": error.to_string()})
172 );
173 }
174 Event::Failed(error) => return Err(error.into()),
175 Event::Refreshed => {}
176 }
177 }
178 }
179 for (id, space, title, expected) in &queued {
180 let revision = receipts
181 .iter()
182 .find(|(n, _)| n == id)
183 .map(|(_, r)| r.to_string());
184 let stored = texts(&section.page(*space)?);
185 println!(
186 "{}",
187 serde_json::json!({"event": "published", "id": id, "space": space.to_string(), "title": title,
188 "revision": revision, "texts": expected, "stored": stored})
189 );
190 }
191 assert!(section.pending()?.is_empty());
192 section.close()?;
193 println!("{}", serde_json::json!({"event": "closed"}));
194 Ok(())
195}
crates/notebook/src/session.rs+62-1
......@@ -1,7 +1,9 @@
11//! The application's view of a notebook: sections opened through a local replica that
22//! publishes page saves to the section file in the background.
33
4use crate::{EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover};
4use crate::{
5 ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,
6};
57use onestore::{
68 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,
79};
......@@ -87,6 +89,14 @@ pub enum Save {
8789 Stale,
8890}
8991
92/// A queued edit and its durable state.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct QueuedEdit {
95 pub id: u64,
96 pub space: ExGuid,
97 pub status: EditStatus,
98}
99
90100/// A section file with its replica and background publication.
91101pub struct Section {
92102 file: PathBuf,
......@@ -246,6 +256,57 @@ impl Section {
246256 self.replica.pending()
247257 }
248258
259 /// Every queued edit with its state: pending, awaiting confirmation of a retained
260 /// attempt, or a conflict awaiting review.
261 pub fn queue(&self) -> Result<Vec<QueuedEdit>> {
262 self.replica
263 .pending()?
264 .into_iter()
265 .map(|edit| {
266 Ok(QueuedEdit {
267 id: edit.id,
268 space: edit.space,
269 status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending),
270 })
271 })
272 .collect()
273 }
274
275 /// The queued edits whose publication conflicted with a remote change.
276 pub fn conflicts(&self) -> Result<Vec<(QueuedEdit, ConflictKind)>> {
277 Ok(self
278 .queue()?
279 .into_iter()
280 .filter_map(|edit| match edit.status {
281 EditStatus::Conflict(kind) => Some((edit, kind)),
282 _ => None,
283 })
284 .collect())
285 }
286
287 /// The page as last observed in the section file, for reviewing a conflict.
288 pub fn remote_page(&self, space: ExGuid) -> Result<Page> {
289 let snapshot = self.replica.remote_snapshot()?;
290 let store = Store::parse(&snapshot)?;
291 let index = RevisionIndex::parse(&store)?;
292 Ok(Page::from_space(&Document::parse(&index)?, space)?)
293 }
294
295 /// Resolves the oldest conflict with a page reviewed against `remote_page`; the
296 /// reviewed model publishes as a whole, keeping the edit's id.
297 pub fn review(&self, id: u64, after: &Page) -> Result<()> {
298 let local = self.replica.snapshot()?;
299 let remote = self.replica.remote_snapshot()?;
300 self.replica.review_page(id, &local, &remote, after)?;
301 self.wake();
302 Ok(())
303 }
304
305 /// Captures both images, the queue and its states in a read-only archive.
306 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
307 self.replica.export_recovery(path)
308 }
309
249310 /// Events since the last poll, oldest first.
250311 pub fn events(&self) -> Vec<Event> {
251312 self.events.try_iter().collect()
crates/notebook/tests/session.rs+81-2
......@@ -1,6 +1,6 @@
11use notebook::{
2 EditStatus,
3 session::{Event, Notebook, Save, Section},
2 ConflictKind, EditStatus, Recovery,
3 session::{Event, Notebook, QueuedEdit, Save, Section},
44};
55use onestore::{ExGuid, PreparedEdit, page::Page};
66use std::{
......@@ -601,3 +601,82 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
601601 );
602602 assert!(connecting.try_recv().is_err());
603603}
604
605#[test]
606fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recovery() {
607 let directory = tempfile::tempdir().unwrap();
608 let file = directory.path().join("notes.one");
609 let cache = directory.path().join("cache");
610 std::fs::write(
611 &file,
612 onestore::create_section("notes.one", "Original", "Author").unwrap(),
613 )
614 .unwrap();
615 let (section, _) = open(&file, &cache);
616 let space = section.pages().unwrap()[0].0;
617 let before = section.page(space).unwrap();
618 let text = first_text(&before);
619 use std::os::unix::fs::PermissionsExt;
620 let permissions = std::fs::metadata(&file).unwrap().permissions();
621 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap();
622 let mut local = before.clone();
623 model_ops::replace_text(&mut local, text, 0..8, "Local");
624 let Save::Queued(id) = section.save(space, &before, &local, "Editor").unwrap() else {
625 panic!()
626 };
627 wait(&section, |event| matches!(event, Event::Unreachable(_)));
628 std::fs::set_permissions(&file, permissions).unwrap();
629 let mut native = before.clone();
630 model_ops::replace_text(&mut native, text, 0..8, "Native");
631 let deadline = Instant::now() + Duration::from_secs(20);
632 loop {
633 let bytes = onestore::read_file(&file).unwrap();
634 match PreparedEdit::page(&bytes, space, &native, "Native")
635 .unwrap()
636 .commit_file(&file)
637 {
638 Ok(()) => break,
639 Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => {
640 assert!(Instant::now() < deadline);
641 std::thread::sleep(Duration::from_millis(20));
642 }
643 Err(error) => panic!("{error:?}"),
644 }
645 }
646 section.wake();
647 wait(
648 &section,
649 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Conflict(ConflictKind::ContentChanged) } if *n == id),
650 );
651 let conflicts = section.conflicts().unwrap();
652 assert_eq!(
653 conflicts,
654 [(
655 QueuedEdit {
656 id,
657 space,
658 status: EditStatus::Conflict(ConflictKind::ContentChanged)
659 },
660 ConflictKind::ContentChanged
661 )]
662 );
663 assert_same(section.page(space).unwrap(), &local);
664 let remote = section.remote_page(space).unwrap();
665 assert_same(remote.clone(), &native);
666 let archive = directory.path().join("review.sqlite");
667 section.export_recovery(&archive).unwrap();
668 let recovery = Recovery::open(&archive).unwrap();
669 assert_eq!(recovery.pending().unwrap().len(), 1);
670 assert_eq!(
671 recovery.status(id).unwrap(),
672 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
673 );
674 let mut reviewed = remote.clone();
675 model_ops::replace_text(&mut reviewed, text, 0..6, "Native and local");
676 section.review(id, &reviewed).unwrap();
677 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
678 published(&section, id);
679 assert_same(stored_page(&file, space), &reviewed);
680 assert!(section.queue().unwrap().is_empty());
681 section.close().unwrap();
682}
crates/snowbound/Cargo.toml+1
......@@ -7,6 +7,7 @@ publish = false
77[dependencies]
88canvas = { path = "../canvas", features = ["gpu"] }
99onestore = { path = "../onestore" }
10notebook = { path = "../notebook" }
1011pollster = "0.4"
1112wgpu.workspace = true
1213parley.workspace = true
crates/snowbound/src/accessibility.rs+5-1
......@@ -1324,7 +1324,11 @@ mod tests {
13241324 .selection(editor.active_outline().id, selection)
13251325 .unwrap()
13261326 );
1327 let count = editor.outlines().iter().map(|o| o.layouts().count()).sum();
1327 let count = editor
1328 .outlines()
1329 .iter()
1330 .map(|o| o.layouts().count())
1331 .sum::<usize>();
13281332 assert_eq!(cached.paragraphs.len(), count);
13291333 }
13301334 };
crates/snowbound/src/main.rs+234-24
......@@ -39,6 +39,8 @@ enum UserEvent {
3939 Quit,
4040 InsertText(String),
4141 Accessibility(accesskit_winit::Event),
42 /// The section's synchronization thread reported an event.
43 Sync,
4244}
4345
4446impl From<accesskit_winit::Event> for UserEvent {
......@@ -75,6 +77,32 @@ enum Input {
7577 reference: Option<Page>,
7678 },
7779 Page(Page),
80 Section {
81 file: PathBuf,
82 title: String,
83 cache: PathBuf,
84 },
85}
86
87/// The opened section and the stored model the editor's page was loaded from.
88struct Session {
89 section: notebook::session::Section,
90 space: onestore::ExGuid,
91 before: Page,
92 title: String,
93 status: &'static str,
94}
95
96impl Session {
97 fn window_title(&self) -> String {
98 let file = self
99 .section
100 .file()
101 .file_name()
102 .map(|name| name.to_string_lossy().into_owned())
103 .unwrap_or_default();
104 format!("{} · {file}{}", self.title, self.status)
105 }
78106}
79107
80108enum Drag {
......@@ -112,6 +140,7 @@ struct State {
112140 renderer: Renderer,
113141 engine: TextEngine,
114142 editor: CanvasEditor,
143 session: Option<Session>,
115144 initial: Vec<(onestore::ExGuid, TextDocument)>,
116145 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
117146 initial_date: Option<u64>,
......@@ -167,13 +196,14 @@ impl State {
167196 Input::Notes {
168197 reference: None, ..
169198 } => "Untitled · Temporary page".into(),
199 Input::Section { title, .. } => title.clone(),
170200 })
171201 .with_inner_size(LogicalSize::new(1000.0, 720.0)),
172202 )?,
173203 );
174204 macos::install_text_input(&window);
175205 let access_adapter =
176 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy);
206 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
177207 window.set_visible(true);
178208 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
179209 Box::new(window.clone()),
......@@ -199,6 +229,7 @@ impl State {
199229 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
200230 eprintln!("Using {} for {target}", path.display());
201231 }
232 let mut session = None;
202233 let (editor, scene) = match input {
203234 Input::Notes {
204235 document,
......@@ -217,7 +248,30 @@ impl State {
217248 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
218249 (editor, Some((scene, [0.0; 2])))
219250 }
251 Input::Section { file, title, cache } => {
252 let section = notebook::session::Section::open(&file, cache, move || {
253 let _ = proxy.send_event(UserEvent::Sync);
254 })?;
255 let (space, _) = section
256 .pages()?
257 .into_iter()
258 .find(|(_, candidate)| *candidate == title)
259 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?;
260 let before = section.page(space)?;
261 let (scene, editor) = PageScene::from_page(before.clone(), &mut engine)?;
262 session = Some(Session {
263 section,
264 space,
265 before,
266 title,
267 status: "",
268 });
269 (editor, Some((scene, [0.0; 2])))
270 }
220271 };
272 if let Some(session) = &session {
273 window.set_title(&session.window_title());
274 }
221275 let initial_date = editor.date().map(|date| date.timestamp());
222276 let initial_layouts = editor
223277 .object_layouts()
......@@ -241,6 +295,7 @@ impl State {
241295 renderer,
242296 engine,
243297 editor,
298 session,
244299 initial,
245300 initial_date,
246301 initial_layouts,
......@@ -331,7 +386,8 @@ impl State {
331386 ),
332387 );
333388 self.update_accessibility()?;
334 if self.editor.active_outline().title {
389 self.persist()?;
390 if self.session.is_none() && self.editor.active_outline().title {
335391 let title = self
336392 .editor
337393 .active_outline()
......@@ -357,6 +413,112 @@ impl State {
357413 Ok(())
358414 }
359415
416 /// Saves the edited page to the section's replica; a page changed underneath the
417 /// editor is reloaded in place of the edit.
418 fn persist(&mut self) -> Result<(), Box<dyn Error>> {
419 let Some(session) = &mut self.session else {
420 return Ok(());
421 };
422 let after = self.editor.page()?;
423 match session
424 .section
425 .save(session.space, &session.before, &after, "snowbound")?
426 {
427 notebook::session::Save::Unchanged => {}
428 notebook::session::Save::Queued(_) => {
429 session.before = session.section.page(session.space)?;
430 session.status = " · saving";
431 self.window.set_title(&session.window_title());
432 }
433 notebook::session::Save::Stale => self.reload()?,
434 }
435 Ok(())
436 }
437
438 /// Replaces the editor with the page currently stored in the section.
439 fn reload(&mut self) -> Result<(), Box<dyn Error>> {
440 let Some(session) = &mut self.session else {
441 return Ok(());
442 };
443 let page = session.section.page(session.space)?;
444 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.engine)?;
445 session.before = page;
446 self.editor = editor;
447 self.scene = Some((scene, [0.0; 2]));
448 self.drag = None;
449 self.read_only_focus = None;
450 self.window.set_title(&session.window_title());
451 self.update_accessibility()?;
452 self.window.request_redraw();
453 Ok(())
454 }
455
456 /// Reviews the oldest conflict on this page: `keep_mine` publishes the editor's page
457 /// over the remote change, otherwise the remote page replaces the editor's.
458 fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box<dyn Error>> {
459 let Some(session) = &mut self.session else {
460 return Ok(());
461 };
462 let Some((edit, _)) = session
463 .section
464 .conflicts()?
465 .into_iter()
466 .find(|(edit, _)| edit.space == session.space)
467 else {
468 return Ok(());
469 };
470 let reviewed = if keep_mine {
471 self.editor.page()?
472 } else {
473 session.section.remote_page(session.space)?
474 };
475 session.section.review(edit.id, &reviewed)?;
476 session.status = " · saving";
477 if keep_mine {
478 session.before = session.section.page(session.space)?;
479 self.window.set_title(&session.window_title());
480 Ok(())
481 } else {
482 self.reload()
483 }
484 }
485
486 /// Applies what the synchronization thread reported since the last poll.
487 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
488 let Some(session) = &mut self.session else {
489 return Ok(());
490 };
491 let mut refreshed = false;
492 for event in session.section.events() {
493 use notebook::session::Event;
494 session.status = match event {
495 Event::Refreshed => {
496 refreshed = true;
497 continue;
498 }
499 Event::Attempt {
500 status: notebook::EditStatus::Published { .. },
501 ..
502 } => " · saved",
503 Event::Attempt {
504 status: notebook::EditStatus::Conflict(_),
505 ..
506 } => " · conflict",
507 Event::Attempt { .. } => " · saving",
508 Event::Unreachable(_) => " · offline",
509 Event::Failed(error) => {
510 eprintln!("Synchronization stopped: {error}");
511 " · not saving"
512 }
513 };
514 }
515 self.window.set_title(&session.window_title());
516 if refreshed && session.section.page(session.space)? != session.before {
517 self.reload()?;
518 }
519 Ok(())
520 }
521
360522 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {
361523 let mut error = None;
362524 let preview = self.preview();
......@@ -659,6 +821,17 @@ impl State {
659821 macos::show_character_palette();
660822 return Ok(());
661823 }
824 if command && shift && self.session.is_some() {
825 match key {
826 Key::Character(character) if character.eq_ignore_ascii_case("k") => {
827 return self.resolve_conflict(true);
828 }
829 Key::Character(character) if character.eq_ignore_ascii_case("t") => {
830 return self.resolve_conflict(false);
831 }
832 _ => {}
833 }
834 }
662835 if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) {
663836 if matches!(
664837 key,
......@@ -932,25 +1105,26 @@ impl State {
9321105impl App {
9331106 fn close(&self, event_loop: &ActiveEventLoop) {
9341107 if self.state.as_ref().is_none_or(|state| {
935 state
936 .editor
937 .caret_outline()
938 .is_none_or(TextOutline::is_empty)
939 && state.initial_date == state.editor.date().map(|date| date.timestamp())
940 && state
941 .initial_layouts
942 .iter()
943 .map(|(id, layout)| (*id, layout))
944 .eq(state.editor.object_layouts())
945 && state
946 .initial
947 .iter()
948 .map(|(id, document)| (id, document))
949 .eq(state
950 .editor
951 .outlines()
1108 state.session.is_some()
1109 || state
1110 .editor
1111 .caret_outline()
1112 .is_none_or(TextOutline::is_empty)
1113 && state.initial_date == state.editor.date().map(|date| date.timestamp())
1114 && state
1115 .initial_layouts
1116 .iter()
1117 .map(|(id, layout)| (*id, layout))
1118 .eq(state.editor.object_layouts())
1119 && state
1120 .initial
9521121 .iter()
953 .map(|outline| (&outline.id, outline.document())))
1122 .map(|(id, document)| (id, document))
1123 .eq(state
1124 .editor
1125 .outlines()
1126 .iter()
1127 .map(|outline| (&outline.id, outline.document())))
9541128 }) || macos::discard_changes()
9551129 {
9561130 event_loop.exit();
......@@ -982,6 +1156,14 @@ impl ApplicationHandler<UserEvent> for App {
9821156 self.close(event_loop);
9831157 return;
9841158 }
1159 UserEvent::Sync => {
1160 if let Some(state) = &mut self.state
1161 && let Err(error) = state.synced()
1162 {
1163 eprintln!("{error}");
1164 }
1165 return;
1166 }
9851167 UserEvent::Accessibility(event) => event,
9861168 };
9871169 let Some(state) = &mut self.state else {
......@@ -1812,12 +1994,33 @@ fn main() -> Result<(), Box<dyn Error>> {
18121994 let mut substitutes = Vec::new();
18131995 let mut reference = None;
18141996 let mut editable = false;
1997 let mut section = None;
1998 let mut cache = None;
18151999 while let Some(arg) = args.next() {
18162000 if arg == "--substitute-font" {
18172001 substitutes.push(PathBuf::from(
18182002 args.next()
18192003 .ok_or("Provide a font file after --substitute-font.")?,
18202004 ));
2005 } else if arg == "--section" {
2006 if reference.is_some() || section.is_some() {
2007 return Err("Only one page can be opened.".into());
2008 }
2009 let file = PathBuf::from(
2010 args.next()
2011 .ok_or("Provide a section file and page title after --section.")?,
2012 );
2013 let title = args
2014 .next()
2015 .ok_or("Provide a page title after the section file.")?
2016 .to_str()
2017 .ok_or("The page title must be valid Unicode.")?
2018 .to_owned();
2019 section = Some((file, title));
2020 } else if arg == "--cache" {
2021 cache = Some(PathBuf::from(
2022 args.next().ok_or("Provide a directory after --cache.")?,
2023 ));
18212024 } else if arg == "--reference" || arg == "--page" {
18222025 if reference.is_some() {
18232026 return Err("Only one page can be opened.".into());
......@@ -1842,14 +2045,14 @@ fn main() -> Result<(), Box<dyn Error>> {
18422045 positional.push(arg);
18432046 }
18442047 }
1845 if editable && !positional.is_empty() {
2048 if (editable || section.is_some()) && !positional.is_empty() {
18462049 return Err(
1847 "Use --page with a section file and page title, without a text file or width.".into(),
2050 "Use --page or --section with a section file and page title, without a text file or width.".into(),
18482051 );
18492052 }
18502053 if positional.len() > 2 {
18512054 return Err(
1852 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..."
2055 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE [--cache DIR]] [--substitute-font FONT_FILE]..."
18532056 .into(),
18542057 );
18552058 }
......@@ -1867,7 +2070,14 @@ fn main() -> Result<(), Box<dyn Error>> {
18672070 } else {
18682071 480.0
18692072 });
1870 let input = if editable {
2073 let input = if let Some((file, title)) = section {
2074 let cache = match cache {
2075 Some(cache) => cache,
2076 None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?)
2077 .join("Library/Caches/snowbound"),
2078 };
2079 Input::Section { file, title, cache }
2080 } else if editable {
18712081 Input::Page(reference.unwrap())
18722082 } else {
18732083 Input::Notes {
tools/TESTING.md+1-1
......@@ -40,7 +40,7 @@ the run's commands, inputs, outputs and teardown evidence.
4040| Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison |
4141| SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection |
4242| SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks |
43| Retained cache migration | Ignored `migrate_retained_cache_copy` test | New destination, unchanged source, images, intent IDs, attempts and receipts |
43| Application session on a share | `session_acceptance.py --help` | Page saves through `notebook::session` on the mounted Samba share, relaunch between launches, cold native reopen of the edited pages, owned VM and clone teardown |
4444
4545To compare an additional **already captured** notebook hierarchy without running
4646OneNote:
tools/session_acceptance.py created+149
......@@ -0,0 +1,149 @@
1#!/usr/bin/env python3
2"""Edit a notebook on the owned Samba share through the application's session, relaunch,
3then cold-open the result in a disposable OneNote clone and compare its text."""
4import argparse
5import html
6import json
7import os
8import re
9import shutil
10import subprocess
11import sys
12import tarfile
13import xml.etree.ElementTree as ET
14from pathlib import Path
15
16ROOT = Path(__file__).resolve().parent.parent
17sys.path.insert(0, str(ROOT / 'tools/w7'))
18sys.path.insert(0, str(ROOT / 'tools'))
19import linux_vm # noqa: E402
20
21FIXTURE = ROOT / 'corpus/outline-edit/before/notebook'
22NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'}
23
24
25def native_texts(read):
26 pages = {}
27 for path in sorted(read.glob('page-*.xml')):
28 root = ET.parse(path).getroot()
29 texts = []
30 for element in root.iter('{%s}T' % NS['one']):
31 texts.append(html.unescape(re.sub(r'<[^>]*>', '', element.text or '')))
32 pages[root.get('name')] = texts
33 return pages
34
35
36def compare(expected, native):
37 """The client's edits carry its launch label; other text is compared by the corpus lanes."""
38 mismatches = []
39 for title, texts in expected.items():
40 body = [text for text in native[title] if text != title]
41 wanted = [text for text in texts if 'launch' in text]
42 assert wanted, title
43 for text in wanted:
44 if text not in body:
45 mismatches.append({'page': title, 'missing': text, 'native': body})
46 return mismatches
47
48
49def main():
50 parser = argparse.ArgumentParser(description=__doc__)
51 parser.add_argument('output', type=Path)
52 parser.add_argument('--linux', help='Disposable Linux VM owned by this run')
53 parser.add_argument('--expected-pages', type=int, default=15)
54 parser.add_argument('--verify-only', action='store_true',
55 help='Compare an existing capture in OUTPUT without the lab.')
56 args = parser.parse_args()
57 output = args.output.resolve()
58 if not args.verify_only and not args.linux:
59 parser.error('--linux names the disposable Linux VM this run owns')
60 if args.verify_only:
61 expected = {}
62 for launch in ('first', 'second'):
63 for line in (output / f'{launch}.log').read_text().splitlines():
64 if line.startswith('{') and '"published"' in line:
65 event = json.loads(line)
66 expected[event['title']] = event['texts']
67 native = native_texts(output / 'result/cold/read')
68 mismatches = compare(expected, native)
69 (output / 'verification.json').write_text(json.dumps(
70 {'expected': expected, 'native': {title: native[title] for title in expected}, 'mismatches': mismatches},
71 indent=2, ensure_ascii=False))
72 assert not mismatches, mismatches
73 print(f'Session acceptance verified: {len(expected)} pages', flush=True)
74 return
75 output.mkdir(parents=True)
76 server = args.linux
77 mount = output / 'mount'
78 mount.mkdir()
79 client = ROOT / 'target/debug/examples/session_client'
80 subprocess.run(['cargo', 'build', '-p', 'notebook', '--example', 'session_client'], cwd=ROOT, check=True)
81 record = {'server': server, 'fixture': str(FIXTURE.relative_to(ROOT)), 'launches': []}
82
83 def unmount():
84 for options in ([], ['-f']):
85 if not os.path.ismount(mount):
86 return
87 subprocess.run(['/sbin/umount', *options, str(mount)], capture_output=True, text=True, timeout=60)
88 if os.path.ismount(mount):
89 raise RuntimeError('The owned SMB mount remains attached')
90
91 try:
92 if not linux_vm.instance_path(server).exists():
93 linux_vm.create_instance(server)
94 linux_vm.launch(server)
95 linux_vm.wait_instance(server, 600)
96 config = linux_vm.load_instance(server)
97 (output / 'linux.json').write_text(json.dumps(config, indent=2))
98 linux_vm.run_ssh(server, 'mkdir /srv/agent/m4-session').check_returncode()
99 with tarfile.open(output / 'input.tar', 'w', dereference=True) as archive:
100 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
101 archive.add(FIXTURE / name, arcname=name)
102 with (output / 'input.tar').open('rb') as stream:
103 subprocess.run(linux_vm.ssh_argv(server, 'tar xf - -C /srv/agent/m4-session'), stdin=stream, check=True)
104 linux_vm.run_ssh(server, 'chmod u+w /srv/agent/m4-session/*').check_returncode()
105 subprocess.run(['/sbin/mount_smbfs', '-N', f'//guest@127.0.0.1:{config["samba_port"]}/agent', mount],
106 check=True, stdin=subprocess.DEVNULL)
107 section = mount / 'm4-session/synthetic.one'
108 assert section.read_bytes() == (FIXTURE / 'synthetic.one').read_bytes()
109 cache = output / 'cache'
110 expected = {}
111 for launch in ('First launch', 'Second launch'):
112 result = subprocess.run([client, section, cache, launch], capture_output=True, text=True, timeout=300)
113 (output / f'{launch.split()[0].lower()}.log').write_text(result.stdout + result.stderr)
114 result.check_returncode()
115 events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith('{')]
116 assert events[0]['event'] == 'opened' and events[0]['pending'] == 0, events[0]
117 published = [e for e in events if e['event'] == 'published']
118 assert published and all(e['revision'] for e in published), published
119 for e in published:
120 assert e['stored'] == e['texts'], e
121 expected[e['title']] = e['texts']
122 record['launches'].append({'label': launch, 'published': published})
123 unmount()
124 result = output / 'result'
125 (result / 'notebook').mkdir(parents=True)
126 with (output / 'result.tar').open('wb') as stream:
127 subprocess.run(linux_vm.ssh_argv(server, 'tar cf - -C /srv/agent/m4-session .'), stdout=stream, check=True)
128 with tarfile.open(output / 'result.tar') as archive:
129 archive.extractall(result / 'notebook', filter='data')
130 subprocess.run([sys.executable, ROOT / 'tools/native_runner.py', result / 'notebook', result / 'cold',
131 '--expected-pages', str(args.expected_pages), '--collect-notebook'], check=True)
132 native = native_texts(result / 'cold/read')
133 mismatches = compare(expected, native)
134 record['native'] = {title: native[title] for title in expected}
135 record['mismatches'] = mismatches
136 (output / 'record.json').write_text(json.dumps(record, indent=2, ensure_ascii=False))
137 assert not mismatches, mismatches
138 print(f'Session acceptance passed: {len(expected)} pages edited twice, reopened natively', flush=True)
139 finally:
140 unmount()
141 if linux_vm.instance_path(server).exists():
142 if linux_vm.running(server):
143 linux_vm.shutdown(server, 60)
144 linux_vm.delete_instance(server)
145 shutil.rmtree(mount, ignore_errors=True)
146
147
148if __name__ == '__main__':
149 main()