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