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