1use super::query_error;
28use std::path::{Path, PathBuf};
29
30use velesdb_core::agent::AgentMemory;
31use velesdb_core::Database;
32
33use super::diagnosis::{diagnose, DiagnosisReport, TargetContract};
34use super::rebuild::{
35 rebuild, RebuildDestination, RebuildJournal, RebuildOutcome, RebuildSource, VectorPolicy,
36};
37use super::state::{CollectionProgress, MigrationLock, MigrationState, Phase};
38use super::strategy::Resolution;
39use crate::embedder::Embedder;
40
41#[derive(Debug)]
43pub struct ExecuteOutcome {
44 pub report: DiagnosisReport,
46 pub rebuild: RebuildOutcome,
48 pub destination: PathBuf,
50 pub workspace: PathBuf,
52}
53
54pub fn execute(
66 store: &Path,
67 scratch_parent: &Path,
68 target: &TargetContract,
69 destination: &Path,
70 embedder: &dyn Embedder,
71 batch: usize,
72) -> Result<ExecuteOutcome, crate::MemoryError> {
73 let report = diagnose(store, scratch_parent, target, Some(destination))?;
74 let staging = stage(&report, destination)?;
75
76 let lock =
77 MigrationLock::acquire(&staging.workspace, "migrate-embeddings").map_err(query_error)?;
78 let result = execute_locked(
79 &report,
80 target,
81 destination,
82 &staging.workspace,
83 &lock,
84 &ExecutePass {
85 embedder,
86 batch,
87 resuming: staging.resuming,
88 settled_fingerprint: &staging.settled_fingerprint,
89 },
90 );
91 let rebuild = reconcile(result, lock.release())?;
96 Ok(ExecuteOutcome {
97 report,
98 rebuild,
99 destination: destination.to_path_buf(),
100 workspace: staging.workspace,
101 })
102}
103
104pub(super) fn reconcile<T>(
113 result: Result<T, crate::MemoryError>,
114 released: Result<(), String>,
115) -> Result<T, crate::MemoryError> {
116 match (result, released) {
117 (Ok(value), Ok(())) => Ok(value),
118 (Ok(_), Err(release_error)) => Err(query_error(format!(
119 "the pass completed, but releasing the migration lock failed: \
120 {release_error}. The canonical lock record remains and must be \
121 removed by hand before the next run"
122 ))),
123 (Err(error), Ok(())) => Err(error),
124 (Err(error), Err(release_error)) => Err(query_error(format!(
125 "{error}; additionally, releasing the migration lock failed: \
126 {release_error} — the canonical lock record remains and must be \
127 removed by hand before the next run"
128 ))),
129 }
130}
131
132struct Staging {
134 workspace: PathBuf,
135 resuming: bool,
136 settled_fingerprint: String,
137}
138
139fn stage(report: &DiagnosisReport, destination: &Path) -> Result<Staging, crate::MemoryError> {
142 if let Resolution::Refuse { because, requested } = &report.resolution {
143 return Err(query_error(format!(
144 "the requested regime '{}' cannot run: {because:?}. Nothing was \
145 created; re-run --dry-run for the full report",
146 regime_word(*requested),
147 )));
148 }
149 {
160 let _settle = Database::open(&report.source_path)?;
161 }
162 let settled_fingerprint = super::filesystem::fingerprint(&report.source_path)?;
163 let workspace = journal_workspace(destination)?;
164 let resuming = workspace.join(super::state::STATE_FILE).exists();
165 ensure_destination(destination, resuming)?;
166 Ok(Staging {
167 workspace,
168 resuming,
169 settled_fingerprint,
170 })
171}
172
173struct ExecutePass<'a> {
178 embedder: &'a dyn Embedder,
179 batch: usize,
180 resuming: bool,
181 settled_fingerprint: &'a str,
182}
183
184fn execute_locked(
185 report: &DiagnosisReport,
186 target: &TargetContract,
187 destination: &Path,
188 workspace: &Path,
189 lock: &MigrationLock,
190 pass: &ExecutePass<'_>,
191) -> Result<RebuildOutcome, crate::MemoryError> {
192 let mut state = journal_entry(report, target, workspace, lock, pass)?;
193 let policy = match report.resolution {
194 Resolution::Reuse => VectorPolicy::Reuse,
195 Resolution::Reembed { .. } => VectorPolicy::Reembed(pass.embedder),
196 Resolution::Refuse { .. } => {
197 unreachable!("execute gated Refuse before the lock was taken")
198 }
199 };
200 let Some(source_dimension) = report.source_dimension else {
201 return Err(query_error(
202 "the source collections do not establish one shared dimension, so \
203 no AgentMemory view can open them; the diagnosis carries the \
204 details",
205 ));
206 };
207
208 let source_db = std::sync::Arc::new(Database::open(&report.source_path)?);
209 let source_memory =
210 AgentMemory::with_dimension(std::sync::Arc::clone(&source_db), source_dimension)?;
211 let destination_db = std::sync::Arc::new(Database::open(destination)?);
212 let destination_memory =
213 AgentMemory::with_dimension(std::sync::Arc::clone(&destination_db), target.dimension)?;
214
215 rebuild(
216 &RebuildSource {
217 db: &source_db,
218 memory: &source_memory,
219 },
220 &RebuildDestination {
221 db: &destination_db,
222 memory: &destination_memory,
223 },
224 &mut state,
225 &RebuildJournal { workspace, lock },
226 &policy,
227 pass.batch,
228 )
229}
230
231const WITNESS_SENTENCE: &str =
235 "velesdb embedder witness v1: one fixed sentence, embedded at prepare and at every resume";
236
237fn embedder_witness(
240 resolution: Resolution,
241 embedder: &dyn Embedder,
242) -> Result<Option<String>, crate::MemoryError> {
243 match resolution {
244 Resolution::Reuse => Ok(None),
245 Resolution::Reembed { .. } => target_embedder_witness(embedder).map(Some),
246 Resolution::Refuse { .. } => {
247 unreachable!("execute gated Refuse before the witness was computed")
248 }
249 }
250}
251
252pub(crate) fn target_embedder_witness(
253 embedder: &dyn Embedder,
254) -> Result<String, crate::MemoryError> {
255 use sha2::Digest;
256 let vector = embedder.embed(WITNESS_SENTENCE).map_err(|err| {
257 query_error(format!(
258 "the target embedder cannot embed the witness: {err}"
259 ))
260 })?;
261 let mut hash = sha2::Sha256::new();
262 for value in &vector {
263 hash.update(value.to_le_bytes());
264 }
265 Ok(format!(
266 "sha256:{}",
267 super::filesystem::encode_hex(&hash.finalize())
268 ))
269}
270
271fn journal_entry(
281 report: &DiagnosisReport,
282 target: &TargetContract,
283 workspace: &Path,
284 lock: &MigrationLock,
285 pass: &ExecutePass<'_>,
286) -> Result<MigrationState, crate::MemoryError> {
287 let witness = embedder_witness(report.resolution, pass.embedder)?;
288 if pass.resuming {
289 return resume_journal(report, target, workspace, pass, witness.as_deref());
290 }
291 let state = MigrationState {
292 format_version: super::state::STATE_FORMAT_VERSION,
293 phase: Phase::Prepared,
294 source_path: report.source_path.clone(),
295 source_fingerprint: pass.settled_fingerprint.to_owned(),
296 target_model: target.model.clone(),
297 target_dimension: target.dimension,
298 progress: super::enumeration::AGENT_COLLECTIONS
299 .iter()
300 .map(|name| {
301 (
302 (*name).to_owned(),
303 CollectionProgress::Facts { cursor: None },
304 )
305 })
306 .collect(),
307 embedder_witness: witness,
308 };
309 state.write(workspace, lock).map_err(query_error)?;
310 Ok(state)
311}
312
313fn resume_journal(
315 report: &DiagnosisReport,
316 target: &TargetContract,
317 workspace: &Path,
318 pass: &ExecutePass<'_>,
319 witness: Option<&str>,
320) -> Result<MigrationState, crate::MemoryError> {
321 let state = MigrationState::read(workspace)
322 .map_err(query_error)?
323 .ok_or_else(|| {
324 query_error(format!(
325 "the journal at {} disappeared between inspection and locking",
326 workspace.display()
327 ))
328 })?;
329 state
330 .may_resume(
331 &report.source_path,
332 pass.settled_fingerprint,
333 &target.model,
334 target.dimension,
335 )
336 .map_err(query_error)?;
337 if state.embedder_witness.as_deref() != witness {
338 return Err(query_error(format!(
339 "this migration was prepared with an embedder whose witness was \
340 {:?}, and the embedder answering to '{}' now produces {:?}. Same \
341 name, different vectors — the model was updated in place, or the \
342 regime changed between runs. Resuming would mix two vector spaces \
343 in one store; start a fresh migration",
344 state.embedder_witness, target.model, witness,
345 )));
346 }
347 Ok(state)
348}
349
350fn regime_word(strategy: super::strategy::Strategy) -> &'static str {
352 match strategy {
353 super::strategy::Strategy::Auto => "auto",
354 super::strategy::Strategy::Reuse => "reuse",
355 super::strategy::Strategy::Reembed => "reembed",
356 }
357}
358
359pub(crate) fn journal_workspace(destination: &Path) -> Result<PathBuf, crate::MemoryError> {
361 let name = destination
362 .file_name()
363 .and_then(|name| name.to_str())
364 .ok_or_else(|| {
365 query_error(format!(
366 "the destination {} has no usable directory name to derive the \
367 journal workspace from",
368 destination.display()
369 ))
370 })?;
371 let workspace = destination.with_file_name(format!("{name}.migration-journal"));
372 std::fs::create_dir_all(&workspace).map_err(|err| {
373 query_error(format!(
374 "cannot create the journal workspace {}: {err}",
375 workspace.display()
376 ))
377 })?;
378 Ok(workspace)
379}
380
381fn ensure_destination(destination: &Path, resuming: bool) -> Result<(), crate::MemoryError> {
383 if !destination.exists() {
384 std::fs::create_dir_all(destination).map_err(|err| {
385 query_error(format!(
386 "cannot create the destination {}: {err}",
387 destination.display()
388 ))
389 })?;
390 return Ok(());
391 }
392 if resuming {
393 return Ok(());
396 }
397 let mut entries = std::fs::read_dir(destination).map_err(|err| {
398 query_error(format!(
399 "cannot inspect the destination {}: {err}",
400 destination.display()
401 ))
402 })?;
403 if entries.next().is_some() {
404 return Err(query_error(format!(
405 "the destination {} already holds data and no migration journal \
406 accounts for it; rebuilding into it could mix two stores, so \
407 choose an empty destination or remove it deliberately",
408 destination.display()
409 )));
410 }
411 Ok(())
412}