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 {
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
26pub 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 let ws = engine
52 .workspace_manager()
53 .get_workspace(&sid)
54 .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
55
56 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 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 let last_read_at = ws.last_read(&req.path);
84
85 drop(ws);
87
88 let op = if is_new { "add" } else { "modify" };
89
90 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 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 let (detected_changes, all_symbol_changes) =
168 detect_symbol_changes_diffed(engine, &req.path, &old_content, &req.content, is_new);
169
170 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) => {} 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 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 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 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
352fn 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 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 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 let old_symbols = match parser.parse_file(file_path, old_content) {
401 Ok(analysis) => analysis.symbols,
402 Err(_) => {
403 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 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 let mut seen_new: std::collections::HashSet<&str> = std::collections::HashSet::new();
441
442 for sym in &new_symbols {
444 if !seen_new.insert(sym.qualified_name.as_str()) {
445 continue; }
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; };
454
455 match old_symbol_text.get(sym.qualified_name.as_str()) {
456 None => {
457 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 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 }
485 }
486 }
487
488 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}