1use super::*;
4
5impl KhiveRuntime {
6 pub async fn merge_note(
17 &self,
18 token: &NamespaceToken,
19 into_id: Uuid,
20 from_id: Uuid,
21 strategy: EntityDedupMergePolicy,
22 content_strategy: ContentMergeStrategy,
23 dry_run: bool,
24 ) -> RuntimeResult<MergeSummary> {
25 self.merge_note_with_reason(
26 token,
27 into_id,
28 from_id,
29 strategy,
30 content_strategy,
31 dry_run,
32 None,
33 )
34 .await
35 }
36
37 #[allow(clippy::too_many_arguments)]
41 pub async fn merge_note_with_reason(
42 &self,
43 token: &NamespaceToken,
44 into_id: Uuid,
45 from_id: Uuid,
46 strategy: EntityDedupMergePolicy,
47 content_strategy: ContentMergeStrategy,
48 dry_run: bool,
49 reason: Option<String>,
50 ) -> RuntimeResult<MergeSummary> {
51 self.merge_note_with_guard(
52 token,
53 into_id,
54 from_id,
55 strategy,
56 content_strategy,
57 dry_run,
58 reason,
59 None,
60 )
61 .await
62 .map(|(summary, _)| summary)
63 }
64
65 #[allow(clippy::too_many_arguments)]
82 pub async fn merge_note_guarded(
83 &self,
84 token: &NamespaceToken,
85 into_id: Uuid,
86 from_id: Uuid,
87 strategy: EntityDedupMergePolicy,
88 content_strategy: ContentMergeStrategy,
89 dry_run: bool,
90 reason: Option<String>,
91 guard: NoteMergeGuard,
92 ) -> RuntimeResult<GuardedNoteMerge> {
93 let (summary, kept_version) = self
94 .merge_note_with_guard(
95 token,
96 into_id,
97 from_id,
98 strategy,
99 content_strategy,
100 dry_run,
101 reason,
102 Some(guard),
103 )
104 .await?;
105 Ok(GuardedNoteMerge {
106 summary,
107 kept_version,
108 })
109 }
110
111 #[allow(clippy::too_many_arguments)]
115 pub(super) async fn merge_note_with_guard(
116 &self,
117 token: &NamespaceToken,
118 into_id: Uuid,
119 from_id: Uuid,
120 strategy: EntityDedupMergePolicy,
121 content_strategy: ContentMergeStrategy,
122 dry_run: bool,
123 reason: Option<String>,
124 merge_guard: Option<NoteMergeGuard>,
125 ) -> RuntimeResult<(MergeSummary, i64)> {
126 if let Some(reason) = reason.as_deref() {
127 crate::secret_gate::check_at(reason, "merge", "reason")?;
128 }
129 if let Some(merge_guard) = merge_guard.as_ref() {
130 merge_guard.check_values()?;
131 }
132 if into_id == from_id {
133 return Err(RuntimeError::InvalidInput(
134 "cannot merge a note into itself".into(),
135 ));
136 }
137 let ns = token.namespace().as_str().to_string();
138 let fts_table = "fts_notes".to_string();
139 let embedding_plan = EmbeddingModelPlan::capture(self);
142 let vec_tables = embedding_plan.vector_tables();
143 let pack_rules = self.pack_edge_rules();
144
145 let note_store = self.notes(token)?;
146 let into_note = note_store
147 .get_note(into_id)
148 .await?
149 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
150 Self::ensure_namespace(&into_note.namespace, &ns)?;
151
152 let from_note = note_store
153 .get_note(from_id)
154 .await?
155 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
156 Self::ensure_namespace(&from_note.namespace, &ns)?;
157
158 if !dry_run {
159 for note in [&into_note, &from_note] {
160 if let Some(error) = self.stream_member_error(note).await? {
161 return Err(error);
162 }
163 }
164 }
165 reject_pack_managed_schedule_mutation(&into_note, "merge")?;
166 reject_pack_managed_schedule_mutation(&from_note, "merge")?;
167
168 let _ = self.graph(token)?;
169 let _ = self.text_for_notes(token)?;
170 let _ = self.events(token)?;
171 for model_name in embedding_plan.model_names() {
172 let _ = self.vectors_for_model(token, model_name)?;
173 }
174
175 let preserve_owner_established = self.is_pack_owned_note_kind(&into_note.kind);
180
181 let pool = self.backend().pool_arc();
182 let writer_task = pool
183 .writer_task_for_runtime_write(RuntimeWriteOperation::MergeNote)
184 .map_err(RuntimeError::Storage)?;
185 let event_context = MergeEventContext {
186 attribution: EventAttribution::from_token(token),
187 reason,
188 force: false,
189 strategy,
190 content_strategy,
191 kind: EventKind::NoteMerged,
192 substrate: SubstrateKind::Note,
193 event_id: None,
194 };
195
196 let (mut summary, updated_note) = if let Some(writer_task) = writer_task {
197 writer_task
198 .send(move |conn| {
199 merge_note_sql(
200 conn,
201 ns,
202 fts_table,
203 vec_tables,
204 into_id,
205 from_id,
206 strategy,
207 content_strategy,
208 dry_run,
209 pack_rules,
210 preserve_owner_established,
211 MergeTxLimits::default(),
212 Some(event_context),
213 merge_guard,
214 )
215 .map_err(|e| {
216 e.into_storage_error(khive_storage::StorageCapability::Notes, "merge_note")
217 })
218 })
219 .await
220 .inspect_err(|error| khive_storage::usage::account_event_write(Err(error)))
221 .map_err(map_merge_note_storage_error)?
222 } else {
223 tokio::task::spawn_blocking(move || {
224 let guard = pool.writer()?;
225 let mut refusal = None;
226 let result = guard.transaction(|conn| {
227 merge_note_sql(
228 conn,
229 ns,
230 fts_table,
231 vec_tables,
232 into_id,
233 from_id,
234 strategy,
235 content_strategy,
236 dry_run,
237 pack_rules,
238 preserve_owner_established,
239 MergeTxLimits::default(),
240 Some(event_context),
241 merge_guard,
242 )
243 .map_err(|error| match error {
244 MergeSqlError::Sqlite(error) => error,
245 MergeSqlError::Refusal(error) => {
246 refusal = Some(error);
247 SqliteError::InvalidData(
248 "note merge refused by transactional policy".to_string(),
249 )
250 }
251 })
252 });
253 match refusal {
254 Some(error) => Err(error),
255 None => result.map_err(RuntimeError::from),
256 }
257 })
258 .await
259 .map_err(|e| RuntimeError::Internal(e.to_string()))??
260 };
261
262 if !dry_run {
264 khive_storage::usage::account_event_write(Ok(1));
265 tracing::info!(
266 into_id = %summary.kept_id,
267 from_id = %summary.removed_id,
268 budget_rows = summary.tx_budget.rows_charged,
269 budget_bytes = summary.tx_budget.bytes_charged,
270 budget_max_rows = summary.tx_budget.max_rows,
271 budget_max_bytes = summary.tx_budget.max_bytes,
272 "merge_note: transaction materialization budget"
273 );
274 }
275
276 if !dry_run {
277 if !embedding_plan.is_empty() {
278 #[cfg(any(test, feature = "fault-injection"))]
279 let reindex_result =
280 if crate::operations::consume_fts_fail_fault(&updated_note.namespace) {
281 Err(RuntimeError::Internal("injected FTS failure".to_string()))
282 } else {
283 self.reindex_note_report_with_plan(token, &updated_note, &embedding_plan)
284 .await
285 };
286 #[cfg(not(any(test, feature = "fault-injection")))]
287 let reindex_result = self
288 .reindex_note_report_with_plan(token, &updated_note, &embedding_plan)
289 .await;
290
291 match reindex_result {
292 Ok(report) => {
293 summary.embedding_truncation = report.truncation;
294 if !report.failures.is_empty() {
295 summary.post_commit_reindex_error = Some(
296 report
297 .failures
298 .iter()
299 .map(|failure| {
300 format!(
301 "model {} {}: {}",
302 failure.model,
303 failure.stage.as_str(),
304 failure.error
305 )
306 })
307 .collect::<Vec<_>>()
308 .join("; "),
309 );
310 }
311 }
312 Err(error) => {
313 tracing::warn!(
314 into_id = %summary.kept_id,
315 from_id = %summary.removed_id,
316 error = %error,
317 "merge_note: committed merge but survivor reindex failed"
318 );
319 summary.post_commit_reindex_error = Some(error.to_string());
320 }
321 }
322 }
323 self.fire_note_mutation_hook(&updated_note.kind, updated_note.id)
326 .await;
327 }
328
329 Ok((summary, updated_note.version))
330 }
331}