1use super::*;
2
3impl RuntimeState {
4 pub(super) fn new(
5 session_manager: SessionManagerControl,
6 controller: Controller,
7 recovery_observer: RecoveryObserver,
8 worker_upgrade_observer: WorkerUpgradeObserver,
9 workspaces: Vec<WorkspaceRecord>,
10 ) -> Self {
11 Self::new_with_controller_loader(
12 session_manager,
13 controller,
14 recovery_observer,
15 worker_upgrade_observer,
16 workspaces,
17 Controller::load,
18 )
19 }
20
21 pub(super) fn new_with_controller_loader(
22 session_manager: SessionManagerControl,
23 controller: Controller,
24 recovery_observer: RecoveryObserver,
25 worker_upgrade_observer: WorkerUpgradeObserver,
26 workspaces: Vec<WorkspaceRecord>,
27 controller_loader: fn() -> Result<Controller>,
28 ) -> Self {
29 let initial_revision = u64::try_from(chrono::Utc::now().timestamp_micros()).unwrap_or(1);
34 let revisions = RuntimeRevisions::new(initial_revision);
35 let (workspaces_tx, _) = tokio::sync::watch::channel(workspaces);
36 let review_config = Arc::new(Mutex::new(controller.config.review.clone()));
40 let review_host = TurnReviewHost::spawn_notifying(
41 session_manager.clone(),
42 {
43 let installed = review_config.clone();
44 Arc::new(move || {
45 installed
46 .lock()
47 .unwrap_or_else(PoisonError::into_inner)
48 .clone()
49 })
50 },
51 revisions.notifier(),
52 );
53 Self {
54 attachments: Mutex::new(BTreeMap::new()),
55 phone_status: Mutex::new(WebViewerStatus::Starting),
56 web_viewer: crate::web_viewer::ViewerControl::new(),
57 ever_attached: AtomicBool::new(false),
58 sessions: Mutex::new(BTreeMap::new()),
59 revisions,
60 workspaces_tx,
61 session_manager,
62 lifecycle: Mutex::new(BTreeMap::new()),
63 close_requested: Mutex::new(BTreeSet::new()),
64 controller: Mutex::new(controller),
65 controller_loader,
66 config_mutation: tokio::sync::Mutex::new(()),
67 recovery_observer,
68 worker_upgrade_observer,
69 notices: Mutex::new(VecDeque::new()),
70 next_notice_id: AtomicU64::new(1),
71 review_config,
72 review_host,
73 wiki: crate::sessionwiki::WikiIndexer::spawn(),
74 }
75 }
76
77 pub fn review_host(&self) -> &TurnReviewHost {
79 &self.review_host
80 }
81
82 pub fn wiki(&self) -> &crate::sessionwiki::WikiIndexer {
84 &self.wiki
85 }
86
87 pub async fn wiki_search(&self, query: String, limit: usize) -> Result<WikiSearchPage> {
90 if crate::sessionwiki::sync_is_stale(self.wiki.last_success()) {
91 self.wiki.request_sync(false);
94 }
95 let live = self.live_session_ids();
96 let rows = blocking(move || crate::sessionwiki::query_rows(&query, limit, &live)).await?;
97 Ok(WikiSearchPage {
100 rows,
101 status: self.wiki.status(),
102 })
103 }
104
105 pub async fn wiki_brief(&self, wiki_id: String, max_chars: usize) -> Result<Option<String>> {
108 blocking(move || crate::sessionwiki::brief(&wiki_id, max_chars)).await
109 }
110
111 pub async fn restore_wiki_session(
119 self: &Arc<Self>,
120 request: WikiRestoreRequest,
121 ) -> Result<Option<RegisteredSession>> {
122 let wiki_id = request.wiki_id.clone();
123 let Some(archived) =
124 blocking(move || crate::sessionwiki::archived_session(&wiki_id)).await?
125 else {
126 return Ok(None);
127 };
128 let project_directory = request
129 .project_directory
130 .clone()
131 .or_else(|| archived.project_directory.clone())
132 .context(
133 "name a project directory: the archived session's own project is no longer on this machine",
134 )?;
135 let source = project_directory.display().to_string();
136 let bundle_id = blocking(move || {
137 crate::controller::create_bundle_from_sources(&[source])
138 .map(|created| created.bundle_id)
139 .map_err(anyhow::Error::new)
140 })
141 .await
142 .context("find or create a bundle for the restored session's project")?;
143 let registered = self
144 .start_create_session(CreateSessionRequest {
145 create_managed_worktree: None,
146 mjolnir_subagents: None,
147 initial_prompt: None,
148 workspace_id: request.workspace_id,
149 profile_id: request.profile_id,
150 bundle_id,
151 project_directory: Some(project_directory),
152 target_template_id: request.target_template_id,
153 additional_mounts: request.additional_mounts,
154 resource_allocation: request.resource_allocation,
155 title: archived.title.clone(),
156 session_title_override: Some(archived.title.clone()),
161 })
162 .await?;
163 let session_id = registered.session.id.clone();
164 let runtime = Arc::clone(self);
165 let handoff_session = session_id.clone();
166 tokio::spawn(async move {
167 if let Err(error) = runtime
168 .install_archive_handoff(&handoff_session, archived.snapshot)
169 .await
170 {
171 tracing::warn!(
172 session_id = %handoff_session,
173 %error,
174 "could not install the restored archive's hand-off"
175 );
176 runtime.push_notice(
177 &handoff_session,
178 format!(
179 "The restored session started without its archived hand-off: {error:#}"
180 ),
181 );
182 }
183 });
184 Ok(Some(registered))
185 }
186
187 fn live_session_ids(&self) -> BTreeSet<String> {
188 self.controller
189 .lock()
190 .unwrap_or_else(PoisonError::into_inner)
191 .state
192 .sessions
193 .keys()
194 .cloned()
195 .collect()
196 }
197
198 async fn install_archive_handoff(
202 &self,
203 session_id: &str,
204 snapshot: mj_core::archive::CanonicalSessionSnapshot,
205 ) -> Result<()> {
206 let handle = self.wait_for_ready_session(session_id).await?;
207 let (config, profile_id) = {
208 let controller = self
209 .controller
210 .lock()
211 .unwrap_or_else(PoisonError::into_inner);
212 let profile_id = controller
213 .state
214 .sessions
215 .get(session_id)
216 .map(|record| record.last_profile.clone());
217 (controller.config.clone(), profile_id)
218 };
219 let context_bytes = crate::handoff::profile_handoff_bytes(
220 profile_id.and_then(|id| config.profiles.get(&id)),
221 );
222 let cancel = CancellationToken::new();
223 let handoff = crate::handoff::build_handoff_context(
224 session_id,
225 &config,
226 &snapshot,
227 context_bytes,
228 &cancel,
229 )
230 .await
231 .context("compact the archived transcript")?;
232 handle
233 .install_prompt_context(format!(
234 "{} {handoff}",
235 crate::compaction::ARCHIVE_HANDOFF_PREAMBLE
236 ))
237 .await
238 .context("install the archived hand-off")?;
239 tracing::info!(
240 session_id,
241 bytes = handoff.len(),
242 "installed the restored archive's hand-off"
243 );
244 Ok(())
245 }
246
247 async fn wait_for_ready_session(
249 &self,
250 session_id: &str,
251 ) -> Result<crate::session_manager::ManagedSessionHandle> {
252 const POLL: Duration = Duration::from_millis(250);
253 let deadline = tokio::time::Instant::now() + Duration::from_secs(30 * 60);
254 loop {
255 match self.session_state(session_id) {
259 Some(
260 SessionState::Provisioning
261 | SessionState::Running
262 | SessionState::Disconnected
263 | SessionState::Checkpointing,
264 ) => {}
265 Some(state) => bail!("session {session_id} is {state:?} before its hand-off"),
266 None => bail!("session {session_id} disappeared before its hand-off"),
267 }
268 if let Ok(handle) = self.session_manager.session(session_id).await {
269 let view = handle.view();
270 if view.connected
271 && view
272 .snapshot
273 .is_some_and(|snapshot| snapshot.operational.native_session_is_ready())
274 {
275 return Ok(handle);
276 }
277 }
278 ensure!(
279 tokio::time::Instant::now() < deadline,
280 "session {session_id} was not ready for its hand-off within 30 minutes"
281 );
282 tokio::time::sleep(POLL).await;
283 }
284 }
285
286 pub(super) fn note_lifecycle_outcome(&self, session_id: &str) {
292 let stopped = {
293 let controller = self
294 .controller
295 .lock()
296 .unwrap_or_else(PoisonError::into_inner);
297 durable_session_state(&controller, session_id) == Some(SessionState::Stopped)
298 };
299 if stopped {
300 self.wiki.request_sync(false);
301 }
302 }
303
304 pub fn allocate_revision(&self) -> u64 {
305 self.revisions.allocate()
306 }
307
308 pub(super) fn publish_revision(&self) -> u64 {
309 self.revisions.publish()
310 }
311
312 pub(super) fn attachments(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Attachment>> {
313 self.attachments
314 .lock()
315 .unwrap_or_else(PoisonError::into_inner)
316 }
317
318 pub(super) fn prune_dead_clients(&self) {
319 self.attachments()
320 .retain(|_, attachment| process_is_alive(attachment.pid));
321 }
322
323 pub(super) fn workspace_has_active_resume(&self, workspace_id: &str) -> bool {
324 self.lifecycle
325 .lock()
326 .unwrap_or_else(PoisonError::into_inner)
327 .values()
328 .any(|active| {
329 active.result.borrow().is_none()
330 && active.resume_workspace_id.as_deref() == Some(workspace_id)
331 })
332 }
333
334 pub fn publish_web_access(&self, access: crate::server::WebViewerAccess) {
335 use crate::server::WebViewerAccess;
336 let status = match &access {
337 WebViewerAccess::Starting => WebViewerStatus::Starting,
338 WebViewerAccess::Ready {
339 viewer_url,
340 viewer_code,
341 qr_login_url,
342 fallback_reason,
343 } => WebViewerStatus::Ready {
344 viewer_url: viewer_url.clone(),
345 viewer_code: viewer_code.clone(),
346 qr_login_url: qr_login_url.clone(),
347 fallback_reason: fallback_reason.clone(),
348 },
349 WebViewerAccess::Failed {
350 address, message, ..
351 } => WebViewerStatus::Error {
352 message: format!("{message} Address: {address}"),
353 },
354 WebViewerAccess::Unavailable(message) => WebViewerStatus::Error {
355 message: message.clone(),
356 },
357 };
358 self.web_viewer.publish(access);
359 self.set_phone_status(status);
360 }
361
362 pub(super) fn set_phone_status(&self, status: WebViewerStatus) {
363 *self
364 .phone_status
365 .lock()
366 .unwrap_or_else(PoisonError::into_inner) = status;
367 }
368
369 pub(super) fn phone_status(&self) -> WebViewerStatus {
370 self.phone_status
371 .lock()
372 .unwrap_or_else(PoisonError::into_inner)
373 .clone()
374 }
375
376 pub(super) fn workspaces(&self) -> tokio::sync::watch::Receiver<Vec<WorkspaceRecord>> {
377 self.workspaces_tx.subscribe()
378 }
379
380 pub(super) fn worker_poll_exclusion_session_ids(
381 &self,
382 controller: &Controller,
383 ) -> BTreeSet<String> {
384 self.lifecycle
385 .lock()
386 .unwrap_or_else(PoisonError::into_inner)
387 .iter()
388 .filter(|(session_id, active)| {
389 active.result.borrow().is_none()
390 && (active.move_source_closed
391 || lifecycle_owns_worker_target(
392 active.kind,
393 controller
394 .state
395 .sessions
396 .get(*session_id)
397 .map(|session| session.state),
398 ))
399 })
400 .map(|(session_id, _)| session_id.clone())
401 .collect()
402 }
403
404 pub fn revisions(&self) -> tokio::sync::watch::Receiver<u64> {
405 self.revisions.subscribe()
406 }
407
408 pub fn with_config<T>(&self, read: impl FnOnce(&Config) -> T) -> T {
411 read(
412 &self
413 .controller
414 .lock()
415 .unwrap_or_else(PoisonError::into_inner)
416 .config,
417 )
418 }
419
420 pub async fn create_quick_bundle(
425 &self,
426 source: String,
427 ) -> std::result::Result<
428 crate::controller::QuickBundleCreation,
429 crate::controller::QuickBundleFailure,
430 > {
431 let _mutation = self.config_mutation.lock().await;
432 tokio::task::spawn_blocking(move || crate::controller::create_quick_bundle(&source))
433 .await
434 .map_err(|error| {
435 crate::controller::QuickBundleFailure::Persistence(anyhow!(
436 "bundle creation task panicked: {error}"
437 ))
438 })?
439 }
440
441 pub(super) fn publish_workspaces(&self, workspaces: Vec<WorkspaceRecord>) {
442 self.workspaces_tx.send_replace(workspaces);
443 self.publish_revision();
444 }
445}