Skip to main content

mj_controller/server/
api.rs

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