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