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
10const STALE_OVERLAY_PREFIX: &str = "STALE_OVERLAY";
14
15pub(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
33pub 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 let ws = engine
60 .workspace_manager()
61 .get_workspace(&sid)
62 .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
63
64 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 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 let last_read_at = ws.last_read(&req.path);
92
93 drop(ws);
95
96 let op = if is_new { "add" } else { "modify" };
97
98 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 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 let (detected_changes, all_symbol_changes) =
176 detect_symbol_changes_diffed(engine, &req.path, &old_content, &req.content, is_new);
177
178 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) => {} 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 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 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 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
360fn 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 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 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 let old_symbols = match parser.parse_file(file_path, old_content) {
409 Ok(analysis) => analysis.symbols,
410 Err(_) => {
411 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 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 let mut seen_new: std::collections::HashSet<&str> = std::collections::HashSet::new();
449
450 for sym in &new_symbols {
452 if !seen_new.insert(sym.qualified_name.as_str()) {
453 continue; }
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; };
462
463 match old_symbol_text.get(sym.qualified_name.as_str()) {
464 None => {
465 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 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 }
493 }
494 }
495
496 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}