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