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`]. The daemon implements it in
13//! `server_runtime::api`; the route tests implement it with a hand-written fake,
14//! so the HTTP contract is tested without a running daemon.
15//!
16//! # The artifact routes are contract
17//!
18//! A caller that runs work somewhere other than this machine cannot read the
19//! working tree to decide whether a change is safe to publish, so four routes
20//! are part of the stable contract rather than conveniences. Changing any of
21//! their media types, response bodies, or status meanings is a change to this
22//! API:
23//!
24//! - `GET /sessions/{session_id}/diff` answers a unified diff as
25//! `text/x-diff; charset=utf-8`, comparing from the revision the caller names
26//! in `base` when it names one. With `json=true` it answers
27//! `application/json` carrying `mj_checkpoint::archive::SessionDiff` instead,
28//! a type that lives in the checkpoint crate, so changing its fields is also
29//! a change here.
30//! - `POST /sessions/{session_id}/export` answers the work in the form the
31//! caller asks for: `kind: "patch"` as `text/x-diff`, `kind: "branch"` as
32//! `{"branch", "remote"}`, and `kind: "bundle"` as
33//! `application/octet-stream` with the bundle named in an attachment
34//! filename.
35//! - `GET` and `PUT /sessions/{session_id}/files` read one file as
36//! `application/octet-stream` and inject one, answering `{"path", "bytes"}`.
37//! Injection requires a live, idle session, because a write into a running
38//! turn has no meaning.
39//! - `GET /sessions/{session_id}/transcript` answers a page of the transcript,
40//! with `next_after_seq` as the cursor to continue from and `latest_seq` to
41//! tell whether the page reached the end.
42//!
43//! Where a route can refuse because of the session's own state — an export with
44//! nothing to export, an injection into a session that is neither live nor idle
45//! — it answers 409 with a sentence the caller can act on. A failure that is not
46//! the caller's to fix is a 5xx. Keeping those apart is part of the contract,
47//! because one is a decision for a person and the other is not.
48
49mod events;
50
51use std::path::{Component, PathBuf};
52use std::sync::Arc;
53use std::time::Duration;
54
55use anyhow::{Context, Result as AnyResult};
56use axum::extract::{Path, Query, State};
57use axum::http::header::{
58 AUTHORIZATION, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue,
59};
60use axum::http::{Request as HttpRequest, StatusCode};
61use axum::middleware::Next;
62use axum::response::{IntoResponse, Response};
63use axum::routing::{get, post};
64use axum::{Json, Router};
65use serde::{Deserialize, Serialize};
66
67use mj_core::state::{
68 MaterializedExecutionState, MaterializedTurn, MaterializedTurnOutcome, TurnOutcomeKind,
69};
70
71use mj_core::relay::CapacityRetry;
72
73use mj_client::session::{BoxFuture, SessionHandle};
74
75use super::{
76 ActionOutcome, ApiError, ControllerAction, ControllerRequest, ServerState,
77 ViewerLifecycleCategory, ViewerSession, ViewerSnapshot, constant_time_eq, create_quick_bundle,
78 require_session_record, validate_action, validate_prompt_text,
79};
80
81/// Response header naming the contract version this server speaks. A client
82/// that understands only version 1 can refuse anything else without parsing a
83/// body it may not recognize.
84pub const API_VERSION_HEADER: &str = "mj-api-version";
85pub const API_VERSION: &str = "1";
86
87/// How long a wait blocks when the caller names no timeout, and the ceiling it
88/// may ask for. Both are generous: a turn routinely runs for minutes, and the
89/// caller is a program that reconnects rather than a person holding a page.
90pub const DEFAULT_WAIT_SECS: u64 = 600;
91pub use mj_core::subagent::MAX_WAIT_SECONDS as MAX_WAIT_SECS;
92
93/// How often a wait re-reads durable state for a session with no live actor.
94const STOPPED_POLL_INTERVAL: Duration = Duration::from_millis(500);
95
96const API_TOKEN_FILE: &str = "api-token";
97const API_TOKEN_BYTES: usize = 32;
98
99mod token;
100pub use token::*;
101mod failure;
102pub use failure::*;
103mod types;
104pub use types::*;
105mod subagent_backend;
106pub use subagent_backend::*;
107mod wait_policy;
108pub use wait_policy::*;
109mod routes;
110pub use routes::*;
111mod config;
112pub(crate) use config::*;
113mod options;
114#[cfg(test)]
115pub(crate) use options::EngineProbe;
116pub(crate) use options::LocalEngineChecks;
117use options::*;
118mod sessions;
119use sessions::*;
120mod subagents;
121pub(crate) use subagents::*;
122mod turns;
123pub use turns::*;
124mod files;
125pub use files::*;
126mod wait;
127use wait::*;
128mod workspaces;
129use workspaces::*;
130
131#[cfg(test)]
132mod tests;