1#![forbid(unsafe_code)]
2
3use std::{
4 collections::{BTreeMap, HashMap, HashSet},
5 error, fmt,
6};
7
8use kcode_kweb_db::NodeId;
9use kcode_session_history::{
10 Session as HistorySession,
11 chatend::{BoxContent, BoxId, BoxState, EventId, PendingId, ToolSlotInput},
12};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use sha2::{Digest, Sha256};
16
17const KWEB_TOOL_INSTANCE: &str = "kweb";
18const RECENT_CONNECTIONS_PER_BOX: usize = 8;
19const RECENT_CONNECTIONS_LOGICAL_SLOT: &str = "recent-connections";
20const RECENT_CONNECTION_IDS_METADATA: &str = "kwebRecentConnectionIds";
21
22pub type Result<T> = std::result::Result<T, Error>;
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct Error {
26 message: String,
27}
28
29impl Error {
30 fn new(message: impl Into<String>) -> Self {
31 Self {
32 message: message.into(),
33 }
34 }
35}
36
37impl fmt::Display for Error {
38 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39 formatter.write_str(&self.message)
40 }
41}
42
43impl error::Error for Error {}
44
45#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Connection {
48 pub id: String,
49 pub short_name: String,
50 pub short_description: String,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Node {
56 pub id: String,
57 pub short_name: String,
58 pub short_description: String,
59 pub long_description: String,
60 pub owner: String,
61 #[serde(default)]
62 pub fixed_connections: Vec<Connection>,
63 #[serde(default)]
64 pub recent_connections: Vec<Connection>,
65 #[serde(default)]
66 pub objects: Vec<String>,
67 #[serde(default)]
68 pub last_modified_by: String,
69 #[serde(default)]
70 pub last_modified_at: Option<String>,
71}
72
73impl Node {
74 pub fn from_kweb_value(value: &Value) -> Result<Self> {
75 let id = required_string(value, "id")?;
76 canonical_node_id(&id)?;
77 let owner = value
78 .get("owner_node_id")
79 .or_else(|| value.get("owner_root_node_id"))
80 .and_then(Value::as_str)
81 .unwrap_or("unowned")
82 .to_owned();
83 if !matches!(owner.as_str(), "self" | "unowned") {
84 canonical_node_id(&owner)?;
85 }
86 let summaries = value
87 .get("connection_summaries")
88 .and_then(Value::as_array)
89 .into_iter()
90 .flatten()
91 .filter_map(|summary| Some((summary.get("id")?.as_str()?.to_owned(), summary)))
92 .collect::<HashMap<_, _>>();
93 let fixed_connections = connections(
94 value.get("fixed_connections"),
95 &summaries,
96 "fixed connection",
97 )?;
98 let recent_connections = connections(
99 value.get("recent_connections"),
100 &summaries,
101 "recent connection",
102 )?;
103 let objects = string_ids(value.get("objects"), "object")?;
104 Ok(Self {
105 id,
106 short_name: optional_string(value, "short_name"),
107 short_description: optional_string(value, "short_description"),
108 long_description: optional_string(value, "long_description"),
109 owner,
110 fixed_connections,
111 recent_connections,
112 objects,
113 last_modified_by: optional_string(value, "last_modified_by"),
114 last_modified_at: value
115 .get("last_modified_at")
116 .and_then(Value::as_str)
117 .map(str::to_owned),
118 })
119 }
120
121 pub fn draft(&self) -> NodeDraft {
122 NodeDraft {
123 short_name: self.short_name.clone(),
124 short_description: self.short_description.clone(),
125 long_description: self.long_description.clone(),
126 owner: self.owner.clone(),
127 fixed_connections: self
128 .fixed_connections
129 .iter()
130 .map(|connection| connection.id.clone())
131 .collect(),
132 recent_connections: self
133 .recent_connections
134 .iter()
135 .map(|connection| connection.id.clone())
136 .collect(),
137 objects: self.objects.clone(),
138 }
139 }
140}
141
142#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct NodeDraft {
145 pub short_name: String,
146 pub short_description: String,
147 pub long_description: String,
148 pub owner: String,
149 #[serde(default)]
150 pub fixed_connections: Vec<String>,
151 #[serde(default)]
152 pub recent_connections: Vec<String>,
153 #[serde(default)]
154 pub objects: Vec<String>,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct StagedCreate {
159 pub pending_id: String,
160 pub data: NodeDraft,
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164enum BoxKind {
165 Loaded,
166 Fixed,
167 Staged,
168 Recent,
169}
170
171impl BoxKind {
172 pub const fn name(self) -> &'static str {
173 match self {
174 Self::Loaded => "Kweb loaded node",
175 Self::Fixed => "Kweb fixed connection",
176 Self::Staged => "Kweb staged node",
177 Self::Recent => "Kweb recent connections",
178 }
179 }
180
181 pub const fn metadata_name(self) -> &'static str {
182 match self {
183 Self::Loaded => "loaded",
184 Self::Fixed => "fixed",
185 Self::Staged => "staged",
186 Self::Recent => "recent",
187 }
188 }
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
192struct BoxSpec {
193 logical_slot: String,
194 kind: BoxKind,
195 text: String,
196 stored_node: Option<Node>,
197 staged_node: Option<NodeDraft>,
198}
199
200#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
201#[serde(rename_all = "camelCase")]
202pub struct LoadReport {
203 pub requested_id: String,
204 pub newly_loaded: bool,
205 pub promoted_from_fixed: bool,
206 pub new_fixed_ids: Vec<String>,
207}
208
209#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct ProjectionItem {
212 pub key: String,
214 pub name: String,
216 pub text: String,
218}
219
220#[derive(Clone, Debug)]
221pub struct Context {
222 root_node_ids: Vec<String>,
223 loaded_node_ids: Vec<String>,
224 fixed_node_ids: Vec<String>,
225 nodes_by_id: BTreeMap<String, Node>,
226}
227
228impl Context {
229 pub fn new(root_node_ids: Vec<String>) -> Result<Self> {
230 if root_node_ids.is_empty() {
231 return Err(Error::new("Kweb context requires at least one root node"));
232 }
233 let mut seen = HashSet::new();
234 for id in &root_node_ids {
235 canonical_node_id(id)?;
236 if !seen.insert(id.clone()) {
237 return Err(Error::new("Kweb root node IDs must be distinct"));
238 }
239 }
240 Ok(Self {
241 root_node_ids,
242 loaded_node_ids: Vec::new(),
243 fixed_node_ids: Vec::new(),
244 nodes_by_id: BTreeMap::new(),
245 })
246 }
247
248 pub fn root_node_ids(&self) -> &[String] {
249 &self.root_node_ids
250 }
251
252 pub fn loaded_node_ids(&self) -> &[String] {
253 &self.loaded_node_ids
254 }
255
256 pub fn fixed_node_ids(&self) -> &[String] {
257 &self.fixed_node_ids
258 }
259
260 pub fn full_node_ids(&self) -> Vec<&str> {
261 self.loaded_node_ids
262 .iter()
263 .chain(&self.fixed_node_ids)
264 .map(String::as_str)
265 .collect()
266 }
267
268 pub fn contains_full_node(&self, id: &str) -> bool {
269 self.loaded_node_ids.iter().any(|candidate| candidate == id)
270 || self.fixed_node_ids.iter().any(|candidate| candidate == id)
271 }
272
273 pub fn node(&self, id: &str) -> Option<&Node> {
274 self.nodes_by_id.get(id)
275 }
276
277 pub fn apply_load(&mut self, requested: Node, fixed: Vec<Node>) -> Result<LoadReport> {
278 let requested_id = requested.id.clone();
279 let was_loaded = self
280 .loaded_node_ids
281 .iter()
282 .any(|candidate| candidate == &requested_id);
283 let was_fixed = self
284 .fixed_node_ids
285 .iter()
286 .any(|candidate| candidate == &requested_id);
287 let previous_full = self
288 .full_node_ids()
289 .into_iter()
290 .map(str::to_owned)
291 .collect::<HashSet<_>>();
292 let expected_fixed = requested
293 .fixed_connections
294 .iter()
295 .map(|connection| connection.id.as_str())
296 .filter(|id| *id != requested_id)
297 .collect::<HashSet<_>>();
298 let provided_fixed = fixed
299 .iter()
300 .map(|node| node.id.as_str())
301 .collect::<HashSet<_>>();
302 if expected_fixed != provided_fixed {
303 return Err(Error::new(format!(
304 "load for {requested_id} did not provide exactly its fixed connections"
305 )));
306 }
307 self.nodes_by_id.insert(requested_id.clone(), requested);
308 for node in fixed {
309 self.nodes_by_id.insert(node.id.clone(), node);
310 }
311 if !was_loaded {
312 self.loaded_node_ids.push(requested_id.clone());
313 }
314 self.rebuild_fixed();
315 let new_fixed_ids = self
316 .fixed_node_ids
317 .iter()
318 .filter(|id| !previous_full.contains(*id))
319 .cloned()
320 .collect();
321 Ok(LoadReport {
322 requested_id,
323 newly_loaded: !was_loaded && !was_fixed,
324 promoted_from_fixed: !was_loaded && was_fixed,
325 new_fixed_ids,
326 })
327 }
328
329 pub fn refresh(&mut self, nodes: impl IntoIterator<Item = Node>) -> Result<()> {
330 for node in nodes {
331 if !self.contains_full_node(&node.id) {
332 return Err(Error::new(format!(
333 "cannot refresh unloaded Kweb node {}",
334 node.id
335 )));
336 }
337 self.nodes_by_id.insert(node.id.clone(), node);
338 }
339 self.rebuild_fixed();
340 Ok(())
341 }
342
343 pub fn restore(
344 &mut self,
345 nodes: impl IntoIterator<Item = Node>,
346 directly_loaded: Vec<String>,
347 ) -> Result<()> {
348 self.nodes_by_id.clear();
349 for node in nodes {
350 self.nodes_by_id.insert(node.id.clone(), node);
351 }
352 let mut seen = HashSet::new();
353 self.loaded_node_ids = directly_loaded
354 .into_iter()
355 .filter(|id| self.nodes_by_id.contains_key(id) && seen.insert(id.clone()))
356 .collect();
357 if !self.nodes_by_id.is_empty() && self.loaded_node_ids.is_empty() {
358 return Err(Error::new(
359 "restored Kweb context contains nodes but no loaded node",
360 ));
361 }
362 self.rebuild_fixed();
363 Ok(())
364 }
365
366 fn box_specs(
367 &self,
368 updates: &BTreeMap<String, NodeDraft>,
369 creates: &[StagedCreate],
370 ) -> Result<Vec<BoxSpec>> {
371 for id in updates.keys() {
372 if !self.contains_full_node(id) {
373 return Err(Error::new(format!(
374 "staged update targets unloaded Kweb node {id}"
375 )));
376 }
377 }
378 let mut specs = Vec::new();
379 for id in &self.loaded_node_ids {
380 specs.push(self.full_box(id, BoxKind::Loaded, updates.get(id))?);
381 }
382 for id in &self.fixed_node_ids {
383 specs.push(self.full_box(id, BoxKind::Fixed, updates.get(id))?);
384 }
385 for create in creates {
386 specs.push(BoxSpec {
387 logical_slot: create.pending_id.clone(),
388 kind: BoxKind::Staged,
389 text: format_node(&create.pending_id, &create.data),
390 stored_node: None,
391 staged_node: Some(create.data.clone()),
392 });
393 }
394 specs.push(BoxSpec {
395 logical_slot: "recent-connections".into(),
396 kind: BoxKind::Recent,
397 text: self.format_recent_connections(updates, creates)?,
398 stored_node: None,
399 staged_node: None,
400 });
401 Ok(specs)
402 }
403
404 pub fn projection(
410 &self,
411 updates: &BTreeMap<String, NodeDraft>,
412 creates: &[StagedCreate],
413 ) -> Result<Vec<ProjectionItem>> {
414 self.box_specs(updates, creates)?
415 .into_iter()
416 .map(|spec| {
417 Ok(ProjectionItem {
418 key: spec.logical_slot,
419 name: spec.kind.name().to_owned(),
420 text: spec.text,
421 })
422 })
423 .collect()
424 }
425
426 pub fn sync_chatend(
432 &self,
433 journal: &mut HistorySession,
434 recorded_at: impl Into<String>,
435 updates: &BTreeMap<String, NodeDraft>,
436 creates: &[StagedCreate],
437 ) -> Result<Vec<BoxId>> {
438 let recorded_at = recorded_at.into();
439 let previous = kweb_box_versions(journal);
440 let specs = self.box_specs(updates, creates)?;
441 let mut desired = Vec::with_capacity(specs.len());
442 let mut recent = None;
443 for spec in specs {
444 let mut metadata = json!({
445 "revisionHash": revision_hash(&spec.text),
446 });
447 if let Some(node) = spec.stored_node {
448 metadata["canonicalNodeId"] = json!(node.id);
449 metadata["storedNode"] = serde_json::to_value(node).map_err(|error| {
450 Error::new(format!("serializing stored Kweb node: {error}"))
451 })?;
452 }
453 if let Some(node) = spec.staged_node {
454 metadata["staged"] = json!(true);
455 metadata["nodeData"] = serde_json::to_value(node).map_err(|error| {
456 Error::new(format!("serializing staged Kweb node: {error}"))
457 })?;
458 }
459 let mut content = BoxContent {
460 text: spec.text,
461 objects: Vec::new(),
462 metadata,
463 };
464 content.use_concise_header();
465 mark_kweb_content(&mut content, &spec.logical_slot, spec.kind.metadata_name());
466 let entry = DesiredKwebBox {
467 logical_slot: spec.logical_slot,
468 name: spec.kind.name().into(),
469 content,
470 };
471 if spec.kind == BoxKind::Recent {
472 if recent.replace(entry).is_some() {
473 return Err(Error::new(
474 "Kweb context produced more than one recent-connections candidate",
475 ));
476 }
477 } else {
478 desired.push(entry);
479 }
480 }
481 let recent = recent
482 .ok_or_else(|| Error::new("Kweb context produced no recent-connections candidate"))?;
483 desired.extend(desired_recent_connection_boxes(journal, recent)?);
484 reconcile_kweb_slots(journal, &recorded_at, desired)?;
485 Ok(changed_kweb_box_ids(journal, &previous))
486 }
487
488 fn full_box(&self, id: &str, kind: BoxKind, update: Option<&NodeDraft>) -> Result<BoxSpec> {
489 let node = self
490 .nodes_by_id
491 .get(id)
492 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
493 let data = update.cloned().unwrap_or_else(|| node.draft());
494 Ok(BoxSpec {
495 logical_slot: id.to_owned(),
496 kind,
497 text: format_node(id, &data),
498 stored_node: Some(node.clone()),
499 staged_node: update.cloned(),
500 })
501 }
502
503 fn format_recent_connections(
504 &self,
505 updates: &BTreeMap<String, NodeDraft>,
506 creates: &[StagedCreate],
507 ) -> Result<String> {
508 let creates_by_id = creates
509 .iter()
510 .map(|create| (create.pending_id.as_str(), &create.data))
511 .collect::<HashMap<_, _>>();
512 let mut summaries = HashMap::new();
513 for node in self.nodes_by_id.values() {
514 summaries.insert(
515 node.id.as_str(),
516 (node.short_name.as_str(), node.short_description.as_str()),
517 );
518 for connection in node
519 .fixed_connections
520 .iter()
521 .chain(&node.recent_connections)
522 {
523 summaries.entry(connection.id.as_str()).or_insert((
524 connection.short_name.as_str(),
525 connection.short_description.as_str(),
526 ));
527 }
528 }
529 let mut recent_ids = Vec::new();
530 let mut seen = HashSet::new();
531 for id in self.full_node_ids() {
532 let node = self
533 .nodes_by_id
534 .get(id)
535 .ok_or_else(|| Error::new(format!("missing full Kweb node {id}")))?;
536 let recent = updates
537 .get(id)
538 .map(|draft| draft.recent_connections.as_slice())
539 .unwrap_or_else(|| &[]);
540 if updates.contains_key(id) {
541 for connection_id in recent {
542 if seen.insert(connection_id.clone()) {
543 recent_ids.push(connection_id.clone());
544 }
545 }
546 } else {
547 for connection in &node.recent_connections {
548 if seen.insert(connection.id.clone()) {
549 recent_ids.push(connection.id.clone());
550 }
551 }
552 }
553 }
554 for create in creates {
555 for connection_id in &create.data.recent_connections {
556 if seen.insert(connection_id.clone()) {
557 recent_ids.push(connection_id.clone());
558 }
559 }
560 }
561 let mut lines = vec!["Recent connections".to_owned()];
562 for id in recent_ids {
563 let staged_summary = updates
564 .get(&id)
565 .or_else(|| creates_by_id.get(id.as_str()).copied())
566 .map(|node| (node.short_name.as_str(), node.short_description.as_str()));
567 let (name, description) = staged_summary
568 .or_else(|| summaries.get(id.as_str()).copied())
569 .ok_or_else(|| {
570 Error::new(format!(
571 "recent connection {id} must resolve to a nonempty short name and short description"
572 ))
573 })?;
574 if name.trim().is_empty() || description.trim().is_empty() {
575 return Err(Error::new(format!(
576 "recent connection {id} must resolve to a nonempty short name and short description"
577 )));
578 }
579 lines.push(format!("{id} · {name}: {description}"));
580 }
581 if lines.len() == 1 {
582 lines.push("None.".into());
583 }
584 Ok(lines.join("\n"))
585 }
586
587 fn rebuild_fixed(&mut self) {
588 let loaded = self.loaded_node_ids.iter().cloned().collect::<HashSet<_>>();
589 let mut seen = loaded.clone();
590 let mut fixed = Vec::new();
591 for id in &self.loaded_node_ids {
592 let Some(node) = self.nodes_by_id.get(id) else {
593 continue;
594 };
595 for connection in &node.fixed_connections {
596 if self.nodes_by_id.contains_key(&connection.id)
597 && seen.insert(connection.id.clone())
598 {
599 fixed.push(connection.id.clone());
600 }
601 }
602 }
603 self.fixed_node_ids = fixed;
604 self.nodes_by_id
605 .retain(|id, _| loaded.contains(id) || seen.contains(id));
606 }
607}
608
609struct DesiredKwebBox {
610 logical_slot: String,
611 name: String,
612 content: BoxContent,
613}
614
615#[derive(Clone, Debug, Eq, PartialEq)]
616struct RecentConnectionEntry {
617 id: String,
618 text: String,
619}
620
621fn mark_kweb_content(content: &mut BoxContent, logical_slot: &str, role: &str) {
622 if !content.metadata.is_object() {
623 content.metadata = json!({});
624 }
625 content.metadata["kwebLogicalSlot"] = json!(logical_slot);
626 content.metadata["kwebRole"] = json!(role);
627}
628
629fn kweb_logical_slot(state: &BoxState, actual_slot: &str) -> String {
630 state
631 .canonical
632 .content
633 .metadata
634 .get("kwebLogicalSlot")
635 .and_then(Value::as_str)
636 .unwrap_or(actual_slot)
637 .to_owned()
638}
639
640fn recent_connection_entries(content: &BoxContent) -> Result<Vec<RecentConnectionEntry>> {
641 let body = content
642 .text
643 .strip_prefix("Recent connections")
644 .ok_or_else(|| Error::new("Kweb recent-connections box has an invalid heading"))?;
645 let body = body
646 .strip_prefix('\n')
647 .ok_or_else(|| Error::new("Kweb recent-connections box has no body"))?;
648 if body == "None." {
649 return Ok(Vec::new());
650 }
651
652 let mut entries: Vec<RecentConnectionEntry> = Vec::new();
653 for line in body.split('\n') {
654 let identifier = line.split_once(" · ").and_then(|(identifier, _)| {
655 let canonical = identifier.parse::<NodeId>().is_ok();
656 let pending = PendingId::parse(identifier.to_owned()).is_ok();
657 (canonical || pending).then_some(identifier)
658 });
659 if let Some(identifier) = identifier {
660 entries.push(RecentConnectionEntry {
661 id: identifier.to_owned(),
662 text: line.to_owned(),
663 });
664 } else {
665 let entry = entries.last_mut().ok_or_else(|| {
666 Error::new("Kweb recent-connections box starts with invalid entry text")
667 })?;
668 entry.text.push('\n');
669 entry.text.push_str(line);
670 }
671 }
672
673 if let Some(expected) = content
674 .metadata
675 .get(RECENT_CONNECTION_IDS_METADATA)
676 .and_then(Value::as_array)
677 {
678 let expected = expected
679 .iter()
680 .map(|value| {
681 value.as_str().ok_or_else(|| {
682 Error::new("Kweb recent-connection IDs metadata contains a non-string value")
683 })
684 })
685 .collect::<Result<Vec<_>>>()?;
686 if expected
687 != entries
688 .iter()
689 .map(|entry| entry.id.as_str())
690 .collect::<Vec<_>>()
691 {
692 return Err(Error::new(
693 "Kweb recent-connection IDs metadata does not match its canonical text",
694 ));
695 }
696 }
697 Ok(entries)
698}
699
700fn format_recent_connection_entries(entries: &[RecentConnectionEntry]) -> String {
701 if entries.is_empty() {
702 return "Recent connections\nNone.".into();
703 }
704 format!(
705 "Recent connections\n{}",
706 entries
707 .iter()
708 .map(|entry| entry.text.as_str())
709 .collect::<Vec<_>>()
710 .join("\n")
711 )
712}
713
714fn revision_hash(text: &str) -> String {
715 hex::encode(Sha256::digest(text.as_bytes()))
716}
717
718fn update_recent_connection_content(
719 content: &mut BoxContent,
720 logical_slot: &str,
721 entries: &[RecentConnectionEntry],
722) {
723 content.text = format_recent_connection_entries(entries);
724 mark_kweb_content(content, logical_slot, "recent");
725 content.metadata["revisionHash"] = json!(revision_hash(&content.text));
726 content.metadata[RECENT_CONNECTION_IDS_METADATA] = json!(
727 entries
728 .iter()
729 .map(|entry| entry.id.as_str())
730 .collect::<Vec<_>>()
731 );
732}
733
734fn desired_recent_connection_boxes(
735 journal: &HistorySession,
736 fresh: DesiredKwebBox,
737) -> Result<Vec<DesiredKwebBox>> {
738 let mut boxes = Vec::new();
739 let mut seen = HashSet::new();
740 let mut used_logical_slots = HashSet::new();
741
742 if let Some(tool) = journal.state().tools.get(KWEB_TOOL_INSTANCE) {
743 for slot in &tool.slots {
744 let state = journal
745 .state()
746 .box_state(slot.box_id)
747 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
748 let logical_slot = kweb_logical_slot(state, &slot.slot);
749 used_logical_slots.insert(logical_slot.clone());
750 if slot.retired
751 || state
752 .canonical
753 .content
754 .metadata
755 .get("kwebRole")
756 .and_then(Value::as_str)
757 != Some("recent")
758 {
759 continue;
760 }
761 let entries = recent_connection_entries(&state.canonical.content)?;
762 for entry in &entries {
763 seen.insert(entry.id.clone());
764 }
765 boxes.push((
766 DesiredKwebBox {
767 logical_slot,
768 name: state.name.clone(),
769 content: state.canonical.content.clone(),
770 },
771 entries,
772 ));
773 }
774 }
775
776 let additions = recent_connection_entries(&fresh.content)?
777 .into_iter()
778 .filter(|entry| seen.insert(entry.id.clone()))
779 .collect::<Vec<_>>();
780 let mut next_addition = 0;
781
782 if let Some((last, entries)) = boxes.last_mut()
783 && entries.len() < RECENT_CONNECTIONS_PER_BOX
784 {
785 let available = RECENT_CONNECTIONS_PER_BOX - entries.len();
786 let end = additions.len().min(available);
787 entries.extend_from_slice(&additions[..end]);
788 next_addition = end;
789 if end > 0 {
790 update_recent_connection_content(&mut last.content, &last.logical_slot, entries);
791 }
792 }
793
794 while next_addition < additions.len() || boxes.is_empty() {
795 let end = (next_addition + RECENT_CONNECTIONS_PER_BOX).min(additions.len());
796 let entries = additions[next_addition..end].to_vec();
797 let mut sequence = boxes.len() + 1;
798 let logical_slot = loop {
799 let candidate = if sequence == 1 {
800 RECENT_CONNECTIONS_LOGICAL_SLOT.to_owned()
801 } else {
802 format!("{RECENT_CONNECTIONS_LOGICAL_SLOT}:{sequence}")
803 };
804 if used_logical_slots.insert(candidate.clone()) {
805 break candidate;
806 }
807 sequence += 1;
808 };
809 let mut content = fresh.content.clone();
810 update_recent_connection_content(&mut content, &logical_slot, &entries);
811 boxes.push((
812 DesiredKwebBox {
813 logical_slot,
814 name: fresh.name.clone(),
815 content,
816 },
817 entries,
818 ));
819 next_addition = end;
820 }
821
822 Ok(boxes.into_iter().map(|(box_spec, _)| box_spec).collect())
823}
824
825type KwebBoxVersions = BTreeMap<BoxId, (String, EventId)>;
826
827fn kweb_box_versions(journal: &HistorySession) -> KwebBoxVersions {
828 journal
829 .state()
830 .tool_layouts
831 .get(KWEB_TOOL_INSTANCE)
832 .into_iter()
833 .flatten()
834 .filter_map(|box_id| {
835 let state = journal.state().box_state(*box_id)?;
836 state
837 .active
838 .then(|| (*box_id, (state.name.clone(), state.canonical.event_id)))
839 })
840 .collect()
841}
842
843fn changed_kweb_box_ids(journal: &HistorySession, previous: &KwebBoxVersions) -> Vec<BoxId> {
844 journal
845 .state()
846 .tool_layouts
847 .get(KWEB_TOOL_INSTANCE)
848 .into_iter()
849 .flatten()
850 .filter_map(|box_id| {
851 let state = journal.state().box_state(*box_id)?;
852 let current = (state.name.as_str(), state.canonical.event_id);
853 let changed = previous
854 .get(box_id)
855 .map(|(name, revision)| (name.as_str(), *revision) != current)
856 .unwrap_or(true);
857 (state.active && changed).then_some(*box_id)
858 })
859 .collect()
860}
861
862fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
863 if used.insert(logical.to_owned()) {
864 return logical.to_owned();
865 }
866 let mut generation = 2_u64;
867 loop {
868 let candidate = format!("{logical}#generation-{generation}");
869 if used.insert(candidate.clone()) {
870 return candidate;
871 }
872 generation += 1;
873 }
874}
875
876fn reconcile_kweb_slots(
877 journal: &mut HistorySession,
878 recorded_at: &str,
879 desired: Vec<DesiredKwebBox>,
880) -> Result<()> {
881 let current = journal
882 .state()
883 .tools
884 .get(KWEB_TOOL_INSTANCE)
885 .cloned()
886 .unwrap_or_default();
887 let desired_by_logical = desired
888 .iter()
889 .enumerate()
890 .map(|(index, entry)| (entry.logical_slot.as_str(), index))
891 .collect::<BTreeMap<_, _>>();
892 if desired_by_logical.len() != desired.len() {
893 return Err(Error::new(
894 "Kweb box layout contains duplicate logical slots",
895 ));
896 }
897 let mut claimed = HashSet::new();
898 let mut actual_by_desired = BTreeMap::new();
899 let mut slots = Vec::with_capacity(current.slots.len() + desired.len());
900 let mut used_actual = current
901 .slots
902 .iter()
903 .map(|slot| slot.slot.clone())
904 .collect::<HashSet<_>>();
905 for slot in ¤t.slots {
906 let state = journal
907 .state()
908 .box_state(slot.box_id)
909 .ok_or_else(|| Error::new("Kweb tool slot box is missing"))?;
910 let logical = kweb_logical_slot(state, &slot.slot);
911 let selected = !slot.retired
912 && desired_by_logical.contains_key(logical.as_str())
913 && claimed.insert(logical.clone());
914 if selected {
915 let entry = &desired[desired_by_logical[logical.as_str()]];
916 slots.push(ToolSlotInput {
917 slot: slot.slot.clone(),
918 name: entry.name.clone(),
919 content: entry.content.clone(),
920 retired: false,
921 });
922 actual_by_desired.insert(entry.logical_slot.clone(), slot.slot.clone());
923 } else {
924 slots.push(ToolSlotInput {
925 slot: slot.slot.clone(),
926 name: state.name.clone(),
927 content: state.canonical.content.clone(),
928 retired: slot.retired || !selected,
929 });
930 }
931 }
932 for entry in &desired {
933 if actual_by_desired.contains_key(&entry.logical_slot) {
934 continue;
935 }
936 let actual = unique_slot(&entry.logical_slot, &mut used_actual);
937 slots.push(ToolSlotInput {
938 slot: actual.clone(),
939 name: entry.name.clone(),
940 content: entry.content.clone(),
941 retired: false,
942 });
943 actual_by_desired.insert(entry.logical_slot.clone(), actual);
944 }
945 let layout_slots = desired
946 .iter()
947 .map(|entry| actual_by_desired[&entry.logical_slot].clone())
948 .collect::<Vec<_>>();
949 journal
950 .apply_tool_slots_with_layout(recorded_at, KWEB_TOOL_INSTANCE, slots, &layout_slots)
951 .map_err(|error| Error::new(format!("applying Kweb projection: {error}")))?;
952 Ok(())
953}
954
955fn connections(
956 value: Option<&Value>,
957 summaries: &HashMap<String, &Value>,
958 label: &str,
959) -> Result<Vec<Connection>> {
960 let mut result = Vec::new();
961 let mut seen = HashSet::new();
962 for entry in value.and_then(Value::as_array).into_iter().flatten() {
963 let id = entry
964 .as_str()
965 .or_else(|| entry.get("id").and_then(Value::as_str))
966 .ok_or_else(|| Error::new(format!("{label} has no node ID")))?
967 .to_owned();
968 canonical_node_id(&id)?;
969 if !seen.insert(id.clone()) {
970 continue;
971 }
972 let summary = summaries.get(&id).copied();
973 result.push(Connection {
974 id,
975 short_name: entry
976 .get("short_name")
977 .and_then(Value::as_str)
978 .or_else(|| summary.and_then(|value| value.get("short_name")?.as_str()))
979 .unwrap_or_default()
980 .to_owned(),
981 short_description: entry
982 .get("short_description")
983 .and_then(Value::as_str)
984 .or_else(|| summary.and_then(|value| value.get("short_description")?.as_str()))
985 .unwrap_or_default()
986 .to_owned(),
987 });
988 }
989 Ok(result)
990}
991
992fn string_ids(value: Option<&Value>, label: &str) -> Result<Vec<String>> {
993 let mut result = Vec::new();
994 let mut seen = HashSet::new();
995 for entry in value.and_then(Value::as_array).into_iter().flatten() {
996 let id = entry
997 .as_str()
998 .ok_or_else(|| Error::new(format!("{label} ID must be a string")))?
999 .to_owned();
1000 if seen.insert(id.clone()) {
1001 result.push(id);
1002 }
1003 }
1004 Ok(result)
1005}
1006
1007fn canonical_node_id(value: &str) -> Result<()> {
1008 value
1009 .parse::<NodeId>()
1010 .map(|_| ())
1011 .map_err(|_| Error::new(format!("{value:?} is not a canonical Kweb node ID")))
1012}
1013
1014fn required_string(value: &Value, key: &str) -> Result<String> {
1015 value
1016 .get(key)
1017 .and_then(Value::as_str)
1018 .map(str::to_owned)
1019 .ok_or_else(|| Error::new(format!("Kweb node has no string {key}")))
1020}
1021
1022fn optional_string(value: &Value, key: &str) -> String {
1023 value
1024 .get(key)
1025 .and_then(Value::as_str)
1026 .unwrap_or_default()
1027 .to_owned()
1028}
1029
1030fn format_node(identifier: &str, node: &NodeDraft) -> String {
1031 [
1032 format!("Node ID: {identifier}"),
1033 format!("Node name: {}", fallback(&node.short_name)),
1034 format!("Node summary: {}", fallback(&node.short_description)),
1035 format!("Node owner ID: {}", fallback(&node.owner)),
1036 "Node long description:".into(),
1037 indent(&node.long_description),
1038 format!(
1039 "Fixed connection IDs: {}",
1040 list_or_none(&node.fixed_connections)
1041 ),
1042 format!(
1043 "Recent connection IDs: {}",
1044 list_or_none(&node.recent_connections)
1045 ),
1046 ]
1047 .join("\n")
1048}
1049
1050fn indent(value: &str) -> String {
1051 fallback(value)
1052 .lines()
1053 .map(|line| format!(" {line}"))
1054 .collect::<Vec<_>>()
1055 .join("\n")
1056}
1057
1058fn fallback(value: &str) -> &str {
1059 if value.trim().is_empty() {
1060 "(none)"
1061 } else {
1062 value
1063 }
1064}
1065
1066fn list_or_none(values: &[String]) -> String {
1067 if values.is_empty() {
1068 "none".into()
1069 } else {
1070 values.join(", ")
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use std::path::PathBuf;
1077 use std::time::{SystemTime, UNIX_EPOCH};
1078
1079 use super::*;
1080 use kcode_session_history::{
1081 Config as HistoryConfig, NewSession, SessionHistory,
1082 chatend::{Representation, SessionKind},
1083 };
1084 use serde_json::json;
1085
1086 fn id(index: u8) -> String {
1087 NodeId::from_bytes([0, 0, 0, 0, 0, index])
1088 .unwrap()
1089 .to_string()
1090 }
1091
1092 fn connection(index: u8) -> Connection {
1093 Connection {
1094 id: id(index),
1095 short_name: format!("Node {index}"),
1096 short_description: format!("Summary {index}"),
1097 }
1098 }
1099
1100 fn node(index: u8, fixed: &[u8], recent: &[u8]) -> Node {
1101 Node {
1102 id: id(index),
1103 short_name: format!("Node {index}"),
1104 short_description: format!("Summary {index}"),
1105 long_description: format!("Long description {index}"),
1106 owner: id(1),
1107 fixed_connections: fixed.iter().copied().map(connection).collect(),
1108 recent_connections: recent.iter().copied().map(connection).collect(),
1109 objects: vec![],
1110 last_modified_by: "test-model-high".into(),
1111 last_modified_at: Some("2026-07-28T00:00:00Z".into()),
1112 }
1113 }
1114
1115 fn node_with_recent_description(
1116 index: u8,
1117 fixed: &[u8],
1118 recent: &[u8],
1119 description: &str,
1120 ) -> Node {
1121 let mut node = node(index, fixed, recent);
1122 for (connection, connection_index) in node.recent_connections.iter_mut().zip(recent) {
1123 connection.short_description = format!("{description} {connection_index}");
1124 }
1125 node
1126 }
1127
1128 fn draft(index: u8, recent: &[u8]) -> NodeDraft {
1129 NodeDraft {
1130 short_name: format!("Node {index}"),
1131 short_description: format!("Summary {index}"),
1132 long_description: format!("Long description {index}"),
1133 owner: id(1),
1134 fixed_connections: Vec::new(),
1135 recent_connections: recent.iter().map(|value| id(*value)).collect(),
1136 objects: Vec::new(),
1137 }
1138 }
1139
1140 fn test_journal(label: &str) -> (PathBuf, HistorySession) {
1141 let root = std::env::temp_dir().join(format!(
1142 "kcode-kweb-context-{label}-{}-{}",
1143 std::process::id(),
1144 SystemTime::now()
1145 .duration_since(UNIX_EPOCH)
1146 .unwrap()
1147 .as_nanos()
1148 ));
1149 let history = SessionHistory::open(HistoryConfig {
1150 directory: root.join("sessions"),
1151 completed_list: root.join("completed.jsonl"),
1152 provider_cost_compatibility: None,
1153 })
1154 .unwrap();
1155 let journal = history
1156 .create_session(NewSession {
1157 kind: SessionKind::Conversation,
1158 created_at: "2026-07-29T00:00:00Z".into(),
1159 effective_context_tokens: 10_000,
1160 channel: Value::Null,
1161 })
1162 .unwrap();
1163 (root, journal)
1164 }
1165
1166 fn recent_box_ids(journal: &HistorySession) -> Vec<BoxId> {
1167 journal
1168 .state()
1169 .tool_layouts
1170 .get(KWEB_TOOL_INSTANCE)
1171 .into_iter()
1172 .flatten()
1173 .copied()
1174 .filter(|box_id| {
1175 journal
1176 .state()
1177 .box_state(*box_id)
1178 .and_then(|state| state.canonical.content.metadata.get("kwebRole"))
1179 .and_then(Value::as_str)
1180 == Some("recent")
1181 })
1182 .collect()
1183 }
1184
1185 #[test]
1186 fn parses_the_kweb_wire_shape_into_typed_connections() {
1187 let parsed = Node::from_kweb_value(&json!({
1188 "id": id(1),
1189 "owner_node_id": id(1),
1190 "short_name": "Root",
1191 "short_description": "Root summary",
1192 "long_description": "Root details",
1193 "fixed_connections": [id(2)],
1194 "recent_connections": [id(3), id(3)],
1195 "objects": [],
1196 "connection_summaries": [
1197 {"id":id(2),"short_name":"Fixed","short_description":"Fixed summary"},
1198 {"id":id(3),"short_name":"Recent","short_description":"Recent summary"}
1199 ]
1200 }))
1201 .unwrap();
1202 assert_eq!(
1203 parsed.fixed_connections,
1204 vec![Connection {
1205 id: id(2),
1206 short_name: "Fixed".into(),
1207 short_description: "Fixed summary".into(),
1208 }]
1209 );
1210 assert_eq!(parsed.recent_connections.len(), 1);
1211 assert_eq!(parsed.recent_connections[0].short_name, "Recent");
1212 }
1213
1214 #[test]
1215 fn loaded_nodes_take_precedence_over_the_fixed_role() {
1216 let mut context = Context::new(vec![id(1)]).unwrap();
1217 context
1218 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1219 .unwrap();
1220 let report = context.apply_load(node(2, &[], &[]), Vec::new()).unwrap();
1221 assert!(report.promoted_from_fixed);
1222 assert_eq!(context.loaded_node_ids(), &[id(1), id(2)]);
1223 assert!(context.fixed_node_ids().is_empty());
1224 assert_eq!(
1225 context
1226 .box_specs(&BTreeMap::new(), &[])
1227 .unwrap()
1228 .iter()
1229 .map(|spec| spec.kind)
1230 .collect::<Vec<_>>(),
1231 vec![BoxKind::Loaded, BoxKind::Loaded, BoxKind::Recent]
1232 );
1233 }
1234
1235 #[test]
1236 fn effect_free_projection_has_stable_keys_and_complete_current_text() {
1237 let mut context = Context::new(vec![id(1)]).unwrap();
1238 context
1239 .apply_load(node(1, &[2], &[3]), vec![node(2, &[], &[])])
1240 .unwrap();
1241
1242 let projected = context.projection(&BTreeMap::new(), &[]).unwrap();
1243 assert_eq!(
1244 projected
1245 .iter()
1246 .map(|item| item.key.as_str())
1247 .collect::<Vec<_>>(),
1248 vec![id(1), id(2), "recent-connections".to_owned()]
1249 );
1250 assert_eq!(projected[0].name, "Kweb loaded node");
1251 assert!(projected[0].text.contains("Long description 1"));
1252 assert_eq!(projected[2].name, "Kweb recent connections");
1253 assert!(projected[2].text.contains(&id(3)));
1254 }
1255
1256 #[test]
1257 fn full_node_kinds_share_one_body_format_without_active_connections() {
1258 let mut context = Context::new(vec![id(1)]).unwrap();
1259 context
1260 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1261 .unwrap();
1262 let create = StagedCreate {
1263 pending_id: "pending:1".into(),
1264 data: draft(3, &[]),
1265 };
1266 let specs = context.box_specs(&BTreeMap::new(), &[create]).unwrap();
1267 assert_eq!(specs[0].kind.name(), "Kweb loaded node");
1268 assert_eq!(specs[1].kind.name(), "Kweb fixed connection");
1269 assert_eq!(specs[2].kind.name(), "Kweb staged node");
1270 for spec in &specs[..3] {
1271 assert!(spec.text.contains("Node ID:"));
1272 assert!(spec.text.contains("Node name:"));
1273 assert!(spec.text.contains("Node owner ID:"));
1274 assert!(spec.text.contains("Fixed connection IDs:"));
1275 assert!(spec.text.contains("Recent connection IDs:"));
1276 assert!(!spec.text.contains("Active"));
1277 }
1278 assert_eq!(
1279 specs[0].text,
1280 concat!(
1281 "Node ID: AAAAAAAB\n",
1282 "Node name: Node 1\n",
1283 "Node summary: Summary 1\n",
1284 "Node owner ID: AAAAAAAB\n",
1285 "Node long description:\n",
1286 " Long description 1\n",
1287 "Fixed connection IDs: AAAAAAAC\n",
1288 "Recent connection IDs: none"
1289 )
1290 );
1291 }
1292
1293 #[test]
1294 fn all_recent_connections_share_one_globally_deduplicated_box() {
1295 let mut context = Context::new(vec![id(1)]).unwrap();
1296 context
1297 .apply_load(node(1, &[2], &[4, 5]), vec![node(2, &[], &[5, 6, 7])])
1298 .unwrap();
1299 let creates = vec![StagedCreate {
1300 pending_id: "pending:1".into(),
1301 data: draft(3, &[6, 7]),
1302 }];
1303 let specs = context.box_specs(&BTreeMap::new(), &creates).unwrap();
1304 let recent = specs
1305 .iter()
1306 .filter(|spec| spec.kind == BoxKind::Recent)
1307 .collect::<Vec<_>>();
1308 assert_eq!(recent.len(), 1);
1309 assert_eq!(
1310 recent[0].text,
1311 format!(
1312 concat!(
1313 "Recent connections\n",
1314 "{} · Node 4: Summary 4\n",
1315 "{} · Node 5: Summary 5\n",
1316 "{} · Node 6: Summary 6\n",
1317 "{} · Node 7: Summary 7"
1318 ),
1319 id(4),
1320 id(5),
1321 id(6),
1322 id(7)
1323 )
1324 );
1325 }
1326
1327 #[test]
1328 fn empty_recent_projection_is_still_one_exact_box() {
1329 let mut context = Context::new(vec![id(1)]).unwrap();
1330 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1331 let specs = context.box_specs(&BTreeMap::new(), &[]).unwrap();
1332 assert_eq!(specs.len(), 2);
1333 assert_eq!(specs[1].kind, BoxKind::Recent);
1334 assert_eq!(specs[1].text, "Recent connections\nNone.");
1335 }
1336
1337 #[test]
1338 fn recent_projection_rejects_missing_name_or_description() {
1339 for missing_name in [true, false] {
1340 let mut source = node(1, &[], &[2]);
1341 if missing_name {
1342 source.recent_connections[0].short_name.clear();
1343 } else {
1344 source.recent_connections[0].short_description.clear();
1345 }
1346 let mut context = Context::new(vec![id(1)]).unwrap();
1347 context.apply_load(source, Vec::new()).unwrap();
1348 assert_eq!(
1349 context
1350 .box_specs(&BTreeMap::new(), &[])
1351 .unwrap_err()
1352 .to_string(),
1353 format!(
1354 "recent connection {} must resolve to a nonempty short name and short description",
1355 id(2)
1356 )
1357 );
1358 }
1359 }
1360
1361 #[test]
1362 fn staged_updates_drive_full_text_and_recent_projection() {
1363 let mut context = Context::new(vec![id(1)]).unwrap();
1364 context
1365 .apply_load(node(1, &[3], &[2]), vec![node(3, &[], &[])])
1366 .unwrap();
1367 let mut updates = BTreeMap::new();
1368 updates.insert(id(1), draft(9, &[3]));
1369 let specs = context.box_specs(&updates, &[]).unwrap();
1370 assert!(specs[0].text.contains("Node name: Node 9"));
1371 assert!(specs[0].staged_node.is_some());
1372 assert!(!specs.last().unwrap().text.contains(&id(2)));
1373 assert!(specs.last().unwrap().text.contains(&id(3)));
1374 }
1375
1376 #[test]
1377 fn sync_fills_permanent_recent_boxes_eight_at_a_time() {
1378 let (root, mut journal) = test_journal("recent-boxes");
1379 let mut context = Context::new(vec![id(1)]).unwrap();
1380 let initial_indices = (2..=19).collect::<Vec<_>>();
1381 context
1382 .apply_load(
1383 node_with_recent_description(1, &[], &initial_indices, "old"),
1384 Vec::new(),
1385 )
1386 .unwrap();
1387 context
1388 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1389 .unwrap();
1390
1391 let original_ids = recent_box_ids(&journal);
1392 assert_eq!(
1393 original_ids
1394 .iter()
1395 .map(|box_id| {
1396 recent_connection_entries(
1397 &journal
1398 .state()
1399 .box_state(*box_id)
1400 .unwrap()
1401 .canonical
1402 .content,
1403 )
1404 .unwrap()
1405 .len()
1406 })
1407 .collect::<Vec<_>>(),
1408 vec![8, 8, 2]
1409 );
1410 let original_revisions = original_ids
1411 .iter()
1412 .map(|box_id| {
1413 journal
1414 .state()
1415 .box_state(*box_id)
1416 .unwrap()
1417 .canonical
1418 .event_id
1419 })
1420 .collect::<Vec<_>>();
1421 journal
1422 .summarize_box("t2", original_ids[0], "retained first box")
1423 .unwrap();
1424 journal.dehydrate_boxes("t3", &original_ids[1..=2]).unwrap();
1425
1426 let expanded_indices = (2..=28).collect::<Vec<_>>();
1427 context
1428 .refresh([node_with_recent_description(
1429 1,
1430 &[],
1431 &expanded_indices,
1432 "new",
1433 )])
1434 .unwrap();
1435 let changed = context
1436 .sync_chatend(&mut journal, "t4", &BTreeMap::new(), &[])
1437 .unwrap();
1438 let current_ids = recent_box_ids(&journal);
1439 assert_eq!(
1440 current_ids
1441 .iter()
1442 .map(|box_id| {
1443 recent_connection_entries(
1444 &journal
1445 .state()
1446 .box_state(*box_id)
1447 .unwrap()
1448 .canonical
1449 .content,
1450 )
1451 .unwrap()
1452 .len()
1453 })
1454 .collect::<Vec<_>>(),
1455 vec![8, 8, 8, 3]
1456 );
1457 assert_eq!(¤t_ids[..3], original_ids.as_slice());
1458 assert!(!changed.contains(&original_ids[0]));
1459 assert!(!changed.contains(&original_ids[1]));
1460 assert!(changed.contains(&original_ids[2]));
1461 assert!(changed.contains(¤t_ids[3]));
1462
1463 let first = journal.state().box_state(original_ids[0]).unwrap();
1464 assert_eq!(first.canonical.event_id, original_revisions[0]);
1465 assert!(matches!(
1466 first.representation,
1467 Representation::Summarized { based_on, .. } if based_on == first.canonical.event_id
1468 ));
1469 let second = journal.state().box_state(original_ids[1]).unwrap();
1470 assert_eq!(second.canonical.event_id, original_revisions[1]);
1471 assert!(matches!(
1472 second.representation,
1473 Representation::Dehydrated { based_on } if based_on == second.canonical.event_id
1474 ));
1475 let third = journal.state().box_state(original_ids[2]).unwrap();
1476 assert_ne!(third.canonical.event_id, original_revisions[2]);
1477 assert!(third.canonical.content.text.contains("old 19"));
1478 assert!(third.canonical.content.text.contains("new 25"));
1479 assert!(!third.canonical.content.text.contains("new 19"));
1480 assert!(matches!(
1481 third.representation,
1482 Representation::Dehydrated { based_on } if based_on == original_revisions[2]
1483 ));
1484
1485 drop(journal);
1486 std::fs::remove_dir_all(root).unwrap();
1487 }
1488
1489 #[test]
1490 fn sync_starts_one_empty_fillable_recent_box() {
1491 let (root, mut journal) = test_journal("empty-recent-box");
1492 let mut context = Context::new(vec![id(1)]).unwrap();
1493 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1494 context
1495 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1496 .unwrap();
1497
1498 let boxes = recent_box_ids(&journal);
1499 assert_eq!(boxes.len(), 1);
1500 let content = &journal
1501 .state()
1502 .box_state(boxes[0])
1503 .unwrap()
1504 .canonical
1505 .content;
1506 assert!(recent_connection_entries(content).unwrap().is_empty());
1507 assert_eq!(content.metadata[RECENT_CONNECTION_IDS_METADATA], json!([]));
1508
1509 drop(journal);
1510 std::fs::remove_dir_all(root).unwrap();
1511 }
1512
1513 #[test]
1514 fn sync_reports_only_changed_boxes_in_projection_order() {
1515 let (root, mut journal) = test_journal("changed-boxes");
1516 let mut context = Context::new(vec![id(1)]).unwrap();
1517 context.apply_load(node(1, &[], &[]), Vec::new()).unwrap();
1518 let initial = context
1519 .sync_chatend(&mut journal, "t1", &BTreeMap::new(), &[])
1520 .unwrap();
1521 assert_eq!(initial.len(), 2);
1522 assert!(
1523 context
1524 .sync_chatend(&mut journal, "t2", &BTreeMap::new(), &[],)
1525 .unwrap()
1526 .is_empty()
1527 );
1528
1529 context
1530 .apply_load(node(1, &[2], &[]), vec![node(2, &[], &[])])
1531 .unwrap();
1532 let changed = context
1533 .sync_chatend(&mut journal, "t3", &BTreeMap::new(), &[])
1534 .unwrap();
1535 assert_eq!(changed.len(), 2);
1536 assert_eq!(
1537 changed
1538 .iter()
1539 .map(|box_id| journal.state().box_state(*box_id).unwrap().name.as_str())
1540 .collect::<Vec<_>>(),
1541 vec!["Kweb loaded node", "Kweb fixed connection"]
1542 );
1543
1544 drop(journal);
1545 std::fs::remove_dir_all(root).unwrap();
1546 }
1547}