Skip to main content

dk_protocol/
connect.rs

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
13/// Handle a CONNECT RPC.
14///
15/// 1. Validates the bearer token.
16/// 2. Looks up the repository by name.
17/// 3. Retrieves a high-level codebase summary (languages, symbol count, file count).
18/// 4. Reads the HEAD commit hash as the current codebase version.
19/// 5. Creates a stateful session and returns the session ID.
20/// 6. Creates a session workspace (isolated overlay for file changes).
21/// 7. Returns workspace ID and concurrency info.
22pub async fn handle_connect(
23    server: &ProtocolServer,
24    req: ConnectRequest,
25) -> Result<Response<ConnectResponse>, Status> {
26    // 1. Auth
27    let authed_agent_id = server.validate_auth(&req.auth_token)?;
28
29    // Check for session resume.
30    //
31    // `take_snapshot` consumes the snapshot so it cannot be reused by a stale
32    // reconnect.  When a valid snapshot is found, its `codebase_version` is
33    // used as the default base_commit so the resumed session starts from the
34    // same commit the old session was on.
35    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                            // Fall through to rehydrate path below (keep resumed_snapshot as None).
62                        }
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    // Epic B: stranded-workspace rehydrate fallback.
76    // Runs when snapshot-resume yielded nothing, but resume_session_id points to
77    // a stranded workspace row. Uses WorkspaceManager::resume.
78    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                    // Use the JWT-validated identity rather than the client-supplied
84                    // agent_id to prevent a client from claiming another agent's session.
85                    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                            // Epic B: reject cross-codebase resume. Resolve req.codebase and
93                            // compare to the rehydrated workspace's repo_id so an agent cannot
94                            // resume a stranded workspace that belongs to a different repository.
95                            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    // Extract the requested base_commit early so it can be validated during
196    // the initial repo lookup (avoids a redundant `get_repo` call later).
197    // If resuming, default to the snapshot's codebase_version so the
198    // workspace starts from the same commit the old session was on.
199    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    // 2-4. Resolve repo, get summary, read HEAD version, and validate
206    //      base_commit if one was provided.  Everything involving
207    //      `GitRepository` (which is !Sync) is scoped inside a block so
208    //      the future remains Send.
209    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        // HEAD commit hash (or "initial" for empty repos).
223        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        // Validate custom base_commit while we still hold git_repo, avoiding
229        // a second `get_repo` call.
230        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 before the next .await to keep the future Send.
243        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    // 5. Create session (session_mgr is lock-free / DashMap-based).
254    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    // 5a. Resolve agent name: use provided name or auto-assign.
262    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    // 5b. Create a changeset (staging area for file changes).
269    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    // 6. Determine workspace mode from request config
276    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    // Use the provided base_commit or default to current HEAD version
282    let base_commit = requested_base_commit.unwrap_or_else(|| version.clone());
283
284    // Create the session workspace
285    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    // 7. Build concurrency info
301    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            // Gather just the paths (avoids cloning file content)
309            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, // include this session
321        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}