Skip to main content

mj_controller/server/
viewer_types.rs

1use super::*;
2
3#[cfg(test)]
4thread_local! {
5    static VIEWER_ROW_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
6}
7
8#[cfg(test)]
9pub(crate) fn take_viewer_row_visits() -> usize {
10    VIEWER_ROW_VISITS.with(|visits| visits.replace(0))
11}
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct ViewerSnapshot {
16    #[serde(default)]
17    pub last_subagent_policy: mj_core::subagent::SubagentPolicy,
18    pub revision: u64,
19    pub generated_at: String,
20    /// Unix time in milliseconds, refreshed when serving the projection.
21    /// Clients use this as the clock for live activity cards.
22    #[serde(default)]
23    pub server_time_ms: i64,
24    /// The controller build serving this viewer, so a browser or the desktop
25    /// window can name the Mjolnir it is talking to. Absent from a snapshot
26    /// written by an older controller.
27    #[serde(default, skip_serializing_if = "String::is_empty")]
28    pub server_version: String,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub workspaces: Vec<ViewerWorkspace>,
31    pub sessions: ViewerSessions,
32    pub profiles: Vec<ViewerProfile>,
33    pub targets: Vec<ViewerTarget>,
34    pub bundles: Vec<ViewerBundle>,
35    /// The bounded part of `[review]` needed to report whether review is
36    /// armed. Reviewer model and effort remain controller-private.
37    #[serde(default)]
38    pub review_config: ViewerReviewConfig,
39    /// One entry per host or fleet that can be probed. Empty until the phone
40    /// server's capacity poller has published a reading.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub capacity: Vec<ViewerTargetCapacity>,
43    /// Recent failed launches, independent of provisional session rollback.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub launch_failures: Vec<ViewerLaunchFailure>,
46}
47
48/// The public wire shape remains an array; in-process publications share rows.
49#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
50#[serde(from = "Vec<ViewerSession>", into = "Vec<ViewerSession>")]
51pub struct ViewerSessions(pub(crate) mj_core::snapshot_map::SnapshotMap<String, ViewerSession>);
52
53impl ViewerSessions {
54    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ViewerSession> + ExactSizeIterator {
55        self.0.values()
56    }
57
58    pub fn len(&self) -> usize {
59        self.0.len()
60    }
61    pub fn is_empty(&self) -> bool {
62        self.0.is_empty()
63    }
64    pub fn push(&mut self, session: ViewerSession) {
65        self.0.insert(session.id.clone(), session);
66    }
67}
68
69impl From<Vec<ViewerSession>> for ViewerSessions {
70    fn from(rows: Vec<ViewerSession>) -> Self {
71        rows.into_iter().collect()
72    }
73}
74
75impl From<ViewerSessions> for Vec<ViewerSession> {
76    fn from(rows: ViewerSessions) -> Self {
77        rows.0.into_values().collect()
78    }
79}
80
81impl FromIterator<ViewerSession> for ViewerSessions {
82    fn from_iter<T: IntoIterator<Item = ViewerSession>>(rows: T) -> Self {
83        Self(rows.into_iter().map(|row| (row.id.clone(), row)).collect())
84    }
85}
86
87impl IntoIterator for ViewerSessions {
88    type Item = ViewerSession;
89    type IntoIter = std::vec::IntoIter<ViewerSession>;
90    fn into_iter(self) -> Self::IntoIter {
91        Vec::from(self).into_iter()
92    }
93}
94
95impl std::ops::Index<usize> for ViewerSessions {
96    type Output = ViewerSession;
97    fn index(&self, index: usize) -> &Self::Output {
98        self.iter().nth(index).expect("viewer row index")
99    }
100}
101
102impl std::ops::IndexMut<usize> for ViewerSessions {
103    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
104        let id = self.0.keys().nth(index).expect("viewer row index").clone();
105        self.0.get_mut(&id).expect("viewer row exists")
106    }
107}
108
109pub(crate) type ViewerChildren =
110    mj_core::snapshot_map::SnapshotMap<String, mj_core::snapshot_map::SnapshotMap<String, ()>>;
111
112/// Carries the launch failure's reason so a client can show why a session
113/// never came up. The reason is the provisioning error chain, the same text
114/// the session's `last_error` already publishes through `mj events`; it is not
115/// the full local diagnostic file, which can hold credentials.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ViewerLaunchFailure {
118    /// Identifies the notice itself, so the browser can dismiss one. It is not
119    /// a session id.
120    pub id: String,
121    pub workspace_id: String,
122    /// The session the failed launch was for, when one had been published.
123    /// Absent when the launch failed before any session record existed.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub session_id: Option<String>,
126    /// Why the launch failed, when the action recorded a reason.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub error: Option<String>,
129}
130
131impl ViewerSnapshot {
132    /// Build the public projection. In particular, this never copies profile
133    /// homes/environment, SSH hosts/keys, container environment, AWS details,
134    /// concrete resource locators, native session IDs, or raw error strings.
135    pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
136        let mut children = ViewerChildren::new();
137        for relation in state.subagents.values() {
138            children
139                .entry(relation.parent_session_id.clone())
140                .or_insert_with(Default::default)
141                .insert(relation.child_session_id.clone(), ());
142        }
143        Self::from_config_records(config, state, revision, state.sessions.values(), &children)
144    }
145
146    pub(crate) fn from_config_records<'a>(
147        config: &Config,
148        state: &AppState,
149        revision: u64,
150        records: impl Iterator<Item = &'a mj_core::state::SessionRecord>,
151        children: &ViewerChildren,
152    ) -> Self {
153        let sessions = records
154            .map(|session| {
155                #[cfg(test)]
156                VIEWER_ROW_VISITS.with(|visits| visits.set(visits.get() + 1));
157                let resume_refusals = config
158                    .targets
159                    .keys()
160                    .filter_map(|target_id| {
161                        crate::controller::resume_compatibility(session, config, target_id)
162                            .err()
163                            .map(|reason| (target_id.clone(), reason))
164                    })
165                    .collect::<BTreeMap<_, _>>();
166                let incompatible = resume_refusals.keys().cloned().collect::<Vec<_>>();
167                let lifecycle = ViewerLifecycleCategory::of(session.state);
168                // A sub-agent child works in its parent's checkout and owns no
169                // worktree, so its project identity has to come from the
170                // parent; its own record would name the parent's session id.
171                let project = state.project_identity_session(session);
172                let source = project.project_source(config);
173                let subagent = state.subagents.get(&session.id);
174                let subagent_session_ids = children
175                    .get(&session.id)
176                    .map(|children| children.keys().cloned().collect())
177                    .unwrap_or_default();
178                let start = session.start_selection();
179                ViewerSession {
180                    subagents: session.subagents.clone().unwrap_or_default(),
181                    at: start.at,
182                    branch: start.branch,
183                    base: start.base,
184                    targeted_turn_control_supported: false,
185                    native_subagents: Vec::new(),
186                    steering: None,
187                    active_prompt_id: None,
188                    cancelling_prompt_id: None,
189                    capacity_retry: None,
190                    retry_assessment_pending: false,
191                    quota_recovery: None,
192                    id: session.id.clone(),
193                    publication_state: session.publication_state(),
194                    managed_checkout_kind: session
195                        .managed_worktree
196                        .as_ref()
197                        .map(|owned| owned.kind),
198                    workspace_id: session.workspace_id.clone(),
199                    title: public_title(session),
200                    subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
201                    subagent_task_name: subagent.map(|child| child.task_name.clone()),
202                    subagent_session_ids,
203                    harness_kind: session.harness_kind.id().into(),
204                    profile_id: session.last_profile.clone(),
205                    bundle_id: session.bundle_id.clone(),
206                    target_id: session.target_template_id.clone(),
207                    state: match session.state {
208                        SessionState::Closing => "suspending",
209                        SessionState::Stopped => "suspended",
210                        _ => session.state.as_str(),
211                    }
212                    .into(),
213                    created_at: session.created_at.clone(),
214                    updated_at: session.updated_at.clone(),
215                    has_error: session.last_error.is_some()
216                        || session.configuration_issue(config).is_some(),
217                    has_checkpoint: session.checkpoint.is_some(),
218                    configuration_issue: session.configuration_issue(config),
219                    // A session that failed to launch (or a close that left it
220                    // dead) carries its reason here so a client need not open
221                    // the local diagnostic to learn why. A failed resume rolls
222                    // the record back to stopped and leaves its reason in the
223                    // same field, so that state reports it too; every
224                    // successful transition clears `last_error`, so this never
225                    // reports a failure the session has since recovered from.
226                    //
227                    // A live session's `last_error` is not published here: it
228                    // can hold a raw provisioning chain naming profile homes
229                    // and SSH hosts. A failed close leaves the session alive
230                    // and still owes the person a reason, so the sentence the
231                    // controller composed for them is published whatever state
232                    // the session is in (#1081).
233                    launch_error: matches!(
234                        session.state,
235                        SessionState::Error | SessionState::Stopped
236                    )
237                    .then(|| session.last_error.clone())
238                    .flatten()
239                    .or_else(|| session.public_error().map(str::to_owned)),
240                    preview: Vec::new(),
241                    queued_prompts: Vec::new(),
242                    active_user_shells: Vec::new(),
243                    background_tasks: Vec::new(),
244                    pending_elicitations: Vec::new(),
245                    conversation_available: false,
246                    prompt_images_supported: false,
247                    incompatible_resume_targets: incompatible.clone(),
248                    resume_refusals,
249                    compatible_resume_targets: config
250                        .targets
251                        .keys()
252                        .filter(|target_id| !incompatible.contains(*target_id))
253                        .cloned()
254                        .collect(),
255                    project_label: source.short,
256                    project_key: project_key(&source.key),
257                    display_location: project.project_target(config, &session.target_template_id),
258                    lifecycle,
259                    transitioning: session.state.transition_kind().is_some(),
260                    latest_event_ordinal: 0,
261                    last_activity_at_ms: None,
262                    activity_details: None,
263                    activity: String::new(),
264                    operation: None,
265                    move_recovery: None,
266                    // Both are replaced for every session by the phone
267                    // projection, from the one shared activity state.
268                    chat_phase: ViewerChatPhase::default(),
269                    is_idle: false,
270                    activity_state: None,
271                    config_options: Vec::new(),
272                    plan_mode_active: None,
273                    turn_review: None,
274                    available_commands: Vec::new(),
275                    // What the durable record alone can justify. The phone server
276                    // widens these once it knows whether the session manager holds
277                    // the session and what the agent has advertised.
278                    capabilities: ViewerSessionCapabilities {
279                        clear_context: false,
280                        open: false,
281                        prompt: false,
282                        run_shell: false,
283                        interrupt_turn: false,
284                        cancel_operation: false,
285                        suspend: lifecycle.is_dashboard_visible(),
286                        destroy: true,
287                        rename: true,
288                        resume: !lifecycle.is_dashboard_visible(),
289                        move_session: false,
290                        set_config: false,
291                        set_plan_mode: false,
292                    },
293                }
294            })
295            .collect();
296        let profiles = config
297            .enabled_profiles()
298            .map(|(id, profile)| ViewerProfile {
299                id: id.to_owned(),
300                harness_kind: profile.kind.id().into(),
301                subagents: profile.subagents.clone(),
302                subagent_discovery_key: config.subagent_discovery_key(id, None),
303                quota: None,
304            })
305            .collect();
306        let targets = config
307            .targets
308            .iter()
309            .map(|(id, target)| ViewerTarget {
310                id: id.clone(),
311                kind: target.kind_name().into(),
312                requires_project_directory: matches!(
313                    target,
314                    TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
315                ),
316                runtime_missing: false,
317                default_candidate: config.is_default_target(id),
318                availability: crate::server::api::LaunchAvailability::Unknown,
319                unavailable_reason: None,
320                recent_project_directories: project_history_host(target)
321                    .map(|host| {
322                        state
323                            .project_directories(host)
324                            .iter()
325                            .map(|directory| directory.to_string_lossy().into_owned())
326                            .collect()
327                    })
328                    .unwrap_or_default(),
329            })
330            .collect();
331        let bundles = config
332            .bundles
333            .iter()
334            .map(|(id, bundle)| ViewerBundle {
335                id: id.clone(),
336                primary_repository: bundle.primary_repo.clone(),
337                repositories: bundle
338                    .repositories
339                    .iter()
340                    .map(|repository| ViewerRepository {
341                        id: repository.id.clone(),
342                        github: repository.github.clone(),
343                        destination: repository.destination.to_string_lossy().into_owned(),
344                    })
345                    .collect(),
346            })
347            .collect();
348        Self {
349            last_subagent_policy: state.last_subagent_policy.clone(),
350            revision,
351            generated_at: now_unix().to_string(),
352            server_time_ms: mj_core::clock::epoch_millis(),
353            server_version: env!("CARGO_PKG_VERSION").to_owned(),
354            workspaces: Vec::new(),
355            sessions,
356            profiles,
357            targets,
358            bundles,
359            review_config: ViewerReviewConfig {
360                enabled: config.review.enabled,
361                tier: config.review.tier.label().to_owned(),
362                profile: config.review.profile.clone(),
363            },
364            capacity: Vec::new(),
365            launch_failures: Vec::new(),
366        }
367    }
368}
369
370/// A stable, opaque grouping key for a project.
371///
372/// The controller's own project identity is a bundle, filesystem path, or Git
373/// remote, and this projection publishes neither. A digest groups exactly as
374/// well and says nothing: two sessions in the same project share a key, and a
375/// key on its own reveals no source.
376pub(super) fn project_key(identity: &str) -> String {
377    use sha2::Digest as _;
378    let digest = Sha256::digest(identity.as_bytes());
379    mj_core::hex::lower_hex(&digest[..8])
380}
381
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct ViewerSession {
385    #[serde(default)]
386    pub subagents: mj_core::subagent::SubagentPolicy,
387    /// Commit the workspace started checked out at, when one was named.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub at: Option<String>,
390    /// Branch created at `at`, or the existing branch checked out without it.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub branch: Option<String>,
393    /// Diff base the session was started with; `at` unless another was named.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub base: Option<String>,
396    #[serde(default)]
397    pub targeted_turn_control_supported: bool,
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub steering: Option<mj_core::relay::SteeringOperation>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub active_prompt_id: Option<String>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub cancelling_prompt_id: Option<String>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
408    #[serde(default)]
409    pub retry_assessment_pending: bool,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
412    pub id: String,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub publication_state: Option<mj_core::state::PublicationState>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
417    #[serde(default, skip_serializing_if = "String::is_empty")]
418    pub workspace_id: String,
419    pub title: String,
420    /// Parent ownership for a borrowed-target child session.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub subagent_parent_id: Option<String>,
423    /// The stable task label chosen by the parent when it spawned this child.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub subagent_task_name: Option<String>,
426    /// Direct children of this parent. Children are deliberately never nested.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub subagent_session_ids: Vec<String>,
429    pub harness_kind: String,
430    pub profile_id: String,
431    pub bundle_id: String,
432    pub target_id: String,
433    pub state: String,
434    pub created_at: String,
435    pub updated_at: String,
436    pub has_error: bool,
437    /// Whether the session has a checkpoint to resume from. A resume restores
438    /// a checkpoint and nothing else, so a session without one, such as a
439    /// launch that failed before its first, cannot be resumed (launch finding
440    /// R6-1). Only the fact travels; the archive's path stays on the
441    /// controller.
442    #[serde(default)]
443    pub has_checkpoint: bool,
444    /// Public identifiers and repair guidance only; never raw runtime errors.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub configuration_issue: Option<String>,
447    /// Why a launch failed, for a session that ended in the error state. This
448    /// is the same provisioning error text `last_error` already publishes
449    /// through `mj events`, surfaced here so `mj sessions`/`mj wait` can show
450    /// the reason instead of a bare "failed to launch".
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub launch_error: Option<String>,
453
454    #[serde(default, skip_serializing_if = "Vec::is_empty")]
455    pub preview: Vec<String>,
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub queued_prompts: Vec<ViewerQueuedPrompt>,
458    #[serde(default, skip_serializing_if = "Vec::is_empty")]
459    pub active_user_shells: Vec<ViewerUserShell>,
460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
461    pub background_tasks: Vec<ViewerBackgroundTask>,
462    /// Form questions the session is blocked on, published so a phone can
463    /// answer them. These are the agent's own questions, already visible in
464    /// the transcript, so they travel whole rather than redacted.
465    #[serde(default, skip_serializing_if = "Vec::is_empty")]
466    pub pending_elicitations: Vec<ElicitationRequest>,
467    pub conversation_available: bool,
468    /// Whether this session's agent advertised support for image content in
469    /// prompts. The viewer offers the image controls only when it did, and the
470    /// server refuses images for a session that did not.
471    #[serde(default)]
472    pub prompt_images_supported: bool,
473    /// Target ids this session cannot resume on. Only the ids travel: the
474    /// controller's reasons name project paths and SSH hosts, which this
475    /// projection deliberately keeps on the controller.
476    ///
477    /// Retained beside `compatible_resume_targets` so a viewer cached from
478    /// before that field existed keeps working through a deployment.
479    #[serde(default, skip_serializing_if = "Vec::is_empty")]
480    pub incompatible_resume_targets: Vec<String>,
481    /// The controller's reason for each id in `incompatible_resume_targets`.
482    /// It names project paths and SSH hosts, so it is never serialized: only
483    /// in-process validation reads it, to tell the caller why a resume or move
484    /// was refused.
485    #[serde(skip)]
486    pub resume_refusals: BTreeMap<String, String>,
487    /// Target ids this session can resume on, so the browser never has to
488    /// subtract one set from another to find out.
489    #[serde(default, skip_serializing_if = "Vec::is_empty")]
490    pub compatible_resume_targets: Vec<String>,
491    /// The canonical short source label for this session: a bundle name, path
492    /// leaf, or repository name, never a source path itself.
493    #[serde(default, skip_serializing_if = "String::is_empty")]
494    pub project_label: String,
495    /// A stable key for grouping sessions by project. The controller's own
496    /// source identity stays private, so what travels is a digest of it:
497    /// enough to group by, and nothing to read.
498    #[serde(default, skip_serializing_if = "String::is_empty")]
499    pub project_key: String,
500    /// The configured target's human-facing project location. This is the
501    /// same target projection the terminal uses while a session is running.
502    #[serde(default)]
503    pub display_location: String,
504    pub lifecycle: ViewerLifecycleCategory,
505    /// A lifecycle transition temporarily owns this session's conversation.
506    /// This remains separate from the coarse lifecycle category so Move can
507    /// hide the old transcript while its durable record is still `Running`.
508    #[serde(default)]
509    pub transitioning: bool,
510    /// How far the controller's projection of this session has advanced. A
511    /// phone compares it against its own read frontier to know what is unread,
512    /// without fetching a transcript to find out.
513    #[serde(default)]
514    pub latest_event_ordinal: u64,
515    /// Durable relay receipt watermark from the materialized projection.
516    /// It remains absent when the background snapshot pipeline has not yet
517    /// delivered a projection for this session.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub last_activity_at_ms: Option<i64>,
520    /// Structured live activity, absent when no operational relay snapshot is
521    /// available for this session.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub activity_details: Option<ViewerActivityDetails>,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub operation: Option<ViewerOperation>,
526    /// Safe recovery choices for a failed or cancelled Move. Diagnostics and
527    /// checkpoint paths remain on the controller; this contains only the
528    /// settings a person may choose again.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub move_recovery: Option<ViewerMoveRecovery>,
531    #[serde(default)]
532    pub chat_phase: ViewerChatPhase,
533    /// Known live activity is idle: no foreground turn, tool, or background work.
534    /// Missing operational state must not be presented as confirmed idle.
535    #[serde(default)]
536    pub is_idle: bool,
537    /// What this session is doing, in the shared vocabulary every part of
538    /// Mjolnir now uses. Richer than `chat_phase`, which has only four values
539    /// and must keep them: this can also say that the daemon cannot see the
540    /// worker and report what was last known about it.
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub activity_state: Option<mj_core::activity::ActivityState>,
543    /// What this session is doing, in the words the dashboard row uses:
544    /// `Turn 43m36s  Step 12s`, `BG 43m36s`, or `[idle]`.
545    #[serde(default, skip_serializing_if = "String::is_empty")]
546    pub activity: String,
547    /// The settings the harness advertised, with the values it accepts.
548    #[serde(default, skip_serializing_if = "Vec::is_empty")]
549    pub config_options: Vec<ViewerConfigOption>,
550    /// Whether plan mode is on, or `None` when this harness has no plan mode.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub plan_mode_active: Option<bool>,
553    /// The review the daemon is running for this session, if any. A phone
554    /// renders the same review the terminal does and resolves it the same way.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub turn_review: Option<ViewerTurnReview>,
557    /// The Mjolnir commands this session accepts, published rather than hardcoded
558    /// in the browser: a command list kept in two places is a command list that
559    /// drifts, which is how `/review` went missing from the phone.
560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
561    pub available_commands: Vec<ViewerMjCommand>,
562    pub capabilities: ViewerSessionCapabilities,
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567pub struct ViewerMoveRecovery {
568    pub operation_id: String,
569    pub source_profile_id: String,
570    pub source_target_template_id: String,
571    pub destination_profile_id: String,
572    pub destination_target_template_id: String,
573    pub phase: String,
574    pub queue: String,
575    pub clear_resource_allocation: bool,
576    /// The source settings are retained so Resume cannot silently inherit a
577    /// partially converted destination record after a failed Move.
578    #[serde(default)]
579    pub source_additional_mounts: Vec<AdditionalMount>,
580    #[serde(default)]
581    pub source_resource_allocation: Option<SessionResourceAllocation>,
582    /// The exact destination settings are needed when a queue admission
583    /// checkpoint pins retry to the already-provisioned destination.
584    #[serde(default)]
585    pub destination_additional_mounts: Vec<AdditionalMount>,
586    #[serde(default)]
587    pub destination_resource_allocation: Option<SessionResourceAllocation>,
588    /// The Move still has the checkpoint a retry restores, so Retry Move is
589    /// possible. Read from [`MoveOperation::checkpoint_retained`], the fact
590    /// the published recovery guidance is written from.
591    pub checkpoint_retained: bool,
592    /// The Move holds the source environment for a retry, so Resume is
593    /// refused: only Retry Move (when the checkpoint is retained) or Destroy
594    /// can act on the session.
595    #[serde(default)]
596    pub environment_retained: bool,
597    pub destination_ready: bool,
598    pub queue_admission_started: bool,
599    pub queue_admission_finished: bool,
600}
601
602impl ViewerMoveRecovery {
603    #[must_use]
604    pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
605        if matches!(operation.phase, MovePhase::Completed) {
606            return None;
607        }
608        Some(Self {
609            operation_id: operation.operation_id.clone(),
610            source_profile_id: operation.source_profile_id.clone(),
611            source_target_template_id: operation.source_target_template_id.clone(),
612            destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
613            destination_target_template_id: operation
614                .selection
615                .target_template_id
616                .clone()
617                .unwrap_or_default(),
618            phase: match operation.phase {
619                MovePhase::Preparing => "preparing",
620                MovePhase::ClosingSource => "closing_source",
621                MovePhase::ResumingDestination => "resuming_destination",
622                MovePhase::StartingQueue => "starting_queue",
623                MovePhase::Completed => "completed",
624                MovePhase::Failed => "failed",
625                MovePhase::Cancelled => "cancelled",
626            }
627            .into(),
628            queue: match operation.queue {
629                ResumeQueueDisposition::Start => "start",
630                ResumeQueueDisposition::Discard => "discard",
631            }
632            .into(),
633            clear_resource_allocation: operation.selection.clear_resource_allocation,
634            source_additional_mounts: operation.source_additional_mounts.clone(),
635            source_resource_allocation: operation.source_resource_allocation.clone(),
636            destination_additional_mounts: operation
637                .selection
638                .additional_mounts
639                .clone()
640                .unwrap_or_default(),
641            destination_resource_allocation: operation.selection.resource_allocation.clone(),
642            checkpoint_retained: operation.checkpoint_retained(),
643            environment_retained: operation.holds_source_environment(),
644            destination_ready: operation.destination_target.is_some()
645                && operation.destination_native_session_id.is_some(),
646            queue_admission_started: operation.queue_admission_started,
647            queue_admission_finished: operation.queue_admission_finished,
648        })
649    }
650}
651
652impl ViewerSession {
653    /// Apply a resolved controller source while keeping paths and remotes out
654    /// of the public projection.
655    pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
656        self.project_label = source.short.clone();
657        self.project_key = project_key(&source.key);
658    }
659}
660
661// One wire representation for the UI and native API activity facts.
662pub use crate::database::{
663    ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
664};
665
666/// One Mjolnir command a phone may offer for this session.
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668#[serde(deny_unknown_fields)]
669pub struct ViewerMjCommand {
670    pub name: String,
671    pub description: String,
672    /// Whether Mjolnir handles this command locally or forwards it to the
673    /// active agent.
674    pub source: ViewerCommandSource,
675    /// What the argument is called, when the command takes one.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub argument: Option<String>,
678}
679
680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "snake_case")]
682pub enum ViewerCommandSource {
683    Mj,
684    Agent,
685}
686
687/// Public review configuration: exactly what `/review status` needs.
688#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(deny_unknown_fields)]
690pub struct ViewerReviewConfig {
691    pub enabled: bool,
692    pub tier: String,
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub profile: Option<String>,
695}
696
697/// A turn review as a phone renders it.
698#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
699#[serde(deny_unknown_fields)]
700pub struct ViewerTurnReview {
701    /// `quick` or `extended`.
702    pub tier: String,
703    /// What the review is doing, in one line.
704    pub status: String,
705    /// One row per reviewing agent: its label and where it has got to.
706    #[serde(default, skip_serializing_if = "Vec::is_empty")]
707    pub roles: Vec<ViewerReviewRole>,
708    /// Present once the review has reached a verdict the user must answer.
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub verdict: Option<ViewerReviewVerdict>,
711}
712
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
714#[serde(deny_unknown_fields)]
715pub struct ViewerReviewRole {
716    pub label: String,
717    /// `pending`, `running`, `done`, `findings`, or `failed`.
718    pub state: String,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(deny_unknown_fields)]
723pub struct ViewerReviewVerdict {
724    /// `clean`, `findings`, or `failed`.
725    pub kind: String,
726    /// The findings, or the failure's reason.
727    pub text: String,
728    /// The resolutions this verdict accepts: `forward`, `dismiss`, `cancel`.
729    /// A phone shows the rest disabled rather than hiding them, so the buttons
730    /// do not move under a thumb.
731    #[serde(default, skip_serializing_if = "Vec::is_empty")]
732    pub allowed: Vec<String>,
733}
734
735impl ViewerTurnReview {
736    /// The phone's view of one review the daemon is running.
737    #[must_use]
738    pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
739        Self {
740            tier: review.tier.label().to_owned(),
741            status: review.status.clone(),
742            roles: review
743                .roles
744                .iter()
745                .map(|role| ViewerReviewRole {
746                    label: role.label.clone(),
747                    state: role.state.label().to_owned(),
748                })
749                .collect(),
750            verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
751                kind: match verdict.kind {
752                    crate::review_host::VerdictKind::Clean => "clean",
753                    crate::review_host::VerdictKind::Findings => "findings",
754                    crate::review_host::VerdictKind::Failed => "failed",
755                }
756                .to_owned(),
757                text: verdict.text.clone(),
758                allowed: verdict
759                    .allowed
760                    .iter()
761                    .filter_map(resolution_name)
762                    .map(str::to_owned)
763                    .collect(),
764            }),
765        }
766    }
767}
768
769/// The wire name of one resolution, shared by the projection and the action
770/// that performs it, so a button's name is the name the server accepts.
771#[must_use]
772pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
773    match resolution {
774        mj_core::review::driver::Resolution::Forwarded => Some("forward"),
775        mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
776        mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
777        // Not resolutions a surface asks for: the review reaches these itself.
778        mj_core::review::driver::Resolution::NothingToReview
779        | mj_core::review::driver::Resolution::CoverageStarted => None,
780    }
781}
782
783/// The resolution a phone's button asked for.
784#[must_use]
785pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
786    match name {
787        "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
788        "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
789        "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
790        _ => None,
791    }
792}
793
794#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct ViewerWorkspace {
797    pub id: String,
798    pub name: String,
799}
800
801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
802#[serde(deny_unknown_fields)]
803pub struct ViewerQueuedPrompt {
804    pub id: String,
805    pub text: String,
806    pub created_at: String,
807}
808
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810#[serde(deny_unknown_fields)]
811pub struct ViewerUserShell {
812    pub id: String,
813    pub command: String,
814    pub started_at_ms: Option<i64>,
815}
816
817/// One command the active agent left running in the background.
818#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(deny_unknown_fields)]
820pub struct ViewerBackgroundTask {
821    pub id: String,
822    pub command: String,
823    pub started_at_ms: i64,
824    pub can_stop: bool,
825}
826
827#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
828#[serde(deny_unknown_fields)]
829pub struct ViewerProfile {
830    pub id: String,
831    #[serde(default)]
832    pub subagent_discovery_key: String,
833    pub harness_kind: String,
834    #[serde(default)]
835    pub subagents: mj_core::subagent::SubagentPolicy,
836    #[serde(default, skip_serializing_if = "Option::is_none")]
837    pub quota: Option<ViewerQuota>,
838}
839
840/// One usage window a harness reports, such as a weekly or five-hour limit.
841///
842/// `percent_used` is the figure a person acts on, so it travels as a number
843/// rather than inside a sentence. The controller computes headroom; this is
844/// its complement, because a bar fills as a limit is consumed.
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846#[serde(deny_unknown_fields)]
847pub struct ViewerQuotaWindow {
848    pub label: String,
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub resets_at_epoch_seconds: Option<i64>,
851    #[serde(default)]
852    pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub banked_resets: Option<u64>,
855    #[serde(default, skip_serializing_if = "Option::is_none")]
856    pub percent_used: Option<u8>,
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub resets_at: Option<String>,
859    /// Whether this window is on course to run out before it resets. The
860    /// controller already computes this; a phone should not have to.
861    pub projects_exhaustion_before_reset: bool,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
865#[serde(deny_unknown_fields)]
866pub struct ViewerQuota {
867    /// One-line rendering, kept so a viewer cached from before the structured
868    /// windows existed keeps working. The Quota page renders `windows`.
869    pub summary: String,
870    #[serde(default, skip_serializing_if = "Vec::is_empty")]
871    pub windows: Vec<ViewerQuotaWindow>,
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub resets_at: Option<String>,
874    pub stale: bool,
875    /// When the reading was taken. A pulled view delivered by push cannot be
876    /// told from a current one without its age, so this is not optional.
877    #[serde(default)]
878    pub refreshed_at_epoch_seconds: u64,
879    /// The provider said to wait: no probe before this time. The windows are
880    /// the last good reading, taken at `refreshed_at_epoch_seconds`.
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub rate_limited_until_epoch_seconds: Option<u64>,
883    /// Error state only. Raw vendor errors may contain paths or account data
884    /// and remain on the controller.
885    pub has_error: bool,
886}
887
888/// What one host or fleet has, and how fresh the reading is.
889///
890/// Every field that carries a reading is optional, and `sampled_at_epoch_seconds`
891/// is present whenever any of them is: a reading without its age cannot be
892/// told from a stale one, which is exactly the case where it matters.
893#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
894#[serde(deny_unknown_fields)]
895pub struct ViewerTargetCapacity {
896    pub id: String,
897    /// The host or fleet as a person names it. Never a locator, an address or
898    /// a full path.
899    pub label: String,
900    pub target_ids: Vec<String>,
901    #[serde(default, skip_serializing_if = "Option::is_none")]
902    pub cpu_percent: Option<u8>,
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub memory_used_bytes: Option<u64>,
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub memory_total_bytes: Option<u64>,
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub logical_cores: Option<u64>,
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub disk_total_bytes: Option<u64>,
911    /// How many machines a fleet is running. Absent for a plain host.
912    #[serde(default, skip_serializing_if = "Option::is_none")]
913    pub virtual_machines: Option<u64>,
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub sampled_at_epoch_seconds: Option<u64>,
916    pub refreshing: bool,
917    pub stale: bool,
918    /// Whether the last probe failed. The probe's own message names hosts and
919    /// commands, so it stays on the controller.
920    pub has_error: bool,
921}
922
923fn unknown_availability() -> crate::server::api::LaunchAvailability {
924    crate::server::api::LaunchAvailability::Unknown
925}
926
927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
928#[serde(deny_unknown_fields)]
929pub struct ViewerTarget {
930    pub id: String,
931    pub kind: String,
932    pub requires_project_directory: bool,
933    /// Whether this target's runtime (Docker, Podman) is not installed on the
934    /// host running the daemon. That is permanent for the host, so pickers
935    /// leave the target out and a request that names it is refused. A host
936    /// that merely did not answer is not this; it stays listed.
937    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
938    pub runtime_missing: bool,
939    /// Whether this target is a default candidate that Mjolnir supplies
940    /// (`Config::with_local_targets`) and the user did not write in
941    /// `config.toml`. A default candidate whose runtime is missing is listed
942    /// nowhere; a configured one stays listed as unavailable.
943    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
944    pub default_candidate: bool,
945    /// Whether the host's last check answered, from the same classifier as
946    /// `/api/v1/options`. `Unknown` until the capacity poller has run.
947    #[serde(default = "unknown_availability")]
948    pub availability: crate::server::api::LaunchAvailability,
949    /// A short sentence for a person when `availability` is `Unavailable`.
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub unavailable_reason: Option<String>,
952    /// Recent raw project directories for this target's physical host. Managed
953    /// targets intentionally publish an empty list because they select a
954    /// configured bundle rather than a host checkout.
955    #[serde(default)]
956    pub recent_project_directories: Vec<String>,
957}
958
959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960#[serde(deny_unknown_fields)]
961pub struct ViewerBundle {
962    pub id: String,
963    pub primary_repository: String,
964    pub repositories: Vec<ViewerRepository>,
965}
966
967#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
968#[serde(deny_unknown_fields)]
969pub struct ViewerRepository {
970    pub id: String,
971    pub github: Option<String>,
972    pub destination: String,
973}
974
975/// What a phone may do with one session, as the controller sees it.
976///
977/// The viewer renders a control because a flag here is true, and for no other
978/// reason. Deciding legality in the browser means copying controller policy
979/// into JavaScript, where it drifts silently: the browser cannot know that a
980/// session is unmanaged, that a lifecycle operation holds it, or that the
981/// harness never advertised the option a control would change.
982#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
983#[serde(deny_unknown_fields)]
984pub struct ViewerSessionCapabilities {
985    #[serde(default)]
986    pub clear_context: bool,
987    pub open: bool,
988    pub prompt: bool,
989    pub run_shell: bool,
990    /// Cancel the turn the agent is working on now, leaving the session alive.
991    pub interrupt_turn: bool,
992    /// Cancel the provision, resume or stop currently running.
993    pub cancel_operation: bool,
994    pub suspend: bool,
995    pub destroy: bool,
996    pub rename: bool,
997    pub resume: bool,
998    /// Prepare and confirm a daemon-owned move to a compatible profile or
999    /// target. The browser must never compose Stop and Resume itself.
1000    #[serde(default)]
1001    pub move_session: bool,
1002    pub set_config: bool,
1003    pub set_plan_mode: bool,
1004}
1005
1006/// The small set of states a phone reasons about, alongside the precise state.
1007///
1008/// A phone groups and filters by this; it shows the precise `state` string as
1009/// the word it prints. Collapsing here rather than in the browser keeps one
1010/// definition of "live" in the controller.
1011#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1012#[serde(rename_all = "kebab-case")]
1013pub enum ViewerLifecycleCategory {
1014    Live,
1015    Starting,
1016    Suspending,
1017    Suspended,
1018    Failed,
1019}
1020
1021/// The published state of a session that has been provisioned and whose
1022/// start is still connecting its worker. Its record says disconnected, which
1023/// reads as a fault while it is only launching (F-12).
1024pub const LAUNCHING_STATE: &str = "launching";
1025
1026/// The name a published session goes by. It is the display title, except
1027/// that a session the harness has not named yet and nobody renamed would
1028/// otherwise be named by its id, which every listing already prints beside
1029/// it (F-12); the title it was created with says more.
1030fn public_title(session: &mj_core::state::SessionRecord) -> String {
1031    session.listed_title().to_owned()
1032}
1033
1034impl ViewerLifecycleCategory {
1035    pub(super) const fn of(state: SessionState) -> Self {
1036        match state {
1037            SessionState::Provisioning => Self::Starting,
1038            // A parked sub-agent stays on the dashboard with its parent.
1039            SessionState::Running
1040            | SessionState::Disconnected
1041            | SessionState::Checkpointing
1042            | SessionState::Parked => Self::Live,
1043            SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1044                Self::Suspending
1045            }
1046            SessionState::Stopped => Self::Suspended,
1047            SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1048                Self::Failed
1049            }
1050        }
1051    }
1052
1053    /// Whether this session belongs on the dashboard. Stopped and failed
1054    /// sessions belong to the resume flow instead, which is where a person can
1055    /// do something about them.
1056    pub const fn is_dashboard_visible(self) -> bool {
1057        matches!(self, Self::Live | Self::Starting | Self::Suspending)
1058    }
1059}
1060
1061#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(rename_all = "kebab-case")]
1063pub enum ViewerOperationKind {
1064    Create,
1065    Resume,
1066    Move,
1067    Suspend,
1068    Destroy,
1069    /// A sub-agent stopped because its parent is being suspended.
1070    Stop,
1071    Cleanup,
1072    Checkpoint,
1073}
1074
1075impl ViewerOperationKind {
1076    pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1077        match self {
1078            Self::Create => Some(SessionTransitionKind::Starting),
1079            Self::Resume => Some(SessionTransitionKind::Resuming),
1080            Self::Move => Some(SessionTransitionKind::Moving),
1081            Self::Suspend => Some(SessionTransitionKind::Suspending),
1082            Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1083            Self::Stop => Some(SessionTransitionKind::Stopping),
1084            // Checkpointing is an ordinary live-session operation. It must
1085            // not replace a readable conversation with a placeholder.
1086            Self::Checkpoint => None,
1087        }
1088    }
1089}
1090
1091/// One stage of a running operation, with the clock it started on.
1092#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093#[serde(deny_unknown_fields)]
1094pub struct ViewerOperationStage {
1095    pub label: String,
1096    pub started_at_epoch_seconds: u64,
1097}
1098
1099/// A provision, resume, stop or checkpoint the controller is running now.
1100///
1101/// A phone that asked for one of these got `202 Accepted` and an identifier
1102/// rather than a result, because the work outlives the request. This is how it
1103/// finds out what happened.
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1105#[serde(deny_unknown_fields)]
1106pub struct ViewerOperation {
1107    pub id: String,
1108    pub session_id: String,
1109    pub kind: ViewerOperationKind,
1110    pub started_at_epoch_seconds: u64,
1111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1112    pub stages: Vec<ViewerOperationStage>,
1113    /// Controller-authored and already meant for a person to read, unlike the
1114    /// error text this projection keeps on the controller.
1115    #[serde(default, skip_serializing_if = "Option::is_none")]
1116    pub notice: Option<String>,
1117    pub cancellable: bool,
1118}
1119
1120/// What the agent is doing, mirroring `RelayExecutionState`.
1121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1122#[serde(rename_all = "kebab-case")]
1123pub enum ViewerChatPhase {
1124    #[default]
1125    Idle,
1126    Running,
1127    Closing,
1128    Closed,
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1132#[serde(deny_unknown_fields)]
1133pub struct ViewerConfigChoice {
1134    pub value: String,
1135    pub name: String,
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub description: Option<String>,
1138}
1139
1140/// One setting the harness advertised, with the values it will accept.
1141///
1142/// The browser completes `/model` and `/effort` from this rather than from a
1143/// list of its own, so a harness that offers something new needs no viewer
1144/// change, and a viewer can never offer a value the harness would refuse.
1145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146#[serde(deny_unknown_fields)]
1147pub struct ViewerConfigOption {
1148    pub key: String,
1149    pub label: String,
1150    #[serde(default, skip_serializing_if = "Option::is_none")]
1151    pub current: Option<String>,
1152    pub choices: Vec<ViewerConfigChoice>,
1153}