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