1use std::path::PathBuf;
2
3use tonic::{Response, Status};
4use tracing::{info, warn};
5
6use dk_engine::workspace::overlay::OverlayEntry;
7
8use crate::file_write::release_on_submit_enabled;
9use crate::merge::release_locks_and_emit;
10use crate::server::ProtocolServer;
11use crate::validation::validate_file_path;
12use crate::{ChangeType, SubmitError, SubmitRequest, SubmitResponse, SubmitStatus};
13
14pub async fn handle_submit(
22 server: &ProtocolServer,
23 req: SubmitRequest,
24) -> Result<Response<SubmitResponse>, Status> {
25 let session = server.validate_session(&req.session_id)?;
27
28 for change in &req.changes {
30 validate_file_path(&change.file_path)?;
31 }
32
33 let sid = req
34 .session_id
35 .parse::<uuid::Uuid>()
36 .map_err(|_| Status::invalid_argument("Invalid session ID"))?;
37 server.session_mgr().touch_session(&sid);
38
39 let engine = server.engine();
43
44 let changeset_id = req.changeset_id.parse::<uuid::Uuid>()
46 .map_err(|_| Status::invalid_argument("invalid changeset_id"))?;
47
48 let ws = engine
50 .workspace_manager()
51 .get_workspace(&sid)
52 .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
53
54 let base_commit = ws.base_commit.clone();
55
56 let (repo_id, work_dir, file_checks) = {
58 let (repo_id, git_repo) = engine
59 .get_repo(&session.codebase)
60 .await
61 .map_err(|e| Status::internal(format!("Repo error: {e}")))?;
62
63 let work_dir = git_repo.path().to_path_buf();
64
65 let checks: Vec<(&crate::Change, bool)> = req
66 .changes
67 .iter()
68 .map(|change| {
69 let exists_in_base = git_repo
70 .read_tree_entry(&base_commit, &change.file_path)
71 .is_ok();
72 (change, exists_in_base)
73 })
74 .collect();
75
76 (repo_id, work_dir, checks)
77 };
79
80 let mut pre_submit_symbols: std::collections::HashMap<String, std::collections::HashMap<String, uuid::Uuid>> = {
84 let mut file_syms = std::collections::HashMap::new();
85 for change in &req.changes {
86 let entry: &mut std::collections::HashMap<String, uuid::Uuid> = file_syms.entry(change.file_path.clone()).or_default();
87 if let Ok(symbols) = engine.symbol_store().find_by_file(repo_id, &change.file_path).await {
88 for sym in symbols {
89 entry.insert(sym.qualified_name, sym.id);
90 }
91 }
92 }
93 file_syms
94 };
95
96 let early_overlay_snapshot = if req.changes.is_empty() {
99 let snap = ws.overlay.list_changes();
100 for (path, _) in &snap {
102 let entry = pre_submit_symbols.entry(path.clone()).or_default();
103 if let Ok(symbols) = engine.symbol_store().find_by_file(repo_id, path).await {
104 for sym in symbols {
105 entry.insert(sym.qualified_name, sym.id);
106 }
107 }
108 }
109 Some(snap)
110 } else {
111 None
112 };
113
114 let mut errors = Vec::new();
115 let mut changed_files = Vec::new();
116
117 for (change, exists_in_base) in &file_checks {
119 match change.r#type() {
120 ChangeType::ModifyFunction | ChangeType::ModifyType => {
121 let in_overlay = ws.overlay.contains(&change.file_path);
123 if !exists_in_base && !in_overlay {
124 errors.push(SubmitError {
125 message: format!("File not found: {}", change.file_path),
126 symbol_id: change.old_symbol_id.clone(),
127 file_path: Some(change.file_path.clone()),
128 });
129 continue;
130 }
131 let is_new = !exists_in_base;
132 if let Err(e) = ws
133 .overlay
134 .write(&change.file_path, change.new_source.as_bytes().to_vec(), is_new)
135 .await
136 {
137 errors.push(SubmitError {
138 message: format!("Write failed: {e}"),
139 symbol_id: None,
140 file_path: Some(change.file_path.clone()),
141 });
142 continue;
143 }
144 changed_files.push(PathBuf::from(&change.file_path));
145 }
146
147 ChangeType::AddFunction | ChangeType::AddType | ChangeType::AddDependency => {
148 let is_new = !exists_in_base;
149 if let Err(e) = ws
150 .overlay
151 .write(&change.file_path, change.new_source.as_bytes().to_vec(), is_new)
152 .await
153 {
154 errors.push(SubmitError {
155 message: format!("Write failed: {e}"),
156 symbol_id: None,
157 file_path: Some(change.file_path.clone()),
158 });
159 continue;
160 }
161 changed_files.push(PathBuf::from(&change.file_path));
162 }
163
164 ChangeType::DeleteFunction => {
165 changed_files.push(PathBuf::from(&change.file_path));
169 }
170 }
171 }
172
173 let overlay_snapshot = early_overlay_snapshot.unwrap_or_else(|| ws.overlay.list_changes());
175
176 if overlay_snapshot.is_empty() && changed_files.is_empty() && errors.is_empty() {
178 warn!(
179 session_id = %req.session_id,
180 "SUBMIT: rejected — no file changes in overlay"
181 );
182 return Ok(Response::new(SubmitResponse {
183 status: SubmitStatus::Rejected.into(),
184 changeset_id: String::new(),
185 new_version: None,
186 errors: vec![SubmitError {
187 message: "No changes to submit".to_string(),
188 symbol_id: None,
189 file_path: None,
190 }],
191 conflict_block: None,
192 review_summary: None,
193 }));
194 }
195
196 drop(ws);
198
199 for (path, entry) in &overlay_snapshot {
205 let (op, content) = match entry {
206 OverlayEntry::Added { content, .. } => {
207 ("add", Some(String::from_utf8_lossy(content).into_owned()))
208 }
209 OverlayEntry::Modified { content, .. } => {
210 ("modify", Some(String::from_utf8_lossy(content).into_owned()))
211 }
212 OverlayEntry::Deleted => ("delete", None),
213 };
214 engine.changeset_store()
215 .upsert_file(changeset_id, path, op, content.as_deref())
216 .await
217 .map_err(|e| Status::internal(format!("changeset file record failed: {e}")))?;
218 if !changed_files.iter().any(|p| p.to_string_lossy() == *path) {
220 changed_files.push(PathBuf::from(path));
221 }
222 }
223
224 if !errors.is_empty() {
226 warn!(
227 session_id = %req.session_id,
228 error_count = errors.len(),
229 "SUBMIT: rejected with errors"
230 );
231 return Ok(Response::new(SubmitResponse {
232 status: SubmitStatus::Rejected.into(),
233 changeset_id: String::new(),
234 new_version: None,
235 errors,
236 conflict_block: None,
237 review_summary: None,
238 }));
239 }
240
241 if let Err(e) = engine
245 .update_files_by_root(repo_id, &work_dir, &changed_files)
246 .await
247 {
248 return Ok(Response::new(SubmitResponse {
249 status: SubmitStatus::Rejected.into(),
250 changeset_id: String::new(),
251 new_version: None,
252 errors: vec![SubmitError {
253 message: format!("Re-indexing failed: {e}"),
254 symbol_id: None,
255 file_path: None,
256 }],
257 conflict_block: None,
258 review_summary: None,
259 }));
260 }
261
262 for file_path in &changed_files {
268 let rel_str = file_path.to_string_lossy().to_string();
269 let file_pre_syms = pre_submit_symbols.get(&rel_str);
270 if let Ok(new_symbols) = engine.symbol_store().find_by_file(repo_id, &rel_str).await {
271 for sym in &new_symbols {
272 let unchanged = file_pre_syms
274 .and_then(|m| m.get(&sym.qualified_name))
275 .is_some_and(|old_id| *old_id == sym.id);
276 if !unchanged {
277 let _ = engine.changeset_store()
278 .record_affected_symbol(changeset_id, sym.id, &sym.qualified_name)
279 .await;
280 }
281 }
282 }
283 }
284
285 engine.changeset_store().update_status(changeset_id, "submitted").await
287 .map_err(|e| Status::internal(format!("changeset status update failed: {e}")))?;
288
289 let affected_files: Vec<crate::FileChange> = overlay_snapshot.iter().map(|(path, entry)| {
291 let operation = match entry {
292 OverlayEntry::Added { .. } => "add",
293 OverlayEntry::Modified { .. } => "modify",
294 OverlayEntry::Deleted => "delete",
295 };
296 crate::FileChange {
297 path: path.clone(),
298 operation: operation.to_string(),
299 }
300 }).collect();
301
302 let mut symbol_changes: Vec<crate::SymbolChangeDetail> = Vec::new();
304 for file_path in &changed_files {
305 let rel_str = file_path.to_string_lossy().to_string();
306 let file_pre_syms = pre_submit_symbols.get(&rel_str);
307 if let Ok(new_symbols) = engine.symbol_store().find_by_file(repo_id, &rel_str).await {
308 for sym in &new_symbols {
310 let change_type = match file_pre_syms.and_then(|m| m.get(&sym.qualified_name)) {
311 Some(old_id) if *old_id == sym.id => continue, Some(_) => "modified",
313 None => "added",
314 };
315 symbol_changes.push(crate::SymbolChangeDetail {
316 symbol_name: sym.qualified_name.clone(),
317 file_path: rel_str.clone(),
318 change_type: change_type.to_string(),
319 kind: sym.kind.to_string(),
320 });
321 }
322 if let Some(old_syms) = file_pre_syms {
324 for name in old_syms.keys() {
325 let still_exists = new_symbols.iter().any(|s| s.qualified_name == *name);
326 if !still_exists {
327 symbol_changes.push(crate::SymbolChangeDetail {
328 symbol_name: name.clone(),
329 file_path: rel_str.clone(),
330 change_type: "deleted".to_string(),
331 kind: String::new(),
332 });
333 }
334 }
335 }
336 }
337 }
338
339 server.event_bus().publish(crate::WatchEvent {
341 event_type: "changeset.submitted".to_string(),
342 changeset_id: changeset_id.to_string(),
343 agent_id: session.agent_id.clone(),
344 affected_symbols: vec![],
345 details: req.intent.clone(),
346 session_id: req.session_id.clone(),
347 affected_files,
348 symbol_changes,
349 repo_id: repo_id.to_string(),
350 event_id: uuid::Uuid::new_v4().to_string(),
351 });
352
353 if release_on_submit_enabled() {
364 let n = release_locks_and_emit(
365 server,
366 repo_id,
367 sid,
368 &req.session_id,
369 &changeset_id.to_string(),
370 )
371 .await;
372 if n > 0 {
373 info!(
374 session_id = %req.session_id,
375 changeset_id = %changeset_id,
376 symbols = n,
377 "lock released on submit"
378 );
379 crate::metrics::incr_locks_released_on_submit(n as u64);
380 }
381 }
382
383 let new_version = {
385 let (_repo_id, git_repo) = engine
386 .get_repo(&session.codebase)
387 .await
388 .map_err(|e| Status::internal(format!("Repo error (head read): {e}")))?;
389 git_repo
390 .head_hash()
391 .ok()
392 .flatten()
393 .unwrap_or_else(|| "pending".to_string())
394 };
395
396 info!(
398 session_id = %req.session_id,
399 changeset_id = %changeset_id,
400 files_changed = changed_files.len(),
401 "SUBMIT: accepted"
402 );
403
404 Ok(Response::new(SubmitResponse {
405 status: SubmitStatus::Accepted.into(),
406 changeset_id: changeset_id.to_string(),
407 new_version: Some(new_version),
408 errors: vec![],
409 conflict_block: None,
410 review_summary: None,
411 }))
412}