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