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