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