1use super::*;
2
3#[derive(Default, Deserialize)]
4pub(super) struct NativeHistoryQuery {
5 before_position: Option<u64>,
6 before_id: Option<String>,
7}
8
9pub(super) async fn transcript_history(
10 State(state): State<ServerState>,
11 Path(session_id): Path<String>,
12 Query(query): Query<NativeHistoryQuery>,
13) -> Result<Json<serde_json::Value>, ApiFailure> {
14 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
15 let before = match (query.before_position, query.before_id) {
16 (Some(position), Some(stable_id)) => Some(mj_core::storage::TranscriptCursor {
17 position,
18 stable_id,
19 }),
20 (None, None) => None,
21 _ => {
22 return Err(ApiFailure::bad_request(
23 "both before_position and before_id are required",
24 ));
25 }
26 };
27 let page = backend(&state)?
28 .transcript_history(session_id, before)
29 .await?;
30 let response = tokio::task::spawn_blocking(move || {
31 let entries = mj_client::transcript::history_entries(&page);
32 serde_json::json!({"items": entries, "before": page.before, "frontier": page.frontier})
33 })
34 .await
35 .map_err(|error| anyhow::anyhow!("history rendering task failed: {error}"))?;
36 Ok(Json(response))
37}
38
39pub(super) async fn native_agent_history(
40 State(state): State<ServerState>,
41 Path((owner, child)): Path<(String, String)>,
42 Query(query): Query<NativeHistoryQuery>,
43) -> Result<Json<serde_json::Value>, ApiFailure> {
44 require_session_record(&state.snapshot_rx.borrow(), &owner)?;
45 let before = query.before_position.zip(query.before_id);
46 let page = backend(&state)?
47 .native_agent_history(owner, child, before)
48 .await?;
49 let items = page
50 .items
51 .iter()
52 .map(|item| {
53 serde_json::json!({
54 "stable_id": item.stable_id,
55 "position": item.position,
56 "role": mj_core::transcript::transcript_item_role(&item.body),
57 "text": mj_transcript::transcript::transcript_item_text(item),
58 })
59 })
60 .collect::<Vec<_>>();
61 Ok(Json(
62 serde_json::json!({"generation": page.generation_ordinal, "items": items, "has_more": page.has_more}),
63 ))
64}
65
66pub(super) async fn prompt(
67 State(state): State<ServerState>,
68 Path(session_id): Path<String>,
69 Json(request): Json<PromptRequest>,
70) -> Result<(StatusCode, Json<PromptResponse>), ApiFailure> {
71 let backend = backend(&state)?.clone();
72 let action = ControllerAction::Prompt {
73 command_id: None,
74 session_id: session_id.clone(),
75 text: request.text.clone(),
76 images: Vec::new(),
77 };
78 let mut snapshot_rx = state.snapshot_rx.clone();
84 let deadline = tokio::time::Instant::now() + PROMPT_READINESS_WAIT;
85 let mut waiting = false;
86 let mut hold: Option<HeldPrompt<'_>> = None;
89 loop {
90 {
91 let snapshot = snapshot_rx.borrow_and_update();
92 validate_action(&action, &snapshot)?;
93 let session = require_session_record(&snapshot, &session_id)?;
94 if session.capabilities.prompt {
95 break;
96 }
97 waiting = if waiting {
102 still_live(session)
103 } else {
104 is_coming_up(session)
105 };
106 if !waiting {
107 return Err(ApiFailure::conflict(prompt_refusal(session)));
108 }
109 }
110 let held = hold.get_or_insert_with(|| state.held_prompts.hold(&session_id));
111 tokio::select! {
112 changed = tokio::time::timeout_at(deadline, snapshot_rx.changed()) => match changed {
113 Ok(Ok(())) => {}
114 Ok(Err(_)) => {
115 return Err(ApiFailure::unavailable("the daemon is shutting down"));
116 }
117 Err(_) => {
118 return Err(ApiFailure::conflict(format!(
119 "this session is still starting after {} seconds; try the prompt again once `mj sessions` shows it running",
120 PROMPT_READINESS_WAIT.as_secs()
121 )));
122 }
123 },
124 () = held.withdrawn() => return Err(withdrawn_prompt()),
125 }
126 }
127 if hold.is_some_and(|held| !held.release()) {
130 return Err(withdrawn_prompt());
131 }
132 let turn_id = backend.prompt(session_id, request.text).await?;
133 Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
134}
135
136fn withdrawn_prompt() -> ApiFailure {
137 ApiFailure::conflict(
138 "this prompt was withdrawn: its turn was interrupted before the session could take it",
139 )
140}
141
142#[derive(Default)]
148pub(crate) struct HeldPrompts {
149 next_id: std::sync::atomic::AtomicU64,
150 held: std::sync::Mutex<
151 std::collections::HashMap<
152 String,
153 std::collections::BTreeMap<u64, tokio::sync::oneshot::Sender<()>>,
154 >,
155 >,
156}
157
158impl HeldPrompts {
159 fn hold(&self, session_id: &str) -> HeldPrompt<'_> {
160 let id = self
161 .next_id
162 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
163 let (withdraw, withdrawn) = tokio::sync::oneshot::channel();
164 self.held
165 .lock()
166 .unwrap_or_else(std::sync::PoisonError::into_inner)
167 .entry(session_id.to_owned())
168 .or_default()
169 .insert(id, withdraw);
170 HeldPrompt {
171 prompts: self,
172 session_id: session_id.to_owned(),
173 id,
174 withdrawn,
175 released: false,
176 }
177 }
178
179 fn withdraw(&self, session_id: &str) -> usize {
182 let withdrawn = self
183 .held
184 .lock()
185 .unwrap_or_else(std::sync::PoisonError::into_inner)
186 .remove(session_id)
187 .unwrap_or_default();
188 let count = withdrawn.len();
189 for withdraw in withdrawn.into_values() {
190 let _ = withdraw.send(());
193 }
194 count
195 }
196
197 fn take(&self, session_id: &str, id: u64) -> bool {
199 let mut held = self
200 .held
201 .lock()
202 .unwrap_or_else(std::sync::PoisonError::into_inner);
203 let Some(prompts) = held.get_mut(session_id) else {
204 return false;
205 };
206 let taken = prompts.remove(&id).is_some();
207 if prompts.is_empty() {
208 held.remove(session_id);
209 }
210 taken
211 }
212}
213
214struct HeldPrompt<'a> {
217 prompts: &'a HeldPrompts,
218 session_id: String,
219 id: u64,
220 withdrawn: tokio::sync::oneshot::Receiver<()>,
221 released: bool,
222}
223
224impl HeldPrompt<'_> {
225 async fn withdrawn(&mut self) {
227 if (&mut self.withdrawn).await.is_err() {
228 std::future::pending::<()>().await;
231 }
232 }
233
234 fn release(mut self) -> bool {
237 self.released = true;
238 self.prompts.take(&self.session_id, self.id)
239 }
240}
241
242impl Drop for HeldPrompt<'_> {
243 fn drop(&mut self) {
244 if !self.released {
245 self.prompts.take(&self.session_id, self.id);
246 }
247 }
248}
249
250const PROMPT_READINESS_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
254
255fn prompt_refusal(session: &ViewerSession) -> String {
261 if session.lifecycle != ViewerLifecycleCategory::Failed {
262 return "this session cannot take a prompt right now".to_owned();
263 }
264 let id = &session.id;
265 let ways_out = [
266 (session.has_checkpoint && session.capabilities.resume)
267 .then(|| format!("resume it with `mj resume --session {id}`")),
268 session
269 .capabilities
270 .destroy
271 .then(|| format!("remove it with `mj destroy --session {id}`")),
272 ]
273 .into_iter()
274 .flatten()
275 .collect::<Vec<_>>();
276 let mut refusal = if session.has_checkpoint {
277 "this session failed, so it cannot take a prompt"
278 } else {
279 "this session failed before it saved a checkpoint, so it cannot take a prompt or be resumed"
280 }
281 .to_owned();
282 if !ways_out.is_empty() {
283 refusal.push_str("; ");
284 refusal.push_str(&ways_out.join(" or "));
285 }
286 refusal.push('.');
287 if let Some(reason) = &session.launch_error {
288 refusal.push_str(" It failed with: ");
289 refusal.push_str(reason);
290 }
291 refusal
292}
293
294fn is_coming_up(session: &ViewerSession) -> bool {
297 use mj_core::state::SessionState;
298 still_live(session)
299 && (session.state == crate::server::LAUNCHING_STATE
300 || matches!(
301 SessionState::from_stored(&session.state),
302 Some(SessionState::Provisioning | SessionState::Disconnected)
303 ))
304}
305
306fn still_live(session: &ViewerSession) -> bool {
308 !session.has_error
309 && matches!(
310 session.lifecycle,
311 ViewerLifecycleCategory::Starting | ViewerLifecycleCategory::Live
312 )
313}
314
315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
320pub struct UsageQuery {
321 pub after_seq: Option<u64>,
322 pub limit: Option<usize>,
323}
324
325pub(super) async fn usage(
326 State(state): State<ServerState>,
327 Path(session_id): Path<String>,
328 Query(query): Query<UsageQuery>,
329) -> Result<Json<crate::database::UsagePage>, ApiFailure> {
330 let page = backend(&state)?
331 .usage(
332 session_id,
333 query.after_seq.unwrap_or(0),
334 query.limit.unwrap_or(200).clamp(1, 1000),
335 )
336 .await?
337 .ok_or_else(|| ApiFailure::not_found("no usage history is recorded for that session"))?;
338 Ok(Json(page))
339}
340
341pub(super) async fn usage_tree(
342 State(state): State<ServerState>,
343 Path(session_id): Path<String>,
344) -> Result<Json<crate::database::UsageTree>, ApiFailure> {
345 let tree = backend(&state)?
346 .usage_tree(session_id)
347 .await?
348 .ok_or_else(|| ApiFailure::not_found("no accounting identity exists for that session"))?;
349 Ok(Json(tree))
350}
351
352pub(super) async fn transcript(
353 State(state): State<ServerState>,
354 Path(session_id): Path<String>,
355 Query(query): Query<TranscriptQuery>,
356) -> Result<Json<TranscriptResponse>, ApiFailure> {
357 let backend = backend(&state)?.clone();
358 let limit = query
359 .limit
360 .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
361 .clamp(1, MAX_TRANSCRIPT_LIMIT);
362 let roles = if query.finished_only {
363 if query
364 .role
365 .iter()
366 .any(|role| *role != mj_core::transcript::TranscriptRole::Agent)
367 {
368 return Err(ApiFailure::bad_request(
369 "finished_only can only be combined with role=agent",
370 ));
371 }
372 vec![mj_core::transcript::TranscriptRole::Agent]
373 } else {
374 query.role
375 };
376 let page = backend
377 .transcript(
378 session_id.clone(),
379 query.after_seq.unwrap_or(0),
380 limit,
381 roles,
382 query.finished_only,
383 )
384 .await?
385 .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
386 Ok(Json(TranscriptResponse {
387 next_after_seq: page.next_after_seq,
388 session_id,
389 latest_seq: page.latest_seq,
390 execution: page.execution,
391 items: page
392 .items
393 .iter()
394 .map(|item| TranscriptItemView {
395 stable_id: item.stable_id.clone(),
396 position: item.position,
397 seq: item.seq(),
398 role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
399 text: mj_transcript::transcript::transcript_item_text(item),
400 created_at_ms: item.created_at_ms,
401 last_changed_at_ms: item.last_changed_at_ms,
402 body: item.body.clone(),
403 })
404 .collect(),
405 }))
406}
407
408pub(super) async fn suspend(
413 State(state): State<ServerState>,
414 Path(session_id): Path<String>,
415 request: Option<Json<SuspendRequest>>,
416) -> Result<(StatusCode, Json<SuspendSessionResponse>), ApiFailure> {
417 let (active_children, publication_state) = {
418 let snapshot = state.snapshot_rx.borrow();
419 let session = require_session_record(&snapshot, &session_id)?;
420 let active_children = session
421 .subagent_session_ids
422 .iter()
423 .filter(|id| {
424 snapshot
425 .sessions
426 .iter()
427 .any(|child| child.id == id.as_str() && child.lifecycle.is_dashboard_visible())
428 })
429 .cloned()
430 .collect::<Vec<_>>();
431 (active_children, session.publication_state)
432 };
433 if publication_state.is_some()
436 && !request
437 .as_ref()
438 .is_some_and(|r| r.acknowledge_unpublished_work)
439 {
440 return Err(ApiFailure::conflict(
441 "publication status is unverified for this live clone; retry with acknowledge_unpublished_work=true to suspend it",
442 ));
443 }
444 let backend = backend(&state)?.clone();
445 let mut not_handed_back = 0;
447 for child_id in &active_children {
448 let handed_back = backend
449 .subagent_handed_back(child_id.clone())
450 .await
451 .unwrap_or_else(|error| {
452 tracing::warn!(
453 %session_id,
454 %child_id,
455 error = format!("{error:#}"),
456 "could not tell whether a sub-agent handed back; warning about it"
457 );
458 false
459 });
460 if !handed_back {
461 not_handed_back += 1;
462 }
463 }
464 backend.cancel_start(session_id.clone()).await?;
465 let status = send_action(
466 &state,
467 ControllerAction::Suspend {
468 session_id: session_id.clone(),
469 acknowledge_unpublished_work: request
470 .as_ref()
471 .is_some_and(|r| r.acknowledge_unpublished_work),
472 },
473 )
474 .await?;
475 Ok((
476 status,
477 Json(SuspendSessionResponse {
478 session_id,
479 stopped_subagents: active_children.len(),
480 subagents_not_handed_back: not_handed_back,
481 warning: mj_core::subagent::suspend_warning(not_handed_back),
482 }),
483 ))
484}
485
486#[derive(Debug, Default, serde::Deserialize)]
487#[serde(deny_unknown_fields)]
488pub(super) struct SuspendRequest {
489 #[serde(default, rename = "acknowledge_active_subagents")]
492 _acknowledge_active_subagents: bool,
493 #[serde(default)]
494 pub(super) acknowledge_unpublished_work: bool,
495}
496
497pub(super) async fn destroy(
498 State(state): State<ServerState>,
499 Path(session_id): Path<String>,
500 request: Option<Json<DestroyRequest>>,
501) -> Result<StatusCode, ApiFailure> {
502 backend(&state)?.cancel_start(session_id.clone()).await?;
503 let delete_branch = request.is_some_and(|r| r.delete_branch);
504 send_action(
505 &state,
506 ControllerAction::Destroy {
507 session_id,
508 delete_branch,
509 },
510 )
511 .await
512}
513
514#[derive(Debug, Default, serde::Deserialize)]
515#[serde(deny_unknown_fields)]
516pub(super) struct DestroyRequest {
517 #[serde(default)]
518 pub(super) delete_branch: bool,
519}
520
521pub(super) async fn interrupt_turn(
522 State(state): State<ServerState>,
523 Path(session_id): Path<String>,
524) -> Result<StatusCode, ApiFailure> {
525 if state.held_prompts.withdraw(&session_id) > 0 {
529 let has_turn = state
530 .snapshot_rx
531 .borrow()
532 .sessions
533 .iter()
534 .any(|session| session.id == session_id && session.capabilities.interrupt_turn);
535 if !has_turn {
536 return Ok(StatusCode::ACCEPTED);
537 }
538 }
539 send_action(&state, ControllerAction::InterruptTurn { session_id }).await
540}
541
542pub(super) async fn start_review(
547 State(state): State<ServerState>,
548 Path(session_id): Path<String>,
549) -> Result<(StatusCode, Json<StartReviewResponse>), ApiFailure> {
550 let status = send_action(
551 &state,
552 ControllerAction::StartReview {
553 session_id: session_id.clone(),
554 },
555 )
556 .await?;
557 Ok((
558 status,
559 Json(StartReviewResponse {
560 session_id,
561 started: true,
562 }),
563 ))
564}
565
566pub(super) async fn resolve_review(
570 State(state): State<ServerState>,
571 Path((session_id, resolution)): Path<(String, String)>,
572) -> Result<(StatusCode, Json<ResolveReviewResponse>), ApiFailure> {
573 if !matches!(resolution.as_str(), "forward" | "dismiss" | "cancel") {
574 return Err(ApiFailure::bad_request(
575 "a review is resolved by forward, dismiss, or cancel",
576 ));
577 }
578 let status = send_action(
579 &state,
580 ControllerAction::ResolveReview {
581 session_id: session_id.clone(),
582 resolution: resolution.clone(),
583 },
584 )
585 .await?;
586 Ok((
587 status,
588 Json(ResolveReviewResponse {
589 session_id,
590 resolution,
591 }),
592 ))
593}
594
595pub(super) async fn review_status(
597 State(state): State<ServerState>,
598 Path(session_id): Path<String>,
599) -> Result<Json<ReviewStatusResponse>, ApiFailure> {
600 let review = {
601 let snapshot = state.snapshot_rx.borrow();
602 require_session_record(&snapshot, &session_id)?
603 .turn_review
604 .clone()
605 };
606 Ok(Json(ReviewStatusResponse { session_id, review }))
607}
608
609pub(super) async fn send_action(
610 state: &ServerState,
611 action: ControllerAction,
612) -> Result<StatusCode, ApiFailure> {
613 crate::server::validate_action_live(state, &action).await?;
614 let (reply, outcome) = tokio::sync::oneshot::channel();
615 state
616 .action_tx
617 .send(ControllerRequest { action, reply })
618 .await
619 .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
620 let outcome = outcome
621 .await
622 .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
623 match outcome.rejection() {
624 Some(rejection) => Err(rejection.into()),
625 None => Ok(StatusCode::ACCEPTED),
626 }
627}