Skip to main content

mj_controller/server/
actions.rs

1use super::*;
2
3/// The complete set of operations a phone may ask the controller to perform.
4/// Secret/config editing is intentionally not representable here, and the one
5/// destructive variant, `ForceClose`, is not representable on the wire: it is
6/// `#[serde(skip)]` so only in-process callers such as the HTTP API can build
7/// it.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
10pub enum ControllerAction {
11    New {
12        #[serde(default)]
13        create_managed_worktree: Option<bool>,
14        /// None follows the global `[subagents] enabled` setting.
15        #[serde(default)]
16        mjolnir_subagents: Option<bool>,
17        /// Which workspace the session belongs to. Optional on the wire so a
18        /// viewer cached from before workspaces reached the phone still parses,
19        /// but a controller holding more than one workspace refuses an empty
20        /// one rather than guessing.
21        #[serde(default)]
22        workspace_id: String,
23        profile_id: String,
24        bundle_id: String,
25        target_id: String,
26        /// Absent means "derive it", which is what the terminal does.
27        #[serde(default)]
28        title: Option<String>,
29        #[serde(default)]
30        project_directory: Option<PathBuf>,
31        /// The repositories the person was shown as having uncommitted changes
32        /// and chose to launch over anyway.
33        ///
34        /// This names them rather than being a bare yes, so an acknowledgement
35        /// cannot be replayed against a set the person never saw: if a
36        /// different repository has gone dirty since the preflight, the launch
37        /// stops and asks again.
38        #[serde(default, skip_serializing_if = "Vec::is_empty")]
39        dirty_ack: Vec<String>,
40    },
41    /// Give a session a new title. The terminal calls this a rename.
42    Rename {
43        session_id: String,
44        title: String,
45    },
46    /// Stop the turn the agent is working on, leaving the session alive. This
47    /// is not `Cancel`, which stops a provision, resume or stop.
48    CancelTurn {
49        session_id: String,
50    },
51    /// Change one setting the harness advertised, such as `model` or `effort`.
52    SetConfig {
53        session_id: String,
54        key: String,
55        value: String,
56    },
57    /// Turn plan mode on or off. The harness decides how, which is why this
58    /// carries an intent rather than a mode id.
59    SetPlanMode {
60        session_id: String,
61        active: bool,
62    },
63    RefreshQuota {
64        profile_id: String,
65    },
66    RefreshCapacity {
67        target_id: String,
68    },
69    Resume {
70        session_id: String,
71        workspace_id: String,
72        profile_id: String,
73        target_id: String,
74        queue: ResumeQueueDisposition,
75        /// A failed Move supplies the settings recorded before source
76        /// teardown. Ordinary Resume requests leave these absent and retain
77        /// the historical inheritance behavior.
78        #[serde(default)]
79        additional_mounts: Option<Vec<AdditionalMount>>,
80        #[serde(default)]
81        resource_allocation: Option<SessionResourceAllocation>,
82    },
83    /// Confirm a previously prepared move. Preparation is a separate
84    /// authenticated request so changing the destination cannot be smuggled
85    /// into a confirmation from an older browser form.
86    Move {
87        request: MoveSessionRequest,
88    },
89    Open {
90        session_id: String,
91    },
92    Prompt {
93        session_id: String,
94        text: String,
95        /// Images to send with the prompt. The controller turns each one into
96        /// the ACP image content block its prompt path already speaks.
97        #[serde(default, skip_serializing_if = "Vec::is_empty")]
98        images: Vec<ViewerPromptImage>,
99    },
100    RunShell {
101        session_id: String,
102        command: String,
103    },
104    CancelShell {
105        session_id: String,
106        shell_command_id: String,
107    },
108    Close {
109        session_id: String,
110    },
111    /// Destroy a session without checkpointing it: the live target is torn
112    /// down, the recovery archive is removed, and sub-agent children are
113    /// destroyed first. This is irreversible.
114    ///
115    /// Skipped by serde on purpose. The browser viewer posts this enum to
116    /// `/actions`, so a wire request must never be able to name this variant;
117    /// it is reachable only from the HTTP API, which builds it in process.
118    #[serde(skip)]
119    ForceClose {
120        session_id: String,
121        /// Whether the managed worktree's branch goes with the session.
122        /// Destruction keeps it unless the request asks for the deletion.
123        delete_branch: bool,
124    },
125    Cancel {
126        session_id: String,
127    },
128    /// Review the turn this session just finished.
129    StartReview {
130        session_id: String,
131    },
132    /// Forward the findings, dismiss them, or cancel the open review.
133    ResolveReview {
134        session_id: String,
135        /// `forward`, `dismiss`, or `cancel`.
136        resolution: String,
137    },
138    RemoveQueuedPrompt {
139        session_id: String,
140        queue_id: String,
141    },
142    /// Answer one of the session's pending form questions.
143    RespondElicitation {
144        session_id: String,
145        elicitation_id: String,
146        response: ElicitationResponse,
147    },
148}
149
150/// One image a phone attached to a prompt. Legacy callers may send inline
151/// base64 data; the server normalizes it into an attachment before dispatch.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct ViewerPromptImage {
155    /// Legacy inline image bytes. New browser uploads and normalized inline
156    /// prompts carry an attachment reference and leave this empty.
157    #[serde(default)]
158    pub data_base64: String,
159    pub mime_type: String,
160    pub width: u32,
161    pub height: u32,
162    /// Session-scoped, immutable image bytes. The worker resolves this just
163    /// before dispatch, keeping browser actions and durable commands small.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub attachment: Option<AttachmentRef>,
166}
167
168/// The controller's answer to one phone action.
169///
170/// The answer means "accepted", not "finished": provisioning, resume and close
171/// run for minutes, and a phone on a mobile network drops a request held open
172/// that long. How the action then goes travels in snapshots — session state,
173/// queued prompts, transcripts, and `has_error`.
174///
175/// Only the outcome crosses this boundary. The controller's own failure text
176/// names profile homes, project paths and SSH hosts, so it stays on the
177/// controller. A caller therefore gets one of two things: a [`Refusal`], whose
178/// sentence was written for it at the place the failure was produced, or a
179/// generic internal failure carrying a reference that also appears in the
180/// daemon log.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ActionOutcome {
183    /// Admitted and now running; watch the snapshot for what happens next.
184    ///
185    /// A `new` action carries the published session id, which is the only way
186    /// its caller learns what it just created.
187    Accepted { session_id: Option<String> },
188    /// The controller already runs as many phone actions as it allows.
189    Busy,
190    /// This session already has an operation running.
191    SessionBusy,
192    /// A cancel found no operation to cancel.
193    NotCancellable,
194    /// The action was refused for a reason the caller can act on, and the
195    /// refusal says what it is.
196    Refused(Refusal),
197    /// The controller could not start the action, for a reason that stays
198    /// server-side. `reference` is logged with the failure, so the person who
199    /// owns the daemon can find the entry that explains it.
200    Failed { reference: String },
201}
202
203impl ActionOutcome {
204    /// Admitted, with no session id to report.
205    pub const fn accepted() -> Self {
206        Self::Accepted { session_id: None }
207    }
208
209    /// The published session id, when this outcome carries one.
210    pub fn session_id(&self) -> Option<&str> {
211        match self {
212            Self::Accepted { session_id } => session_id.as_deref(),
213            _ => None,
214        }
215    }
216
217    /// The reply an outcome owes the phone, or `None` when it was accepted.
218    pub(super) fn rejection(&self) -> Option<ApiError> {
219        match self {
220            Self::Accepted { .. } => None,
221            Self::Busy => Some(ApiError::new(
222                StatusCode::TOO_MANY_REQUESTS,
223                "the controller is at its concurrent action limit; retry shortly",
224            )),
225            Self::SessionBusy => Some(ApiError::new(
226                StatusCode::CONFLICT,
227                "another operation is already running for this session",
228            )),
229            Self::NotCancellable => Some(ApiError::new(
230                StatusCode::CONFLICT,
231                "the session has no cancellable operation",
232            )),
233            // A refusal is a precondition the caller can fix, so it answers
234            // 4xx with the sentence written for it: 409 for a state that has
235            // to change first, 422 for a request naming something unusable.
236            Self::Refused(refusal) => Some(ApiError::new(
237                match refusal.kind() {
238                    RefusalKind::Precondition => StatusCode::CONFLICT,
239                    RefusalKind::Unusable => StatusCode::UNPROCESSABLE_ENTITY,
240                },
241                refusal.message().to_owned(),
242            )),
243            Self::Failed { reference } => Some(ApiError::new(
244                StatusCode::INTERNAL_SERVER_ERROR,
245                format!(
246                    "the controller could not start this action; \
247                     the daemon log records the reason under reference {reference}"
248                ),
249            )),
250        }
251    }
252}
253
254#[derive(Debug)]
255pub struct ControllerRequest {
256    pub action: ControllerAction,
257    pub reply: tokio::sync::oneshot::Sender<ActionOutcome>,
258}
259
260/// A phone request to create or reuse a quick project bundle. This has its
261/// own channel because bundle creation returns a durable id and must publish a
262/// config snapshot before the HTTP request can succeed; [`ControllerAction`]
263/// intentionally carries only action admission outcomes.
264#[derive(Debug)]
265pub struct BundleRequest {
266    pub source: String,
267    pub reply: tokio::sync::oneshot::Sender<Result<String, BundleFailure>>,
268}
269
270/// Safe failure classes for bundle creation. Detailed controller errors stay
271/// in daemon logs; a browser only needs to know whether to fix its source or
272/// report a server-side failure.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum BundleFailure {
275    InvalidSource,
276    Controller,
277}
278
279/// A phone acknowledging how far it has read a conversation.
280///
281/// This deliberately is not a `ControllerAction`: the viewer posts it after
282/// every conversation fetch, and a fetch follows every revision. Routing it
283/// through the action pipeline made each receipt reload the controller, bump
284/// the revision and broadcast a snapshot, which triggered the next fetch, so
285/// viewer and controller never went quiet; it also consumed the session's
286/// single action slot, intermittently rejecting real actions. A receipt
287/// therefore travels on its own channel and only persists one cursor field.
288/// A phone asking whether a session it is about to create would launch
289/// cleanly, and which network sources it will use first.
290///
291/// This is not a `ControllerAction`: it starts nothing, it takes no session
292/// slot, and it must answer before the person has decided anything. It also
293/// needs the controller, because resolving a local repository's configured
294/// remotes is a fact about the disk rather than about the projection.
295///
296/// Resume preflights share this channel, and so the concurrency cap on it,
297/// because they do the same kind of work on the same disk.
298#[derive(Debug)]
299pub enum PreflightRequest {
300    New(NewPreflightRequest),
301    Resume(ResumePreflightRequest),
302    CompletePath(PathCompletionRequest),
303}
304
305/// A browser asking what a half-typed path could be. It shares the preflight
306/// channel because it does the same kind of work: one short-lived, cancellable
307/// look at a local or remote filesystem, under the same concurrency cap.
308#[derive(Debug)]
309pub struct PathCompletionRequest {
310    pub host: CompletionHost,
311    pub prefix: String,
312    pub kind: CompletionKind,
313    pub reply: tokio::sync::oneshot::Sender<Result<PathCompletion, String>>,
314}
315
316#[derive(Debug)]
317pub struct NewPreflightRequest {
318    pub bundle_id: String,
319    pub target_id: String,
320    pub project_directory: Option<PathBuf>,
321    pub remote_repairs: Vec<mj_core::local_git::LocalRemoteRepair>,
322    pub reply: tokio::sync::oneshot::Sender<Result<PreflightNew, PreflightFailure>>,
323}
324
325/// A resume preflight for one stopped session and one destination target. It
326/// travels on the same channel and under the same concurrency cap as the
327/// new-session preflight because it does the same kind of work: reading a
328/// working tree and asking a remote about itself.
329#[derive(Debug)]
330pub struct ResumePreflightRequest {
331    pub session_id: String,
332    pub target_id: String,
333    pub reply: tokio::sync::oneshot::Sender<Result<PreflightResume, PreflightFailure>>,
334}
335
336/// What a resume preflight found.
337///
338/// `Ready` covers every resume that changes nothing about where repository
339/// content comes from. `ConvertingRawCheckout` means this resume moves a
340/// local checkout into an isolated workspace, and carries the preview the
341/// person has to confirm. `Unavailable` reports why the conversion cannot be
342/// planned, in the plan's own words, because that message says what to do
343/// about it (add a remote, commit a submodule) and the browser has no other
344/// way to learn it.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(tag = "kind", rename_all = "kebab-case")]
347pub enum PreflightResume {
348    Ready,
349    ConvertingRawCheckout {
350        preview: Box<mj_core::state::RawConversionPreview>,
351    },
352    Unavailable {
353        detail: String,
354    },
355}
356
357/// A move preparation is intentionally separate from action admission. It
358/// performs read-only compatibility checks and returns the exact fingerprint
359/// the later confirmation must echo; it never interrupts the source session.
360#[derive(Debug)]
361pub struct MovePreparationRequest {
362    pub selection: MoveSelection,
363    pub reply: tokio::sync::oneshot::Sender<Result<MovePreparation, String>>,
364}
365
366/// A preflight can fail because the requested bare directory is unusable, an
367/// isolated repository lacks a usable network source, or the controller-side
368/// check itself could not complete. The HTTP surface keeps those outcomes
369/// distinct without carrying filesystem, Git, or SSH details to the phone.
370#[derive(Debug)]
371pub enum PreflightFailure {
372    Validation,
373    /// A configured isolated-session repository cannot be used as a network
374    /// source. The detail is safe for the phone and tells the person how to
375    /// choose the supported raw-local path instead.
376    InvalidRepository(String),
377    Controller(String),
378}
379
380/// One configured repository's network clone and publication destinations.
381/// URLs have already been passed through the shared display sanitizer before
382/// they reach a phone.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct PreflightRepository {
386    pub id: String,
387    pub fetch_url: String,
388    pub default_branch: String,
389    pub push_urls: Vec<String>,
390}
391
392/// What a preflight found. Isolated sessions expose their complete network
393/// source plan so the person can review it before creation. Raw-local targets
394/// leave the plan empty because they use the selected checkout directly;
395/// isolated targets set `local_changes_excluded` to make the copy boundary
396/// explicit.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(deny_unknown_fields)]
399pub struct PreflightNew {
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub project_directory: Option<PathBuf>,
402    #[serde(default)]
403    pub managed_worktree: mj_core::state::ManagedWorktreeOptions,
404    #[serde(default)]
405    pub remote_repairs: Vec<mj_core::local_git::LocalRemoteRepair>,
406    #[serde(default)]
407    pub dirty_repositories: Vec<String>,
408    #[serde(default)]
409    pub remote_repositories: Vec<PreflightRepository>,
410    pub local_changes_excluded: bool,
411}
412
413/// What a phone asks about, or stores against, its own identity.
414///
415/// These travel on their own channel rather than as actions, for the reason a
416/// read receipt does: they are frequent, they start nothing, and routing them
417/// through the action pipeline would consume the session's single action slot
418/// and reload the controller on every keystroke.
419#[derive(Debug)]
420pub enum ClientStateRequest {
421    Read {
422        client_id: String,
423        session_id: String,
424        reply: tokio::sync::oneshot::Sender<Result<ViewerClientState, String>>,
425    },
426    SaveDraft {
427        client_id: String,
428        session_id: String,
429        draft: String,
430        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
431    },
432    MarkWorkspaceRead {
433        client_id: String,
434        workspace_id: String,
435        reply: tokio::sync::oneshot::Sender<Result<(), String>>,
436    },
437    History {
438        session_id: String,
439        query: String,
440        scope: String,
441        reply: tokio::sync::oneshot::Sender<Result<ViewerPromptHistory, String>>,
442    },
443}
444
445#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(deny_unknown_fields)]
447pub struct ViewerClientState {
448    pub draft: String,
449    pub through_event_ordinal: u64,
450}
451
452#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct ViewerPromptHistory {
455    pub entries: Vec<String>,
456    /// Whether the search stopped before it ran out of history, so a phone can
457    /// say the answer is partial rather than presenting it as complete.
458    pub truncated: bool,
459}
460
461#[derive(Debug)]
462pub struct ReadReceiptRequest {
463    pub client_id: String,
464    pub session_id: String,
465    pub through: u64,
466    pub reply: tokio::sync::oneshot::Sender<Result<(), String>>,
467}
468
469/// A phone request to stop one currently projected background task.
470///
471/// This is intentionally not a [`ControllerAction`]. The request is already
472/// validated against the current operational snapshot by the HTTP handler,
473/// then the controller resolves the live session handle and waits for the
474/// provider acknowledgement in a supervised task.
475#[derive(Debug)]
476pub struct BackgroundTaskStopRequest {
477    pub session_id: String,
478    pub background_task_id: String,
479    pub reply: tokio::sync::oneshot::Sender<Result<(), BackgroundTaskStopFailure>>,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483pub enum BackgroundTaskStopFailure {
484    /// The session manager could not resolve the live session handle.
485    SessionUnavailable,
486    /// The provider or relay rejected the stop request.
487    Provider,
488    /// The stop task itself failed before reaching the provider.
489    Internal,
490}