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