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