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