1use tonic::{Response, Status};
2use tracing::{info, warn};
3
4use dk_engine::workspace::session_manager::ResumeResult;
5use dk_engine::workspace::session_workspace::WorkspaceMode;
6
7use crate::server::ProtocolServer;
8use crate::{
9 ActiveSessionSummary, CodebaseSummary, ConnectRequest, ConnectResponse,
10 WorkspaceConcurrencyInfo,
11};
12
13pub async fn handle_connect(
23 server: &ProtocolServer,
24 req: ConnectRequest,
25) -> Result<Response<ConnectResponse>, Status> {
26 let authed_agent_id = server.validate_auth(&req.auth_token)?;
28
29 let mut resumed_snapshot: Option<crate::session::SessionSnapshot> = None;
36 if let Some(ref ws_config) = req.workspace_config {
37 if let Some(ref resume_id_str) = ws_config.resume_session_id {
38 match resume_id_str.parse::<uuid::Uuid>() {
39 Ok(resume_id) => {
40 match server.session_mgr().take_snapshot(&resume_id) {
41 Some(snapshot) => {
42 if snapshot.codebase != req.codebase {
43 return Err(Status::invalid_argument(format!(
44 "Cannot resume session from codebase '{}' into '{}'",
45 snapshot.codebase, req.codebase
46 )));
47 }
48 info!(
49 resume_from = %resume_id,
50 agent_id = %snapshot.agent_id,
51 base_version = %snapshot.codebase_version,
52 "CONNECT: resuming from previous session snapshot"
53 );
54 resumed_snapshot = Some(snapshot);
55 }
56 None => {
57 warn!(
58 resume_session_id = %resume_id,
59 "CONNECT: resume snapshot not found; trying stranded workspace rehydrate"
60 );
61 }
63 }
64 }
65 Err(_) => {
66 return Err(Status::invalid_argument(format!(
67 "resume_session_id '{}' is not a valid UUID",
68 resume_id_str
69 )));
70 }
71 }
72 }
73 }
74
75 if resumed_snapshot.is_none() {
79 if let Some(ref ws_config) = req.workspace_config {
80 if let Some(ref resume_id_str) = ws_config.resume_session_id {
81 if let Ok(dead) = resume_id_str.parse::<uuid::Uuid>() {
82 let new_sid = uuid::Uuid::new_v4();
83 let agent_id = authed_agent_id.clone();
86 let mgr = server.engine().workspace_manager();
87 match mgr.resume(&dead, new_sid, &agent_id).await {
88 Ok(ResumeResult::Ok(_)) => {
89 let ws = mgr
90 .get_workspace(&new_sid)
91 .ok_or_else(|| Status::internal("rehydrated workspace not found"))?;
92 let (expected_repo_id, _git) = server
96 .engine()
97 .get_repo(&req.codebase)
98 .await
99 .map_err(|e| {
100 Status::invalid_argument(format!(
101 "codebase lookup failed: {e}"
102 ))
103 })?;
104 if ws.repo_id != expected_repo_id {
105 return Err(Status::invalid_argument(
106 "Cannot resume stranded workspace from a different codebase"
107 .to_string(),
108 ));
109 }
110 let base_commit = ws.base_commit.clone();
111 let changeset_id = ws.changeset_id;
112 let workspace_id = ws.id;
113 info!(
114 resume_from = %dead,
115 new_session_id = %new_sid,
116 workspace_id = %workspace_id,
117 "CONNECT: rehydrated stranded workspace"
118 );
119 return Ok(Response::new(ConnectResponse {
120 session_id: new_sid.to_string(),
121 codebase_version: base_commit,
122 changeset_id: changeset_id.to_string(),
123 workspace_id: workspace_id.to_string(),
124 ..Default::default()
125 }));
126 }
127 Ok(ResumeResult::Contended(syms)) => {
128 let mut st = Status::failed_precondition("resume contended");
129 st.metadata_mut().insert(
130 "dk-error",
131 tonic::metadata::MetadataValue::try_from("RESUME_CONTENDED")
132 .expect("static ascii"),
133 );
134 let json_syms: Vec<serde_json::Value> = syms
135 .iter()
136 .map(|s| {
137 serde_json::json!({
138 "qualified_name": s.qualified_name,
139 "file_path": s.file_path,
140 "claimant_session": s.claimant_session.to_string(),
141 "claimant_agent": s.claimant_agent,
142 })
143 })
144 .collect();
145 if let Ok(json) = serde_json::to_string(&json_syms) {
146 if let Ok(mv) =
147 tonic::metadata::MetadataValue::try_from(json.as_str())
148 {
149 st.metadata_mut()
150 .insert("dk-conflicting-symbols", mv);
151 }
152 }
153 return Err(st);
154 }
155 Ok(ResumeResult::AlreadyResumed(resumed_sid)) => {
156 let mut st = Status::already_exists("already resumed");
157 st.metadata_mut().insert(
158 "dk-error",
159 tonic::metadata::MetadataValue::try_from("ALREADY_RESUMED")
160 .expect("static"),
161 );
162 st.metadata_mut().insert(
163 "dk-new-session-id",
164 tonic::metadata::MetadataValue::try_from(
165 resumed_sid.to_string().as_str(),
166 )
167 .expect("uuid is ascii"),
168 );
169 return Err(st);
170 }
171 Ok(ResumeResult::Abandoned) => {
172 let mut st =
173 Status::failed_precondition("session abandoned");
174 st.metadata_mut().insert(
175 "dk-error",
176 tonic::metadata::MetadataValue::try_from("SESSION_ABANDONED")
177 .expect("static"),
178 );
179 return Err(st);
180 }
181 Ok(ResumeResult::NotStranded) => {
182 warn!(
183 resume_session_id = %dead,
184 "CONNECT: resume requested but workspace not stranded \
185 — falling through to new session"
186 );
187 }
188 Err(e) => return Err(Status::internal(e.to_string())),
189 }
190 }
191 }
192 }
193 }
194
195 let requested_base_commit = req
200 .workspace_config
201 .as_ref()
202 .and_then(|c| c.base_commit.clone())
203 .or_else(|| resumed_snapshot.as_ref().map(|s| s.codebase_version.clone()));
204
205 let engine = server.engine();
210
211 let (repo_id, version, summary) = {
212 let (repo_id, git_repo) = engine
213 .get_repo(&req.codebase)
214 .await
215 .map_err(|e| match e {
216 dk_core::Error::AmbiguousRepoName(_) => Status::invalid_argument(
217 format!("Ambiguous repository name: use the full 'owner/repo' form ({e})"),
218 ),
219 _ => Status::not_found(format!("Repository not found: {e}")),
220 })?;
221
222 let version = git_repo
224 .head_hash()
225 .map_err(|e| Status::internal(format!("Failed to read HEAD: {e}")))?
226 .unwrap_or_else(|| "initial".to_string());
227
228 if let Some(ref base) = requested_base_commit {
231 if base != &version && base != "initial" {
232 git_repo
233 .list_tree_files(base)
234 .map_err(|_| {
235 Status::invalid_argument(format!(
236 "base_commit '{base}' does not resolve to a valid commit"
237 ))
238 })?;
239 }
240 }
241
242 drop(git_repo);
244
245 let summary = engine
246 .codebase_summary(repo_id)
247 .await
248 .map_err(|e| Status::internal(format!("Failed to get summary: {e}")))?;
249
250 (repo_id, version, summary)
251 };
252
253 let session_id = server.session_mgr().create_session(
255 req.agent_id.clone(),
256 req.codebase.clone(),
257 req.intent.clone(),
258 version.clone(),
259 );
260
261 let agent_name = if req.agent_name.is_empty() {
263 engine.workspace_manager().next_agent_name(&repo_id)
264 } else {
265 req.agent_name.clone()
266 };
267
268 let changeset = engine
270 .changeset_store()
271 .create(repo_id, Some(session_id), &req.agent_id, &req.intent, Some(&version), &agent_name)
272 .await
273 .map_err(|e| Status::internal(format!("failed to create changeset: {e}")))?;
274
275 let ws_mode = match req.workspace_config.as_ref().map(|c| c.mode()) {
277 Some(crate::WorkspaceMode::Persistent) => WorkspaceMode::Persistent { expires_at: None },
278 _ => WorkspaceMode::Ephemeral,
279 };
280
281 let base_commit = requested_base_commit.unwrap_or_else(|| version.clone());
283
284 let workspace_id = engine
286 .workspace_manager()
287 .create_workspace(
288 session_id,
289 repo_id,
290 req.agent_id.clone(),
291 changeset.id,
292 req.intent.clone(),
293 base_commit,
294 ws_mode,
295 agent_name.clone(),
296 )
297 .await
298 .map_err(|e| Status::internal(format!("failed to create workspace: {e}")))?;
299
300 let other_session_ids = engine
302 .workspace_manager()
303 .active_sessions_for_repo(repo_id, Some(session_id));
304
305 let mut other_sessions = Vec::new();
306 for other_sid in &other_session_ids {
307 if let Some(other_ws) = engine.workspace_manager().get_workspace(other_sid) {
308 let active_files: Vec<String> = other_ws.overlay.list_paths();
310
311 other_sessions.push(ActiveSessionSummary {
312 agent_id: other_ws.agent_id.clone(),
313 intent: other_ws.intent.clone(),
314 active_files,
315 });
316 }
317 }
318
319 let concurrency = WorkspaceConcurrencyInfo {
320 active_sessions: (other_session_ids.len() + 1) as u32, other_sessions,
322 };
323
324 info!(
325 session_id = %session_id,
326 changeset_id = %changeset.id,
327 workspace_id = %workspace_id,
328 agent_id = %req.agent_id,
329 agent_name = %agent_name,
330 codebase = %req.codebase,
331 active_sessions = concurrency.active_sessions,
332 "CONNECT: session, changeset, and workspace created"
333 );
334
335 Ok(Response::new(ConnectResponse {
336 session_id: session_id.to_string(),
337 codebase_version: version,
338 summary: Some(CodebaseSummary {
339 languages: summary.languages,
340 total_symbols: summary.total_symbols,
341 total_files: summary.total_files,
342 }),
343 changeset_id: changeset.id.to_string(),
344 workspace_id: workspace_id.to_string(),
345 concurrency: Some(concurrency),
346 }))
347}