1use std::collections::{BTreeMap, BTreeSet};
29use std::path::{Path, PathBuf};
30
31use super::authority::CURATED_TOOL;
32use super::{
33 BoundEntry, CuratedRef, HandoffLedgerV1, KnowledgeExcerpt, KnowledgeFactMini, MAX_CURATED_REFS,
34 MAX_KNOWLEDGE_FACTS, MAX_LEDGER_FINDINGS, PartKind, SCHEMA_VERSION, SessionExcerpt,
35 ToolCallsSummary, load_served, manifest_md5, persist_ledger,
36};
37use crate::core::context_admission::carrier::Withheld;
38use crate::core::context_admission::stored::SourceRef;
39
40pub const MAX_MERGE_INPUTS: usize = 8;
42const MAX_DECISIONS: usize = 10;
44const MAX_NEXT_STEPS: usize = 20;
45
46const SOURCE_CHANGED: &str = "store.source_changed";
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct FactConflict {
52 pub category: String,
53 pub key: String,
54 pub values: usize,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Dropped {
61 pub findings: usize,
62 pub facts: usize,
63 pub curated_refs: usize,
64 pub unbound_curated: usize,
66 pub decisions: usize,
67 pub next_steps: usize,
68}
69
70#[derive(Debug)]
72pub struct MergeOutcome {
73 pub ledger: HandoffLedgerV1,
74 pub path: PathBuf,
75 pub conflicts: Vec<FactConflict>,
76 pub distinct_tasks: usize,
77 pub dropped: Dropped,
78}
79
80struct Input {
82 ledger: HandoffLedgerV1,
83 digest: String,
86 bound: Vec<BoundEntry>,
88 files: Option<(String, Vec<(String, String, String)>)>,
90}
91
92impl Input {
93 fn bound(&self, kind: PartKind, index: usize) -> bool {
94 self.bound
95 .iter()
96 .any(|entry| entry.kind == kind && entry.index == index)
97 }
98}
99
100pub fn merge_ledgers(paths: &[PathBuf]) -> Result<MergeOutcome, String> {
102 let unique: BTreeSet<&Path> = paths.iter().map(PathBuf::as_path).collect();
103 if unique.len() > MAX_MERGE_INPUTS {
104 return Err(format!("merge reads at most {MAX_MERGE_INPUTS} ledgers"));
105 }
106 let mut inputs = Vec::with_capacity(unique.len());
107 for path in unique {
108 let served = load_served(path)?;
109 inputs.push(Input {
110 digest: super::ledger_content_md5(&served.ledger),
111 ledger: served.ledger,
112 bound: served
113 .lineage
114 .map(|lineage| lineage.bound)
115 .unwrap_or_default(),
116 files: served.files,
117 });
118 }
119 inputs
121 .sort_by(|a, b| (&a.ledger.created_at, &a.digest).cmp(&(&b.ledger.created_at, &b.digest)));
122 inputs.dedup_by(|a, b| a.digest == b.digest);
123 if inputs.len() < 2 {
124 return Err("merge needs at least two different ledgers".to_string());
125 }
126 let project_root = inputs[0].ledger.project_root.clone();
127 if inputs
128 .iter()
129 .any(|input| input.ledger.project_root != project_root)
130 {
131 return Err("merge refused: the ledgers belong to different projects".to_string());
132 }
133
134 let mut dropped = Dropped::default();
135 let (findings, finding_bound) = merge_findings(&inputs, &mut dropped);
136 let (facts, fact_bound, conflicts) = merge_facts(&inputs, &mut dropped);
137 let curated = merge_curated(&inputs, &mut dropped);
138 let any_bound = finding_bound.iter().chain(&fact_bound).any(|bound| *bound);
139 let (curated_refs, sources) = rebind(&inputs, curated, any_bound, &mut dropped)?;
140 let tasks: BTreeSet<&str> = inputs
141 .iter()
142 .filter_map(|input| input.ledger.session.task.as_deref())
143 .collect();
144 let distinct_tasks = tasks.len();
145 let merged_from: Vec<String> = inputs.iter().map(|input| input.digest.clone()).collect();
146
147 let mut ledger = HandoffLedgerV1 {
148 schema_version: SCHEMA_VERSION,
149 created_at: inputs
150 .iter()
151 .map(|input| input.ledger.created_at.clone())
152 .max()
153 .unwrap_or_default(),
154 content_md5: String::new(),
155 manifest_md5: manifest_md5(),
156 project_root: project_root.clone(),
157 agent_id: None,
158 client_name: None,
159 workflow: common(&inputs, |ledger| ledger.workflow.as_ref()),
160 session_snapshot: String::new(),
163 session: SessionExcerpt {
164 id: format!("merge-{}", short_digest(&merged_from)),
165 task: (distinct_tasks == 1)
166 .then(|| tasks.first().map(|task| (*task).to_owned()))
167 .flatten(),
168 decisions: keep_last(
169 union(&inputs, |ledger| &ledger.session.decisions),
170 MAX_DECISIONS,
171 &mut dropped.decisions,
172 ),
173 findings,
174 next_steps: keep_first(
175 union(&inputs, |ledger| &ledger.session.next_steps),
176 MAX_NEXT_STEPS,
177 &mut dropped.next_steps,
178 ),
179 },
180 tool_calls: sum_tool_calls(&inputs),
181 evidence_keys: inputs
182 .iter()
183 .flat_map(|input| input.ledger.evidence_keys.iter().cloned())
184 .collect::<BTreeSet<_>>()
185 .into_iter()
186 .collect(),
187 knowledge: KnowledgeExcerpt {
188 project_hash: inputs
189 .iter()
190 .find_map(|input| input.ledger.knowledge.project_hash.clone()),
191 facts,
192 },
193 curated_refs,
194 active_overlays: merge_overlays(&inputs),
195 merged_from,
196 request_id: None,
197 request_digest: None,
198 };
199 ledger.content_md5 = super::ledger_content_md5(&ledger);
200
201 let path = persist_ledger(&ledger, sources, |kind, index| {
202 let flags = match kind {
203 PartKind::Finding => &finding_bound,
204 PartKind::Fact => &fact_bound,
205 };
206 flags.get(index).copied().unwrap_or(false)
207 })?;
208 Ok(MergeOutcome {
209 ledger,
210 path,
211 conflicts,
212 distinct_tasks,
213 dropped,
214 })
215}
216
217fn merge_findings(inputs: &[Input], dropped: &mut Dropped) -> (Vec<String>, Vec<bool>) {
221 let mut order: Vec<String> = Vec::new();
222 let mut bound: BTreeMap<String, bool> = BTreeMap::new();
223 for input in inputs {
224 for (index, finding) in input.ledger.session.findings.iter().enumerate() {
225 let is_bound = input.bound(PartKind::Finding, index);
226 if let Some(flag) = bound.get_mut(finding) {
227 *flag |= is_bound;
228 } else {
229 order.push(finding.clone());
230 bound.insert(finding.clone(), is_bound);
231 }
232 }
233 }
234 let kept = keep_last(order, MAX_LEDGER_FINDINGS, &mut dropped.findings);
235 let flags = kept.iter().map(|finding| bound[finding]).collect();
236 (kept, flags)
237}
238
239fn merge_facts(
243 inputs: &[Input],
244 dropped: &mut Dropped,
245) -> (Vec<KnowledgeFactMini>, Vec<bool>, Vec<FactConflict>) {
246 let mut facts: Vec<(KnowledgeFactMini, bool)> = Vec::new();
247 for input in inputs {
248 for (index, fact) in input.ledger.knowledge.facts.iter().enumerate() {
249 let is_bound = input.bound(PartKind::Fact, index);
250 let same = facts.iter_mut().find(|(kept, _)| {
251 kept.category == fact.category && kept.key == fact.key && kept.value == fact.value
252 });
253 match same {
254 Some((kept, flag)) => {
255 kept.confidence = kept.confidence.max(fact.confidence);
256 *flag |= is_bound;
257 }
258 None => facts.push((fact.clone(), is_bound)),
259 }
260 }
261 }
262 let mut values: BTreeMap<(&str, &str), usize> = BTreeMap::new();
263 for (fact, _) in &facts {
264 *values.entry((&fact.category, &fact.key)).or_insert(0) += 1;
265 }
266 let conflicts = values
267 .into_iter()
268 .filter(|(_, count)| *count > 1)
269 .map(|((category, key), values)| FactConflict {
270 category: category.to_owned(),
271 key: key.to_owned(),
272 values,
273 })
274 .collect();
275 let kept = keep_first(facts, MAX_KNOWLEDGE_FACTS, &mut dropped.facts);
276 let (facts, flags): (Vec<_>, Vec<_>) = kept.into_iter().unzip();
277 (facts, flags, conflicts)
278}
279
280fn merge_curated(inputs: &[Input], dropped: &mut Dropped) -> Vec<CuratedRef> {
283 let mut seen = BTreeSet::new();
284 let refs = inputs
285 .iter()
286 .flat_map(|input| input.ledger.curated_refs.iter())
287 .filter(|excerpt| {
288 let digest = crate::core::hasher::hash_hex(excerpt.content.as_bytes());
289 seen.insert((excerpt.path.clone(), digest))
290 })
291 .cloned()
292 .collect();
293 keep_first(refs, MAX_CURATED_REFS, &mut dropped.curated_refs)
294}
295
296fn merge_overlays(inputs: &[Input]) -> Vec<serde_json::Value> {
297 let mut seen = BTreeSet::new();
298 inputs
299 .iter()
300 .flat_map(|input| input.ledger.active_overlays.iter())
301 .filter(|overlay| seen.insert(overlay.to_string()))
302 .cloned()
303 .collect()
304}
305
306fn sum_tool_calls(inputs: &[Input]) -> ToolCallsSummary {
307 let mut sum = ToolCallsSummary::default();
308 for calls in inputs.iter().map(|input| &input.ledger.tool_calls) {
309 sum.total += calls.total;
310 for (tool, count) in &calls.by_tool {
311 *sum.by_tool.entry(tool.clone()).or_insert(0) += count;
312 }
313 for (mode, count) in &calls.by_ctx_read_mode {
314 *sum.by_ctx_read_mode.entry(mode.clone()).or_insert(0) += count;
315 }
316 }
317 sum
318}
319
320fn common<T: Clone + serde::Serialize>(
322 inputs: &[Input],
323 value: impl Fn(&HandoffLedgerV1) -> Option<&T>,
324) -> Option<T> {
325 let first = value(&inputs.first()?.ledger)?;
326 let first_json = serde_json::to_value(first).ok()?;
327 inputs
328 .iter()
329 .all(|input| {
330 value(&input.ledger)
331 .and_then(|other| serde_json::to_value(other).ok())
332 .is_some_and(|other| other == first_json)
333 })
334 .then(|| first.clone())
335}
336
337fn union(inputs: &[Input], texts: impl Fn(&HandoffLedgerV1) -> &Vec<String>) -> Vec<String> {
339 let mut seen = BTreeSet::new();
340 inputs
341 .iter()
342 .flat_map(|input| texts(&input.ledger).iter())
343 .filter(|text| seen.insert(text.as_str()))
344 .cloned()
345 .collect()
346}
347
348fn keep_first<T>(mut items: Vec<T>, bound: usize, dropped: &mut usize) -> Vec<T> {
349 *dropped = items.len().saturating_sub(bound);
350 items.truncate(bound);
351 items
352}
353
354fn keep_last<T>(mut items: Vec<T>, bound: usize, dropped: &mut usize) -> Vec<T> {
355 let cut = items.len().saturating_sub(bound);
356 *dropped = cut;
357 items.drain(..cut);
358 items
359}
360
361fn short_digest(inputs: &[String]) -> String {
362 let joined = inputs.join("\n");
363 crate::core::hasher::hash_hex(joined.as_bytes())
364 .chars()
365 .take(16)
366 .collect()
367}
368
369fn rebind(
383 inputs: &[Input],
384 curated: Vec<CuratedRef>,
385 any_bound: bool,
386 dropped: &mut Dropped,
387) -> Result<(Vec<CuratedRef>, Vec<SourceRef>), String> {
388 let required = crate::core::context_admission::stored::source_authority_required();
389 let refused = || Withheld::new(&[SOURCE_CHANGED]).message("handoff merge");
390 let mut remaining = crate::core::limits::max_read_bytes();
391 crate::core::archive::authority::without_capture(|| {
392 let mut sources = Vec::new();
393 let mut kept = Vec::new();
394 for excerpt in curated {
395 let named = inputs.iter().find_map(|input| {
396 let (project, files) = input.files.as_ref()?;
397 files
398 .iter()
399 .find(|(path, tool, _)| *path == excerpt.path && tool == CURATED_TOOL)
400 .map(|(path, tool, digest)| (project, path, tool, digest))
401 });
402 let Some((project, path, tool, digest)) = named else {
403 dropped.unbound_curated += 1;
404 continue;
405 };
406 match SourceRef::read_as_admitted_within(path, project, tool, digest, &mut remaining) {
407 Some(source) => {
408 sources.push(source);
409 kept.push(excerpt);
410 }
411 None if required => return Err(refused()),
412 None => dropped.unbound_curated += 1,
413 }
414 }
415 if any_bound {
416 for input in inputs.iter().filter(|input| !input.bound.is_empty()) {
417 let Some((project, files)) = input.files.as_ref() else {
418 continue;
419 };
420 for (path, tool, digest) in files.iter().filter(|(_, tool, _)| tool != CURATED_TOOL)
421 {
422 match SourceRef::read_as_admitted_within(
423 path,
424 project,
425 tool,
426 digest,
427 &mut remaining,
428 ) {
429 Some(source) => sources.push(source),
430 None if required => return Err(refused()),
431 None => {}
432 }
433 }
434 }
435 }
436 Ok((kept, sources))
437 })
438}