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