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