Skip to main content

dk_protocol/
submit.rs

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
14/// Handle a SUBMIT RPC.
15///
16/// 1. Validates the session.
17/// 2. Resolves the repo to obtain the working directory path.
18/// 3. Applies file-level writes through the session workspace overlay.
19/// 4. Re-opens the repo and re-indexes changed files through the engine.
20/// 5. Returns ACCEPTED with a new changeset ID, or REJECTED with errors.
21pub async fn handle_submit(
22    server: &ProtocolServer,
23    req: SubmitRequest,
24) -> Result<Response<SubmitResponse>, Status> {
25    // 1. Validate session
26    let session = server.validate_session(&req.session_id)?;
27    crate::require_live_session::require_live_session(server, &req.session_id).await?;
28
29    // Validate all file paths before any processing
30    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    // 2. Resolve repo — extract work_dir, repo_id, and file-existence checks
41    //    in a single get_repo call. The `GitRepository` (gix::Repository is
42    //    !Sync) is dropped before any subsequent .await.
43    let engine = server.engine();
44
45    // Parse changeset_id from request
46    let changeset_id = req.changeset_id.parse::<uuid::Uuid>()
47        .map_err(|_| Status::invalid_argument("invalid changeset_id"))?;
48
49    // Get workspace for this session
50    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    // Single get_repo call: extract work_dir and pre-compute is_new for each file
58    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        // git_repo is dropped here
79    };
80
81    // Snapshot the existing symbols per file (file_path -> (qualified_name -> id))
82    // for files that will be changed.  After re-indexing we compare by ID so that
83    // modifications (same name, new UUID) are still detected.
84    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    // Snapshot overlay early so we can reuse it for both pre_submit_symbols
98    // (MCP path) and later for changeset_files, avoiding a redundant call.
99    let early_overlay_snapshot = if req.changes.is_empty() {
100        let snap = ws.overlay.list_changes();
101        // Populate pre_submit_symbols from overlay paths so symbol diffs are accurate.
102        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    // 3. Apply each change through the session workspace overlay.
119    for (change, exists_in_base) in &file_checks {
120        match change.r#type() {
121            ChangeType::ModifyFunction | ChangeType::ModifyType => {
122                // Target file must already exist in base or overlay
123                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                // For deletes we track the file as changed so the engine
167                // can re-index it (the function body will have been removed
168                // from the source by the agent).
169                changed_files.push(PathBuf::from(&change.file_path));
170            }
171        }
172    }
173
174    // Reuse early snapshot if available (MCP path), otherwise take it now.
175    let overlay_snapshot = early_overlay_snapshot.unwrap_or_else(|| ws.overlay.list_changes());
176
177    // Reject empty changesets — there must be at least one file modification.
178    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 the workspace guard before further async work
198    drop(ws);
199
200    // Record file changes in changeset — always use the overlay as the
201    // single source of truth.  This unifies the "standard" path (changes
202    // sent inline via req.changes) and the "MCP" path (files written
203    // earlier via dk_file_write).  The overlay is session-scoped, so we
204    // only capture files belonging to this session.
205    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        // Ensure MCP-path files end up in changed_files for re-indexing
220        if !changed_files.iter().any(|p| p.to_string_lossy() == *path) {
221            changed_files.push(PathBuf::from(path));
222        }
223    }
224
225    // If any change failed, reject the whole submission.
226    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    // 4. Re-index changed files through the semantic graph.
243    //    Use `update_files_by_root` which takes a `&Path` instead of
244    //    `&GitRepository` (the latter is !Sync and cannot cross .await).
245    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    // Record only NEW or CHANGED symbols in the changeset.
264    // A symbol is "affected" if:
265    //   (a) its qualified_name did not exist before (new symbol), OR
266    //   (b) its qualified_name existed but its UUID changed after re-index
267    //       (the symbol was modified -- see symbols.rs ON CONFLICT ... SET id).
268    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                // Record if the symbol is new OR its ID changed (modified).
274                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    // Update changeset status to "submitted"
287    engine.changeset_store().update_status(changeset_id, "submitted").await
288        .map_err(|e| Status::internal(format!("changeset status update failed: {e}")))?;
289
290    // Build affected_files list from overlay (unified source of truth)
291    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    // Build symbol_changes from pre/post symbol comparison
304    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            // Detect added/modified symbols
310            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, // unchanged
313                    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            // Detect deleted symbols: only check symbols that belonged to THIS file
324            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    // Publish event
341    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    // ── Release-on-submit (default on; opt out via DKOD_RELEASE_ON_SUBMIT=0) ──
355    // Locks historically lived until `dk_merge`, which is 1–5 minutes after
356    // this point when DKOD_CODE_REVIEW + the LAND fix-loop are enabled.
357    // Releasing here collapses the hold window from minutes to the few
358    // milliseconds between submit and the blocked waiter's next
359    // `dk_file_write`. The idempotent call in `handle_merge` still runs,
360    // so crashed-before-merge sessions don't leave stranded locks.
361    //
362    // Flag preserved as a rollback valve — set DKOD_RELEASE_ON_SUBMIT=0 to
363    // revert to merge-time release without a code change.
364    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    // Read HEAD version without holding the GitRepository across awaits.
385    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    // 5. Return ACCEPTED with the changeset ID from the request.
398    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}