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