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