Skip to main content

dk_protocol/
file_write.rs

1use tonic::{Response, Status};
2use tracing::{info, warn};
3
4use dk_engine::conflict::{AcquireOutcome, SymbolClaim};
5use crate::server::ProtocolServer;
6use crate::stale_overlay::{is_stale, CompetingChangeset};
7use crate::validation::{validate_file_path, MAX_FILE_SIZE};
8use crate::{ConflictWarning, FileWriteRequest, FileWriteResponse, SymbolChange};
9
10/// Message prefix MCP uses to distinguish STALE_OVERLAY from SYMBOL_LOCKED.
11/// Both flow through `ConflictWarning` with an empty `new_hash`, so the
12/// prefix is the contract. Do not change without updating the MCP parser.
13const STALE_OVERLAY_PREFIX: &str = "STALE_OVERLAY";
14
15/// Env flag for the release-locks-at-submit + STALE_OVERLAY behavior.
16///
17/// **Default: on.** Opt out with `DKOD_RELEASE_ON_SUBMIT=0` (also `false`,
18/// `FALSE`, `no`) if you need to revert to the old "release at merge"
19/// behavior. The flag is preserved as a rollback valve — flipping it off
20/// makes both the release-at-submit call site in `handle_submit` and the
21/// STALE_OVERLAY pre-write check in `handle_file_write` no-ops in a single
22/// place.
23///
24/// Shared with `submit.rs` so both call sites read the flag with identical
25/// semantics — preventing drift if one handler's parse logic is ever
26/// tweaked without the other.
27pub(crate) fn release_on_submit_enabled() -> bool {
28    std::env::var("DKOD_RELEASE_ON_SUBMIT")
29        .map(|v| !matches!(v.as_str(), "0" | "false" | "FALSE" | "no"))
30        .unwrap_or(true)
31}
32
33/// Handle a FileWrite RPC.
34///
35/// Writes a file through the session workspace overlay and optionally
36/// detects symbol changes by parsing the new content.
37pub async fn handle_file_write(
38    server: &ProtocolServer,
39    req: FileWriteRequest,
40) -> Result<Response<FileWriteResponse>, Status> {
41    validate_file_path(&req.path)?;
42
43    if req.content.len() > MAX_FILE_SIZE {
44        return Err(Status::invalid_argument("file content exceeds 50MB limit"));
45    }
46
47    let session = server.validate_session(&req.session_id)?;
48    crate::require_live_session::require_live_session(server, &req.session_id).await?;
49
50    let sid = req
51        .session_id
52        .parse::<uuid::Uuid>()
53        .map_err(|_| Status::invalid_argument("Invalid session ID"))?;
54    server.session_mgr().touch_session(&sid);
55
56    let engine = server.engine();
57
58    // Get workspace for this session
59    let ws = engine
60        .workspace_manager()
61        .get_workspace(&sid)
62        .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
63
64    // Determine if the file is new (not in base tree) and read old content
65    // in a single get_repo call. Drop git_repo before async work to keep
66    // future Send.
67    let (repo_id, is_new, old_content) = {
68        let (rid, git_repo) = engine
69            .get_repo(&session.codebase)
70            .await
71            .map_err(|e| Status::internal(format!("Repo error: {e}")))?;
72        match git_repo.read_tree_entry(&ws.base_commit, &req.path) {
73            Ok(bytes) => (rid, false, bytes),
74            Err(e) => {
75                // File not in base tree — treat as new. Log the error in case
76                // it's a transient git failure rather than a genuine "not found".
77                warn!(
78                    path = %req.path,
79                    base_commit = %ws.base_commit,
80                    error = %e,
81                    "read_tree_entry failed — treating file as new"
82                );
83                (rid, true, Vec::new())
84            }
85        }
86    };
87    let repo_id_str = repo_id.to_string();
88    let changeset_id = ws.changeset_id;
89    let agent_name = ws.agent_name.clone();
90    // Snapshot last-read for the STALE_OVERLAY check before we drop ws.
91    let last_read_at = ws.last_read(&req.path);
92
93    // Drop workspace guard — overlay write is deferred until after lock acquisition
94    drop(ws);
95
96    let op = if is_new { "add" } else { "modify" };
97
98    // ── STALE_OVERLAY pre-write check (DKOD_RELEASE_ON_SUBMIT only) ──
99    // Rationale: once locks release at `dk_submit` (instead of `dk_merge`),
100    // a waiter can re-acquire a symbol seconds after the holder submits.
101    // If the waiter skips the re-read step from the SYMBOL_LOCKED recovery
102    // contract, it would silently clobber the still-in-flight overlay.
103    // Best-effort backstop — AST merger at merge remains the final
104    // authority; a race that slips past this check degrades to today's
105    // merge-time reconciliation, not to data loss.
106    if release_on_submit_enabled() {
107        let competitors_raw = match engine
108            .changeset_store()
109            .list_path_competitors(repo_id, &req.path)
110            .await
111        {
112            Ok(rows) => rows,
113            Err(e) => {
114                // Storage error — fail open (don't block the write) so a
115                // transient DB hiccup never blocks user-visible writes.
116                // The spike shows up in the standard tracing output if
117                // this ever becomes chronic.
118                warn!(
119                    session_id = %sid,
120                    path = %req.path,
121                    error = %e,
122                    "STALE_OVERLAY check failed — skipping (fail-open)"
123                );
124                Vec::new()
125            }
126        };
127        let competitors: Vec<CompetingChangeset> = competitors_raw
128            .into_iter()
129            .map(|(cs_id, cs_sid, state, updated)| CompetingChangeset {
130                changeset_id: cs_id,
131                session_id: cs_sid,
132                state,
133                updated_at: updated,
134            })
135            .collect();
136
137        if let Some(stale) = is_stale(sid, last_read_at, &competitors) {
138            info!(
139                session_id = %sid,
140                path = %req.path,
141                competing_changeset = %stale.changeset_id,
142                competing_state = %stale.state,
143                "STALE_OVERLAY: write rejected (session view predates competing submit)"
144            );
145            crate::metrics::incr_stale_overlay_rejected();
146
147            let message = format!(
148                "{prefix}: your overlay for path={path} predates competing \
149                 changeset {cid} (state={state}). Call dk_file_read('{path}') \
150                 to refresh, then retry dk_file_write.",
151                prefix = STALE_OVERLAY_PREFIX,
152                path = req.path,
153                cid = stale.changeset_id,
154                state = stale.state,
155            );
156
157            return Ok(Response::new(FileWriteResponse {
158                new_hash: String::new(),
159                detected_changes: Vec::new(),
160                conflict_warnings: vec![ConflictWarning {
161                    file_path: req.path.clone(),
162                    symbol_name: String::new(),
163                    conflicting_agent: String::new(),
164                    conflicting_session_id: stale
165                        .session_id
166                        .map(|s| s.to_string())
167                        .unwrap_or_default(),
168                    message,
169                }],
170            }));
171        }
172    }
173
174    // Detect symbol changes from req.content directly — no overlay needed yet.
175    let (detected_changes, all_symbol_changes) =
176        detect_symbol_changes_diffed(engine, &req.path, &old_content, &req.content, is_new);
177
178    // ── Symbol locking (acquire with rollback) ──
179    // Attempt to acquire locks for each changed symbol. If any fails, roll back
180    // all previously acquired locks and reject the write. No overlay write, no
181    // changeset store entry — completely clean rejection.
182    let claimable: Vec<&crate::SymbolChangeDetail> = all_symbol_changes
183        .iter()
184        .filter(|sc| sc.change_type == "added" || sc.change_type == "modified" || sc.change_type == "deleted")
185        .collect();
186
187    let mut acquired: Vec<String> = Vec::new();
188    let mut locked_symbols: Vec<ConflictWarning> = Vec::new();
189
190    for sc in &claimable {
191        let kind = sc.kind.parse::<dk_core::SymbolKind>().unwrap_or(dk_core::SymbolKind::Function);
192        match server.claim_tracker().acquire_lock(
193            repo_id,
194            &req.path,
195            SymbolClaim {
196                session_id: sid,
197                agent_name: agent_name.clone(),
198                qualified_name: sc.symbol_name.clone(),
199                kind,
200                first_touched_at: chrono::Utc::now(),
201            },
202        ).await {
203            Ok(AcquireOutcome::Fresh) => acquired.push(sc.symbol_name.clone()),
204            Ok(AcquireOutcome::ReAcquired) => {} // already held — exclude from rollback
205            Err(sl) => {
206                warn!(
207                    session_id = %sid,
208                    path = %req.path,
209                    symbol = %sl.qualified_name,
210                    locked_by = %sl.locked_by_agent,
211                    "SYMBOL_LOCKED: write rejected"
212                );
213                locked_symbols.push(ConflictWarning {
214                    file_path: req.path.clone(),
215                    symbol_name: sl.qualified_name.clone(),
216                    conflicting_agent: sl.locked_by_agent.clone(),
217                    conflicting_session_id: sl.locked_by_session.to_string(),
218                    message: format!(
219                        "SYMBOL_LOCKED: '{}' is locked by agent '{}'. Call dk_watch(filter: '{}') to wait, then dk_file_read and retry.",
220                        sl.qualified_name, sl.locked_by_agent, crate::merge::EVENT_LOCK_RELEASED,
221                    ),
222                });
223            }
224        }
225    }
226
227    if !locked_symbols.is_empty() {
228        // Roll back any locks acquired before the failure and emit events
229        // so any agent that raced and observed the transient lock can wake up.
230        for name in &acquired {
231            server.claim_tracker().release_lock(repo_id, &req.path, sid, name).await;
232            server.event_bus().publish(crate::WatchEvent {
233                event_type: crate::merge::EVENT_LOCK_RELEASED.to_string(),
234                changeset_id: String::new(),
235                agent_id: agent_name.clone(),
236                affected_symbols: vec![name.clone()],
237                details: format!("Symbol lock rolled back on {}", req.path),
238                session_id: req.session_id.clone(),
239                affected_files: vec![crate::FileChange {
240                    path: req.path.clone(),
241                    operation: "unlock".to_string(),
242                }],
243                symbol_changes: vec![],
244                repo_id: repo_id_str.clone(),
245                event_id: uuid::Uuid::new_v4().to_string(),
246            });
247        }
248
249        info!(
250            session_id = %sid,
251            path = %req.path,
252            locked_count = locked_symbols.len(),
253            rolled_back = acquired.len(),
254            "FILE_WRITE: rejected — symbols locked, rolled back partial locks"
255        );
256
257        return Ok(Response::new(FileWriteResponse {
258            new_hash: String::new(),
259            detected_changes: Vec::new(),
260            conflict_warnings: locked_symbols,
261        }));
262    }
263
264    // All locks acquired — now write the overlay and changeset store.
265    // If either fails, release all acquired locks before propagating the error.
266    let ws = match engine.workspace_manager().get_workspace(&sid) {
267        Some(ws) => ws,
268        None => {
269            for name in &acquired {
270                server.claim_tracker().release_lock(repo_id, &req.path, sid, name).await;
271                server.event_bus().publish(crate::WatchEvent {
272                    event_type: crate::merge::EVENT_LOCK_RELEASED.to_string(),
273                    changeset_id: String::new(),
274                    agent_id: agent_name.clone(),
275                    affected_symbols: vec![name.clone()],
276                    details: format!("Symbol lock released on error in {}", req.path),
277                    session_id: req.session_id.clone(),
278                    affected_files: vec![crate::FileChange {
279                        path: req.path.clone(),
280                        operation: "unlock".to_string(),
281                    }],
282                    symbol_changes: vec![],
283                    repo_id: repo_id_str.clone(),
284                    event_id: uuid::Uuid::new_v4().to_string(),
285                });
286            }
287            return Err(Status::not_found("Workspace not found for session"));
288        }
289    };
290
291    let new_hash = match ws.overlay.write(&req.path, req.content.clone(), is_new).await {
292        Ok(hash) => hash,
293        Err(e) => {
294            for name in &acquired {
295                server.claim_tracker().release_lock(repo_id, &req.path, sid, name).await;
296                server.event_bus().publish(crate::WatchEvent {
297                    event_type: crate::merge::EVENT_LOCK_RELEASED.to_string(),
298                    changeset_id: String::new(),
299                    agent_id: agent_name.clone(),
300                    affected_symbols: vec![name.clone()],
301                    details: format!("Symbol lock released on error in {}", req.path),
302                    session_id: req.session_id.clone(),
303                    affected_files: vec![crate::FileChange {
304                        path: req.path.clone(),
305                        operation: "unlock".to_string(),
306                    }],
307                    symbol_changes: vec![],
308                    repo_id: repo_id_str.clone(),
309                    event_id: uuid::Uuid::new_v4().to_string(),
310                });
311            }
312            return Err(Status::internal(format!("Write failed: {e}")));
313        }
314    };
315
316    drop(ws);
317
318    let content_str = std::str::from_utf8(&req.content).ok();
319    let _ = engine
320        .changeset_store()
321        .upsert_file(changeset_id, &req.path, op, content_str)
322        .await;
323
324    let conflict_warnings: Vec<ConflictWarning> = Vec::new();
325
326    // Emit a file.modified (or file.added) event
327    let event_type = if is_new { "file.added" } else { "file.modified" };
328    server.event_bus().publish(crate::WatchEvent {
329        event_type: event_type.to_string(),
330        changeset_id: changeset_id.to_string(),
331        agent_id: session.agent_id.clone(),
332        affected_symbols: vec![],
333        details: format!("file {}: {}", op, req.path),
334        session_id: req.session_id.clone(),
335        affected_files: vec![crate::FileChange {
336            path: req.path.clone(),
337            operation: op.to_string(),
338        }],
339        symbol_changes: all_symbol_changes,
340        repo_id: repo_id_str,
341        event_id: uuid::Uuid::new_v4().to_string(),
342    });
343
344    info!(
345        session_id = %req.session_id,
346        path = %req.path,
347        hash = %new_hash,
348        changes = detected_changes.len(),
349        conflicts = conflict_warnings.len(),
350        "FILE_WRITE: completed"
351    );
352
353    Ok(Response::new(FileWriteResponse {
354        new_hash,
355        detected_changes,
356        conflict_warnings,
357    }))
358}
359
360/// Parse both old and new file content, diff per-symbol source text,
361/// and return only symbols that actually changed.
362///
363/// Returns `(detected_changes, all_symbol_change_details)`:
364/// - `detected_changes`: `SymbolChange` for the gRPC response (only truly changed symbols)
365/// - `all_symbol_change_details`: `SymbolChangeDetail` for claims + events (added/modified/deleted)
366fn detect_symbol_changes_diffed(
367    engine: &dk_engine::repo::Engine,
368    path: &str,
369    old_content: &[u8],
370    new_content: &[u8],
371    is_new_file: bool,
372) -> (Vec<SymbolChange>, Vec<crate::SymbolChangeDetail>) {
373    let file_path = std::path::Path::new(path);
374    let parser = engine.parser();
375
376    if !parser.supports_file(file_path) {
377        return (Vec::new(), Vec::new());
378    }
379
380    // Parse new file
381    let new_symbols = match parser.parse_file(file_path, new_content) {
382        Ok(analysis) => analysis.symbols,
383        Err(_) => return (Vec::new(), Vec::new()),
384    };
385
386    // If file is new, all symbols are "added"
387    if is_new_file || old_content.is_empty() {
388        let changes: Vec<SymbolChange> = new_symbols
389            .iter()
390            .map(|sym| SymbolChange {
391                symbol_name: sym.qualified_name.clone(),
392                change_type: sym.kind.to_string(),
393            })
394            .collect();
395        let details: Vec<crate::SymbolChangeDetail> = new_symbols
396            .iter()
397            .map(|sym| crate::SymbolChangeDetail {
398                symbol_name: sym.qualified_name.clone(),
399                file_path: path.to_string(),
400                change_type: "added".to_string(),
401                kind: sym.kind.to_string(),
402            })
403            .collect();
404        return (changes, details);
405    }
406
407    // Parse old file to get baseline symbols
408    let old_symbols = match parser.parse_file(file_path, old_content) {
409        Ok(analysis) => analysis.symbols,
410        Err(_) => {
411            // Can't parse old file — fall back to treating all new symbols as modified
412            let changes: Vec<SymbolChange> = new_symbols
413                .iter()
414                .map(|sym| SymbolChange {
415                    symbol_name: sym.qualified_name.clone(),
416                    change_type: sym.kind.to_string(),
417                })
418                .collect();
419            let details: Vec<crate::SymbolChangeDetail> = new_symbols
420                .iter()
421                .map(|sym| crate::SymbolChangeDetail {
422                    symbol_name: sym.qualified_name.clone(),
423                    file_path: path.to_string(),
424                    change_type: "modified".to_string(),
425                    kind: sym.kind.to_string(),
426                })
427                .collect();
428            return (changes, details);
429        }
430    };
431
432    // Build a map of old symbol qualified_name → source text.
433    // Use entry().or_insert() to keep the first occurrence when duplicate
434    // qualified names exist (e.g., overloaded methods in Java/Kotlin/C#).
435    let mut old_symbol_text: std::collections::HashMap<&str, &[u8]> = std::collections::HashMap::new();
436    for sym in &old_symbols {
437        let start = sym.span.start_byte as usize;
438        let end = sym.span.end_byte as usize;
439        if start <= end && end <= old_content.len() {
440            old_symbol_text.entry(sym.qualified_name.as_str()).or_insert(&old_content[start..end]);
441        }
442    }
443
444    let mut detected_changes = Vec::new();
445    let mut all_details = Vec::new();
446
447    // Deduplicate new symbols while preserving original parse order.
448    let mut seen_new: std::collections::HashSet<&str> = std::collections::HashSet::new();
449
450    // Compare each deduplicated new symbol against its old version
451    for sym in &new_symbols {
452        if !seen_new.insert(sym.qualified_name.as_str()) {
453            continue; // duplicate qualified name — already handled
454        }
455        let start = sym.span.start_byte as usize;
456        let end = sym.span.end_byte as usize;
457        let new_text = if start <= end && end <= new_content.len() {
458            &new_content[start..end]
459        } else {
460            continue; // invalid or inverted span, skip
461        };
462
463        match old_symbol_text.get(sym.qualified_name.as_str()) {
464            None => {
465                // Symbol not in old file — added
466                detected_changes.push(SymbolChange {
467                    symbol_name: sym.qualified_name.clone(),
468                    change_type: sym.kind.to_string(),
469                });
470                all_details.push(crate::SymbolChangeDetail {
471                    symbol_name: sym.qualified_name.clone(),
472                    file_path: path.to_string(),
473                    change_type: "added".to_string(),
474                    kind: sym.kind.to_string(),
475                });
476            }
477            Some(old_text) => {
478                if *old_text != new_text {
479                    // Symbol text changed — modified
480                    detected_changes.push(SymbolChange {
481                        symbol_name: sym.qualified_name.clone(),
482                        change_type: sym.kind.to_string(),
483                    });
484                    all_details.push(crate::SymbolChangeDetail {
485                        symbol_name: sym.qualified_name.clone(),
486                        file_path: path.to_string(),
487                        change_type: "modified".to_string(),
488                        kind: sym.kind.to_string(),
489                    });
490                }
491                // else: symbol text identical — skip (no claim needed)
492            }
493        }
494    }
495
496    // Detect deleted symbols (deduplicated to avoid double-reporting overloads)
497    let new_names: std::collections::HashSet<&str> = new_symbols
498        .iter()
499        .map(|s| s.qualified_name.as_str())
500        .collect();
501    let old_names: std::collections::HashSet<&str> = old_symbols
502        .iter()
503        .map(|s| s.qualified_name.as_str())
504        .collect();
505    for old_name in &old_names {
506        if !new_names.contains(old_name) {
507            if let Some(old_sym) = old_symbols.iter().find(|s| s.qualified_name.as_str() == *old_name) {
508                all_details.push(crate::SymbolChangeDetail {
509                    symbol_name: old_sym.qualified_name.clone(),
510                    file_path: path.to_string(),
511                    change_type: "deleted".to_string(),
512                    kind: old_sym.kind.to_string(),
513                });
514            }
515        }
516    }
517
518    (detected_changes, all_details)
519}