1mod events;
16
17use std::path::{Component, PathBuf};
18use std::sync::Arc;
19use std::time::Duration;
20
21use anyhow::{Context, Result as AnyResult};
22use axum::extract::{Path, Query, State};
23use axum::http::header::{
24 AUTHORIZATION, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, HeaderValue,
25};
26use axum::http::{Request as HttpRequest, StatusCode};
27use axum::middleware::Next;
28use axum::response::{IntoResponse, Response};
29use axum::routing::{get, post};
30use axum::{Json, Router};
31use serde::{Deserialize, Serialize};
32
33use mj_core::state::{
34 MaterializedExecutionState, MaterializedTurn, MaterializedTurnOutcome, TurnOutcomeKind,
35};
36
37use mj_core::relay::{CapacityRetry, is_capacity_stop_reason};
38
39use mj_client::session::{BoxFuture, SessionHandle};
40
41use super::{
42 ActionOutcome, ApiError, COOKIE_NAME, ControllerAction, ControllerRequest, ServerState,
43 ViewerLifecycleCategory, ViewerSession, ViewerSnapshot, constant_time_eq, cookie_value,
44 create_quick_bundle, now_unix, require_session_record, session_cookie_valid, validate_action,
45 validate_prompt_text,
46};
47
48pub const API_VERSION_HEADER: &str = "mj-api-version";
52pub const API_VERSION: &str = "1";
53
54pub const DEFAULT_WAIT_SECS: u64 = 600;
58pub use mj_core::subagent::MAX_WAIT_SECONDS as MAX_WAIT_SECS;
59
60const STOPPED_POLL_INTERVAL: Duration = Duration::from_millis(500);
62
63const API_TOKEN_FILE: &str = "api-token";
64const API_TOKEN_BYTES: usize = 32;
65
66pub fn api_token_path() -> PathBuf {
70 mj_core::config::data_dir().join(API_TOKEN_FILE)
71}
72
73pub fn load_or_create_api_token(path: &std::path::Path) -> AnyResult<String> {
79 match std::fs::read_to_string(path) {
80 Ok(token) if token.trim().len() >= 32 => return Ok(token.trim().to_owned()),
81 Ok(token) => tracing::warn!(
82 path = %path.display(),
83 bytes = token.trim().len(),
84 "Mjolnir API token is too short; generating a new one revokes the old token"
85 ),
86 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
87 Err(error) => tracing::warn!(
88 path = %path.display(),
89 "could not read the Mjolnir API token ({error}); generating a new one revokes the old token"
90 ),
91 }
92 let mut bytes = [0_u8; API_TOKEN_BYTES];
93 getrandom::fill(&mut bytes)
94 .map_err(|error| anyhow::anyhow!("generate Mjolnir API token: {error}"))?;
95 let token = hex_lower(&bytes);
96 mj_core::config::atomic_write(path, token.as_bytes())
97 .with_context(|| format!("persist Mjolnir API token {}", path.display()))?;
98 Ok(token)
99}
100
101fn hex_lower(bytes: &[u8]) -> String {
102 use std::fmt::Write as _;
103 bytes.iter().fold(String::new(), |mut text, byte| {
104 let _ = write!(text, "{byte:02x}");
105 text
106 })
107}
108
109#[derive(Debug)]
120pub struct ApiFailure {
121 pub status: StatusCode,
122 pub message: String,
123}
124
125impl ApiFailure {
126 pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
127 Self {
128 status,
129 message: message.into(),
130 }
131 }
132
133 pub fn bad_request(message: impl Into<String>) -> Self {
134 Self::new(StatusCode::BAD_REQUEST, message)
135 }
136
137 pub fn conflict(message: impl Into<String>) -> Self {
138 Self::new(StatusCode::CONFLICT, message)
139 }
140
141 pub fn not_found(message: impl Into<String>) -> Self {
142 Self::new(StatusCode::NOT_FOUND, message)
143 }
144
145 pub fn unavailable(message: impl Into<String>) -> Self {
146 Self::new(StatusCode::SERVICE_UNAVAILABLE, message)
147 }
148}
149
150impl std::fmt::Display for ApiFailure {
151 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 write!(formatter, "{}: {}", self.status, self.message)
153 }
154}
155
156impl From<ApiError> for ApiFailure {
157 fn from(error: ApiError) -> Self {
158 Self::new(error.status, error.message)
159 }
160}
161
162impl From<anyhow::Error> for ApiFailure {
163 fn from(error: anyhow::Error) -> Self {
164 Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}"))
165 }
166}
167
168#[derive(Debug, Serialize)]
169struct FailureBody {
170 error: String,
171}
172
173impl IntoResponse for ApiFailure {
174 fn into_response(self) -> Response {
175 (
176 self.status,
177 Json(FailureBody {
178 error: self.message,
179 }),
180 )
181 .into_response()
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct ApiBackgroundWork {
192 pub known: Option<bool>,
193 pub tasks: Vec<mj_core::relay::BackgroundCommand>,
194}
195
196impl From<&mj_core::relay::RelayOperationalState> for ApiBackgroundWork {
197 fn from(state: &mj_core::relay::RelayOperationalState) -> Self {
198 Self {
199 known: state.background_work_known,
200 tasks: state.background_commands.clone(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct ApiSession {
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub background_work: Option<ApiBackgroundWork>,
212 pub id: String,
213 pub workspace_id: String,
214 pub title: String,
215 pub harness_kind: String,
216 pub profile_id: String,
217 pub target_id: String,
218 pub bundle_id: String,
219 pub state: String,
220 pub lifecycle: ViewerLifecycleCategory,
221 pub chat_phase: super::ViewerChatPhase,
222 pub is_idle: bool,
223 pub has_error: bool,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub error: Option<String>,
227 pub created_at: String,
228 pub updated_at: String,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub last_turn_diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
236 #[serde(default)]
237 pub config_options: Vec<super::ViewerConfigOption>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
240}
241
242impl From<&ViewerSession> for ApiSession {
243 fn from(session: &ViewerSession) -> Self {
244 Self {
245 background_work: None,
246 id: session.id.clone(),
247 workspace_id: session.workspace_id.clone(),
248 title: session.title.clone(),
249 harness_kind: session.harness_kind.clone(),
250 profile_id: session.profile_id.clone(),
251 target_id: session.target_id.clone(),
252 bundle_id: session.bundle_id.clone(),
253 state: session.state.clone(),
254 lifecycle: session.lifecycle,
255 chat_phase: session.chat_phase,
256 is_idle: session.is_idle,
257 has_error: session.has_error,
258 error: session.launch_error.clone(),
259 created_at: session.created_at.clone(),
260 updated_at: session.updated_at.clone(),
261 last_turn_outcome: None,
262 last_turn_diagnostic: None,
263 config_options: session.config_options.clone(),
264 pending_elicitations: session.pending_elicitations.clone(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct SessionListResponse {
271 pub sessions: Vec<ApiSession>,
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277pub struct StartSessionRequest {
278 #[serde(default)]
279 pub create_managed_worktree: Option<bool>,
280 #[serde(default)]
282 pub mjolnir_subagents: Option<bool>,
283 #[serde(default)]
284 pub workspace_id: Option<String>,
285 pub profile_id: String,
286 pub target_id: String,
287 #[serde(default)]
288 pub bundle_id: Option<String>,
289 #[serde(default)]
290 pub project_directory: Option<PathBuf>,
291 #[serde(default)]
292 pub title: Option<String>,
293 #[serde(default)]
294 pub model: Option<String>,
295 #[serde(default)]
296 pub effort: Option<String>,
297 #[serde(default)]
298 pub prompt: Option<String>,
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct StartSessionResponse {
303 pub session_id: String,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub turn_id: Option<u64>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct SubagentSourceRange {
313 pub file: PathBuf,
314 pub start: u64,
315 pub end: u64,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct SpawnSubagentRequest {
321 pub task_name: String,
322 pub instructions: String,
323 #[serde(default)]
324 pub profile_id: Option<String>,
325 #[serde(default)]
326 pub model: Option<String>,
327 #[serde(default)]
328 pub effort: Option<String>,
329 #[serde(default)]
330 pub working_directory: Option<PathBuf>,
331 #[serde(default)]
332 pub context: Option<String>,
333 #[serde(default)]
334 pub files: Vec<SubagentSourceRange>,
335 pub request_key: String,
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
339pub struct SubagentView {
340 pub parent_session_id: String,
341 pub task_name: String,
342 pub request_key: String,
343 pub session: ApiSession,
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub struct SubagentListResponse {
348 pub subagents: Vec<SubagentView>,
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352#[serde(deny_unknown_fields)]
353pub struct PromptRequest {
354 pub text: String,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
358pub struct PromptResponse {
359 pub turn_id: u64,
362}
363
364#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
365#[serde(deny_unknown_fields)]
366pub struct WaitRequest {
367 #[serde(default)]
369 pub return_on_input: bool,
370 #[serde(default)]
374 pub turn_id: Option<u64>,
375 #[serde(default)]
376 pub timeout_secs: Option<u64>,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382pub enum WaitOutcome {
383 InputRequired,
385 Finished,
387 Error,
389 Cancelled,
391 QuotaLimit,
393 Timeout,
395 Stopped,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub struct WaitCapacityRetry {
401 pub attempt: u32,
402 pub retry_at_ms: i64,
403}
404
405impl From<&CapacityRetry> for WaitCapacityRetry {
406 fn from(retry: &CapacityRetry) -> Self {
407 Self {
408 attempt: retry.attempt,
409 retry_at_ms: retry.retry_at_ms,
410 }
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum RelayState {
422 Connected,
424 Disconnected,
426 Unreachable,
428 TargetMissing,
430 ProjectionIntegrity,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct RelayHealth {
436 pub state: RelayState,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub detail: Option<String>,
440}
441
442impl From<&mj_client::session::ManagedSessionView> for RelayHealth {
443 fn from(view: &mj_client::session::ManagedSessionView) -> Self {
444 use mj_client::session::ViewError;
445 match &view.error {
448 Some(error) => Self {
449 state: match error {
450 ViewError::Unreachable(_) => RelayState::Unreachable,
451 ViewError::TargetMissing(_) => RelayState::TargetMissing,
452 ViewError::ProjectionIntegrity(_) => RelayState::ProjectionIntegrity,
453 },
454 detail: Some(error.detail().to_owned()),
455 },
456 None if view.connected => Self {
457 state: RelayState::Connected,
458 detail: None,
459 },
460 None => Self {
461 state: RelayState::Disconnected,
462 detail: None,
463 },
464 }
465 }
466}
467
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469pub struct WaitResponse {
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub diagnostic: Option<mj_core::diagnostic::TurnDiagnostic>,
472
473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
474 pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub usage: Option<mj_core::usage::TokenUsage>,
477 pub outcome: WaitOutcome,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub stop_reason: Option<String>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub message: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub final_message: Option<String>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub turn_id: Option<u64>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub turn_number: Option<u64>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub elapsed_ms: Option<i64>,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub capacity_retry: Option<WaitCapacityRetry>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub relay: Option<RelayHealth>,
503 pub session: ApiSession,
504}
505
506pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 200;
511pub const MAX_TRANSCRIPT_LIMIT: usize = 1_000;
512
513#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
514pub struct TranscriptQuery {
515 #[serde(default)]
516 pub role: Option<mj_core::transcript::TranscriptRole>,
517 #[serde(default)]
519 pub after_seq: Option<u64>,
520 #[serde(default)]
521 pub limit: Option<usize>,
522}
523
524#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
525pub struct TranscriptItemView {
526 pub stable_id: String,
527 pub position: u64,
528 pub seq: u64,
531 pub role: String,
532 pub text: String,
534 pub created_at_ms: i64,
535 pub last_changed_at_ms: i64,
536 pub body: mj_core::transcript::TranscriptBody,
538}
539
540#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
541pub struct TranscriptResponse {
542 #[serde(default)]
543 pub next_after_seq: u64,
544 pub session_id: String,
545 pub latest_seq: u64,
548 pub execution: MaterializedExecutionState,
549 pub items: Vec<TranscriptItemView>,
550}
551
552#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct TurnState {
560 pub execution: MaterializedExecutionState,
561 pub active_turn: Option<MaterializedTurn>,
562 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
563}
564
565pub use crate::database::TurnSummary;
566
567#[derive(Debug, Clone, Default, PartialEq, Eq)]
570pub struct StartFollowup {
571 pub model: Option<String>,
572 pub effort: Option<String>,
573 pub prompt: Option<String>,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
578pub enum StartStatus {
579 Pending,
581 Submitted { turn_id: u64 },
583 Failed { message: String },
585}
586
587pub use crate::database::TranscriptPage;
589
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct PushedBranch {
593 pub branch: String,
594 pub remote: String,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599pub struct FileQuery {
600 pub path: String,
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
606#[serde(rename_all = "snake_case")]
607pub enum ExportKind {
608 Patch,
610 Branch,
612 Bundle,
614}
615
616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
617pub struct ExportRequest {
618 pub kind: ExportKind,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub branch: Option<String>,
622}
623
624#[derive(Debug, Clone)]
626pub struct BundleExport {
627 pub repository: String,
628 pub bytes: Vec<u8>,
629}
630
631#[derive(Debug)]
633pub enum ExportError {
634 Refused(String),
637 Failed(anyhow::Error),
639}
640
641impl From<ExportError> for ApiFailure {
642 fn from(error: ExportError) -> Self {
643 match error {
644 ExportError::Refused(message) => Self::conflict(message),
645 ExportError::Failed(error) => Self::from(error),
646 }
647 }
648}
649
650pub trait SubagentBackend: Send + Sync {
659 fn events(
660 &self,
661 filter: crate::database::ApiEventFilter,
662 after_seq: Option<u64>,
663 ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
664 events::load_events(filter, after_seq)
665 }
666
667 fn profile_config(
668 &self,
669 profile: String,
670 model: Option<String>,
671 refresh: bool,
672 ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
673 Box::pin(crate::controller::profile_config::discover(
674 profile, model, refresh,
675 ))
676 }
677 fn start_subagent(
678 &self,
679 _request: crate::controller::RegisterSubagentRequest,
680 ) -> BoxFuture<'_, AnyResult<mj_core::subagent::SubagentRecord>> {
681 Box::pin(async { anyhow::bail!("sub-agent creation is unavailable") })
682 }
683 fn list_subagents(
684 &self,
685 parent_session_id: String,
686 ) -> BoxFuture<'_, AnyResult<Vec<mj_core::subagent::SubagentRecord>>> {
687 Box::pin(async move {
688 tokio::task::spawn_blocking(move || crate::database::list_subagents(&parent_session_id))
689 .await?
690 })
691 }
692 fn read_context_file(
693 &self,
694 session_id: String,
695 path: PathBuf,
696 ) -> BoxFuture<'_, std::result::Result<Vec<u8>, ExportError>> {
697 self.read_file(session_id, path)
698 }
699 fn set_config(
700 &self,
701 session_id: String,
702 key: String,
703 value: String,
704 ) -> BoxFuture<'_, AnyResult<()>> {
705 Box::pin(async move {
706 self.session_handle(session_id)
707 .await?
708 .ok_or_else(|| anyhow::anyhow!("session has no live actor"))?
709 .set_config(key, value)
710 .await
711 })
712 }
713 fn cancel_start(&self, _session_id: String) -> BoxFuture<'_, AnyResult<()>> {
714 Box::pin(async { Ok(()) })
715 }
716 fn session_handle(&self, session_id: String)
718 -> BoxFuture<'_, AnyResult<Option<SessionHandle>>>;
719
720 fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>>;
722
723 fn turn_state(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>>;
725
726 fn turn_summary(
728 &self,
729 session_id: String,
730 turn_start_position: u64,
731 ) -> BoxFuture<'_, AnyResult<TurnSummary>>;
732
733 fn start_followup(
735 &self,
736 session_id: String,
737 followup: StartFollowup,
738 ) -> BoxFuture<'_, AnyResult<()>>;
739
740 fn start_status(&self, session_id: String) -> BoxFuture<'_, AnyResult<Option<StartStatus>>>;
742
743 fn transcript(
745 &self,
746 session_id: String,
747 after_seq: u64,
748 limit: usize,
749 role: Option<mj_core::transcript::TranscriptRole>,
750 ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>>;
751
752 fn usage(
753 &self,
754 session_id: String,
755 after_seq: u64,
756 limit: usize,
757 ) -> BoxFuture<'_, AnyResult<Option<crate::database::UsagePage>>> {
758 Box::pin(async move {
759 tokio::task::spawn_blocking(move || {
760 crate::database::load_session_usage(&session_id, after_seq, limit)
761 })
762 .await?
763 })
764 }
765
766 fn diff(&self, session_id: String) -> BoxFuture<'_, Result<String, ExportError>>;
768
769 fn read_file(
771 &self,
772 session_id: String,
773 path: PathBuf,
774 ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>>;
775
776 fn write_file(
777 &self,
778 _session_id: String,
779 _path: PathBuf,
780 _bytes: Vec<u8>,
781 _overwrite: bool,
782 ) -> BoxFuture<'_, Result<(), ExportError>> {
783 Box::pin(async { Err(ExportError::Refused("file injection is unavailable".into())) })
784 }
785
786 fn push_branch(
788 &self,
789 session_id: String,
790 branch: String,
791 ) -> BoxFuture<'_, Result<PushedBranch, ExportError>>;
792
793 fn bundle(&self, session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>>;
795}
796
797fn backend(state: &ServerState) -> Result<&Arc<dyn SubagentBackend>, ApiFailure> {
798 state
799 .subagent
800 .as_ref()
801 .ok_or_else(|| ApiFailure::unavailable("this server has no subagent backend installed"))
802}
803
804pub fn map_stop_reason(stop_reason: &str) -> (WaitOutcome, Option<String>) {
816 use mj_core::state::{PromptCompletion, classify_prompt_completion};
817
818 match classify_prompt_completion(stop_reason) {
819 PromptCompletion::Finished => (WaitOutcome::Finished, None),
820 PromptCompletion::Cancelled => (WaitOutcome::Cancelled, None),
821 PromptCompletion::QuotaLimit => (WaitOutcome::QuotaLimit, None),
822 PromptCompletion::Error => (WaitOutcome::Error, Some(stop_reason.to_owned())),
823 }
824}
825
826#[derive(Debug, Clone, Default, PartialEq)]
828pub struct WaitObservation {
829 pub background_work: Option<ApiBackgroundWork>,
830 pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
831 pub lifecycle: Option<ViewerLifecycleCategory>,
832 pub launch_failed: bool,
834 pub launch_error: Option<String>,
836 pub execution: MaterializedExecutionState,
837 pub active_turn: Option<MaterializedTurn>,
838 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
839 pub queued: usize,
840 pub capacity_retry: Option<CapacityRetry>,
841 pub start_status: Option<StartStatus>,
842}
843
844#[derive(Debug, Clone, PartialEq, Eq)]
846pub struct WaitDecision {
847 pub outcome: WaitOutcome,
848 pub stop_reason: Option<String>,
849 pub message: Option<String>,
850 pub turn_id: Option<u64>,
851 pub turn_start_position: Option<u64>,
853}
854
855impl WaitDecision {
856 fn simple(outcome: WaitOutcome, message: Option<String>) -> Self {
857 Self {
858 outcome,
859 stop_reason: None,
860 message,
861 turn_id: None,
862 turn_start_position: None,
863 }
864 }
865
866 fn from_outcome(outcome: &MaterializedTurnOutcome) -> Self {
867 let (kind, stop_reason, message) = match &outcome.outcome {
868 TurnOutcomeKind::Completed { stop_reason } => {
869 let (kind, message) = map_stop_reason(stop_reason);
870 (
871 kind,
872 Some(stop_reason.clone()),
873 outcome
874 .diagnostic
875 .as_ref()
876 .map(|d| d.message.clone())
877 .or(message),
878 )
879 }
880 TurnOutcomeKind::Rejected { message } => {
881 (WaitOutcome::Error, None, Some(message.clone()))
882 }
883 TurnOutcomeKind::Interrupted { message } => {
884 (WaitOutcome::Error, None, Some(message.clone()))
885 }
886 };
887 Self {
888 outcome: kind,
889 stop_reason,
890 message,
891 turn_id: outcome.accepted_ordinal,
892 turn_start_position: outcome.turn_start_position,
893 }
894 }
895}
896
897pub fn resolve_wait(observation: &WaitObservation, request: &WaitRequest) -> Option<WaitDecision> {
923 let stopping = matches!(
924 observation.lifecycle,
925 Some(ViewerLifecycleCategory::Stopped | ViewerLifecycleCategory::Stopping)
926 ) || matches!(
927 observation.execution,
928 MaterializedExecutionState::Closing | MaterializedExecutionState::Closed
929 );
930 if stopping {
931 return Some(WaitDecision::simple(
932 WaitOutcome::Stopped,
933 Some("the session is stopped or stopping".to_owned()),
934 ));
935 }
936 if observation.launch_failed {
937 return Some(WaitDecision::simple(
938 WaitOutcome::Error,
939 Some(
940 observation
941 .launch_error
942 .clone()
943 .unwrap_or_else(|| "the session failed to launch".to_owned()),
944 ),
945 ));
946 }
947 if let Some(StartStatus::Failed { message }) = &observation.start_status {
948 return Some(WaitDecision::simple(
949 WaitOutcome::Error,
950 Some(message.clone()),
951 ));
952 }
953 if observation.lifecycle == Some(ViewerLifecycleCategory::Failed) {
954 return Some(WaitDecision::simple(
955 WaitOutcome::Error,
956 Some("the session is in a failed state".to_owned()),
957 ));
958 }
959 let retry_pending = |outcome: &MaterializedTurnOutcome| {
960 observation.capacity_retry.is_some()
961 && matches!(
962 &outcome.outcome,
963 TurnOutcomeKind::Completed { stop_reason } if is_capacity_stop_reason(stop_reason)
964 )
965 };
966 let target = request.turn_id.or(match &observation.start_status {
967 Some(StartStatus::Submitted { turn_id }) => Some(*turn_id),
968 _ => None,
969 });
970 let target_finished = target.is_some_and(|target| {
971 observation
972 .last_turn_outcome
973 .as_ref()
974 .is_some_and(|outcome| {
975 outcome
976 .accepted_ordinal
977 .is_some_and(|ordinal| ordinal >= target)
978 && !retry_pending(outcome)
979 })
980 });
981 if request.return_on_input && !target_finished && !observation.pending_elicitations.is_empty() {
982 return Some(WaitDecision {
983 outcome: WaitOutcome::InputRequired,
984 stop_reason: None,
985 message: Some("the harness needs a response to a structured input request".into()),
986 turn_id: observation
987 .active_turn
988 .as_ref()
989 .and_then(|turn| turn.accepted_ordinal),
990 turn_start_position: None,
991 });
992 }
993 match target {
994 Some(target) => {
995 let outcome = observation.last_turn_outcome.as_ref()?;
996 if outcome
997 .accepted_ordinal
998 .is_none_or(|ordinal| ordinal < target)
999 {
1000 return None;
1001 }
1002 if retry_pending(outcome) {
1003 return None;
1004 }
1005 Some(WaitDecision::from_outcome(outcome))
1006 }
1007 None => {
1008 if observation.execution != MaterializedExecutionState::Idle
1009 || observation.active_turn.is_some()
1010 || observation.queued > 0
1011 {
1012 return None;
1013 }
1014 match observation.last_turn_outcome.as_ref() {
1015 Some(outcome) if retry_pending(outcome) => None,
1016 Some(outcome) => Some(WaitDecision::from_outcome(outcome)),
1017 None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
1021 }
1022 }
1023 }
1024}
1025
1026pub(super) fn router(state: ServerState) -> Router<ServerState> {
1031 Router::new()
1032 .route("/events", get(events::events))
1033 .route("/profiles/{profile_id}/config", get(profile_config))
1034 .route(
1035 "/sessions/{session_id}/config",
1036 axum::routing::patch(set_config),
1037 )
1038 .route("/sessions", get(list_sessions).post(start_session))
1039 .route("/sessions/{session_id}", get(get_session))
1040 .route(
1041 "/sessions/{session_id}/subagents",
1042 get(list_subagents).post(spawn_subagent),
1043 )
1044 .route("/sessions/{session_id}/prompt", post(prompt))
1045 .route("/sessions/{session_id}/transcript", get(transcript))
1046 .route("/sessions/{session_id}/usage", get(usage))
1047 .route("/sessions/{session_id}/wait", post(wait))
1048 .route("/sessions/{session_id}/close", post(close))
1049 .route("/sessions/{session_id}/cancel-turn", post(cancel_turn))
1050 .route("/sessions/{session_id}/diff", get(diff))
1051 .route(
1052 "/sessions/{session_id}/files",
1053 get(read_file)
1054 .put(write_file)
1055 .layer(axum::extract::DefaultBodyLimit::max(
1056 mj_checkpoint::archive::MAX_SESSION_FILE_BYTES as usize,
1057 )),
1058 )
1059 .route("/sessions/{session_id}/elicitations", get(elicitations))
1060 .route(
1061 "/sessions/{session_id}/elicitations/{elicitation_id}",
1062 post(respond_elicitation),
1063 )
1064 .route("/sessions/{session_id}/export", post(export))
1065 .route_layer(axum::middleware::from_fn_with_state(
1066 state,
1067 require_api_auth,
1068 ))
1069 .layer(axum::middleware::from_fn(api_response_headers))
1072}
1073
1074async fn require_api_auth(
1080 State(state): State<ServerState>,
1081 request: HttpRequest<axum::body::Body>,
1082 next: Next,
1083) -> Result<Response, ApiFailure> {
1084 let bearer = request
1085 .headers()
1086 .get(AUTHORIZATION)
1087 .and_then(|value| value.to_str().ok())
1088 .and_then(|value| value.strip_prefix("Bearer "))
1089 .map(str::trim);
1090 if bearer.is_some_and(|token| {
1091 constant_time_eq(state.api_token.as_bytes(), token.as_bytes()) && !token.is_empty()
1092 }) {
1093 return Ok(next.run(request).await);
1094 }
1095 let cookie = request
1096 .headers()
1097 .get(COOKIE)
1098 .and_then(|value| value.to_str().ok())
1099 .and_then(|header| cookie_value(header, COOKIE_NAME));
1100 if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1101 return Ok(next.run(request).await);
1102 }
1103 Err(ApiFailure::new(
1104 StatusCode::UNAUTHORIZED,
1105 "supply the API token from the api-token file as a bearer token",
1106 ))
1107}
1108
1109async fn api_response_headers(request: HttpRequest<axum::body::Body>, next: Next) -> Response {
1112 let mut response = next.run(request).await;
1113 let headers = response.headers_mut();
1114 headers.insert(API_VERSION_HEADER, HeaderValue::from_static(API_VERSION));
1115 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1116 response
1117}
1118
1119#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1124#[serde(deny_unknown_fields)]
1125pub struct SessionListQuery {
1126 pub workspace_id: Option<String>,
1127}
1128
1129#[derive(Debug, Default, Deserialize)]
1130#[serde(deny_unknown_fields)]
1131struct ProfileConfigQuery {
1132 model: Option<String>,
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize)]
1136#[serde(deny_unknown_fields)]
1137pub struct SetConfigRequest {
1138 pub key: String,
1139 pub value: String,
1140}
1141
1142async fn profile_config(
1143 State(state): State<ServerState>,
1144 Path(profile_id): Path<String>,
1145 Query(query): Query<ProfileConfigQuery>,
1146) -> Result<Json<mj_core::worker_launch::ProfileConfig>, ApiFailure> {
1147 super::require_profile(&state.snapshot_rx.borrow(), &profile_id)?;
1148 let choices = backend(&state)?
1149 .profile_config(profile_id, query.model, false)
1150 .await
1151 .map_err(|error| ApiFailure::unavailable(format!("profile discovery failed: {error:#}")))?;
1152 Ok(Json(choices))
1153}
1154
1155pub(crate) fn validate_selectors(
1156 choices: &mj_core::worker_launch::ProfileConfig,
1157 model: Option<&str>,
1158 effort: Option<&str>,
1159) -> Result<(), ApiFailure> {
1160 for (key, value, offered) in [
1161 ("model", model, &choices.models),
1162 ("effort", effort, &choices.efforts),
1163 ] {
1164 if let Some(value) = value
1165 && !offered.iter().any(|choice| choice.value == value)
1166 {
1167 return Err(ApiFailure::bad_request(format!(
1168 "this profile does not offer {value:?} as {key}; choices: {}",
1169 offered
1170 .iter()
1171 .map(|choice| choice.value.as_str())
1172 .collect::<Vec<_>>()
1173 .join(", ")
1174 )));
1175 }
1176 }
1177 Ok(())
1178}
1179
1180async fn set_config(
1181 State(state): State<ServerState>,
1182 Path(session_id): Path<String>,
1183 Json(request): Json<SetConfigRequest>,
1184) -> Result<Json<ApiSession>, ApiFailure> {
1185 validate_action(
1186 &ControllerAction::SetConfig {
1187 session_id: session_id.clone(),
1188 key: request.key.clone(),
1189 value: request.value.clone(),
1190 },
1191 &state.snapshot_rx.borrow(),
1192 )?;
1193 let backend = backend(&state)?;
1194 backend
1195 .set_config(session_id.clone(), request.key, request.value)
1196 .await
1197 .map_err(|error| ApiFailure::conflict(format!("configuration failed: {error:#}")))?;
1198 let mut session = ApiSession::from(require_session_record(
1199 &state.snapshot_rx.borrow(),
1200 &session_id,
1201 )?);
1202 if let Some(handle) = backend.session_handle(session_id).await?
1203 && let Some(snapshot) = handle.view().snapshot
1204 {
1205 session.config_options =
1206 super::session_config_view(session.harness_kind.parse()?, &snapshot.operational);
1207 }
1208 Ok(Json(session))
1209}
1210
1211async fn list_sessions(
1212 State(state): State<ServerState>,
1213 Query(query): Query<SessionListQuery>,
1214) -> Result<Json<SessionListResponse>, ApiFailure> {
1215 let snapshot = state.snapshot_rx.borrow();
1216 Ok(Json(SessionListResponse {
1217 sessions: snapshot
1218 .sessions
1219 .iter()
1220 .filter(|session| {
1221 query
1222 .workspace_id
1223 .as_ref()
1224 .is_none_or(|id| &session.workspace_id == id)
1225 })
1226 .map(ApiSession::from)
1227 .collect(),
1228 }))
1229}
1230
1231async fn get_session(
1232 State(state): State<ServerState>,
1233 Path(session_id): Path<String>,
1234) -> Result<Json<ApiSession>, ApiFailure> {
1235 let mut session = {
1236 let snapshot = state.snapshot_rx.borrow();
1237 ApiSession::from(require_session_record(&snapshot, &session_id)?)
1238 };
1239 if let Ok(backend) = backend(&state) {
1240 if let Some(turn) = backend.turn_state(session_id.clone()).await? {
1241 session.last_turn_diagnostic = turn
1242 .last_turn_outcome
1243 .as_ref()
1244 .and_then(|turn| turn.diagnostic.clone());
1245 session.last_turn_outcome = turn.last_turn_outcome.map(api_turn_outcome);
1246 }
1247 if let Some(handle) = backend.session_handle(session_id).await? {
1248 let view = handle.view();
1249 if view.connected
1250 && let Some(snapshot) = view.snapshot
1251 {
1252 session.background_work = Some(ApiBackgroundWork::from(&snapshot.operational));
1253 }
1254 }
1255 }
1256 Ok(Json(session))
1257}
1258
1259async fn start_session(
1267 State(state): State<ServerState>,
1268 Json(request): Json<StartSessionRequest>,
1269) -> Result<(StatusCode, Json<StartSessionResponse>), ApiFailure> {
1270 let backend = backend(&state)?.clone();
1271 if let Some(prompt) = &request.prompt {
1272 validate_prompt_text(prompt, false)?;
1273 }
1274 super::require_profile(&state.snapshot_rx.borrow(), &request.profile_id)?;
1275 super::require_target(&state.snapshot_rx.borrow(), &request.target_id)?;
1276 if request.model.is_some() || request.effort.is_some() {
1277 let mut choices = backend
1278 .profile_config(request.profile_id.clone(), request.model.clone(), false)
1279 .await
1280 .map_err(|error| {
1281 ApiFailure::unavailable(format!("profile discovery failed: {error:#}"))
1282 })?;
1283 if validate_selectors(
1284 &choices,
1285 request.model.as_deref(),
1286 request.effort.as_deref(),
1287 )
1288 .is_err()
1289 {
1290 choices = backend
1291 .profile_config(request.profile_id.clone(), request.model.clone(), true)
1292 .await
1293 .map_err(|error| {
1294 ApiFailure::unavailable(format!("profile discovery failed: {error:#}"))
1295 })?;
1296 }
1297 validate_selectors(
1298 &choices,
1299 request.model.as_deref(),
1300 request.effort.as_deref(),
1301 )?;
1302 }
1303 let bundle_id = match (&request.bundle_id, &request.project_directory) {
1304 (Some(bundle_id), _) => bundle_id.clone(),
1305 (None, Some(directory)) => {
1308 create_quick_bundle(&state, directory.display().to_string()).await?
1309 }
1310 (None, None) => {
1311 return Err(ApiFailure::bad_request(
1312 "supply bundle_id, project_directory, or both",
1313 ));
1314 }
1315 };
1316 let action = ControllerAction::New {
1317 create_managed_worktree: request.create_managed_worktree,
1318 mjolnir_subagents: request.mjolnir_subagents,
1319 workspace_id: request.workspace_id.clone().unwrap_or_default(),
1320 profile_id: request.profile_id.clone(),
1321 bundle_id,
1322 target_id: request.target_id.clone(),
1323 title: request.title.clone(),
1324 project_directory: request.project_directory.clone(),
1325 dirty_ack: Vec::new(),
1326 };
1327 validate_action(&action, &state.snapshot_rx.borrow())?;
1328
1329 let (reply, outcome) = tokio::sync::oneshot::channel();
1330 state
1331 .action_tx
1332 .send(ControllerRequest { action, reply })
1333 .await
1334 .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
1335 let outcome = outcome
1336 .await
1337 .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
1338 if let Some(rejection) = outcome.rejection() {
1339 return Err(rejection.into());
1340 }
1341 let ActionOutcome::Accepted {
1342 session_id: Some(session_id),
1343 } = outcome
1344 else {
1345 return Err(ApiFailure::new(
1346 StatusCode::INTERNAL_SERVER_ERROR,
1347 "the controller accepted the session but published no id",
1348 ));
1349 };
1350
1351 backend
1352 .start_followup(
1353 session_id.clone(),
1354 StartFollowup {
1355 model: request.model,
1356 effort: request.effort,
1357 prompt: request.prompt,
1358 },
1359 )
1360 .await?;
1361 Ok((
1362 StatusCode::CREATED,
1363 Json(StartSessionResponse {
1364 session_id,
1365 turn_id: None,
1366 }),
1367 ))
1368}
1369
1370const MAX_SUBAGENT_CONTEXT_BYTES: usize = 256 * 1024;
1371
1372async fn spawn_subagent(
1373 State(state): State<ServerState>,
1374 Path(parent_session_id): Path<String>,
1375 Json(request): Json<SpawnSubagentRequest>,
1376) -> Result<(StatusCode, Json<SubagentView>), ApiFailure> {
1377 let backend = backend(&state)?.clone();
1378 let parent = {
1379 let snapshot = state.snapshot_rx.borrow();
1380 require_session_record(&snapshot, &parent_session_id)?.clone()
1381 };
1382 if !matches!(parent.harness_kind.as_str(), "claude" | "codex") {
1383 return Err(ApiFailure::conflict(
1384 "only Claude and Codex sessions can spawn sub-agents",
1385 ));
1386 }
1387 validate_prompt_text(&request.instructions, false)?;
1388 if request.task_name.trim().is_empty() {
1389 return Err(ApiFailure::bad_request("task_name cannot be empty"));
1390 }
1391 if request.request_key.trim().is_empty() {
1392 return Err(ApiFailure::bad_request("request_key cannot be empty"));
1393 }
1394 let profile_id = request
1395 .profile_id
1396 .clone()
1397 .unwrap_or_else(|| parent.profile_id.clone());
1398 let mut selected_model = request.model.clone();
1399 let mut selected_effort = request.effort.clone();
1400 if profile_id == parent.profile_id
1401 && (selected_model.is_none() || selected_effort.is_none())
1402 && let Some(handle) = backend.session_handle(parent_session_id.clone()).await?
1403 && let Some(snapshot) = handle.view().snapshot
1404 {
1405 selected_model =
1406 selected_model.or_else(|| snapshot.operational.config.get("model").cloned());
1407 selected_effort =
1408 selected_effort.or_else(|| snapshot.operational.config.get("effort").cloned());
1409 }
1410 if selected_model.is_some() || selected_effort.is_some() {
1411 let choices = backend
1412 .profile_config(profile_id.clone(), selected_model.clone(), false)
1413 .await
1414 .map_err(|error| {
1415 ApiFailure::unavailable(format!("profile discovery failed: {error:#}"))
1416 })?;
1417 validate_selectors(
1418 &choices,
1419 selected_model.as_deref(),
1420 selected_effort.as_deref(),
1421 )?;
1422 }
1423
1424 let initial_prompt = build_subagent_prompt(
1425 &backend,
1426 &parent_session_id,
1427 &request.instructions,
1428 request.context.as_deref(),
1429 &request.files,
1430 )
1431 .await?;
1432 let relation = backend
1433 .start_subagent(crate::controller::RegisterSubagentRequest {
1434 parent_session_id: parent_session_id.clone(),
1435 task_name: request.task_name,
1436 profile_id,
1437 model: selected_model.clone(),
1438 effort: selected_effort.clone(),
1439 working_directory: request.working_directory.unwrap_or_default(),
1440 initial_prompt: initial_prompt.clone(),
1441 request_key: request.request_key,
1442 })
1443 .await
1444 .map_err(|error| ApiFailure::conflict(format!("sub-agent creation failed: {error:#}")))?;
1445 backend
1446 .start_followup(
1447 relation.child_session_id.clone(),
1448 StartFollowup {
1449 model: selected_model,
1450 effort: selected_effort,
1451 prompt: Some(initial_prompt),
1452 },
1453 )
1454 .await?;
1455 let session = {
1456 let snapshot = state.snapshot_rx.borrow();
1457 ApiSession::from(require_session_record(
1458 &snapshot,
1459 &relation.child_session_id,
1460 )?)
1461 };
1462 Ok((
1463 StatusCode::CREATED,
1464 Json(SubagentView {
1465 parent_session_id,
1466 task_name: relation.task_name,
1467 request_key: relation.request_key,
1468 session,
1469 }),
1470 ))
1471}
1472
1473async fn list_subagents(
1474 State(state): State<ServerState>,
1475 Path(parent_session_id): Path<String>,
1476) -> Result<Json<SubagentListResponse>, ApiFailure> {
1477 {
1478 let snapshot = state.snapshot_rx.borrow();
1479 require_session_record(&snapshot, &parent_session_id)?;
1480 }
1481 let records = backend(&state)?
1482 .list_subagents(parent_session_id.clone())
1483 .await?;
1484 let snapshot = state.snapshot_rx.borrow();
1485 let subagents = records
1486 .into_iter()
1487 .map(|record| {
1488 let session = require_session_record(&snapshot, &record.child_session_id)?;
1489 Ok(SubagentView {
1490 parent_session_id: parent_session_id.clone(),
1491 task_name: record.task_name,
1492 request_key: record.request_key,
1493 session: ApiSession::from(session),
1494 })
1495 })
1496 .collect::<Result<Vec<_>, ApiFailure>>()?;
1497 Ok(Json(SubagentListResponse { subagents }))
1498}
1499
1500pub(crate) async fn build_subagent_prompt(
1501 backend: &Arc<dyn SubagentBackend>,
1502 parent_session_id: &str,
1503 instructions: &str,
1504 context: Option<&str>,
1505 ranges: &[SubagentSourceRange],
1506) -> Result<String, ApiFailure> {
1507 let mut prompt = String::new();
1508 prompt.push_str(instructions.trim());
1509 if let Some(context) = context.map(str::trim).filter(|context| !context.is_empty()) {
1510 prompt.push_str("\n\n<parent_context>\n");
1511 prompt.push_str(context);
1512 prompt.push_str("\n</parent_context>");
1513 }
1514 for range in ranges {
1515 if range.file.as_os_str().is_empty()
1516 || range.file.is_absolute()
1517 || range
1518 .file
1519 .components()
1520 .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
1521 {
1522 return Err(ApiFailure::bad_request(format!(
1523 "source path {} must be relative and must not contain '..'",
1524 range.file.display()
1525 )));
1526 }
1527 if range.start == 0 || range.end < range.start {
1528 return Err(ApiFailure::bad_request(format!(
1529 "invalid source range {}:{}-{}; lines are one-based and inclusive",
1530 range.file.display(),
1531 range.start,
1532 range.end
1533 )));
1534 }
1535 let bytes = backend
1536 .read_context_file(parent_session_id.to_owned(), range.file.clone())
1537 .await
1538 .map_err(ApiFailure::from)?;
1539 let text = std::str::from_utf8(&bytes).map_err(|_| {
1540 ApiFailure::bad_request(format!(
1541 "source file {} is not UTF-8 text",
1542 range.file.display()
1543 ))
1544 })?;
1545 let lines = text.lines().collect::<Vec<_>>();
1546 if range.end > lines.len() as u64 {
1547 return Err(ApiFailure::bad_request(format!(
1548 "source range {}:{}-{} exceeds its {} lines",
1549 range.file.display(),
1550 range.start,
1551 range.end,
1552 lines.len()
1553 )));
1554 }
1555 prompt.push_str(&format!(
1556 "\n\n--- source {:?}, lines {}-{} (one-based, inclusive) ---\n",
1557 range.file.to_string_lossy(),
1558 range.start,
1559 range.end
1560 ));
1561 for (offset, line) in lines[(range.start - 1) as usize..range.end as usize]
1562 .iter()
1563 .enumerate()
1564 {
1565 prompt.push_str(&format!("{:>6} {line}\n", range.start as usize + offset));
1566 }
1567 prompt.push_str("--- end source ---");
1568 if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
1569 return Err(ApiFailure::bad_request(format!(
1570 "sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
1571 )));
1572 }
1573 }
1574 if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
1575 return Err(ApiFailure::bad_request(format!(
1576 "sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
1577 )));
1578 }
1579 Ok(prompt)
1580}
1581
1582async fn prompt(
1583 State(state): State<ServerState>,
1584 Path(session_id): Path<String>,
1585 Json(request): Json<PromptRequest>,
1586) -> Result<(StatusCode, Json<PromptResponse>), ApiFailure> {
1587 let backend = backend(&state)?.clone();
1588 {
1589 let snapshot = state.snapshot_rx.borrow();
1590 let action = ControllerAction::Prompt {
1591 session_id: session_id.clone(),
1592 text: request.text.clone(),
1593 images: Vec::new(),
1594 };
1595 validate_action(&action, &snapshot)?;
1596 let session = require_session_record(&snapshot, &session_id)?;
1597 if !session.capabilities.prompt {
1598 return Err(ApiFailure::conflict(
1599 "this session cannot take a prompt right now",
1600 ));
1601 }
1602 }
1603 let turn_id = backend.prompt(session_id, request.text).await?;
1604 Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
1605}
1606
1607#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1612pub struct UsageQuery {
1613 pub after_seq: Option<u64>,
1614 pub limit: Option<usize>,
1615}
1616
1617async fn usage(
1618 State(state): State<ServerState>,
1619 Path(session_id): Path<String>,
1620 Query(query): Query<UsageQuery>,
1621) -> Result<Json<crate::database::UsagePage>, ApiFailure> {
1622 let page = backend(&state)?
1623 .usage(
1624 session_id,
1625 query.after_seq.unwrap_or(0),
1626 query.limit.unwrap_or(200).clamp(1, 1000),
1627 )
1628 .await?
1629 .ok_or_else(|| ApiFailure::not_found("no usage history is recorded for that session"))?;
1630 Ok(Json(page))
1631}
1632
1633async fn transcript(
1634 State(state): State<ServerState>,
1635 Path(session_id): Path<String>,
1636 Query(query): Query<TranscriptQuery>,
1637) -> Result<Json<TranscriptResponse>, ApiFailure> {
1638 let backend = backend(&state)?.clone();
1639 let limit = query
1640 .limit
1641 .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
1642 .clamp(1, MAX_TRANSCRIPT_LIMIT);
1643 let page = backend
1644 .transcript(
1645 session_id.clone(),
1646 query.after_seq.unwrap_or(0),
1647 limit,
1648 query.role,
1649 )
1650 .await?
1651 .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
1652 Ok(Json(TranscriptResponse {
1653 next_after_seq: page.next_after_seq,
1654 session_id,
1655 latest_seq: page.latest_seq,
1656 execution: page.execution,
1657 items: page
1658 .items
1659 .iter()
1660 .map(|item| TranscriptItemView {
1661 stable_id: item.stable_id.clone(),
1662 position: item.position,
1663 seq: item.seq(),
1664 role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
1665 text: mj_transcript::transcript::transcript_item_text(item),
1666 created_at_ms: item.created_at_ms,
1667 last_changed_at_ms: item.last_changed_at_ms,
1668 body: item.body.clone(),
1669 })
1670 .collect(),
1671 }))
1672}
1673
1674async fn close(
1675 State(state): State<ServerState>,
1676 Path(session_id): Path<String>,
1677 request: Option<Json<CloseRequest>>,
1678) -> Result<StatusCode, ApiFailure> {
1679 let force = request.as_ref().is_some_and(|request| request.force);
1680 let active_children = if force {
1681 0
1684 } else {
1685 let snapshot = state.snapshot_rx.borrow();
1686 let session = require_session_record(&snapshot, &session_id)?;
1687 session
1688 .subagent_session_ids
1689 .iter()
1690 .filter(|child_id| {
1691 snapshot.sessions.iter().any(|child| {
1692 child.id == child_id.as_str()
1693 && !matches!(
1694 child.state.as_str(),
1695 "stopped" | "lost" | "error" | "destroyed-with-data-loss"
1696 )
1697 })
1698 })
1699 .count()
1700 };
1701 if active_children > 0
1702 && !request
1703 .as_ref()
1704 .is_some_and(|request| request.acknowledge_active_subagents)
1705 {
1706 return Err(ApiFailure::conflict(format!(
1707 "session has {} sub-agent(s); retry with acknowledge_active_subagents=true to stop children first",
1708 active_children
1709 )));
1710 }
1711 backend(&state)?.cancel_start(session_id.clone()).await?;
1712 if force {
1713 return send_action(&state, ControllerAction::ForceClose { session_id }).await;
1714 }
1715 send_action(&state, ControllerAction::Close { session_id }).await
1716}
1717
1718#[derive(Debug, Default, serde::Deserialize)]
1719#[serde(deny_unknown_fields)]
1720struct CloseRequest {
1721 #[serde(default)]
1722 acknowledge_active_subagents: bool,
1723 #[serde(default)]
1725 force: bool,
1726}
1727
1728async fn cancel_turn(
1729 State(state): State<ServerState>,
1730 Path(session_id): Path<String>,
1731) -> Result<StatusCode, ApiFailure> {
1732 send_action(&state, ControllerAction::CancelTurn { session_id }).await
1733}
1734
1735async fn send_action(
1736 state: &ServerState,
1737 action: ControllerAction,
1738) -> Result<StatusCode, ApiFailure> {
1739 validate_action(&action, &state.snapshot_rx.borrow())?;
1740 let (reply, outcome) = tokio::sync::oneshot::channel();
1741 state
1742 .action_tx
1743 .send(ControllerRequest { action, reply })
1744 .await
1745 .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
1746 let outcome = outcome
1747 .await
1748 .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
1749 match outcome.rejection() {
1750 Some(rejection) => Err(rejection.into()),
1751 None => Ok(StatusCode::ACCEPTED),
1752 }
1753}
1754
1755async fn diff(
1757 State(state): State<ServerState>,
1758 Path(session_id): Path<String>,
1759) -> Result<Response, ApiFailure> {
1760 let backend = backend(&state)?.clone();
1761 let diff = backend.diff(session_id).await?;
1762 Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
1763}
1764
1765#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct WriteFileQuery {
1772 pub path: PathBuf,
1773 #[serde(default)]
1774 pub overwrite: bool,
1775}
1776
1777#[derive(Debug, Clone, Serialize, Deserialize)]
1778pub struct WriteFileResponse {
1779 pub path: PathBuf,
1780 pub bytes: usize,
1781}
1782
1783async fn write_file(
1784 State(state): State<ServerState>,
1785 Path(session_id): Path<String>,
1786 Query(query): Query<WriteFileQuery>,
1787 bytes: axum::body::Bytes,
1788) -> Result<Json<WriteFileResponse>, ApiFailure> {
1789 mj_core::config::validate_relative_destination(&query.path)
1790 .map_err(|error| ApiFailure::bad_request(format!("{error:#}")))?;
1791 {
1792 let snapshot = state.snapshot_rx.borrow();
1793 let session = require_session_record(&snapshot, &session_id)?;
1794 if !session.is_idle || session.lifecycle != ViewerLifecycleCategory::Live {
1795 return Err(ApiFailure::conflict(
1796 "session must be live and idle for file injection",
1797 ));
1798 }
1799 }
1800 let count = bytes.len();
1801 backend(&state)?
1802 .write_file(
1803 session_id,
1804 query.path.clone(),
1805 bytes.to_vec(),
1806 query.overwrite,
1807 )
1808 .await?;
1809 Ok(Json(WriteFileResponse {
1810 path: query.path,
1811 bytes: count,
1812 }))
1813}
1814
1815async fn elicitations(
1816 State(state): State<ServerState>,
1817 Path(session_id): Path<String>,
1818) -> Result<Json<Vec<mj_core::elicitation::ElicitationRequest>>, ApiFailure> {
1819 let snapshot = state.snapshot_rx.borrow();
1820 Ok(Json(
1821 require_session_record(&snapshot, &session_id)?
1822 .pending_elicitations
1823 .clone(),
1824 ))
1825}
1826
1827async fn respond_elicitation(
1828 State(state): State<ServerState>,
1829 Path((session_id, elicitation_id)): Path<(String, String)>,
1830 Json(response): Json<mj_core::elicitation::ElicitationResponse>,
1831) -> Result<StatusCode, ApiFailure> {
1832 send_action(
1833 &state,
1834 ControllerAction::RespondElicitation {
1835 session_id,
1836 elicitation_id,
1837 response,
1838 },
1839 )
1840 .await
1841}
1842
1843async fn read_file(
1844 State(state): State<ServerState>,
1845 Path(session_id): Path<String>,
1846 Query(query): Query<FileQuery>,
1847) -> Result<Response, ApiFailure> {
1848 let backend = backend(&state)?.clone();
1849 let path = PathBuf::from(&query.path);
1850 if query.path.trim().is_empty()
1851 || path.is_absolute()
1852 || path
1853 .components()
1854 .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
1855 {
1856 return Err(ApiFailure::bad_request(
1857 "path must be relative to the session workspace and must not contain '..'",
1858 ));
1859 }
1860 let bytes = backend.read_file(session_id, path).await?;
1861 Ok(([(CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
1862}
1863
1864async fn export(
1866 State(state): State<ServerState>,
1867 Path(session_id): Path<String>,
1868 Json(request): Json<ExportRequest>,
1869) -> Result<Response, ApiFailure> {
1870 let backend = backend(&state)?.clone();
1871 match request.kind {
1872 ExportKind::Patch => {
1873 let diff = backend.diff(session_id).await?;
1874 Ok(([(CONTENT_TYPE, "text/x-diff; charset=utf-8")], diff).into_response())
1875 }
1876 ExportKind::Branch => {
1877 let branch = request
1878 .branch
1879 .as_deref()
1880 .map(str::trim)
1881 .filter(|branch| !branch.is_empty())
1882 .ok_or_else(|| ApiFailure::bad_request("a branch export needs a branch name"))?
1883 .to_owned();
1884 let pushed = backend.push_branch(session_id, branch).await?;
1885 Ok(Json(pushed).into_response())
1886 }
1887 ExportKind::Bundle => {
1888 let bundle = backend.bundle(session_id.clone()).await?;
1889 let filename: String = format!("{session_id}-{}.bundle", bundle.repository)
1892 .chars()
1893 .map(|character| match character {
1894 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => character,
1895 _ => '-',
1896 })
1897 .collect();
1898 Ok((
1899 [
1900 (CONTENT_TYPE, "application/octet-stream".to_owned()),
1901 (
1902 CONTENT_DISPOSITION,
1903 format!("attachment; filename=\"{filename}\""),
1904 ),
1905 ],
1906 bundle.bytes,
1907 )
1908 .into_response())
1909 }
1910 }
1911}
1912
1913async fn wait(
1914 State(state): State<ServerState>,
1915 Path(session_id): Path<String>,
1916 Json(request): Json<WaitRequest>,
1917) -> Result<Json<WaitResponse>, ApiFailure> {
1918 let timeout = request.timeout_secs.unwrap_or(DEFAULT_WAIT_SECS);
1919 if timeout == 0 || timeout > MAX_WAIT_SECS {
1920 return Err(ApiFailure::bad_request(format!(
1921 "timeout_secs must be between 1 and {MAX_WAIT_SECS}"
1922 )));
1923 }
1924 let backend = backend(&state)?.clone();
1925 {
1926 let snapshot = state.snapshot_rx.borrow();
1927 require_session_record(&snapshot, &session_id)?;
1928 }
1929 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout);
1930 let mut snapshot_rx = state.snapshot_rx.clone();
1931 let mut handle = backend.session_handle(session_id.clone()).await?;
1932
1933 loop {
1934 let start_status = backend.start_status(session_id.clone()).await?;
1935 let live = handle.as_ref().map(SessionHandle::view);
1936 let relay = live.as_ref().map(RelayHealth::from);
1937 let durable = match live.as_ref().and_then(|view| view.snapshot.as_ref()) {
1938 Some(_) => None,
1939 None => backend.turn_state(session_id.clone()).await?,
1940 };
1941 let (session_facts, observation) = {
1942 let snapshot = snapshot_rx.borrow();
1943 let session = require_session_record(&snapshot, &session_id)?;
1944 let observation = build_observation(
1945 &snapshot,
1946 session,
1947 live.as_ref(),
1948 durable.as_ref(),
1949 start_status,
1950 );
1951 (ApiSession::from(session), observation)
1952 };
1953 if let Some(decision) = resolve_wait(&observation, &request) {
1954 return Ok(Json(
1955 finish_wait(
1956 &backend,
1957 &session_id,
1958 session_facts,
1959 observation,
1960 decision,
1961 relay,
1962 )
1963 .await?,
1964 ));
1965 }
1966
1967 let changed = async {
1968 match handle.as_mut() {
1969 Some(handle) => {
1970 let _ = handle.changed().await;
1971 }
1972 None => tokio::time::sleep(STOPPED_POLL_INTERVAL).await,
1975 }
1976 };
1977 tokio::select! {
1978 () = changed => {}
1979 published = snapshot_rx.changed() => {
1983 if published.is_err() {
1984 return Err(ApiFailure::unavailable(
1985 "the controller stopped publishing session state",
1986 ));
1987 }
1988 }
1989 () = tokio::time::sleep_until(deadline) => {
1990 let snapshot = snapshot_rx.borrow();
1991 let session = require_session_record(&snapshot, &session_id)?;
1992 return Ok(Json(WaitResponse {
1993 diagnostic: None,
1994 pending_elicitations: Vec::new(),
1995 usage: None,
1996 outcome: WaitOutcome::Timeout,
1997 stop_reason: None,
1998 message: Some(format!("the turn was still running after {timeout} seconds")),
1999 final_message: None,
2000 turn_id: request.turn_id.or_else(|| {
2001 observation.active_turn.as_ref().and_then(|turn| turn.accepted_ordinal)
2002 }),
2003 turn_number: None,
2004 elapsed_ms: None,
2005 capacity_retry: observation.capacity_retry.as_ref().map(WaitCapacityRetry::from),
2006 relay,
2007 session: ApiSession::from(session),
2008 }));
2009 }
2010 () = state.shutdown.cancelled() => {
2011 return Err(ApiFailure::unavailable("the server is shutting down"));
2012 }
2013 }
2014 if handle.as_ref().is_some_and(SessionHandle::is_stopped) {
2018 handle = backend.session_handle(session_id.clone()).await?;
2019 }
2020 }
2021}
2022
2023fn build_observation(
2024 snapshot: &ViewerSnapshot,
2025 session: &ViewerSession,
2026 live: Option<&mj_client::session::ManagedSessionView>,
2027 durable: Option<&TurnState>,
2028 start_status: Option<StartStatus>,
2029) -> WaitObservation {
2030 let mut observation = WaitObservation {
2031 pending_elicitations: session.pending_elicitations.clone(),
2032 lifecycle: Some(session.lifecycle),
2033 launch_failed: snapshot
2034 .launch_failures
2035 .iter()
2036 .any(|failure| failure.session_id.as_deref() == Some(session.id.as_str())),
2037 launch_error: snapshot
2040 .launch_failures
2041 .iter()
2042 .find(|failure| failure.session_id.as_deref() == Some(session.id.as_str()))
2043 .and_then(|failure| failure.error.clone())
2044 .or_else(|| session.launch_error.clone()),
2045 capacity_retry: session.capacity_retry.clone(),
2046 start_status,
2047 ..WaitObservation::default()
2048 };
2049 if let Some(view) = live
2050 && view.connected
2051 && let Some(snapshot) = &view.snapshot
2052 {
2053 observation.background_work = Some(ApiBackgroundWork::from(&snapshot.operational));
2054 }
2055 if let Some(snapshot) = live.and_then(|view| view.snapshot.as_ref()) {
2056 observation
2057 .pending_elicitations
2058 .clone_from(&snapshot.materialized.pending_elicitations);
2059 observation.execution = snapshot.materialized.execution;
2060 observation.active_turn = snapshot.materialized.active_turn.clone();
2061 observation
2062 .last_turn_outcome
2063 .clone_from(&snapshot.materialized.last_turn_outcome);
2064 observation.queued = snapshot.materialized.queued_prompts.len();
2065 observation
2066 .capacity_retry
2067 .clone_from(&snapshot.operational.capacity_retry);
2068 } else if let Some(durable) = durable {
2069 observation.execution = durable.execution;
2070 observation.active_turn = durable.active_turn.clone();
2071 observation
2072 .last_turn_outcome
2073 .clone_from(&durable.last_turn_outcome);
2074 }
2075 observation
2076}
2077
2078fn api_turn_outcome(mut turn: MaterializedTurnOutcome) -> MaterializedTurnOutcome {
2081 turn.usage = None;
2082 turn.diagnostic = None;
2083 turn
2084}
2085
2086async fn finish_wait(
2087 backend: &Arc<dyn SubagentBackend>,
2088 session_id: &str,
2089 mut session: ApiSession,
2090 observation: WaitObservation,
2091 decision: WaitDecision,
2092 relay: Option<RelayHealth>,
2093) -> Result<WaitResponse, ApiFailure> {
2094 session
2095 .background_work
2096 .clone_from(&observation.background_work);
2097 session
2098 .last_turn_outcome
2099 .clone_from(&observation.last_turn_outcome);
2100 session.last_turn_diagnostic = session
2101 .last_turn_outcome
2102 .as_ref()
2103 .and_then(|turn| turn.diagnostic.clone());
2104 session.last_turn_outcome = session.last_turn_outcome.map(api_turn_outcome);
2105 let summary = match decision.turn_start_position {
2106 Some(position) => Some(
2107 backend
2108 .turn_summary(session_id.to_owned(), position)
2109 .await?,
2110 ),
2111 None => None,
2112 };
2113 Ok(WaitResponse {
2114 diagnostic: observation
2115 .last_turn_outcome
2116 .as_ref()
2117 .filter(|turn| {
2118 turn.turn_start_position.is_some()
2119 && turn.turn_start_position == decision.turn_start_position
2120 })
2121 .and_then(|turn| turn.diagnostic.clone()),
2122 pending_elicitations: if decision.outcome == WaitOutcome::InputRequired {
2123 observation.pending_elicitations.clone()
2124 } else {
2125 Vec::new()
2126 },
2127 usage: observation
2128 .last_turn_outcome
2129 .as_ref()
2130 .filter(|turn| {
2131 turn.turn_start_position.is_some()
2132 && turn.turn_start_position == decision.turn_start_position
2133 })
2134 .and_then(|turn| turn.usage.clone()),
2135 outcome: decision.outcome,
2136 stop_reason: decision.stop_reason,
2137 message: decision.message,
2138 final_message: summary
2139 .as_ref()
2140 .and_then(|summary| summary.final_message.clone()),
2141 turn_id: decision.turn_id,
2142 turn_number: summary.as_ref().map(|summary| summary.turn_number),
2143 elapsed_ms: summary
2144 .as_ref()
2145 .map(|summary| summary.last_changed_at_ms - summary.turn_started_at_ms),
2146 capacity_retry: observation
2147 .capacity_retry
2148 .as_ref()
2149 .map(WaitCapacityRetry::from),
2150 relay,
2151 session,
2152 })
2153}
2154
2155#[cfg(test)]
2156mod tests {
2157 use super::*;
2158 use std::collections::BTreeMap;
2159 use std::sync::Mutex;
2160
2161 use axum::body::Body;
2162 use axum::http::Request;
2163 use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, SET_COOKIE};
2164 use http_body_util::BodyExt as _;
2165 use tokio::sync::{mpsc, watch};
2166 use tower::ServiceExt as _;
2167
2168 use super::super::{
2169 ControllerRequest, ServerOptions, ServerRequests, ViewerSnapshot, router,
2170 tests::sample_config_state,
2171 };
2172
2173 fn error_event(seq: u64) -> crate::database::ApiEvent {
2174 crate::database::ApiEvent {
2175 seq,
2176 session_id: "session-1".into(),
2177 recorded_at_ms: 10,
2178 event: crate::database::ApiEventData::Error {
2179 message: "test failure".into(),
2180 command_id: None,
2181 },
2182 }
2183 }
2184
2185 #[tokio::test]
2186 async fn bundle_export_distinguishes_deferral_from_failure() {
2187 for fails in [false, true] {
2188 let (app, _actions, _snapshots, _bundles) = api_app(
2189 Arc::new(FakeBackend {
2190 bundle_fails: fails,
2191 ..Default::default()
2192 }),
2193 |_| {},
2194 );
2195 let response = app
2196 .oneshot(
2197 bearer(Request::post("/api/v1/sessions/session-1/export"))
2198 .header(CONTENT_TYPE, "application/json")
2199 .body(Body::from(r#"{"kind":"bundle"}"#))
2200 .unwrap(),
2201 )
2202 .await
2203 .unwrap();
2204 assert_eq!(
2205 response.status(),
2206 if fails {
2207 StatusCode::INTERNAL_SERVER_ERROR
2208 } else {
2209 StatusCode::CONFLICT
2210 }
2211 );
2212 }
2213 }
2214
2215 #[tokio::test]
2216 async fn wait_reports_background_knowledge_without_claiming_checkpoint_readiness() {
2217 let root = tempfile::tempdir().unwrap();
2218 let relay =
2219 mj_worker::relay::DurableRelay::open(root.path(), "session-1", "1.0.0").unwrap();
2220 let materialized = mj_core::state::MaterializedSession::empty("session-1");
2221 let mut live = mj_client::session::ManagedSessionView {
2222 connected: true,
2223 error: None,
2224 snapshot: Some(mj_core::state::ManagedSessionSnapshot {
2225 subagent_requests: Vec::new(),
2226 subagent_results: Vec::new(),
2227 window: mj_core::state::ProjectionWindow::of(&materialized),
2228 materialized,
2229 operational: relay.operational_state(),
2230 latest_credential_sync_signal: None,
2231 worker_build: None,
2232 }),
2233 };
2234 let (config, state) = sample_config_state();
2235 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
2236 let session = &snapshot.sessions[0];
2237 let backend: Arc<dyn SubagentBackend> = Arc::new(FakeBackend::default());
2238 for known in [None, Some(false), Some(true)] {
2239 live.snapshot
2240 .as_mut()
2241 .unwrap()
2242 .operational
2243 .background_work_known = known;
2244 let observation = build_observation(&snapshot, session, Some(&live), None, None);
2245 let decision = resolve_wait(&observation, &WaitRequest::default()).unwrap();
2246 let response = finish_wait(
2247 &backend,
2248 &session.id,
2249 ApiSession::from(session),
2250 observation,
2251 decision,
2252 None,
2253 )
2254 .await
2255 .unwrap();
2256 assert_eq!(response.session.background_work.unwrap().known, known);
2257 }
2258 live.snapshot
2259 .as_mut()
2260 .unwrap()
2261 .operational
2262 .background_commands
2263 .push(mj_core::relay::BackgroundCommand {
2264 id: "task-1".into(),
2265 started_at_ms: 1,
2266 command: "background agent".into(),
2267 can_stop: false,
2268 });
2269 let observation = build_observation(&snapshot, session, Some(&live), None, None);
2270 assert_eq!(observation.background_work.unwrap().tasks[0].id, "task-1");
2271 live.connected = false;
2272 assert!(
2273 build_observation(&snapshot, session, Some(&live), None, None)
2274 .background_work
2275 .is_none()
2276 );
2277 }
2278
2279 #[tokio::test]
2280 async fn event_stream_replays_then_follows_live_events_with_version_and_ids() {
2281 let backend = Arc::new(FakeBackend::default());
2282 backend
2283 .events
2284 .lock()
2285 .unwrap()
2286 .extend([error_event(1), error_event(2)]);
2287 let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2288 let response = app
2289 .oneshot(
2290 bearer(Request::get(
2291 "/api/v1/events?session_id=session-1&workspace_id=default",
2292 ))
2293 .header("Last-Event-ID", "1")
2294 .body(Body::empty())
2295 .unwrap(),
2296 )
2297 .await
2298 .unwrap();
2299 assert_eq!(response.status(), StatusCode::OK);
2300 assert_eq!(response.headers()[API_VERSION_HEADER], API_VERSION);
2301 assert_eq!(response.headers()[CONTENT_TYPE], "text/event-stream");
2302 let mut body = response.into_body();
2303 let frame = tokio::time::timeout(Duration::from_secs(2), body.frame())
2304 .await
2305 .unwrap()
2306 .unwrap()
2307 .unwrap();
2308 let text = std::str::from_utf8(frame.data_ref().unwrap()).unwrap();
2309 assert!(text.contains("id: 2"), "{text}");
2310 assert!(text.contains("event: error"), "{text}");
2311 backend.events.lock().unwrap().push(error_event(3));
2312 let frame = tokio::time::timeout(Duration::from_secs(2), body.frame())
2313 .await
2314 .unwrap()
2315 .unwrap()
2316 .unwrap();
2317 assert!(
2318 std::str::from_utf8(frame.data_ref().unwrap())
2319 .unwrap()
2320 .contains("id: 3")
2321 );
2322 let queries = backend.event_queries.lock().unwrap();
2323 assert_eq!(queries[0].0.workspace_id.as_deref(), Some("default"));
2324 assert_eq!(queries[0].1, Some(1));
2325 }
2326
2327 #[tokio::test]
2328 async fn event_stream_rejects_an_unknown_session_instead_of_waiting_forever() {
2329 let backend = Arc::new(FakeBackend::default());
2330 let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2331 let response = app
2332 .oneshot(
2333 bearer(Request::get(
2334 "/api/v1/events?session_id=session-that-never-existed",
2335 ))
2336 .body(Body::empty())
2337 .unwrap(),
2338 )
2339 .await
2340 .unwrap();
2341
2342 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2343 assert!(backend.event_queries.lock().unwrap().is_empty());
2344 }
2345
2346 #[tokio::test]
2347 async fn event_stream_slow_readers_do_not_block_requests_or_shutdown() {
2348 let backend = Arc::new(FakeBackend::default());
2349 backend.events.lock().unwrap().extend((1..=200).map(|seq| {
2350 let mut event = error_event(seq);
2351 event.event = crate::database::ApiEventData::Error {
2352 message: "x".repeat(8192),
2353 command_id: None,
2354 };
2355 event
2356 }));
2357 let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2358 let stream = app
2359 .clone()
2360 .oneshot(
2361 bearer(Request::get("/api/v1/events?after_seq=0"))
2362 .body(Body::empty())
2363 .unwrap(),
2364 )
2365 .await
2366 .unwrap();
2367 tokio::task::yield_now().await;
2369 let response = tokio::time::timeout(
2370 Duration::from_secs(2),
2371 app.oneshot(
2372 bearer(Request::get("/api/v1/sessions"))
2373 .body(Body::empty())
2374 .unwrap(),
2375 ),
2376 )
2377 .await
2378 .unwrap()
2379 .unwrap();
2380 assert_eq!(response.status(), StatusCode::OK);
2381 backend.shutdown.cancel();
2382 let body = tokio::time::timeout(Duration::from_secs(2), stream.into_body().collect())
2383 .await
2384 .unwrap()
2385 .unwrap()
2386 .to_bytes();
2387 assert!(
2388 body.len() < 200 * 8192,
2389 "shutdown must not drain the entire unread history"
2390 );
2391 }
2392
2393 #[tokio::test]
2394 async fn event_stream_without_cursor_starts_at_the_current_frontier() {
2395 let backend = Arc::new(FakeBackend::default());
2396 backend.events.lock().unwrap().push(error_event(1));
2397 let (app, _actions, _snapshots, _bundles) = api_app(backend.clone(), |_| {});
2398 let response = app
2399 .oneshot(
2400 bearer(Request::get("/api/v1/events"))
2401 .body(Body::empty())
2402 .unwrap(),
2403 )
2404 .await
2405 .unwrap();
2406 backend.events.lock().unwrap().push(error_event(2));
2407 let mut body = response.into_body();
2408 let frame = tokio::time::timeout(Duration::from_secs(2), body.frame())
2409 .await
2410 .unwrap()
2411 .unwrap()
2412 .unwrap();
2413 assert!(
2414 std::str::from_utf8(frame.data_ref().unwrap())
2415 .unwrap()
2416 .contains("id: 2")
2417 );
2418 }
2419
2420 #[tokio::test]
2421 async fn event_stream_rejects_bad_cursors_and_requires_authentication() {
2422 let backend = Arc::new(FakeBackend::default());
2423 backend.events.lock().unwrap().push(error_event(1));
2424 let (app, _actions, _snapshots, _bundles) = api_app(backend, |_| {});
2425 for (uri, header) in [
2426 ("/api/v1/events?after_seq=0", "1"),
2427 ("/api/v1/events", "invalid"),
2428 ("/api/v1/events?after_seq=2", "2"),
2429 ("/api/v1/events", "18446744073709551615"),
2430 ] {
2431 let response = app
2432 .clone()
2433 .oneshot(
2434 bearer(Request::get(uri))
2435 .header("Last-Event-ID", header)
2436 .body(Body::empty())
2437 .unwrap(),
2438 )
2439 .await
2440 .unwrap();
2441 assert_eq!(
2442 response.status(),
2443 StatusCode::BAD_REQUEST,
2444 "{uri}, {header}"
2445 );
2446 }
2447 let response = app
2448 .oneshot(Request::get("/api/v1/events").body(Body::empty()).unwrap())
2449 .await
2450 .unwrap();
2451 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2452 }
2453
2454 #[derive(Default)]
2458 struct FakeBackend {
2459 turn_states: Mutex<Vec<Option<TurnState>>>,
2462 prompt_ordinal: u64,
2463 prompts: Mutex<Vec<(String, String)>>,
2464 summary: Option<TurnSummary>,
2465 followups: Mutex<Vec<(String, StartFollowup)>>,
2467 start_status: Option<StartStatus>,
2468 transcript: Mutex<Option<TranscriptPage>>,
2470 transcript_limits: Mutex<Vec<usize>>,
2471 diff: Option<String>,
2474 file: Option<Vec<u8>>,
2475 pushed: Option<PushedBranch>,
2476 bundle: Option<BundleExport>,
2477 diff_fails: bool,
2479 bundle_fails: bool,
2480 file_paths: Mutex<Vec<PathBuf>>,
2482 file_writes: Mutex<Vec<(PathBuf, Vec<u8>, bool)>>,
2483 events: Mutex<Vec<crate::database::ApiEvent>>,
2484 shutdown: tokio_util::sync::CancellationToken,
2485 event_queries: Mutex<Vec<(crate::database::ApiEventFilter, Option<u64>)>>,
2486 }
2487
2488 impl FakeBackend {
2489 fn next_turn_state(&self) -> Option<TurnState> {
2490 let mut states = self.turn_states.lock().unwrap();
2491 if states.len() > 1 {
2492 states.remove(0)
2493 } else {
2494 states.first().cloned().flatten()
2495 }
2496 }
2497 }
2498
2499 impl SubagentBackend for FakeBackend {
2500 fn events(
2501 &self,
2502 filter: crate::database::ApiEventFilter,
2503 after_seq: Option<u64>,
2504 ) -> BoxFuture<'_, AnyResult<crate::database::ApiEventPage>> {
2505 Box::pin(async move {
2506 self.event_queries
2507 .lock()
2508 .unwrap()
2509 .push((filter.clone(), after_seq));
2510 let events = self.events.lock().unwrap();
2511 let latest_seq = events.last().map_or(0, |e| e.seq);
2512 let cursor = after_seq.unwrap_or(latest_seq);
2513 let page: Vec<_> = events
2514 .iter()
2515 .filter(|e| {
2516 e.seq > cursor
2517 && filter
2518 .session_id
2519 .as_ref()
2520 .is_none_or(|id| id == &e.session_id)
2521 })
2522 .take(200)
2523 .cloned()
2524 .collect();
2525 Ok(crate::database::ApiEventPage {
2526 next_after_seq: page.last().map_or(latest_seq.max(cursor), |e| e.seq),
2527 latest_seq,
2528 events: page,
2529 })
2530 })
2531 }
2532
2533 fn profile_config(
2534 &self,
2535 _profile: String,
2536 _model: Option<String>,
2537 _refresh: bool,
2538 ) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
2539 Box::pin(async {
2540 Ok(mj_core::worker_launch::ProfileConfig {
2541 model: Some("kimi-code/k3".into()),
2542 models: vec![mj_core::acp::SessionConfigChoice {
2543 value: "kimi-code/k3".into(),
2544 name: "K3".into(),
2545 description: None,
2546 }],
2547 efforts: vec![mj_core::acp::SessionConfigChoice {
2548 value: "high".into(),
2549 name: "High".into(),
2550 description: None,
2551 }],
2552 observed_at: 1,
2553 })
2554 })
2555 }
2556
2557 fn session_handle(
2558 &self,
2559 _session_id: String,
2560 ) -> BoxFuture<'_, AnyResult<Option<SessionHandle>>> {
2561 Box::pin(async { Ok(None) })
2562 }
2563 fn prompt(&self, session_id: String, text: String) -> BoxFuture<'_, AnyResult<u64>> {
2564 Box::pin(async move {
2565 self.prompts.lock().unwrap().push((session_id, text));
2566 Ok(self.prompt_ordinal)
2567 })
2568 }
2569 fn turn_state(&self, _session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>> {
2570 Box::pin(async { Ok(self.next_turn_state()) })
2571 }
2572 fn turn_summary(
2573 &self,
2574 _session_id: String,
2575 _turn_start_position: u64,
2576 ) -> BoxFuture<'_, AnyResult<TurnSummary>> {
2577 Box::pin(async {
2578 self.summary
2579 .clone()
2580 .context("this fake has no turn summary")
2581 })
2582 }
2583 fn start_followup(
2584 &self,
2585 session_id: String,
2586 followup: StartFollowup,
2587 ) -> BoxFuture<'_, AnyResult<()>> {
2588 Box::pin(async move {
2589 self.followups.lock().unwrap().push((session_id, followup));
2590 Ok(())
2591 })
2592 }
2593 fn start_status(
2594 &self,
2595 _session_id: String,
2596 ) -> BoxFuture<'_, AnyResult<Option<StartStatus>>> {
2597 Box::pin(async { Ok(self.start_status.clone()) })
2598 }
2599 fn transcript(
2600 &self,
2601 _session_id: String,
2602 _after_seq: u64,
2603 limit: usize,
2604 _role: Option<mj_core::transcript::TranscriptRole>,
2605 ) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>> {
2606 Box::pin(async move {
2607 self.transcript_limits.lock().unwrap().push(limit);
2608 Ok(self.transcript.lock().unwrap().clone())
2609 })
2610 }
2611 fn diff(&self, _session_id: String) -> BoxFuture<'_, Result<String, ExportError>> {
2612 Box::pin(async {
2613 if self.diff_fails {
2614 return Err(ExportError::Failed(anyhow::anyhow!("git exploded")));
2615 }
2616 self.diff
2617 .clone()
2618 .ok_or_else(|| ExportError::Refused("this session has no live target".into()))
2619 })
2620 }
2621 fn read_file(
2622 &self,
2623 _session_id: String,
2624 path: PathBuf,
2625 ) -> BoxFuture<'_, Result<Vec<u8>, ExportError>> {
2626 Box::pin(async move {
2627 self.file_paths.lock().unwrap().push(path);
2628 self.file
2629 .clone()
2630 .ok_or_else(|| ExportError::Refused("this session has no live target".into()))
2631 })
2632 }
2633 fn write_file(
2634 &self,
2635 _session_id: String,
2636 path: PathBuf,
2637 bytes: Vec<u8>,
2638 overwrite: bool,
2639 ) -> BoxFuture<'_, Result<(), ExportError>> {
2640 Box::pin(async move {
2641 self.file_writes
2642 .lock()
2643 .unwrap()
2644 .push((path, bytes, overwrite));
2645 Ok(())
2646 })
2647 }
2648 fn push_branch(
2649 &self,
2650 _session_id: String,
2651 branch: String,
2652 ) -> BoxFuture<'_, Result<PushedBranch, ExportError>> {
2653 Box::pin(async move {
2654 self.pushed
2655 .clone()
2656 .map(|pushed| PushedBranch { branch, ..pushed })
2657 .ok_or_else(|| ExportError::Refused("this session is running a turn".into()))
2658 })
2659 }
2660 fn bundle(&self, _session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>> {
2661 Box::pin(async {
2662 if self.bundle_fails {
2663 return Err(ExportError::Failed(anyhow::anyhow!(
2664 "checkpoint storage failed"
2665 )));
2666 }
2667 self.bundle.clone().ok_or_else(|| {
2668 ExportError::Refused("no commits beyond the session base".into())
2669 })
2670 })
2671 }
2672 }
2673
2674 fn api_app(
2678 backend: Arc<FakeBackend>,
2679 adjust: impl FnOnce(&mut ViewerSnapshot),
2680 ) -> (
2681 axum::Router,
2682 mpsc::Receiver<ControllerRequest>,
2683 watch::Sender<ViewerSnapshot>,
2684 mpsc::Receiver<super::super::BundleRequest>,
2685 ) {
2686 let (config, state) = sample_config_state();
2687 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
2691 adjust(&mut snapshot);
2692 let (snapshot_tx, snapshot_rx) = watch::channel(snapshot);
2693 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
2694 let (action_tx, action_rx) = mpsc::channel(8);
2695 let (bundle_tx, bundle_rx) = mpsc::channel(8);
2696 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
2697 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
2698 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
2699 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
2700 let (dictation_tx, _dictation_rx) = mpsc::channel(8);
2701 let mut options = ServerOptions::new(
2702 "127.0.0.1:0".parse().unwrap(),
2703 snapshot_rx,
2704 conversation_rx,
2705 ServerRequests {
2706 action_tx,
2707 bundle_tx,
2708 receipt_tx,
2709 preflight_tx,
2710 move_preparation_tx,
2711 client_state_tx,
2712 dictation_tx,
2713 },
2714 )
2715 .unwrap()
2716 .with_test_credentials("123456", b"01234567890123456789012345678901");
2717 options.shutdown = backend.shutdown.clone();
2718 options.set_subagent_backend(backend);
2719 (router(options), action_rx, snapshot_tx, bundle_rx)
2720 }
2721
2722 fn bearer(request: axum::http::request::Builder) -> axum::http::request::Builder {
2723 request.header(AUTHORIZATION, "Bearer test-api-token")
2724 }
2725
2726 async fn login_cookie(app: &axum::Router) -> String {
2727 let response = app
2728 .clone()
2729 .oneshot(
2730 Request::post("/auth/session")
2731 .header(CONTENT_TYPE, "application/json")
2732 .body(Body::from(r#"{"code":"123456"}"#))
2733 .unwrap(),
2734 )
2735 .await
2736 .unwrap();
2737 assert_eq!(response.status(), StatusCode::NO_CONTENT);
2738 response
2739 .headers()
2740 .get(SET_COOKIE)
2741 .unwrap()
2742 .to_str()
2743 .unwrap()
2744 .split(';')
2745 .next()
2746 .unwrap()
2747 .to_owned()
2748 }
2749
2750 async fn json_body(response: Response) -> serde_json::Value {
2751 let body = response.into_body().collect().await.unwrap().to_bytes();
2752 serde_json::from_slice(&body).unwrap()
2753 }
2754
2755 #[tokio::test]
2756 async fn the_api_refuses_an_unauthenticated_caller_and_still_names_its_version() {
2757 let (app, _actions, _snapshot_tx, _bundles) =
2758 api_app(Arc::new(FakeBackend::default()), |_| {});
2759
2760 let response = app
2761 .clone()
2762 .oneshot(
2763 Request::get("/api/v1/sessions")
2764 .body(Body::empty())
2765 .unwrap(),
2766 )
2767 .await
2768 .unwrap();
2769 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2770 assert_eq!(
2771 response.headers().get(API_VERSION_HEADER).unwrap(),
2772 API_VERSION,
2773 "a client must be able to tell a wrong token from a wrong server"
2774 );
2775 assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store");
2776
2777 let response = app
2778 .clone()
2779 .oneshot(
2780 Request::get("/api/v1/sessions")
2781 .header(AUTHORIZATION, "Bearer wrong-token")
2782 .body(Body::empty())
2783 .unwrap(),
2784 )
2785 .await
2786 .unwrap();
2787 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2788 }
2789
2790 #[tokio::test]
2791 async fn workspace_filter_excludes_other_workspaces() {
2792 let (app, _, _, _) = api_app(Arc::new(FakeBackend::default()), |snapshot| {
2793 snapshot.sessions[0].workspace_id = "mine".into();
2794 let mut other = snapshot.sessions[0].clone();
2795 other.id = "other".into();
2796 other.workspace_id = "theirs".into();
2797 snapshot.sessions.push(other);
2798 });
2799 let response = app
2800 .oneshot(
2801 bearer(Request::get("/api/v1/sessions?workspace_id=mine"))
2802 .body(Body::empty())
2803 .unwrap(),
2804 )
2805 .await
2806 .unwrap();
2807 let body = json_body(response).await;
2808 assert_eq!(body["sessions"].as_array().unwrap().len(), 1);
2809 assert_eq!(body["sessions"][0]["id"], "session-1");
2810 }
2811
2812 #[tokio::test]
2813 async fn invalid_model_is_rejected_before_bundling_or_provisioning() {
2814 let backend = Arc::new(FakeBackend::default());
2815 let (app, mut actions, _, mut bundles) = api_app(backend.clone(), |_| {});
2816 let response = app.oneshot(start_request(r#"{"profile_id":"codex-1","target_id":"raw","project_directory":"/work/hel","model":"k3"}"#.into())).await.unwrap();
2817 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2818 assert!(
2819 json_body(response).await["error"]
2820 .as_str()
2821 .unwrap()
2822 .contains("kimi-code/k3")
2823 );
2824 assert!(actions.try_recv().is_err());
2825 assert!(bundles.try_recv().is_err());
2826 assert!(backend.followups.lock().unwrap().is_empty());
2827 }
2828
2829 #[test]
2830 fn closing_supersedes_a_failed_initial_configuration() {
2831 let observation = WaitObservation {
2832 lifecycle: Some(ViewerLifecycleCategory::Stopping),
2833 start_status: Some(StartStatus::Failed {
2834 message: "bad model".into(),
2835 }),
2836 ..Default::default()
2837 };
2838 assert_eq!(
2839 resolve_wait(&observation, &WaitRequest::default())
2840 .unwrap()
2841 .outcome,
2842 WaitOutcome::Stopped
2843 );
2844 }
2845
2846 #[tokio::test]
2847 async fn either_the_bearer_token_or_the_viewer_cookie_lists_sessions() {
2848 let (app, _actions, _snapshot_tx, _bundles) =
2849 api_app(Arc::new(FakeBackend::default()), |_| {});
2850 let cookie = login_cookie(&app).await;
2851
2852 for request in [
2853 bearer(Request::get("/api/v1/sessions")),
2854 Request::get("/api/v1/sessions").header(COOKIE, cookie),
2855 ] {
2856 let response = app
2857 .clone()
2858 .oneshot(request.body(Body::empty()).unwrap())
2859 .await
2860 .unwrap();
2861 assert_eq!(response.status(), StatusCode::OK);
2862 assert_eq!(
2863 response.headers().get(API_VERSION_HEADER).unwrap(),
2864 API_VERSION
2865 );
2866 let body = json_body(response).await;
2867 assert_eq!(body["sessions"][0]["id"], "session-1");
2868 }
2869 }
2870
2871 #[tokio::test]
2872 async fn one_session_is_readable_by_id_and_an_unknown_one_is_not_found() {
2873 let (app, _actions, _snapshot_tx, _bundles) =
2874 api_app(Arc::new(FakeBackend::default()), |_| {});
2875
2876 let response = app
2877 .clone()
2878 .oneshot(
2879 bearer(Request::get("/api/v1/sessions/session-1"))
2880 .body(Body::empty())
2881 .unwrap(),
2882 )
2883 .await
2884 .unwrap();
2885 assert_eq!(response.status(), StatusCode::OK);
2886 assert_eq!(json_body(response).await["id"], "session-1");
2887
2888 let response = app
2889 .oneshot(
2890 bearer(Request::get("/api/v1/sessions/session-9"))
2891 .body(Body::empty())
2892 .unwrap(),
2893 )
2894 .await
2895 .unwrap();
2896 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2897 }
2898
2899 #[tokio::test]
2900 async fn a_prompt_is_validated_before_it_reaches_the_backend() {
2901 let backend = Arc::new(FakeBackend::default());
2905 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
2906 let response = app
2907 .oneshot(
2908 bearer(Request::post("/api/v1/sessions/session-1/prompt"))
2909 .header(CONTENT_TYPE, "application/json")
2910 .body(Body::from(r#"{"text":"go"}"#))
2911 .unwrap(),
2912 )
2913 .await
2914 .unwrap();
2915 assert_eq!(response.status(), StatusCode::CONFLICT);
2916 assert!(backend.prompts.lock().unwrap().is_empty());
2917
2918 let backend = Arc::new(FakeBackend {
2919 prompt_ordinal: 17,
2920 ..FakeBackend::default()
2921 });
2922 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |snapshot| {
2923 snapshot.sessions[0].capabilities.prompt = true;
2924 });
2925
2926 let response = app
2927 .clone()
2928 .oneshot(
2929 bearer(Request::post("/api/v1/sessions/session-1/prompt"))
2930 .header(CONTENT_TYPE, "application/json")
2931 .body(Body::from(r#"{"text":"!ls"}"#))
2932 .unwrap(),
2933 )
2934 .await
2935 .unwrap();
2936 assert_eq!(
2937 response.status(),
2938 StatusCode::BAD_REQUEST,
2939 "a leading ! is a shell command, not a prompt"
2940 );
2941 assert!(backend.prompts.lock().unwrap().is_empty());
2942
2943 let response = app
2944 .oneshot(
2945 bearer(Request::post("/api/v1/sessions/session-1/prompt"))
2946 .header(CONTENT_TYPE, "application/json")
2947 .body(Body::from(r#"{"text":"add a README line"}"#))
2948 .unwrap(),
2949 )
2950 .await
2951 .unwrap();
2952 assert_eq!(response.status(), StatusCode::ACCEPTED);
2953 assert_eq!(json_body(response).await["turn_id"], 17);
2954 assert_eq!(
2955 backend.prompts.lock().unwrap().as_slice(),
2956 [("session-1".to_owned(), "add a README line".to_owned())]
2957 );
2958 }
2959
2960 fn start_body(extra: &str) -> String {
2961 format!(r#"{{"profile_id":"codex-1","target_id":"podman","bundle_id":"hel"{extra}}}"#)
2962 }
2963
2964 fn start_request(body: String) -> Request<Body> {
2965 bearer(Request::post("/api/v1/sessions"))
2966 .header(CONTENT_TYPE, "application/json")
2967 .body(Body::from(body))
2968 .unwrap()
2969 }
2970
2971 #[tokio::test]
2972 async fn start_returns_the_created_session_and_hands_its_prompt_to_the_followup() {
2973 let backend = Arc::new(FakeBackend::default());
2974 let (app, mut actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
2975
2976 let response = tokio::spawn(app.oneshot(start_request(start_body(
2977 r#","prompt":"add a README line""#,
2978 ))));
2979 let request = actions.recv().await.unwrap();
2980 assert_eq!(
2981 request.action,
2982 ControllerAction::New {
2983 mjolnir_subagents: None,
2984 create_managed_worktree: None,
2985 workspace_id: String::new(),
2986 profile_id: "codex-1".into(),
2987 bundle_id: "hel".into(),
2988 target_id: "podman".into(),
2989 title: None,
2990 project_directory: None,
2991 dirty_ack: Vec::new(),
2992 }
2993 );
2994 request
2995 .reply
2996 .send(ActionOutcome::Accepted {
2997 session_id: Some("session-2".into()),
2998 })
2999 .unwrap();
3000
3001 let response = response.await.unwrap().unwrap();
3002 assert_eq!(response.status(), StatusCode::CREATED);
3003 assert_eq!(json_body(response).await["session_id"], "session-2");
3004 let followups = backend.followups.lock().unwrap();
3005 assert_eq!(followups.len(), 1);
3006 assert_eq!(followups[0].0, "session-2");
3007 assert_eq!(
3008 followups[0].1.prompt.as_deref(),
3009 Some("add a README line"),
3010 "the first prompt is the backend's to submit once the harness is ready"
3011 );
3012 }
3013
3014 #[tokio::test]
3015 async fn start_rejects_a_request_that_still_sends_an_idempotency_key() {
3016 let backend = Arc::new(FakeBackend::default());
3017 let (app, mut actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3018
3019 let response = app
3020 .oneshot(start_request(start_body(r#","idempotency_key":"key-1""#)))
3021 .await
3022 .unwrap();
3023 assert_eq!(
3024 response.status(),
3025 StatusCode::UNPROCESSABLE_ENTITY,
3026 "the field is gone, so the body no longer parses"
3027 );
3028 let body = response.into_body().collect().await.unwrap().to_bytes();
3029 let body = String::from_utf8_lossy(&body);
3030 assert!(
3031 body.contains("idempotency_key"),
3032 "the refusal must name the field it did not expect: {body}"
3033 );
3034 assert!(
3035 actions.try_recv().is_err(),
3036 "a request that does not parse must not reach the controller"
3037 );
3038 assert!(backend.followups.lock().unwrap().is_empty());
3039 }
3040
3041 #[tokio::test]
3042 async fn start_refuses_a_shell_command_as_a_first_prompt() {
3043 let backend = Arc::new(FakeBackend::default());
3044 let (app, mut actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3045
3046 let response = app
3047 .oneshot(start_request(start_body(r#","prompt":"!ls""#)))
3048 .await
3049 .unwrap();
3050 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3051 assert!(actions.try_recv().is_err());
3052 assert!(backend.followups.lock().unwrap().is_empty());
3053 }
3054
3055 #[tokio::test]
3056 async fn a_project_directory_without_a_bundle_creates_the_quick_bundle_first() {
3057 let backend = Arc::new(FakeBackend::default());
3058 let (app, mut actions, _snapshot_tx, mut bundles) = api_app(backend, |_| {});
3059
3060 let response = tokio::spawn(
3061 app.oneshot(start_request(
3062 r#"{"profile_id":"codex-1","target_id":"raw","project_directory":"/work/hel"}"#
3063 .to_owned(),
3064 )),
3065 );
3066
3067 let bundle = bundles.recv().await.unwrap();
3068 assert_eq!(bundle.source, "/work/hel");
3069 bundle.reply.send(Ok("hel".to_owned())).unwrap();
3070
3071 let request = actions.recv().await.unwrap();
3072 assert_eq!(
3073 request.action,
3074 ControllerAction::New {
3075 mjolnir_subagents: None,
3076 create_managed_worktree: None,
3077 workspace_id: String::new(),
3078 profile_id: "codex-1".into(),
3079 bundle_id: "hel".into(),
3080 target_id: "raw".into(),
3081 title: None,
3082 project_directory: Some(PathBuf::from("/work/hel")),
3083 dirty_ack: Vec::new(),
3084 }
3085 );
3086 request
3087 .reply
3088 .send(ActionOutcome::Accepted {
3089 session_id: Some("session-2".into()),
3090 })
3091 .unwrap();
3092 assert_eq!(
3093 response.await.unwrap().unwrap().status(),
3094 StatusCode::CREATED
3095 );
3096 }
3097
3098 #[tokio::test]
3099 async fn the_transcript_clamps_its_limit_and_reads_items_as_text() {
3100 let backend = Arc::new(FakeBackend {
3101 transcript: Mutex::new(Some(TranscriptPage {
3102 next_after_seq: 9,
3103 items: vec![Arc::new(mj_core::transcript::TranscriptItem {
3104 stable_id: "item-1".into(),
3105 position: 4,
3106 latest_content_event_ordinal: Some(9),
3107 created_at_ms: 10,
3108 last_changed_at_ms: 20,
3109 body: mj_core::transcript::TranscriptBody::Agent {
3110 chunks: vec![
3111 serde_json::json!({"content": {"type": "text", "text": "added "}}),
3112 serde_json::json!({"content": {"type": "text", "text": "the line"}}),
3113 ],
3114 streaming: false,
3115 },
3116 })],
3117 latest_seq: 9,
3118 execution: MaterializedExecutionState::Idle,
3119 })),
3120 ..FakeBackend::default()
3121 });
3122 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3123
3124 let response = app
3125 .oneshot(
3126 bearer(Request::get(
3127 "/api/v1/sessions/session-1/transcript?after_seq=3&limit=5000",
3128 ))
3129 .body(Body::empty())
3130 .unwrap(),
3131 )
3132 .await
3133 .unwrap();
3134 assert_eq!(response.status(), StatusCode::OK);
3135 let body = json_body(response).await;
3136 assert_eq!(body["latest_seq"], 9);
3137 assert_eq!(
3138 body["items"][0]["seq"], 9,
3139 "an agent message pages by its latest content, not by where it started"
3140 );
3141 assert_eq!(body["items"][0]["role"], "agent");
3142 assert_eq!(
3143 body["items"][0]["text"], "added the line",
3144 "a reading caller gets the message, not its chunks"
3145 );
3146 assert_eq!(body["items"][0]["body"]["kind"], "agent");
3147 assert_eq!(
3148 backend.transcript_limits.lock().unwrap().as_slice(),
3149 [MAX_TRANSCRIPT_LIMIT],
3150 "an oversized limit is clamped rather than refused"
3151 );
3152 }
3153
3154 #[tokio::test]
3155 async fn a_session_with_no_projection_row_has_no_transcript() {
3156 let (app, _actions, _snapshot_tx, _bundles) =
3157 api_app(Arc::new(FakeBackend::default()), |_| {});
3158 let response = app
3159 .oneshot(
3160 bearer(Request::get("/api/v1/sessions/session-1/transcript"))
3161 .body(Body::empty())
3162 .unwrap(),
3163 )
3164 .await
3165 .unwrap();
3166 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3167 }
3168
3169 #[tokio::test]
3170 async fn close_and_cancel_turn_reach_the_controller_as_typed_actions() {
3171 let (app, mut actions, _snapshot_tx, _bundles) =
3172 api_app(Arc::new(FakeBackend::default()), |snapshot| {
3173 snapshot.sessions[0].capabilities.cancel_turn = true;
3174 });
3175
3176 for (path, expected) in [
3177 (
3178 "/api/v1/sessions/session-1/close",
3179 ControllerAction::Close {
3180 session_id: "session-1".into(),
3181 },
3182 ),
3183 (
3184 "/api/v1/sessions/session-1/cancel-turn",
3185 ControllerAction::CancelTurn {
3186 session_id: "session-1".into(),
3187 },
3188 ),
3189 ] {
3190 let response = tokio::spawn(
3191 app.clone()
3192 .oneshot(bearer(Request::post(path)).body(Body::empty()).unwrap()),
3193 );
3194 let request = actions.recv().await.unwrap();
3195 assert_eq!(request.action, expected);
3196 request
3197 .reply
3198 .send(super::super::ActionOutcome::accepted())
3199 .unwrap();
3200 let response = response.await.unwrap().unwrap();
3201 assert_eq!(response.status(), StatusCode::ACCEPTED);
3202 }
3203 }
3204
3205 #[tokio::test]
3206 async fn a_forced_close_reaches_the_controller_as_a_force_close_action() {
3207 let (app, mut actions, _snapshot_tx, _bundles) =
3208 api_app(Arc::new(FakeBackend::default()), |_| {});
3209
3210 let response = tokio::spawn(
3211 app.oneshot(
3212 bearer(Request::post("/api/v1/sessions/session-1/close"))
3213 .header(CONTENT_TYPE, "application/json")
3214 .body(Body::from(r#"{"force":true}"#))
3215 .unwrap(),
3216 ),
3217 );
3218 let request = actions.recv().await.unwrap();
3219 assert_eq!(
3220 request.action,
3221 ControllerAction::ForceClose {
3222 session_id: "session-1".into(),
3223 }
3224 );
3225 request
3226 .reply
3227 .send(super::super::ActionOutcome::accepted())
3228 .unwrap();
3229 let response = response.await.unwrap().unwrap();
3230 assert_eq!(response.status(), StatusCode::ACCEPTED);
3231 }
3232
3233 #[tokio::test]
3234 async fn a_forced_close_ignores_active_subagents_that_refuse_a_plain_close() {
3235 let adjust = |snapshot: &mut ViewerSnapshot| {
3236 let mut child = snapshot.sessions[0].clone();
3237 child.id = "child-1".into();
3238 child.state = "running".into();
3239 child.subagent_session_ids.clear();
3240 snapshot.sessions[0].subagent_session_ids = vec!["child-1".into()];
3241 snapshot.sessions.push(child);
3242 };
3243
3244 let (app, _actions, _snapshot_tx, _bundles) =
3245 api_app(Arc::new(FakeBackend::default()), adjust);
3246 let response = app
3247 .oneshot(
3248 bearer(Request::post("/api/v1/sessions/session-1/close"))
3249 .body(Body::empty())
3250 .unwrap(),
3251 )
3252 .await
3253 .unwrap();
3254 assert_eq!(response.status(), StatusCode::CONFLICT);
3255
3256 let (app, mut actions, _snapshot_tx, _bundles) =
3257 api_app(Arc::new(FakeBackend::default()), adjust);
3258 let response = tokio::spawn(
3259 app.oneshot(
3260 bearer(Request::post("/api/v1/sessions/session-1/close"))
3261 .header(CONTENT_TYPE, "application/json")
3262 .body(Body::from(r#"{"force":true}"#))
3263 .unwrap(),
3264 ),
3265 );
3266 let request = actions.recv().await.unwrap();
3267 assert_eq!(
3268 request.action,
3269 ControllerAction::ForceClose {
3270 session_id: "session-1".into(),
3271 }
3272 );
3273 request
3274 .reply
3275 .send(super::super::ActionOutcome::accepted())
3276 .unwrap();
3277 let response = response.await.unwrap().unwrap();
3278 assert_eq!(response.status(), StatusCode::ACCEPTED);
3279 }
3280
3281 #[test]
3282 fn a_force_close_is_not_wire_representable() {
3283 assert!(
3286 serde_json::from_str::<ControllerAction>(
3287 r#"{"action":"force-close","session_id":"s"}"#
3288 )
3289 .is_err()
3290 );
3291 }
3292
3293 #[tokio::test]
3294 async fn cancel_turn_is_refused_when_there_is_no_turn_to_cancel() {
3295 let (app, _actions, _snapshot_tx, _bundles) =
3296 api_app(Arc::new(FakeBackend::default()), |_| {});
3297 let response = app
3298 .oneshot(
3299 bearer(Request::post("/api/v1/sessions/session-1/cancel-turn"))
3300 .body(Body::empty())
3301 .unwrap(),
3302 )
3303 .await
3304 .unwrap();
3305 assert_eq!(response.status(), StatusCode::CONFLICT);
3306 }
3307
3308 #[tokio::test(start_paused = true)]
3309 async fn wait_returns_the_named_turn_s_outcome_once_the_backend_publishes_it() {
3310 let backend = Arc::new(FakeBackend {
3311 turn_states: Mutex::new(vec![
3312 Some(TurnState {
3313 execution: MaterializedExecutionState::Running { started_at_ms: 10 },
3314 active_turn: Some(MaterializedTurn {
3315 command_id: "prompt-1".into(),
3316 accepted_ordinal: Some(5),
3317 turn_start_position: 6,
3318 started_at_ms: 10,
3319 }),
3320 last_turn_outcome: None,
3321 }),
3322 Some(TurnState {
3323 execution: MaterializedExecutionState::Idle,
3324 active_turn: None,
3325 last_turn_outcome: Some(MaterializedTurnOutcome {
3326 diagnostic: None,
3327 usage: Some(mj_core::usage::TokenUsage::from_acp(
3328 mj_core::config::HarnessKind::Codex,
3329 agent_client_protocol::schema::v1::Usage::new(30, 20, 10),
3330 )),
3331 command_id: "prompt-1".into(),
3332 accepted_ordinal: Some(5),
3333 turn_start_position: Some(6),
3334 completed_ordinal: 9,
3335 completed_at_ms: 900,
3336 outcome: TurnOutcomeKind::Completed {
3337 stop_reason: "end_turn".into(),
3338 },
3339 }),
3340 }),
3341 ]),
3342 summary: Some(TurnSummary {
3343 turn_number: 3,
3344 turn_started_at_ms: 100,
3345 last_changed_at_ms: 900,
3346 final_message: Some("added the line".into()),
3347 }),
3348 ..FakeBackend::default()
3349 });
3350 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3351
3352 let response = app
3353 .oneshot(
3354 bearer(Request::post("/api/v1/sessions/session-1/wait"))
3355 .header(CONTENT_TYPE, "application/json")
3356 .body(Body::from(r#"{"turn_id":5}"#))
3357 .unwrap(),
3358 )
3359 .await
3360 .unwrap();
3361 assert_eq!(response.status(), StatusCode::OK);
3362 let body = json_body(response).await;
3363 assert_eq!(body["outcome"], "finished");
3364 assert_eq!(body["turn_id"], 5);
3365 assert_eq!(body["turn_number"], 3);
3366 assert_eq!(body["elapsed_ms"], 800);
3367 assert_eq!(body["final_message"], "added the line");
3368 assert_eq!(body["stop_reason"], "end_turn");
3369 assert_eq!(body["usage"]["scope"], "last_request");
3370 assert_eq!(body["usage"]["total_tokens"], 30);
3371 assert!(body["usage"].get("thought_tokens").is_none());
3372 assert!(body["session"]["last_turn_outcome"].get("usage").is_none());
3373 }
3374
3375 #[tokio::test(start_paused = true)]
3376 async fn wait_preserves_quota_diagnostic_without_scheduling_retry() {
3377 let diagnostic = mj_core::diagnostic::TurnDiagnostic::from_provider(&serde_json::json!({
3378 "code":"provider.auth_error", "message":"Five-hour usage limit exceeded; resets at 23:00 UTC.",
3379 "details":{"statusCode":403,"resetAt":"23:00 UTC"}
3380 })).unwrap();
3381 let mut turn = completed(5, "QuotaLimit");
3382 turn.diagnostic = Some(diagnostic.clone());
3383 let backend = Arc::new(FakeBackend {
3384 turn_states: Mutex::new(vec![Some(TurnState {
3385 execution: MaterializedExecutionState::Idle,
3386 active_turn: None,
3387 last_turn_outcome: Some(turn),
3388 })]),
3389 summary: Some(TurnSummary {
3390 turn_number: 1,
3391 turn_started_at_ms: 100,
3392 last_changed_at_ms: 500,
3393 final_message: None,
3394 }),
3395 ..FakeBackend::default()
3396 });
3397 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3398 let response = app
3399 .oneshot(
3400 bearer(Request::post("/api/v1/sessions/session-1/wait"))
3401 .header(CONTENT_TYPE, "application/json")
3402 .body(Body::from(r#"{"turn_id":5}"#))
3403 .unwrap(),
3404 )
3405 .await
3406 .unwrap();
3407 assert_eq!(response.status(), StatusCode::OK);
3408 let body = json_body(response).await;
3409 assert_eq!(body["outcome"], "quota_limit");
3410 assert_eq!(body["message"], diagnostic.message);
3411 assert_eq!(body["diagnostic"]["http_status"], 403);
3412 assert_eq!(body["diagnostic"]["reset_at"], "23:00 UTC");
3413 assert_eq!(body["session"]["last_turn_diagnostic"], body["diagnostic"]);
3414 assert!(body["capacity_retry"].is_null());
3415 assert!(
3416 body["session"]["last_turn_outcome"]
3417 .get("diagnostic")
3418 .is_none()
3419 );
3420 }
3421
3422 #[tokio::test(start_paused = true)]
3423 async fn wait_reports_a_timeout_rather_than_guessing_at_a_running_turn() {
3424 let backend = Arc::new(FakeBackend {
3425 turn_states: Mutex::new(vec![Some(TurnState {
3426 execution: MaterializedExecutionState::Running { started_at_ms: 10 },
3427 active_turn: Some(MaterializedTurn {
3428 command_id: "prompt-1".into(),
3429 accepted_ordinal: Some(5),
3430 turn_start_position: 6,
3431 started_at_ms: 10,
3432 }),
3433 last_turn_outcome: None,
3434 })]),
3435 ..FakeBackend::default()
3436 });
3437 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3438
3439 let response = app
3440 .oneshot(
3441 bearer(Request::post("/api/v1/sessions/session-1/wait"))
3442 .header(CONTENT_TYPE, "application/json")
3443 .body(Body::from(r#"{"turn_id":5,"timeout_secs":2}"#))
3444 .unwrap(),
3445 )
3446 .await
3447 .unwrap();
3448 assert_eq!(response.status(), StatusCode::OK);
3449 let body = json_body(response).await;
3450 assert_eq!(body["outcome"], "timeout");
3451 assert_eq!(body["turn_id"], 5);
3452 }
3453
3454 #[tokio::test]
3455 async fn wait_refuses_a_timeout_outside_its_bounds() {
3456 let (app, _actions, _snapshot_tx, _bundles) =
3457 api_app(Arc::new(FakeBackend::default()), |_| {});
3458 for body in [r#"{"timeout_secs":0}"#, r#"{"timeout_secs":100000}"#] {
3459 let response = app
3460 .clone()
3461 .oneshot(
3462 bearer(Request::post("/api/v1/sessions/session-1/wait"))
3463 .header(CONTENT_TYPE, "application/json")
3464 .body(Body::from(body))
3465 .unwrap(),
3466 )
3467 .await
3468 .unwrap();
3469 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3470 }
3471 }
3472
3473 #[test]
3474 fn stop_reasons_map_to_outcomes_and_unknown_ones_stay_visible() {
3475 assert_eq!(map_stop_reason("end_turn"), (WaitOutcome::Finished, None));
3476 assert_eq!(map_stop_reason("EndTurn"), (WaitOutcome::Finished, None));
3477 assert_eq!(map_stop_reason("cancelled"), (WaitOutcome::Cancelled, None));
3478 assert_eq!(
3479 map_stop_reason("ModelCapacity"),
3480 (WaitOutcome::QuotaLimit, None)
3481 );
3482 assert_eq!(
3483 map_stop_reason("refusal"),
3484 (WaitOutcome::Error, Some("refusal".to_owned())),
3485 "an unrecognized ending must not be reported as success"
3486 );
3487 }
3488
3489 fn completed(accepted_ordinal: u64, stop_reason: &str) -> MaterializedTurnOutcome {
3490 MaterializedTurnOutcome {
3491 diagnostic: None,
3492 usage: None,
3493 command_id: format!("prompt-{accepted_ordinal}"),
3494 accepted_ordinal: Some(accepted_ordinal),
3495 turn_start_position: Some(accepted_ordinal + 1),
3496 completed_ordinal: accepted_ordinal + 2,
3497 completed_at_ms: 500,
3498 outcome: TurnOutcomeKind::Completed {
3499 stop_reason: stop_reason.into(),
3500 },
3501 }
3502 }
3503
3504 fn idle(outcome: Option<MaterializedTurnOutcome>) -> WaitObservation {
3505 WaitObservation {
3506 lifecycle: Some(ViewerLifecycleCategory::Live),
3507 execution: MaterializedExecutionState::Idle,
3508 last_turn_outcome: outcome,
3509 ..WaitObservation::default()
3510 }
3511 }
3512
3513 #[test]
3514 fn an_earlier_prompt_s_outcome_never_answers_a_later_prompt_s_wait() {
3515 let request = WaitRequest {
3516 return_on_input: false,
3517 turn_id: Some(12),
3518 timeout_secs: None,
3519 };
3520 assert_eq!(
3523 resolve_wait(&idle(Some(completed(10, "end_turn"))), &request),
3524 None
3525 );
3526 let decision = resolve_wait(&idle(Some(completed(12, "end_turn"))), &request)
3527 .expect("B's own outcome ends the wait");
3528 assert_eq!(decision.outcome, WaitOutcome::Finished);
3529 assert_eq!(decision.turn_id, Some(12));
3530 }
3531
3532 #[test]
3533 fn a_capacity_outcome_only_ends_the_wait_once_no_retry_is_armed() {
3534 let request = WaitRequest {
3535 return_on_input: false,
3536 turn_id: Some(10),
3537 timeout_secs: None,
3538 };
3539 let mut pending = idle(Some(completed(10, "ModelCapacity")));
3540 pending.capacity_retry = Some(CapacityRetry {
3541 attempt: 1,
3542 retry_at_ms: 60_000,
3543 command_id: "capacity-retry-10".into(),
3544 submitted: false,
3545 });
3546 assert_eq!(
3547 resolve_wait(&pending, &request),
3548 None,
3549 "the worker will retry, so the caller must not prompt over it"
3550 );
3551
3552 let settled = idle(Some(completed(10, "ModelCapacity")));
3553 assert_eq!(
3554 resolve_wait(&settled, &request).unwrap().outcome,
3555 WaitOutcome::QuotaLimit
3556 );
3557 }
3558
3559 #[test]
3560 fn rejections_stopped_sessions_and_an_empty_session_each_end_the_wait() {
3561 let anything = WaitRequest::default();
3562
3563 let mut rejected = idle(None);
3564 rejected.last_turn_outcome = Some(MaterializedTurnOutcome {
3565 diagnostic: None,
3566 usage: None,
3567 command_id: "prompt-1".into(),
3568 accepted_ordinal: Some(4),
3569 turn_start_position: None,
3570 completed_ordinal: 5,
3571 completed_at_ms: 10,
3572 outcome: TurnOutcomeKind::Rejected {
3573 message: "transport failed".into(),
3574 },
3575 });
3576 let decision = resolve_wait(&rejected, &anything).unwrap();
3577 assert_eq!(decision.outcome, WaitOutcome::Error);
3578 assert_eq!(decision.message.as_deref(), Some("transport failed"));
3579
3580 let mut stopped = idle(Some(completed(10, "end_turn")));
3581 stopped.lifecycle = Some(ViewerLifecycleCategory::Stopped);
3582 assert_eq!(
3583 resolve_wait(&stopped, &anything).unwrap().outcome,
3584 WaitOutcome::Stopped,
3585 "a stopped session cannot finish a turn, whatever its last one did"
3586 );
3587
3588 let decision = resolve_wait(&idle(None), &anything).unwrap();
3589 assert_eq!(decision.outcome, WaitOutcome::Finished);
3590 assert_eq!(
3591 decision.turn_id, None,
3592 "an idle session with nothing queued has no turn to name"
3593 );
3594
3595 let mut running = idle(None);
3596 running.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
3597 assert_eq!(resolve_wait(&running, &anything), None);
3598
3599 let mut queued = idle(Some(completed(10, "end_turn")));
3600 queued.queued = 1;
3601 assert_eq!(
3602 resolve_wait(&queued, &anything),
3603 None,
3604 "a queued prompt means the session is not done"
3605 );
3606 }
3607
3608 #[test]
3609 fn a_launch_failure_fails_the_wait_but_an_unrelated_session_error_does_not() {
3610 let mut launch_failed = idle(None);
3611 launch_failed.launch_failed = true;
3612 launch_failed.launch_error =
3613 Some("worker bootstrap failed: Connection closed by 10.0.0.1 port 22".into());
3614 let decision = resolve_wait(&launch_failed, &WaitRequest::default()).unwrap();
3615 assert_eq!(
3616 decision.outcome,
3617 WaitOutcome::Error,
3618 "nothing will finish a turn on a session that never launched"
3619 );
3620 assert_eq!(
3621 decision.message.as_deref(),
3622 Some("worker bootstrap failed: Connection closed by 10.0.0.1 port 22"),
3623 "the wait reports why the launch failed, not a bare sentence"
3624 );
3625
3626 let mut launch_failed_bare = idle(None);
3629 launch_failed_bare.launch_failed = true;
3630 assert_eq!(
3631 resolve_wait(&launch_failed_bare, &WaitRequest::default())
3632 .unwrap()
3633 .message
3634 .as_deref(),
3635 Some("the session failed to launch")
3636 );
3637
3638 let failed_start = WaitObservation {
3639 start_status: Some(StartStatus::Failed {
3640 message: "the profile has no home".into(),
3641 }),
3642 ..idle(None)
3643 };
3644 let decision = resolve_wait(&failed_start, &WaitRequest::default()).unwrap();
3645 assert_eq!(decision.outcome, WaitOutcome::Error);
3646 assert_eq!(decision.message.as_deref(), Some("the profile has no home"));
3647
3648 let durable_failure = WaitObservation {
3649 lifecycle: Some(ViewerLifecycleCategory::Failed),
3650 ..idle(None)
3651 };
3652 let decision = resolve_wait(&durable_failure, &WaitRequest::default()).unwrap();
3653 assert_eq!(decision.outcome, WaitOutcome::Error);
3654 assert_eq!(
3655 decision.message.as_deref(),
3656 Some("the session is in a failed state")
3657 );
3658
3659 let running = WaitObservation {
3662 execution: MaterializedExecutionState::Running { started_at_ms: 1 },
3663 active_turn: Some(MaterializedTurn {
3664 command_id: "prompt-12".into(),
3665 accepted_ordinal: Some(12),
3666 turn_start_position: 13,
3667 started_at_ms: 1,
3668 }),
3669 ..idle(Some(completed(10, "end_turn")))
3670 };
3671 assert_eq!(
3672 resolve_wait(
3673 &running,
3674 &WaitRequest {
3675 return_on_input: false,
3676 turn_id: Some(12),
3677 timeout_secs: None,
3678 }
3679 ),
3680 None,
3681 "a stale session error must not report a running turn as failed"
3682 );
3683 }
3684
3685 #[test]
3686 fn a_launch_failure_for_another_session_is_not_this_session_s() {
3687 let (config, state) = sample_config_state();
3688 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3689 let session_id = snapshot.sessions[0].id.clone();
3690 snapshot.launch_failures = vec![super::super::ViewerLaunchFailure {
3691 id: format!("{}-4", std::process::id()),
3692 workspace_id: snapshot.sessions[0].workspace_id.clone(),
3693 session_id: Some("some-other-session".to_owned()),
3694 error: Some("worker bootstrap failed".to_owned()),
3695 }];
3696
3697 let observation = build_observation(&snapshot, &snapshot.sessions[0], None, None, None);
3698 assert!(
3699 !observation.launch_failed,
3700 "another session's failed launch says nothing about this one"
3701 );
3702
3703 snapshot.launch_failures[0].session_id = Some(session_id);
3704 let observation = build_observation(&snapshot, &snapshot.sessions[0], None, None, None);
3705 assert!(observation.launch_failed);
3706 }
3707
3708 #[test]
3709 fn api_session_exposes_a_launch_failure_reason_only_when_the_session_errored() {
3710 let (config, mut state) = sample_config_state();
3711
3712 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3715 let running = ApiSession::from(&snapshot.sessions[0]);
3716 assert!(
3717 running.has_error,
3718 "the running session still flags an error"
3719 );
3720 assert_eq!(
3721 running.error, None,
3722 "a running session does not expose raw error text"
3723 );
3724 assert!(
3725 serde_json::to_value(&running)
3726 .unwrap()
3727 .get("error")
3728 .is_none(),
3729 "the error field is omitted when there is nothing to show"
3730 );
3731
3732 state.sessions.get_mut("session-1").unwrap().state = mj_core::state::SessionState::Error;
3735 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3736 let failed = ApiSession::from(&snapshot.sessions[0]);
3737 assert_eq!(
3738 failed.error.as_deref(),
3739 Some("secret-token at /highly/secret/codex"),
3740 "a failed launch surfaces its recorded reason"
3741 );
3742 assert_eq!(
3743 serde_json::to_value(&failed).unwrap()["error"],
3744 "secret-token at /highly/secret/codex"
3745 );
3746 }
3747
3748 #[test]
3749 fn relay_health_names_each_way_the_live_view_can_be_unusable() {
3750 use mj_client::session::{ManagedSessionView, ViewError};
3751
3752 let connected = ManagedSessionView {
3753 connected: true,
3754 ..ManagedSessionView::default()
3755 };
3756 assert_eq!(
3757 RelayHealth::from(&connected),
3758 RelayHealth {
3759 state: RelayState::Connected,
3760 detail: None,
3761 }
3762 );
3763 assert_eq!(
3764 RelayHealth::from(&ManagedSessionView::default()).state,
3765 RelayState::Disconnected,
3766 "not yet attached is not the same as a failure"
3767 );
3768
3769 for (error, expected) in [
3770 (
3771 ViewError::Unreachable("ssh: connection refused".into()),
3772 RelayState::Unreachable,
3773 ),
3774 (
3775 ViewError::TargetMissing("container gone".into()),
3776 RelayState::TargetMissing,
3777 ),
3778 (
3779 ViewError::ProjectionIntegrity("digest mismatch".into()),
3780 RelayState::ProjectionIntegrity,
3781 ),
3782 ] {
3783 let detail = error.detail().to_owned();
3784 let view = ManagedSessionView {
3787 connected: true,
3788 error: Some(error),
3789 ..ManagedSessionView::default()
3790 };
3791 assert_eq!(
3792 RelayHealth::from(&view),
3793 RelayHealth {
3794 state: expected,
3795 detail: Some(detail),
3796 }
3797 );
3798 }
3799 }
3800
3801 #[tokio::test]
3802 async fn the_diff_route_answers_a_patch_and_maps_export_failures() {
3803 let backend = Arc::new(FakeBackend {
3804 diff: Some("--- a/one\n+++ b/one\n".to_owned()),
3805 ..FakeBackend::default()
3806 });
3807 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3808
3809 let response = app
3810 .clone()
3811 .oneshot(
3812 bearer(Request::get("/api/v1/sessions/session-1/diff"))
3813 .body(Body::empty())
3814 .unwrap(),
3815 )
3816 .await
3817 .unwrap();
3818 assert_eq!(response.status(), StatusCode::OK);
3819 assert_eq!(
3820 response.headers().get(CONTENT_TYPE).unwrap(),
3821 "text/x-diff; charset=utf-8"
3822 );
3823 let body = response.into_body().collect().await.unwrap().to_bytes();
3824 assert!(String::from_utf8_lossy(&body).contains("+++ b/one"));
3825
3826 let (app, _actions, _snapshot_tx, _bundles) =
3828 api_app(Arc::new(FakeBackend::default()), |_| {});
3829 let response = app
3830 .oneshot(
3831 bearer(Request::get("/api/v1/sessions/session-1/diff"))
3832 .body(Body::empty())
3833 .unwrap(),
3834 )
3835 .await
3836 .unwrap();
3837 assert_eq!(response.status(), StatusCode::CONFLICT);
3838
3839 let (app, _actions, _snapshot_tx, _bundles) = api_app(
3840 Arc::new(FakeBackend {
3841 diff_fails: true,
3842 ..FakeBackend::default()
3843 }),
3844 |_| {},
3845 );
3846 let response = app
3847 .oneshot(
3848 bearer(Request::get("/api/v1/sessions/session-1/diff"))
3849 .body(Body::empty())
3850 .unwrap(),
3851 )
3852 .await
3853 .unwrap();
3854 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
3855 assert_eq!(json_body(response).await["error"], "git exploded");
3856 }
3857
3858 #[tokio::test]
3859 async fn the_file_route_returns_bytes_and_refuses_a_path_that_leaves_the_workspace() {
3860 let backend = Arc::new(FakeBackend {
3861 file: Some(b"file bytes".to_vec()),
3862 ..FakeBackend::default()
3863 });
3864 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend.clone(), |_| {});
3865
3866 let response = app
3867 .clone()
3868 .oneshot(
3869 bearer(Request::get(
3870 "/api/v1/sessions/session-1/files?path=app/README.md",
3871 ))
3872 .body(Body::empty())
3873 .unwrap(),
3874 )
3875 .await
3876 .unwrap();
3877 assert_eq!(response.status(), StatusCode::OK);
3878 assert_eq!(
3879 response.headers().get(CONTENT_TYPE).unwrap(),
3880 "application/octet-stream"
3881 );
3882 let body = response.into_body().collect().await.unwrap().to_bytes();
3883 assert_eq!(body.as_ref(), b"file bytes");
3884 assert_eq!(
3885 backend.file_paths.lock().unwrap().as_slice(),
3886 [PathBuf::from("app/README.md")]
3887 );
3888
3889 for path in ["../etc/passwd", "/etc/passwd"] {
3890 let response = app
3891 .clone()
3892 .oneshot(
3893 bearer(Request::get(format!(
3894 "/api/v1/sessions/session-1/files?path={path}"
3895 )))
3896 .body(Body::empty())
3897 .unwrap(),
3898 )
3899 .await
3900 .unwrap();
3901 assert_eq!(
3902 response.status(),
3903 StatusCode::BAD_REQUEST,
3904 "{path} must never reach the target"
3905 );
3906 }
3907 assert_eq!(
3908 backend.file_paths.lock().unwrap().len(),
3909 1,
3910 "a rejected path is not sent to the backend"
3911 );
3912 }
3913
3914 #[tokio::test]
3915 async fn the_export_route_serves_each_kind_in_its_own_form() {
3916 let backend = Arc::new(FakeBackend {
3917 diff: Some("--- a/one\n".to_owned()),
3918 pushed: Some(PushedBranch {
3919 branch: String::new(),
3920 remote: "origin".to_owned(),
3921 }),
3922 bundle: Some(BundleExport {
3923 repository: "app".to_owned(),
3924 bytes: b"bundle bytes".to_vec(),
3925 }),
3926 ..FakeBackend::default()
3927 });
3928 let (app, _actions, _snapshot_tx, _bundles) = api_app(backend, |_| {});
3929
3930 let response = app
3931 .clone()
3932 .oneshot(
3933 bearer(Request::post("/api/v1/sessions/session-1/export"))
3934 .header(CONTENT_TYPE, "application/json")
3935 .body(Body::from(r#"{"kind":"patch"}"#))
3936 .unwrap(),
3937 )
3938 .await
3939 .unwrap();
3940 assert_eq!(
3941 response.headers().get(CONTENT_TYPE).unwrap(),
3942 "text/x-diff; charset=utf-8"
3943 );
3944
3945 let response = app
3946 .clone()
3947 .oneshot(
3948 bearer(Request::post("/api/v1/sessions/session-1/export"))
3949 .header(CONTENT_TYPE, "application/json")
3950 .body(Body::from(r#"{"kind":"branch","branch":"review/one"}"#))
3951 .unwrap(),
3952 )
3953 .await
3954 .unwrap();
3955 assert_eq!(response.status(), StatusCode::OK);
3956 let body = json_body(response).await;
3957 assert_eq!(body["branch"], "review/one");
3958 assert_eq!(body["remote"], "origin");
3959
3960 let response = app
3961 .clone()
3962 .oneshot(
3963 bearer(Request::post("/api/v1/sessions/session-1/export"))
3964 .header(CONTENT_TYPE, "application/json")
3965 .body(Body::from(r#"{"kind":"branch"}"#))
3966 .unwrap(),
3967 )
3968 .await
3969 .unwrap();
3970 assert_eq!(
3971 response.status(),
3972 StatusCode::BAD_REQUEST,
3973 "a branch export without a branch name is the caller's mistake"
3974 );
3975
3976 let response = app
3977 .oneshot(
3978 bearer(Request::post("/api/v1/sessions/session-1/export"))
3979 .header(CONTENT_TYPE, "application/json")
3980 .body(Body::from(r#"{"kind":"bundle"}"#))
3981 .unwrap(),
3982 )
3983 .await
3984 .unwrap();
3985 assert_eq!(
3986 response.headers().get(CONTENT_TYPE).unwrap(),
3987 "application/octet-stream"
3988 );
3989 assert_eq!(
3990 response.headers().get(CONTENT_DISPOSITION).unwrap(),
3991 "attachment; filename=\"session-1-app.bundle\""
3992 );
3993 let body = response.into_body().collect().await.unwrap().to_bytes();
3994 assert_eq!(body.as_ref(), b"bundle bytes");
3995 }
3996
3997 #[tokio::test]
3998 async fn an_empty_bundle_is_refused_rather_than_served_as_an_empty_file() {
3999 let (app, _actions, _snapshot_tx, _bundles) =
4000 api_app(Arc::new(FakeBackend::default()), |_| {});
4001 let response = app
4002 .oneshot(
4003 bearer(Request::post("/api/v1/sessions/session-1/export"))
4004 .header(CONTENT_TYPE, "application/json")
4005 .body(Body::from(r#"{"kind":"bundle"}"#))
4006 .unwrap(),
4007 )
4008 .await
4009 .unwrap();
4010 assert_eq!(response.status(), StatusCode::CONFLICT);
4011 assert_eq!(
4012 json_body(response).await["error"],
4013 "no commits beyond the session base"
4014 );
4015 }
4016 fn input_request() -> mj_core::elicitation::ElicitationRequest {
4017 mj_core::elicitation::ElicitationRequest::from_acp_params("question-1", serde_json::json!({
4018 "sessionId": "session-1", "mode": "form", "message": "Choose a name", "requestedSchema": {
4019 "type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}
4020 }
4021 })).unwrap()
4022 }
4023
4024 #[tokio::test]
4025 async fn file_upload_accepts_large_binary_bodies_and_rejects_unsafe_paths_and_limits() {
4026 let backend = Arc::new(FakeBackend::default());
4027 let (app, _actions, snapshots, _bundles) = api_app(backend.clone(), |snapshot| {
4028 snapshot.sessions[0].is_idle = true;
4029 snapshot.sessions[0].lifecycle = ViewerLifecycleCategory::Live;
4030 });
4031 let payload: Vec<u8> = (0..3 * 1024 * 1024).map(|i| (i % 251) as u8).collect();
4032 let response = app
4033 .clone()
4034 .oneshot(
4035 bearer(Request::put(
4036 "/api/v1/sessions/session-1/files?path=input/data.bin&overwrite=true",
4037 ))
4038 .body(Body::from(payload.clone()))
4039 .unwrap(),
4040 )
4041 .await
4042 .unwrap();
4043 assert_eq!(response.status(), StatusCode::OK);
4044 assert_eq!(json_body(response).await["bytes"], payload.len());
4045 assert_eq!(
4046 backend.file_writes.lock().unwrap()[0],
4047 (PathBuf::from("input/data.bin"), payload, true)
4048 );
4049 for path in ["../outside", "/absolute", "nested/../../outside"] {
4050 let response = app
4051 .clone()
4052 .oneshot(
4053 bearer(Request::put(format!(
4054 "/api/v1/sessions/session-1/files?path={path}"
4055 )))
4056 .body(Body::from("bad"))
4057 .unwrap(),
4058 )
4059 .await
4060 .unwrap();
4061 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4062 }
4063 let response = app
4064 .clone()
4065 .oneshot(
4066 bearer(Request::put("/api/v1/sessions/session-1/files?path=large"))
4067 .body(Body::from(vec![
4068 0;
4069 mj_checkpoint::archive::MAX_SESSION_FILE_BYTES
4070 as usize
4071 + 1
4072 ]))
4073 .unwrap(),
4074 )
4075 .await
4076 .unwrap();
4077 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4078 snapshots.send_modify(|s| s.sessions[0].is_idle = false);
4079 let response = app
4080 .oneshot(
4081 bearer(Request::put("/api/v1/sessions/session-1/files?path=busy"))
4082 .body(Body::from("bad"))
4083 .unwrap(),
4084 )
4085 .await
4086 .unwrap();
4087 assert_eq!(response.status(), StatusCode::CONFLICT);
4088 assert_eq!(backend.file_writes.lock().unwrap().len(), 1);
4089 }
4090
4091 #[tokio::test]
4092 async fn structured_inputs_are_listed_validated_and_forwarded() {
4093 let (app, mut actions, _snapshots, _bundles) =
4094 api_app(Arc::new(FakeBackend::default()), |s| {
4095 s.sessions[0].pending_elicitations = vec![input_request()]
4096 });
4097 let response = app
4098 .clone()
4099 .oneshot(
4100 bearer(Request::get("/api/v1/sessions/session-1/elicitations"))
4101 .body(Body::empty())
4102 .unwrap(),
4103 )
4104 .await
4105 .unwrap();
4106 assert_eq!(json_body(response).await[0]["id"], "question-1");
4107 let response = app
4108 .clone()
4109 .oneshot(
4110 bearer(Request::post(
4111 "/api/v1/sessions/session-1/elicitations/question-1",
4112 ))
4113 .header(CONTENT_TYPE, "application/json")
4114 .body(Body::from(r#"{"action":"accept","content":{}}"#))
4115 .unwrap(),
4116 )
4117 .await
4118 .unwrap();
4119 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4120 assert!(actions.try_recv().is_err());
4121 let response = tokio::spawn(
4122 app.oneshot(
4123 bearer(Request::post(
4124 "/api/v1/sessions/session-1/elicitations/question-1",
4125 ))
4126 .header(CONTENT_TYPE, "application/json")
4127 .body(Body::from(
4128 r#"{"action":"accept","content":{"name":"example"}}"#,
4129 ))
4130 .unwrap(),
4131 ),
4132 );
4133 let action = actions.recv().await.unwrap();
4134 assert!(
4135 matches!(action.action, ControllerAction::RespondElicitation { elicitation_id, .. } if elicitation_id == "question-1")
4136 );
4137 action
4138 .reply
4139 .send(ActionOutcome::Accepted { session_id: None })
4140 .unwrap();
4141 assert_eq!(
4142 response.await.unwrap().unwrap().status(),
4143 StatusCode::ACCEPTED
4144 );
4145 }
4146
4147 #[test]
4148 fn input_aware_wait_is_opt_in_and_respects_completed_turns_and_stopping() {
4149 let mut observation = WaitObservation {
4150 pending_elicitations: vec![input_request()],
4151 execution: MaterializedExecutionState::Running { started_at_ms: 1 },
4152 ..Default::default()
4153 };
4154 assert!(resolve_wait(&observation, &WaitRequest::default()).is_none());
4155 let mut request = WaitRequest {
4156 return_on_input: true,
4157 ..Default::default()
4158 };
4159 assert_eq!(
4160 resolve_wait(&observation, &request).unwrap().outcome,
4161 WaitOutcome::InputRequired
4162 );
4163 observation.last_turn_outcome = Some(completed(5, "end_turn"));
4164 request.turn_id = Some(5);
4165 assert_eq!(
4166 resolve_wait(&observation, &request).unwrap().outcome,
4167 WaitOutcome::Finished
4168 );
4169 observation.lifecycle = Some(ViewerLifecycleCategory::Stopping);
4170 assert_eq!(
4171 resolve_wait(&observation, &request).unwrap().outcome,
4172 WaitOutcome::Stopped
4173 );
4174 }
4175
4176 #[tokio::test]
4177 async fn input_aware_wait_returns_the_form_without_needing_a_turn_summary() {
4178 let (app, _actions, _snapshots, _bundles) =
4179 api_app(Arc::new(FakeBackend::default()), |s| {
4180 s.sessions[0].pending_elicitations = vec![input_request()]
4181 });
4182 let response = app
4183 .oneshot(
4184 bearer(Request::post("/api/v1/sessions/session-1/wait"))
4185 .header(CONTENT_TYPE, "application/json")
4186 .body(Body::from(r#"{"return_on_input":true}"#))
4187 .unwrap(),
4188 )
4189 .await
4190 .unwrap();
4191 assert_eq!(response.status(), StatusCode::OK);
4192 let body = json_body(response).await;
4193 assert_eq!(body["outcome"], "input_required");
4194 assert_eq!(body["pending_elicitations"][0]["id"], "question-1");
4195 }
4196}