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
28    // Validate all file paths before any processing
29    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    // 2. Resolve repo — extract work_dir, repo_id, and file-existence checks
40    //    in a single get_repo call. The `GitRepository` (gix::Repository is
41    //    !Sync) is dropped before any subsequent .await.
42    let engine = server.engine();
43
44    // Parse changeset_id from request
45    let changeset_id = req.changeset_id.parse::<uuid::Uuid>()
46        .map_err(|_| Status::invalid_argument("invalid changeset_id"))?;
47
48    // Get workspace for this session
49    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    // Single get_repo call: extract work_dir and pre-compute is_new for each file
57    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        // git_repo is dropped here
78    };
79
80    // Snapshot the existing symbols per file (file_path -> (qualified_name -> id))
81    // for files that will be changed.  After re-indexing we compare by ID so that
82    // modifications (same name, new UUID) are still detected.
83    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    // Snapshot overlay early so we can reuse it for both pre_submit_symbols
97    // (MCP path) and later for changeset_files, avoiding a redundant call.
98    let early_overlay_snapshot = if req.changes.is_empty() {
99        let snap = ws.overlay.list_changes();
100        // Populate pre_submit_symbols from overlay paths so symbol diffs are accurate.
101        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    // 3. Apply each change through the session workspace overlay.
118    for (change, exists_in_base) in &file_checks {
119        match change.r#type() {
120            ChangeType::ModifyFunction | ChangeType::ModifyType => {
121                // Target file must already exist in base or overlay
122                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                // For deletes we track the file as changed so the engine
166                // can re-index it (the function body will have been removed
167                // from the source by the agent).
168                changed_files.push(PathBuf::from(&change.file_path));
169            }
170        }
171    }
172
173    // Reuse early snapshot if available (MCP path), otherwise take it now.
174    let overlay_snapshot = early_overlay_snapshot.unwrap_or_else(|| ws.overlay.list_changes());
175
176    // Reject empty changesets — there must be at least one file modification.
177    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 the workspace guard before further async work
197    drop(ws);
198
199    // Record file changes in changeset — always use the overlay as the
200    // single source of truth.  This unifies the "standard" path (changes
201    // sent inline via req.changes) and the "MCP" path (files written
202    // earlier via dk_file_write).  The overlay is session-scoped, so we
203    // only capture files belonging to this session.
204    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        // Ensure MCP-path files end up in changed_files for re-indexing
219        if !changed_files.iter().any(|p| p.to_string_lossy() == *path) {
220            changed_files.push(PathBuf::from(path));
221        }
222    }
223
224    // If any change failed, reject the whole submission.
225    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    // 4. Re-index changed files through the semantic graph.
242    //    Use `update_files_by_root` which takes a `&Path` instead of
243    //    `&GitRepository` (the latter is !Sync and cannot cross .await).
244    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    // Record only NEW or CHANGED symbols in the changeset.
263    // A symbol is "affected" if:
264    //   (a) its qualified_name did not exist before (new symbol), OR
265    //   (b) its qualified_name existed but its UUID changed after re-index
266    //       (the symbol was modified -- see symbols.rs ON CONFLICT ... SET id).
267    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                // Record if the symbol is new OR its ID changed (modified).
273                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    // Update changeset status to "submitted"
286    engine.changeset_store().update_status(changeset_id, "submitted").await
287        .map_err(|e| Status::internal(format!("changeset status update failed: {e}")))?;
288
289    // Build affected_files list from overlay (unified source of truth)
290    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    // Build symbol_changes from pre/post symbol comparison
303    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            // Detect added/modified symbols
309            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, // unchanged
312                    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            // Detect deleted symbols: only check symbols that belonged to THIS file
323            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    // Publish event
340    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    // ── Release-on-submit (DKOD_RELEASE_ON_SUBMIT) ──
354    // Locks historically lived until `dk_merge`, which is 1–5 minutes after
355    // this point when DKOD_CODE_REVIEW + the LAND fix-loop are enabled.
356    // Releasing here collapses the hold window from minutes to the few
357    // milliseconds between submit and the blocked waiter's next
358    // `dk_file_write`. The idempotent call in `handle_merge` still runs,
359    // so crashed-before-merge sessions don't leave stranded locks.
360    //
361    // Gated so PR1 ships flag-off and the testbed can flip it only after
362    // the STALE_OVERLAY backstop counter stays at zero.
363    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    // Read HEAD version without holding the GitRepository across awaits.
384    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    // 5. Return ACCEPTED with the changeset ID from the request.
397    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}