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