1use std::sync::Arc;
2use std::time::Duration;
3
4use rmcp::handler::server::wrapper::{Json, Parameters};
5use rmcp::model::ErrorCode;
6use rmcp::{tool, tool_router, ErrorData};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use super::{to_error, McpServer};
11use crate::mutation::catchup::CatchUpConfig;
12use crate::mutation::controller::ControllerConfig;
13use crate::service::{JobPhase, MigrationStartConfig, MigrationStatus, OnlineMigrationManager};
14use crate::DynEmbedder;
15
16const DEFAULT_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
17const DEFAULT_FACT_BATCH: usize = 256;
18const DEFAULT_REPLAY_BATCH: usize = 256;
19const DEFAULT_EDGE_CAP: usize = 4_096;
20const DEFAULT_OBSERVATION_WINDOW: usize = 3;
21const DEFAULT_VERIFICATION_RESERVE_MS: u64 = 100;
22
23#[derive(Debug, Deserialize, JsonSchema)]
24#[schemars(transform = crate::schema::strip_int_formats)]
25struct MigrationStartParams {
26 target_backend: String,
27 pause_budget_ms: u64,
28 journal_max_bytes: Option<u64>,
29 fact_batch: Option<usize>,
30 replay_batch: Option<usize>,
31 edge_cap: Option<usize>,
32 observation_window: Option<usize>,
33 verification_reserve_ms: Option<u64>,
34}
35
36#[derive(Debug, Serialize, JsonSchema)]
37#[schemars(transform = crate::schema::strip_int_formats)]
38struct MigrationStatusResult {
39 configured: bool,
40 job: Option<MigrationJobResult>,
41}
42
43#[derive(Debug, Serialize, JsonSchema)]
44#[schemars(transform = crate::schema::strip_int_formats)]
45struct MigrationJobResult {
46 epoch_id: String,
47 phase: String,
48 running: bool,
49 target_backend: String,
50 target_model: String,
51 target_dimension: usize,
52 destination: String,
53 cancellation_requested: bool,
54 recovery_action: Option<String>,
55 last_error: Option<String>,
56 progress: MigrationProgressResult,
57}
58
59#[derive(Debug, Serialize, JsonSchema)]
60#[schemars(transform = crate::schema::strip_int_formats)]
61struct MigrationProgressResult {
62 base_facts: u64,
63 base_edge_sets: u64,
64 base_batches: u64,
65 input_watermark: u64,
66 output_watermark: u64,
67 distinct_dirty_facts: u64,
68 distinct_edge_sources: u64,
69 pending_journal_bytes: u64,
70 estimated_pause_ms: Option<u64>,
71 measured_cutover_ms: Option<u64>,
72}
73
74#[tool_router(router = migration_tool_router, vis = "pub(super)")]
75impl McpServer {
76 #[tool(
77 name = "migration_start",
78 output_schema = crate::schema::wire_safe_output_schema::<MigrationStatusResult>(),
79 description = "Start one daemon-owned online embedding migration in the background. The existing MCP transport remains the only control boundary; no listener or credential is persisted. `target_backend` selects a backend configured in this daemon's environment. `pause_budget_ms` is the maximum measured request pause. Returns immediately with durable job status; poll migration_status. Refuses a pre-existing destination or another non-terminal epoch."
80 )]
81 async fn migration_start(
82 &self,
83 Parameters(params): Parameters<MigrationStartParams>,
84 ) -> Result<Json<MigrationStatusResult>, ErrorData> {
85 let manager = self.require_migration_manager()?;
86 let status = tokio::task::spawn_blocking(move || {
87 manager.start(¶ms.target_backend, params.config())?;
88 current_status(&manager)
89 })
90 .await
91 .map_err(super::join_error)?
92 .map_err(to_error)?;
93 Ok(Json(status))
94 }
95
96 #[tool(
97 name = "migration_status",
98 output_schema = crate::schema::wire_safe_output_schema::<MigrationStatusResult>(),
99 description = "Read the durable online embedding migration phase, progress watermarks, convergence estimate, measured cutover, cancellation flag, last error and required recovery action. Returns immediately and performs no migration work."
100 )]
101 async fn migration_status(&self) -> Result<Json<MigrationStatusResult>, ErrorData> {
102 let Some(manager) = self.online_migration.clone() else {
103 return Ok(Json(MigrationStatusResult {
104 configured: false,
105 job: None,
106 }));
107 };
108 let status = tokio::task::spawn_blocking(move || current_status(&manager))
109 .await
110 .map_err(super::join_error)?
111 .map_err(to_error)?;
112 Ok(Json(status))
113 }
114
115 #[tool(
116 name = "migration_cancel",
117 output_schema = crate::schema::wire_safe_output_schema::<MigrationStatusResult>(),
118 description = "Durably request cancellation while the source remains authoritative. A running worker observes the request at its next bounded batch; a paused job is cancelled immediately. From quiescing onward cancellation refuses and reports the required recovery action instead of guessing a rollback."
119 )]
120 async fn migration_cancel(&self) -> Result<Json<MigrationStatusResult>, ErrorData> {
121 let manager = self.require_migration_manager()?;
122 let status = tokio::task::spawn_blocking(move || {
123 manager.cancel()?;
124 current_status(&manager)
125 })
126 .await
127 .map_err(super::join_error)?
128 .map_err(to_error)?;
129 Ok(Json(status))
130 }
131
132 #[tool(
133 name = "migration_recover",
134 output_schema = crate::schema::wire_safe_output_schema::<MigrationStatusResult>(),
135 description = "Resume a durable prepared, capturing, base-copied, catching-up, non-converging or cutover-ready job after verifying the environment-backed target model, dimension and vector witness. Quiescing or activated jobs refuse here until crash-safe cutover recovery has restored a single authoritative generation."
136 )]
137 async fn migration_recover(&self) -> Result<Json<MigrationStatusResult>, ErrorData> {
138 let manager = self.require_migration_manager()?;
139 let status = tokio::task::spawn_blocking(move || {
140 manager.recover()?;
141 current_status(&manager)
142 })
143 .await
144 .map_err(super::join_error)?
145 .map_err(to_error)?;
146 Ok(Json(status))
147 }
148
149 fn require_migration_manager(
150 &self,
151 ) -> Result<Arc<OnlineMigrationManager<DynEmbedder>>, ErrorData> {
152 self.online_migration.clone().ok_or_else(|| {
153 ErrorData::new(
154 ErrorCode::INTERNAL_ERROR,
155 "online migration is not configured for this server".to_owned(),
156 None,
157 )
158 })
159 }
160}
161
162impl MigrationStartParams {
163 fn config(&self) -> MigrationStartConfig {
164 MigrationStartConfig {
165 journal_max_bytes: self.journal_max_bytes.unwrap_or(DEFAULT_JOURNAL_BYTES),
166 catch_up: CatchUpConfig {
167 fact_batch: self.fact_batch.unwrap_or(DEFAULT_FACT_BATCH),
168 replay_batch: self.replay_batch.unwrap_or(DEFAULT_REPLAY_BATCH),
169 edge_cap: self.edge_cap.unwrap_or(DEFAULT_EDGE_CAP),
170 },
171 controller: ControllerConfig {
172 observation_window: self
173 .observation_window
174 .unwrap_or(DEFAULT_OBSERVATION_WINDOW),
175 pause_budget: Duration::from_millis(self.pause_budget_ms),
176 verification_reserve: Duration::from_millis(
177 self.verification_reserve_ms
178 .unwrap_or(DEFAULT_VERIFICATION_RESERVE_MS),
179 ),
180 },
181 }
182 }
183}
184
185fn current_status(
186 manager: &OnlineMigrationManager<DynEmbedder>,
187) -> Result<MigrationStatusResult, crate::MemoryError> {
188 Ok(MigrationStatusResult {
189 configured: true,
190 job: manager.status()?.map(MigrationJobResult::from),
191 })
192}
193
194impl From<MigrationStatus> for MigrationJobResult {
195 fn from(status: MigrationStatus) -> Self {
196 let record = status.record;
197 let progress = MigrationProgressResult {
198 base_facts: record.progress.base_facts,
199 base_edge_sets: record.progress.base_edge_sets,
200 base_batches: record.progress.base_batches,
201 input_watermark: record.progress.input_watermark,
202 output_watermark: record.progress.output_watermark,
203 distinct_dirty_facts: record.progress.distinct_dirty_facts,
204 distinct_edge_sources: record.progress.distinct_edge_sources,
205 pending_journal_bytes: record.progress.pending_journal_bytes,
206 estimated_pause_ms: record.progress.estimated_pause.map(duration_millis),
207 measured_cutover_ms: record.progress.measured_cutover.map(duration_millis),
208 };
209 let identity = &record.spec.identity;
210 Self {
211 epoch_id: identity.epoch_id().to_owned(),
212 phase: phase_name(record.phase).to_owned(),
213 running: status.running,
214 target_backend: record.spec.target_backend.clone(),
215 target_model: identity.target_model().to_owned(),
216 target_dimension: identity.target_dimension(),
217 destination: identity.destination_path().display().to_string(),
218 cancellation_requested: record.cancellation_requested,
219 recovery_action: record.recovery_action,
220 last_error: record.last_error,
221 progress,
222 }
223 }
224}
225
226fn phase_name(phase: JobPhase) -> &'static str {
227 match phase {
228 JobPhase::Prepared => "prepared",
229 JobPhase::Capturing => "capturing",
230 JobPhase::BaseCopied => "base_copied",
231 JobPhase::CatchingUp => "catching_up",
232 JobPhase::NonConverging => "non_converging",
233 JobPhase::CutoverReady => "cutover_ready",
234 JobPhase::Quiescing => "quiescing",
235 JobPhase::Activated => "activated",
236 JobPhase::Committed => "committed",
237 JobPhase::Cancelled => "cancelled",
238 }
239}
240
241fn duration_millis(duration: Duration) -> u64 {
242 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
243}
244
245#[cfg(test)]
246#[path = "migration_tools_tests.rs"]
247mod tests;