use std::path::PathBuf;
use tonic::{Response, Status};
use tracing::{info, warn};
use dk_engine::workspace::overlay::OverlayEntry;
use crate::server::ProtocolServer;
use crate::validation::validate_file_path;
use crate::{ChangeType, SubmitError, SubmitRequest, SubmitResponse, SubmitStatus};
pub async fn handle_submit(
server: &ProtocolServer,
req: SubmitRequest,
) -> Result<Response<SubmitResponse>, Status> {
let session = server.validate_session(&req.session_id)?;
for change in &req.changes {
validate_file_path(&change.file_path)?;
}
let sid = req
.session_id
.parse::<uuid::Uuid>()
.map_err(|_| Status::invalid_argument("Invalid session ID"))?;
server.session_mgr().touch_session(&sid);
let engine = server.engine();
let changeset_id = req.changeset_id.parse::<uuid::Uuid>()
.map_err(|_| Status::invalid_argument("invalid changeset_id"))?;
let ws = engine
.workspace_manager()
.get_workspace(&sid)
.ok_or_else(|| Status::not_found("Workspace not found for session"))?;
let base_commit = ws.base_commit.clone();
let (repo_id, work_dir, file_checks) = {
let (repo_id, git_repo) = engine
.get_repo(&session.codebase)
.await
.map_err(|e| Status::internal(format!("Repo error: {e}")))?;
let work_dir = git_repo.path().to_path_buf();
let checks: Vec<(&crate::Change, bool)> = req
.changes
.iter()
.map(|change| {
let exists_in_base = git_repo
.read_tree_entry(&base_commit, &change.file_path)
.is_ok();
(change, exists_in_base)
})
.collect();
(repo_id, work_dir, checks)
};
let mut pre_submit_symbols: std::collections::HashMap<String, std::collections::HashMap<String, uuid::Uuid>> = {
let mut file_syms = std::collections::HashMap::new();
for change in &req.changes {
let entry: &mut std::collections::HashMap<String, uuid::Uuid> = file_syms.entry(change.file_path.clone()).or_default();
if let Ok(symbols) = engine.symbol_store().find_by_file(repo_id, &change.file_path).await {
for sym in symbols {
entry.insert(sym.qualified_name, sym.id);
}
}
}
file_syms
};
let early_overlay_snapshot = if req.changes.is_empty() {
let snap = ws.overlay.list_changes();
for (path, _) in &snap {
let entry = pre_submit_symbols.entry(path.clone()).or_default();
if let Ok(symbols) = engine.symbol_store().find_by_file(repo_id, path).await {
for sym in symbols {
entry.insert(sym.qualified_name, sym.id);
}
}
}
Some(snap)
} else {
None
};
let mut errors = Vec::new();
let mut changed_files = Vec::new();
for (change, exists_in_base) in &file_checks {
match change.r#type() {
ChangeType::ModifyFunction | ChangeType::ModifyType => {
let in_overlay = ws.overlay.contains(&change.file_path);
if !exists_in_base && !in_overlay {
errors.push(SubmitError {
message: format!("File not found: {}", change.file_path),
symbol_id: change.old_symbol_id.clone(),
file_path: Some(change.file_path.clone()),
});
continue;
}
let is_new = !exists_in_base;
if let Err(e) = ws
.overlay
.write(&change.file_path, change.new_source.as_bytes().to_vec(), is_new)
.await
{
errors.push(SubmitError {
message: format!("Write failed: {e}"),
symbol_id: None,
file_path: Some(change.file_path.clone()),
});
continue;
}
changed_files.push(PathBuf::from(&change.file_path));
}
ChangeType::AddFunction | ChangeType::AddType | ChangeType::AddDependency => {
let is_new = !exists_in_base;
if let Err(e) = ws
.overlay
.write(&change.file_path, change.new_source.as_bytes().to_vec(), is_new)
.await
{
errors.push(SubmitError {
message: format!("Write failed: {e}"),
symbol_id: None,
file_path: Some(change.file_path.clone()),
});
continue;
}
changed_files.push(PathBuf::from(&change.file_path));
}
ChangeType::DeleteFunction => {
changed_files.push(PathBuf::from(&change.file_path));
}
}
}
let overlay_snapshot = early_overlay_snapshot.unwrap_or_else(|| ws.overlay.list_changes());
if overlay_snapshot.is_empty() && changed_files.is_empty() && errors.is_empty() {
warn!(
session_id = %req.session_id,
"SUBMIT: rejected — no file changes in overlay"
);
return Ok(Response::new(SubmitResponse {
status: SubmitStatus::Rejected.into(),
changeset_id: String::new(),
new_version: None,
errors: vec![SubmitError {
message: "No changes to submit".to_string(),
symbol_id: None,
file_path: None,
}],
conflict_block: None,
review_summary: None,
}));
}
drop(ws);
for (path, entry) in &overlay_snapshot {
let (op, content) = match entry {
OverlayEntry::Added { content, .. } => {
("add", Some(String::from_utf8_lossy(content).into_owned()))
}
OverlayEntry::Modified { content, .. } => {
("modify", Some(String::from_utf8_lossy(content).into_owned()))
}
OverlayEntry::Deleted => ("delete", None),
};
engine.changeset_store()
.upsert_file(changeset_id, path, op, content.as_deref())
.await
.map_err(|e| Status::internal(format!("changeset file record failed: {e}")))?;
if !changed_files.iter().any(|p| p.to_string_lossy() == *path) {
changed_files.push(PathBuf::from(path));
}
}
if !errors.is_empty() {
warn!(
session_id = %req.session_id,
error_count = errors.len(),
"SUBMIT: rejected with errors"
);
return Ok(Response::new(SubmitResponse {
status: SubmitStatus::Rejected.into(),
changeset_id: String::new(),
new_version: None,
errors,
conflict_block: None,
review_summary: None,
}));
}
if let Err(e) = engine
.update_files_by_root(repo_id, &work_dir, &changed_files)
.await
{
return Ok(Response::new(SubmitResponse {
status: SubmitStatus::Rejected.into(),
changeset_id: String::new(),
new_version: None,
errors: vec![SubmitError {
message: format!("Re-indexing failed: {e}"),
symbol_id: None,
file_path: None,
}],
conflict_block: None,
review_summary: None,
}));
}
for file_path in &changed_files {
let rel_str = file_path.to_string_lossy().to_string();
let file_pre_syms = pre_submit_symbols.get(&rel_str);
if let Ok(new_symbols) = engine.symbol_store().find_by_file(repo_id, &rel_str).await {
for sym in &new_symbols {
let unchanged = file_pre_syms
.and_then(|m| m.get(&sym.qualified_name))
.is_some_and(|old_id| *old_id == sym.id);
if !unchanged {
let _ = engine.changeset_store()
.record_affected_symbol(changeset_id, sym.id, &sym.qualified_name)
.await;
}
}
}
}
engine.changeset_store().update_status(changeset_id, "submitted").await
.map_err(|e| Status::internal(format!("changeset status update failed: {e}")))?;
let affected_files: Vec<crate::FileChange> = overlay_snapshot.iter().map(|(path, entry)| {
let operation = match entry {
OverlayEntry::Added { .. } => "add",
OverlayEntry::Modified { .. } => "modify",
OverlayEntry::Deleted => "delete",
};
crate::FileChange {
path: path.clone(),
operation: operation.to_string(),
}
}).collect();
let mut symbol_changes: Vec<crate::SymbolChangeDetail> = Vec::new();
for file_path in &changed_files {
let rel_str = file_path.to_string_lossy().to_string();
let file_pre_syms = pre_submit_symbols.get(&rel_str);
if let Ok(new_symbols) = engine.symbol_store().find_by_file(repo_id, &rel_str).await {
for sym in &new_symbols {
let change_type = match file_pre_syms.and_then(|m| m.get(&sym.qualified_name)) {
Some(old_id) if *old_id == sym.id => continue, Some(_) => "modified",
None => "added",
};
symbol_changes.push(crate::SymbolChangeDetail {
symbol_name: sym.qualified_name.clone(),
file_path: rel_str.clone(),
change_type: change_type.to_string(),
kind: sym.kind.to_string(),
});
}
if let Some(old_syms) = file_pre_syms {
for name in old_syms.keys() {
let still_exists = new_symbols.iter().any(|s| s.qualified_name == *name);
if !still_exists {
symbol_changes.push(crate::SymbolChangeDetail {
symbol_name: name.clone(),
file_path: rel_str.clone(),
change_type: "deleted".to_string(),
kind: String::new(),
});
}
}
}
}
}
server.event_bus().publish(crate::WatchEvent {
event_type: "changeset.submitted".to_string(),
changeset_id: changeset_id.to_string(),
agent_id: session.agent_id.clone(),
affected_symbols: vec![],
details: req.intent.clone(),
session_id: req.session_id.clone(),
affected_files,
symbol_changes,
repo_id: repo_id.to_string(),
event_id: uuid::Uuid::new_v4().to_string(),
});
let new_version = {
let (_repo_id, git_repo) = engine
.get_repo(&session.codebase)
.await
.map_err(|e| Status::internal(format!("Repo error (head read): {e}")))?;
git_repo
.head_hash()
.ok()
.flatten()
.unwrap_or_else(|| "pending".to_string())
};
info!(
session_id = %req.session_id,
changeset_id = %changeset_id,
files_changed = changed_files.len(),
"SUBMIT: accepted"
);
Ok(Response::new(SubmitResponse {
status: SubmitStatus::Accepted.into(),
changeset_id: changeset_id.to_string(),
new_version: Some(new_version),
errors: vec![],
conflict_block: None,
review_summary: None,
}))
}