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