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