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: request.command_id.clone(),
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
133 .prompt_with_id(session_id, request.text, request.command_id)
134 .await?;
135 Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
136}
137
138fn withdrawn_prompt() -> ApiFailure {
139 ApiFailure::conflict(
140 "this prompt was withdrawn: its turn was interrupted before the session could take it",
141 )
142}
143
144#[derive(Default)]
150pub(crate) struct HeldPrompts {
151 next_id: std::sync::atomic::AtomicU64,
152 held: std::sync::Mutex<
153 std::collections::HashMap<
154 String,
155 std::collections::BTreeMap<u64, tokio::sync::oneshot::Sender<()>>,
156 >,
157 >,
158}
159
160impl HeldPrompts {
161 fn hold(&self, session_id: &str) -> HeldPrompt<'_> {
162 let id = self
163 .next_id
164 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
165 let (withdraw, withdrawn) = tokio::sync::oneshot::channel();
166 self.held
167 .lock()
168 .unwrap_or_else(std::sync::PoisonError::into_inner)
169 .entry(session_id.to_owned())
170 .or_default()
171 .insert(id, withdraw);
172 HeldPrompt {
173 prompts: self,
174 session_id: session_id.to_owned(),
175 id,
176 withdrawn,
177 released: false,
178 }
179 }
180
181 fn withdraw(&self, session_id: &str) -> usize {
184 let withdrawn = self
185 .held
186 .lock()
187 .unwrap_or_else(std::sync::PoisonError::into_inner)
188 .remove(session_id)
189 .unwrap_or_default();
190 let count = withdrawn.len();
191 for withdraw in withdrawn.into_values() {
192 let _ = withdraw.send(());
195 }
196 count
197 }
198
199 fn take(&self, session_id: &str, id: u64) -> bool {
201 let mut held = self
202 .held
203 .lock()
204 .unwrap_or_else(std::sync::PoisonError::into_inner);
205 let Some(prompts) = held.get_mut(session_id) else {
206 return false;
207 };
208 let taken = prompts.remove(&id).is_some();
209 if prompts.is_empty() {
210 held.remove(session_id);
211 }
212 taken
213 }
214}
215
216struct HeldPrompt<'a> {
219 prompts: &'a HeldPrompts,
220 session_id: String,
221 id: u64,
222 withdrawn: tokio::sync::oneshot::Receiver<()>,
223 released: bool,
224}
225
226impl HeldPrompt<'_> {
227 async fn withdrawn(&mut self) {
229 if (&mut self.withdrawn).await.is_err() {
230 std::future::pending::<()>().await;
233 }
234 }
235
236 fn release(mut self) -> bool {
239 self.released = true;
240 self.prompts.take(&self.session_id, self.id)
241 }
242}
243
244impl Drop for HeldPrompt<'_> {
245 fn drop(&mut self) {
246 if !self.released {
247 self.prompts.take(&self.session_id, self.id);
248 }
249 }
250}
251
252const PROMPT_READINESS_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
256
257fn prompt_refusal(session: &ViewerSession) -> String {
263 if session.lifecycle != ViewerLifecycleCategory::Failed {
264 return "this session cannot take a prompt right now".to_owned();
265 }
266 let id = &session.id;
267 let ways_out = [
268 (session.has_checkpoint && session.capabilities.resume)
269 .then(|| format!("resume it with `mj resume --session {id}`")),
270 session
271 .capabilities
272 .destroy
273 .then(|| format!("remove it with `mj destroy --session {id}`")),
274 ]
275 .into_iter()
276 .flatten()
277 .collect::<Vec<_>>();
278 let mut refusal = if session.has_checkpoint {
279 "this session failed, so it cannot take a prompt"
280 } else {
281 "this session failed before it saved a checkpoint, so it cannot take a prompt or be resumed"
282 }
283 .to_owned();
284 if !ways_out.is_empty() {
285 refusal.push_str("; ");
286 refusal.push_str(&ways_out.join(" or "));
287 }
288 refusal.push('.');
289 if let Some(reason) = &session.launch_error {
290 refusal.push_str(" It failed with: ");
291 refusal.push_str(reason);
292 }
293 refusal
294}
295
296fn is_coming_up(session: &ViewerSession) -> bool {
299 use mj_core::state::SessionState;
300 still_live(session)
301 && (session.state == crate::server::LAUNCHING_STATE
302 || matches!(
303 SessionState::from_stored(&session.state),
304 Some(SessionState::Provisioning | SessionState::Disconnected)
305 ))
306}
307
308fn still_live(session: &ViewerSession) -> bool {
310 !session.has_error
311 && matches!(
312 session.lifecycle,
313 ViewerLifecycleCategory::Starting | ViewerLifecycleCategory::Live
314 )
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct UsageQuery {
323 pub after_seq: Option<u64>,
324 pub limit: Option<usize>,
325}
326
327pub(super) async fn usage(
328 State(state): State<ServerState>,
329 Path(session_id): Path<String>,
330 Query(query): Query<UsageQuery>,
331) -> Result<Json<crate::database::UsagePage>, ApiFailure> {
332 let page = backend(&state)?
333 .usage(
334 session_id,
335 query.after_seq.unwrap_or(0),
336 query.limit.unwrap_or(200).clamp(1, 1000),
337 )
338 .await?
339 .ok_or_else(|| ApiFailure::not_found("no usage history is recorded for that session"))?;
340 Ok(Json(page))
341}
342
343pub(super) async fn usage_tree(
344 State(state): State<ServerState>,
345 Path(session_id): Path<String>,
346) -> Result<Json<crate::database::UsageTree>, ApiFailure> {
347 let tree = backend(&state)?
348 .usage_tree(session_id)
349 .await?
350 .ok_or_else(|| ApiFailure::not_found("no accounting identity exists for that session"))?;
351 Ok(Json(tree))
352}
353
354pub(super) async fn usage_report(
355 State(state): State<ServerState>,
356 Query(query): Query<mj_core::usage::SessionUsageQuery>,
357) -> Result<Json<mj_core::usage::SessionUsageReport>, ApiFailure> {
358 Ok(Json(backend(&state)?.usage_report(query).await?))
359}
360
361pub(super) async fn transcript(
362 State(state): State<ServerState>,
363 Path(session_id): Path<String>,
364 Query(query): Query<TranscriptQuery>,
365) -> Result<Json<TranscriptResponse>, ApiFailure> {
366 let backend = backend(&state)?.clone();
367 let limit = query
368 .limit
369 .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
370 .clamp(1, MAX_TRANSCRIPT_LIMIT);
371 let roles = if query.finished_only {
372 if query
373 .role
374 .iter()
375 .any(|role| *role != mj_core::transcript::TranscriptRole::Agent)
376 {
377 return Err(ApiFailure::bad_request(
378 "finished_only can only be combined with role=agent",
379 ));
380 }
381 vec![mj_core::transcript::TranscriptRole::Agent]
382 } else {
383 query.role
384 };
385 let page = backend
386 .transcript(
387 session_id.clone(),
388 query.after_seq.unwrap_or(0),
389 limit,
390 roles,
391 query.finished_only,
392 )
393 .await?
394 .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
395 Ok(Json(TranscriptResponse {
396 next_after_seq: page.next_after_seq,
397 session_id,
398 latest_seq: page.latest_seq,
399 execution: page.execution,
400 items: page
401 .items
402 .iter()
403 .map(|item| TranscriptItemView {
404 stable_id: item.stable_id.clone(),
405 position: item.position,
406 seq: item.seq(),
407 role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
408 text: mj_transcript::transcript::transcript_item_text(item),
409 created_at_ms: item.created_at_ms,
410 last_changed_at_ms: item.last_changed_at_ms,
411 body: item.body.clone(),
412 })
413 .collect(),
414 }))
415}
416
417pub(super) async fn suspend(
422 State(state): State<ServerState>,
423 Path(session_id): Path<String>,
424 request: Option<Json<SuspendRequest>>,
425) -> Result<(StatusCode, Json<SuspendSessionResponse>), ApiFailure> {
426 let (active_children, publication_state) = {
427 let snapshot = state.snapshot_rx.borrow();
428 let session = require_session_record(&snapshot, &session_id)?;
429 let active_children = session
430 .subagent_session_ids
431 .iter()
432 .filter(|id| {
433 snapshot
434 .sessions
435 .iter()
436 .any(|child| child.id == id.as_str() && child.lifecycle.is_dashboard_visible())
437 })
438 .cloned()
439 .collect::<Vec<_>>();
440 (active_children, session.publication_state)
441 };
442 if publication_state.is_some()
445 && !request
446 .as_ref()
447 .is_some_and(|r| r.acknowledge_unpublished_work)
448 {
449 return Err(ApiFailure::conflict(
450 "publication status is unverified for this live clone; retry with acknowledge_unpublished_work=true to suspend it",
451 ));
452 }
453 let backend = backend(&state)?.clone();
454 let mut not_handed_back = 0;
456 for child_id in &active_children {
457 let handed_back = backend
458 .subagent_handed_back(child_id.clone())
459 .await
460 .unwrap_or_else(|error| {
461 tracing::warn!(
462 %session_id,
463 %child_id,
464 error = format!("{error:#}"),
465 "could not tell whether a sub-agent handed back; warning about it"
466 );
467 false
468 });
469 if !handed_back {
470 not_handed_back += 1;
471 }
472 }
473 backend.cancel_start(session_id.clone()).await?;
474 let status = send_action(
475 &state,
476 ControllerAction::Suspend {
477 session_id: session_id.clone(),
478 acknowledge_unpublished_work: request
479 .as_ref()
480 .is_some_and(|r| r.acknowledge_unpublished_work),
481 },
482 )
483 .await?;
484 Ok((
485 status,
486 Json(SuspendSessionResponse {
487 session_id,
488 stopped_subagents: active_children.len(),
489 subagents_not_handed_back: not_handed_back,
490 warning: mj_core::subagent::suspend_warning(not_handed_back),
491 }),
492 ))
493}
494
495#[derive(Debug, Default, serde::Deserialize)]
496#[serde(deny_unknown_fields)]
497pub(super) struct SuspendRequest {
498 #[serde(default, rename = "acknowledge_active_subagents")]
501 _acknowledge_active_subagents: bool,
502 #[serde(default)]
503 pub(super) acknowledge_unpublished_work: bool,
504}
505
506pub(super) async fn destroy(
507 State(state): State<ServerState>,
508 Path(session_id): Path<String>,
509 request: Option<Json<DestroyRequest>>,
510) -> Result<StatusCode, ApiFailure> {
511 backend(&state)?.cancel_start(session_id.clone()).await?;
512 let delete_branch = request.is_some_and(|r| r.delete_branch);
513 send_action(
514 &state,
515 ControllerAction::Destroy {
516 session_id,
517 delete_branch,
518 },
519 )
520 .await
521}
522
523#[derive(Debug, Default, serde::Deserialize)]
524#[serde(deny_unknown_fields)]
525pub(super) struct DestroyRequest {
526 #[serde(default)]
527 pub(super) delete_branch: bool,
528}
529
530pub(super) async fn interrupt_turn(
531 State(state): State<ServerState>,
532 Path(session_id): Path<String>,
533) -> Result<StatusCode, ApiFailure> {
534 if state.held_prompts.withdraw(&session_id) > 0 {
538 let has_turn = state
539 .snapshot_rx
540 .borrow()
541 .sessions
542 .iter()
543 .any(|session| session.id == session_id && session.capabilities.interrupt_turn);
544 if !has_turn {
545 return Ok(StatusCode::ACCEPTED);
546 }
547 }
548 send_action(&state, ControllerAction::InterruptTurn { session_id }).await
549}
550
551pub(super) async fn start_review(
556 State(state): State<ServerState>,
557 Path(session_id): Path<String>,
558) -> Result<(StatusCode, Json<StartReviewResponse>), ApiFailure> {
559 let status = send_action(
560 &state,
561 ControllerAction::StartReview {
562 session_id: session_id.clone(),
563 },
564 )
565 .await?;
566 Ok((
567 status,
568 Json(StartReviewResponse {
569 session_id,
570 started: true,
571 }),
572 ))
573}
574
575pub(super) async fn resolve_review(
579 State(state): State<ServerState>,
580 Path((session_id, resolution)): Path<(String, String)>,
581) -> Result<(StatusCode, Json<ResolveReviewResponse>), ApiFailure> {
582 if !matches!(resolution.as_str(), "forward" | "dismiss" | "cancel") {
583 return Err(ApiFailure::bad_request(
584 "a review is resolved by forward, dismiss, or cancel",
585 ));
586 }
587 let status = send_action(
588 &state,
589 ControllerAction::ResolveReview {
590 session_id: session_id.clone(),
591 resolution: resolution.clone(),
592 },
593 )
594 .await?;
595 Ok((
596 status,
597 Json(ResolveReviewResponse {
598 session_id,
599 resolution,
600 }),
601 ))
602}
603
604pub(super) async fn review_status(
606 State(state): State<ServerState>,
607 Path(session_id): Path<String>,
608) -> Result<Json<ReviewStatusResponse>, ApiFailure> {
609 let review = {
610 let snapshot = state.snapshot_rx.borrow();
611 require_session_record(&snapshot, &session_id)?
612 .turn_review
613 .clone()
614 };
615 Ok(Json(ReviewStatusResponse { session_id, review }))
616}
617
618pub(super) async fn send_action(
619 state: &ServerState,
620 action: ControllerAction,
621) -> Result<StatusCode, ApiFailure> {
622 crate::server::validate_action_live(state, &action).await?;
623 let (reply, outcome) = tokio::sync::oneshot::channel();
624 state
625 .action_tx
626 .send(ControllerRequest { action, reply })
627 .await
628 .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
629 let outcome = outcome
630 .await
631 .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
632 match outcome.rejection() {
633 Some(rejection) => Err(rejection.into()),
634 None => Ok(StatusCode::ACCEPTED),
635 }
636}
637
638pub(super) async fn clear_queue(
640 State(state): State<ServerState>,
641 Path(session_id): Path<String>,
642 Json(request): Json<ClearQueueRequest>,
643) -> Result<(StatusCode, Json<PromptResponse>), ApiFailure> {
644 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
645 let command_id = match request.command_id {
646 Some(id) => id,
647 None => format!("api-clear-queue-{}", mj_core::state::new_session_id()?),
648 };
649 super::super::validate_public_id(&command_id)?;
650 let handle = backend(&state)?
651 .session_handle(session_id)
652 .await?
653 .ok_or_else(|| ApiFailure::conflict("session has no live worker"))?;
654 let turn_id = handle
655 .submit(command_id, mj_core::relay::RelayCommand::ClearQueuedPrompts)
656 .await?;
657 Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
658}