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                profile: config.review.profile.clone(),
402            },
403            capacity: Vec::new(),
404            launch_failures: Vec::new(),
405        }
406    }
407}
408
409/// A stable, opaque grouping key for a project.
410///
411/// The controller's own project identity is a bundle, filesystem path, or Git
412/// remote, and this projection publishes neither. A digest groups exactly as
413/// well and says nothing: two sessions in the same project share a key, and a
414/// key on its own reveals no source.
415pub(super) fn project_key(identity: &str) -> String {
416    use sha2::Digest as _;
417    let digest = Sha256::digest(identity.as_bytes());
418    mj_core::hex::lower_hex(&digest[..8])
419}
420
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct ViewerSession {
424    #[serde(default)]
425    pub subagents: mj_core::subagent::SubagentPolicy,
426    /// Commit the workspace started checked out at, when one was named.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub at: Option<String>,
429    /// Branch created at `at`, or the existing branch checked out without it.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub branch: Option<String>,
432    /// Diff base the session was started with; `at` unless another was named.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub base: Option<String>,
435    #[serde(default)]
436    pub targeted_turn_control_supported: bool,
437    #[serde(default, skip_serializing_if = "Vec::is_empty")]
438    pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub steering: Option<mj_core::relay::SteeringOperation>,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub active_prompt_id: Option<String>,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub cancelling_prompt_id: Option<String>,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
447    #[serde(default)]
448    pub retry_assessment_pending: bool,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
451    pub id: String,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub publication_state: Option<mj_core::state::PublicationState>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
456    #[serde(default, skip_serializing_if = "String::is_empty")]
457    pub workspace_id: String,
458    pub title: String,
459    /// Parent ownership for a borrowed-target child session.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub subagent_parent_id: Option<String>,
462    /// The stable task label chosen by the parent when it spawned this child.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub subagent_task_name: Option<String>,
465    /// Direct children of this parent. Children are deliberately never nested.
466    #[serde(default, skip_serializing_if = "Vec::is_empty")]
467    pub subagent_session_ids: Vec<String>,
468    pub harness_kind: String,
469    pub profile_id: String,
470    pub bundle_id: String,
471    pub target_id: String,
472    pub state: String,
473    pub created_at: String,
474    pub updated_at: String,
475    pub has_error: bool,
476    /// Whether the session has a checkpoint to resume from. A resume restores
477    /// a checkpoint and nothing else, so a session without one, such as a
478    /// launch that failed before its first, cannot be resumed (launch finding
479    /// R6-1). Only the fact travels; the archive's path stays on the
480    /// controller.
481    #[serde(default)]
482    pub has_checkpoint: bool,
483    /// Public identifiers and repair guidance only; never raw runtime errors.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub configuration_issue: Option<String>,
486    /// Why a launch failed, for a session that ended in the error state. This
487    /// is the same provisioning error text `last_error` already publishes
488    /// through `mj events`, surfaced here so `mj sessions`/`mj wait` can show
489    /// the reason instead of a bare "failed to launch".
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub launch_error: Option<String>,
492    /// "disk full: precision-3260 has 0 B free on / …" while the disk this
493    /// session's target writes to is full. The daemon's storage owner decides;
494    /// automatic recovery waits on the same fact.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub storage_problem: Option<String>,
497
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub preview: Vec<String>,
500    #[serde(default, skip_serializing_if = "Vec::is_empty")]
501    pub queued_prompts: Vec<ViewerQueuedPrompt>,
502    #[serde(default, skip_serializing_if = "Vec::is_empty")]
503    pub active_user_shells: Vec<ViewerUserShell>,
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub background_tasks: Vec<ViewerBackgroundTask>,
506    /// Form questions the session is blocked on, published so a phone can
507    /// answer them. These are the agent's own questions, already visible in
508    /// the transcript, so they travel whole rather than redacted.
509    #[serde(default, skip_serializing_if = "Vec::is_empty")]
510    pub pending_elicitations: Vec<ElicitationRequest>,
511    pub conversation_available: bool,
512    /// Whether this session's agent advertised support for image content in
513    /// prompts. The viewer offers the image controls only when it did, and the
514    /// server refuses images for a session that did not.
515    #[serde(default)]
516    pub prompt_images_supported: bool,
517    /// Target ids this session cannot resume on. Only the ids travel: the
518    /// controller's reasons name project paths and SSH hosts, which this
519    /// projection deliberately keeps on the controller.
520    ///
521    /// Retained beside `compatible_resume_targets` so a viewer cached from
522    /// before that field existed keeps working through a deployment.
523    #[serde(default, skip_serializing_if = "Vec::is_empty")]
524    pub incompatible_resume_targets: Vec<String>,
525    /// The controller's reason for each id in `incompatible_resume_targets`.
526    /// It names project paths and SSH hosts, so it is never serialized: only
527    /// in-process validation reads it, to tell the caller why a resume or move
528    /// was refused.
529    #[serde(skip)]
530    pub resume_refusals: BTreeMap<String, String>,
531    /// Target ids this session can resume on, so the browser never has to
532    /// subtract one set from another to find out.
533    #[serde(default, skip_serializing_if = "Vec::is_empty")]
534    pub compatible_resume_targets: Vec<String>,
535    /// The canonical short source label for this session: a bundle name, path
536    /// leaf, or repository name, never a source path itself.
537    #[serde(default, skip_serializing_if = "String::is_empty")]
538    pub project_label: String,
539    /// A stable key for grouping sessions by project. The controller's own
540    /// source identity stays private, so what travels is a digest of it:
541    /// enough to group by, and nothing to read.
542    #[serde(default, skip_serializing_if = "String::is_empty")]
543    pub project_key: String,
544    /// The configured target's human-facing project location. This is the
545    /// same target projection the terminal uses while a session is running.
546    #[serde(default)]
547    pub display_location: String,
548    /// Per-session container CPU limit overriding the target template's. It
549    /// is applied the next time the session's container is created.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub container_cpus: Option<String>,
552    /// Per-session container memory limit overriding the target template's.
553    /// It is applied the next time the session's container is created.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub container_memory: Option<String>,
556    /// Extra directories attached to this session's container, in the same
557    /// persisted shape the terminal's container dialog edits.
558    #[serde(default, skip_serializing_if = "Vec::is_empty")]
559    pub additional_mounts: Vec<AdditionalMount>,
560    pub lifecycle: ViewerLifecycleCategory,
561    /// A lifecycle transition temporarily owns this session's conversation.
562    /// This remains separate from the coarse lifecycle category so Move can
563    /// hide the old transcript while its durable record is still `Running`.
564    #[serde(default)]
565    pub transitioning: bool,
566    /// How far the controller's projection of this session has advanced. A
567    /// phone compares it against its own read frontier to know what is unread,
568    /// without fetching a transcript to find out.
569    #[serde(default)]
570    pub latest_event_ordinal: u64,
571    /// Durable relay receipt watermark from the materialized projection.
572    /// It remains absent when the background snapshot pipeline has not yet
573    /// delivered a projection for this session.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub last_activity_at_ms: Option<i64>,
576    /// When the newest top-level user or agent message in the browser
577    /// transcript was recorded, in epoch milliseconds. Tool calls and status
578    /// lines do not move it. Absent when the transcript holds no such message
579    /// with a recording time.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub last_message_at_ms: Option<i64>,
582    /// Structured live activity, absent when no operational relay snapshot is
583    /// available for this session.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub activity_details: Option<ViewerActivityDetails>,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub operation: Option<ViewerOperation>,
588    /// Safe recovery choices for a failed or cancelled Move. Diagnostics and
589    /// checkpoint paths remain on the controller; this contains only the
590    /// settings a person may choose again.
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub move_recovery: Option<ViewerMoveRecovery>,
593    #[serde(default)]
594    pub chat_phase: ViewerChatPhase,
595    /// Known live activity is idle: no foreground turn, tool, or background work.
596    /// Missing operational state must not be presented as confirmed idle.
597    #[serde(default)]
598    pub is_idle: bool,
599    /// What this session is doing, in the shared vocabulary every part of
600    /// Mjolnir now uses. Richer than `chat_phase`, which has only four values
601    /// and must keep them: this can also say that the daemon cannot see the
602    /// worker and report what was last known about it.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub activity_state: Option<mj_core::activity::ActivityState>,
605    /// What this session is doing, in the words the dashboard row uses:
606    /// `Turn 43m36s  Step 12s`, `BG 43m36s`, or `[idle]`.
607    #[serde(default, skip_serializing_if = "String::is_empty")]
608    pub activity: String,
609    /// The settings the harness advertised, with the values it accepts.
610    #[serde(default, skip_serializing_if = "Vec::is_empty")]
611    pub config_options: Vec<ViewerConfigOption>,
612    /// Whether plan mode is on, or `None` when this harness has no plan mode.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub plan_mode_active: Option<bool>,
615    /// The review the daemon is running for this session, if any. A phone
616    /// renders the same review the terminal does and resolves it the same way.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub turn_review: Option<ViewerTurnReview>,
619    /// The Mjolnir commands this session accepts, published rather than hardcoded
620    /// in the browser: a command list kept in two places is a command list that
621    /// drifts, which is how `/review` went missing from the phone.
622    #[serde(default, skip_serializing_if = "Vec::is_empty")]
623    pub available_commands: Vec<ViewerMjCommand>,
624    pub capabilities: ViewerSessionCapabilities,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(deny_unknown_fields)]
629pub struct ViewerMoveRecovery {
630    pub operation_id: String,
631    pub source_profile_id: String,
632    pub source_target_template_id: String,
633    pub destination_profile_id: String,
634    pub destination_target_template_id: String,
635    pub phase: String,
636    pub queue: String,
637    pub clear_resource_allocation: bool,
638    /// The source settings are retained so Resume cannot silently inherit a
639    /// partially converted destination record after a failed Move.
640    #[serde(default)]
641    pub source_additional_mounts: Vec<AdditionalMount>,
642    #[serde(default)]
643    pub source_resource_allocation: Option<SessionResourceAllocation>,
644    /// The exact destination settings are needed when a queue admission
645    /// checkpoint pins retry to the already-provisioned destination.
646    #[serde(default)]
647    pub destination_additional_mounts: Vec<AdditionalMount>,
648    #[serde(default)]
649    pub destination_resource_allocation: Option<SessionResourceAllocation>,
650    /// The Move still has the checkpoint a retry restores, so Retry Move is
651    /// possible. Read from [`MoveOperation::checkpoint_retained`], the fact
652    /// the published recovery guidance is written from.
653    pub checkpoint_retained: bool,
654    /// The Move holds the source environment for a retry, so Resume is
655    /// refused: only Retry Move (when the checkpoint is retained) or Destroy
656    /// can act on the session.
657    #[serde(default)]
658    pub environment_retained: bool,
659    pub destination_ready: bool,
660    pub queue_admission_started: bool,
661    pub queue_admission_finished: bool,
662}
663
664impl ViewerMoveRecovery {
665    #[must_use]
666    pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
667        if matches!(operation.phase, MovePhase::Completed) {
668            return None;
669        }
670        Some(Self {
671            operation_id: operation.operation_id.clone(),
672            source_profile_id: operation.source_profile_id.clone(),
673            source_target_template_id: operation.source_target_template_id.clone(),
674            destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
675            destination_target_template_id: operation
676                .selection
677                .target_template_id
678                .clone()
679                .unwrap_or_default(),
680            phase: match operation.phase {
681                MovePhase::Preparing => "preparing",
682                MovePhase::ClosingSource => "closing_source",
683                MovePhase::ResumingDestination => "resuming_destination",
684                MovePhase::StartingQueue => "starting_queue",
685                MovePhase::Completed => "completed",
686                MovePhase::Failed => "failed",
687                MovePhase::Cancelled => "cancelled",
688            }
689            .into(),
690            queue: match operation.queue {
691                ResumeQueueDisposition::Start => "start",
692                ResumeQueueDisposition::Discard => "discard",
693            }
694            .into(),
695            clear_resource_allocation: operation.selection.clear_resource_allocation,
696            source_additional_mounts: operation.source_additional_mounts.clone(),
697            source_resource_allocation: operation.source_resource_allocation.clone(),
698            destination_additional_mounts: operation
699                .selection
700                .additional_mounts
701                .clone()
702                .unwrap_or_default(),
703            destination_resource_allocation: operation.selection.resource_allocation.clone(),
704            checkpoint_retained: operation.checkpoint_retained(),
705            environment_retained: operation.holds_source_environment(),
706            destination_ready: operation.destination_target.is_some()
707                && operation.destination_native_session_id.is_some(),
708            queue_admission_started: operation.queue_admission_started,
709            queue_admission_finished: operation.queue_admission_finished,
710        })
711    }
712}
713
714impl ViewerSession {
715    /// Apply a resolved controller source while keeping paths and remotes out
716    /// of the public projection.
717    pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
718        self.project_label = source.short.clone();
719        self.project_key = project_key(&source.key);
720    }
721}
722
723// One wire representation for the UI and native API activity facts.
724pub use crate::database::{
725    ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
726};
727
728/// One Mjolnir command a phone may offer for this session.
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(deny_unknown_fields)]
731pub struct ViewerMjCommand {
732    pub name: String,
733    pub description: String,
734    /// Whether Mjolnir handles this command locally or forwards it to the
735    /// active agent.
736    pub source: ViewerCommandSource,
737    /// What the argument is called, when the command takes one.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub argument: Option<String>,
740}
741
742#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
743#[serde(rename_all = "snake_case")]
744pub enum ViewerCommandSource {
745    Mj,
746    Agent,
747}
748
749/// Public review configuration: exactly what `/review status` needs.
750#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(deny_unknown_fields)]
752pub struct ViewerReviewConfig {
753    pub enabled: bool,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub profile: Option<String>,
756}
757
758/// A turn review as a phone renders it.
759#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct ViewerTurnReview {
762    /// What the review is doing, in one line.
763    pub status: String,
764    /// One row per reviewing agent: its label and where it has got to.
765    #[serde(default, skip_serializing_if = "Vec::is_empty")]
766    pub roles: Vec<ViewerReviewRole>,
767    /// Present once the review has reached a verdict the user must answer.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub verdict: Option<ViewerReviewVerdict>,
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct ViewerReviewRole {
775    pub label: String,
776    /// `pending`, `running`, `done`, `findings`, or `failed`.
777    pub state: String,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[serde(deny_unknown_fields)]
782pub struct ViewerReviewVerdict {
783    /// `clean`, `findings`, or `failed`.
784    pub kind: String,
785    /// The findings, or the failure's reason.
786    pub text: String,
787    /// The resolutions this verdict accepts: `forward`, `dismiss`, `cancel`.
788    /// A phone shows the rest disabled rather than hiding them, so the buttons
789    /// do not move under a thumb.
790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
791    pub allowed: Vec<String>,
792}
793
794impl ViewerTurnReview {
795    /// The phone's view of one review the daemon is running.
796    #[must_use]
797    pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
798        Self {
799            status: review.status.clone(),
800            roles: review
801                .roles
802                .iter()
803                .map(|role| ViewerReviewRole {
804                    label: role.label.clone(),
805                    state: role.state.label().to_owned(),
806                })
807                .collect(),
808            verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
809                kind: match verdict.kind {
810                    crate::review_host::VerdictKind::Clean => "clean",
811                    crate::review_host::VerdictKind::Findings => "findings",
812                    crate::review_host::VerdictKind::Failed => "failed",
813                }
814                .to_owned(),
815                text: verdict.text.clone(),
816                allowed: verdict
817                    .allowed
818                    .iter()
819                    .filter_map(resolution_name)
820                    .map(str::to_owned)
821                    .collect(),
822            }),
823        }
824    }
825}
826
827/// The wire name of one resolution, shared by the projection and the action
828/// that performs it, so a button's name is the name the server accepts.
829#[must_use]
830pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
831    match resolution {
832        mj_core::review::driver::Resolution::Forwarded => Some("forward"),
833        mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
834        mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
835        // Not resolutions a surface asks for: the review reaches these itself.
836        mj_core::review::driver::Resolution::NothingToReview
837        | mj_core::review::driver::Resolution::CoverageStarted => None,
838    }
839}
840
841/// The resolution a phone's button asked for.
842#[must_use]
843pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
844    match name {
845        "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
846        "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
847        "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
848        _ => None,
849    }
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853#[serde(deny_unknown_fields)]
854pub struct ViewerWorkspace {
855    pub id: String,
856    pub name: String,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860#[serde(deny_unknown_fields)]
861pub struct ViewerQueuedPrompt {
862    pub id: String,
863    pub text: String,
864    pub created_at: String,
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
868#[serde(deny_unknown_fields)]
869pub struct ViewerUserShell {
870    pub id: String,
871    pub command: String,
872    pub started_at_ms: Option<i64>,
873}
874
875/// One command the active agent left running in the background.
876#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
877#[serde(deny_unknown_fields)]
878pub struct ViewerBackgroundTask {
879    pub id: String,
880    pub command: String,
881    pub started_at_ms: i64,
882    pub can_stop: bool,
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
886#[serde(deny_unknown_fields)]
887pub struct ViewerProfile {
888    pub id: String,
889    #[serde(default)]
890    pub subagent_discovery_key: String,
891    #[serde(default)]
892    pub capabilities_key: String,
893    #[serde(default)]
894    pub subagent_profile_ids: Vec<String>,
895    pub harness_kind: String,
896    #[serde(default)]
897    pub subagents: mj_core::subagent::SubagentPolicy,
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    pub quota: Option<ViewerQuota>,
900}
901
902/// One usage window a harness reports, such as a weekly or five-hour limit.
903///
904/// `percent_used` is the figure a person acts on, so it travels as a number
905/// rather than inside a sentence. The controller computes headroom; this is
906/// its complement, because a bar fills as a limit is consumed.
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
908#[serde(deny_unknown_fields)]
909pub struct ViewerQuotaWindow {
910    pub label: String,
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub resets_at_epoch_seconds: Option<i64>,
913    #[serde(default)]
914    pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub banked_resets: Option<u64>,
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub percent_used: Option<u8>,
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub resets_at: Option<String>,
921    /// Whether this window is on course to run out before it resets. The
922    /// controller already computes this; a phone should not have to.
923    pub projects_exhaustion_before_reset: bool,
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
927#[serde(deny_unknown_fields)]
928pub struct ViewerQuota {
929    /// One-line rendering, kept so a viewer cached from before the structured
930    /// windows existed keeps working. The Quota page renders `windows`.
931    pub summary: String,
932    #[serde(default, skip_serializing_if = "Vec::is_empty")]
933    pub windows: Vec<ViewerQuotaWindow>,
934    #[serde(default, skip_serializing_if = "Option::is_none")]
935    pub resets_at: Option<String>,
936    pub stale: bool,
937    /// When the reading was taken. A pulled view delivered by push cannot be
938    /// told from a current one without its age, so this is not optional.
939    #[serde(default)]
940    pub refreshed_at_epoch_seconds: u64,
941    /// The provider said to wait: no probe before this time. The windows are
942    /// the last good reading, taken at `refreshed_at_epoch_seconds`.
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub rate_limited_until_epoch_seconds: Option<u64>,
945    /// Error state only. Raw vendor errors may contain paths or account data
946    /// and remain on the controller.
947    pub has_error: bool,
948}
949
950/// What one host or fleet has, and how fresh the reading is.
951///
952/// Every field that carries a reading is optional, and `sampled_at_epoch_seconds`
953/// is present whenever any of them is: a reading without its age cannot be
954/// told from a stale one, which is exactly the case where it matters.
955#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
956#[serde(deny_unknown_fields)]
957pub struct ViewerTargetCapacity {
958    pub id: String,
959    /// The host or fleet as a person names it. Never a locator, an address or
960    /// a full path.
961    pub label: String,
962    pub target_ids: Vec<String>,
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub cpu_percent: Option<u8>,
965    #[serde(default, skip_serializing_if = "Option::is_none")]
966    pub memory_used_bytes: Option<u64>,
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub memory_total_bytes: Option<u64>,
969    #[serde(default, skip_serializing_if = "Option::is_none")]
970    pub logical_cores: Option<u64>,
971    #[serde(default, skip_serializing_if = "Option::is_none")]
972    pub disk_total_bytes: Option<u64>,
973    /// How many machines a fleet is running. Absent for a plain host.
974    #[serde(default, skip_serializing_if = "Option::is_none")]
975    pub virtual_machines: Option<u64>,
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub sampled_at_epoch_seconds: Option<u64>,
978    pub refreshing: bool,
979    pub stale: bool,
980    /// Whether the last probe failed. The probe's own message names hosts and
981    /// commands, so it stays on the controller.
982    pub has_error: bool,
983    /// The storage owner's verdict on each filesystem Mjolnir writes to
984    /// here; for a fleet, on each instance.
985    #[serde(default, skip_serializing_if = "Vec::is_empty")]
986    pub storage: Vec<ViewerTargetStorage>,
987}
988
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
990#[serde(deny_unknown_fields)]
991pub struct ViewerTargetStorage {
992    /// The machine, for a fleet; absent for a plain host.
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub machine: Option<String>,
995    pub mount: String,
996    pub available_bytes: u64,
997    /// Free bytes the filesystem keeps for root, which Mjolnir cannot use.
998    pub reserved_bytes: u64,
999    pub condition: mj_core::targets::storage::StorageCondition,
1000    /// The sentence a full or low filesystem owes the person.
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub detail: Option<String>,
1003}
1004
1005impl ViewerTargetStorage {
1006    pub fn list(views: &[&mj_core::targets::storage::TargetStorageView], fleet: bool) -> Vec<Self> {
1007        use mj_core::targets::storage::StorageCondition;
1008        views
1009            .iter()
1010            .flat_map(|view| {
1011                view.filesystems.iter().map(move |filesystem| Self {
1012                    machine: fleet.then(|| view.host.clone()),
1013                    mount: filesystem.space.mount.clone(),
1014                    available_bytes: filesystem.space.available_bytes,
1015                    reserved_bytes: filesystem.space.reserved_bytes,
1016                    condition: filesystem.condition,
1017                    detail: (filesystem.condition != StorageCondition::Ok)
1018                        .then(|| view.explanation(filesystem)),
1019                })
1020            })
1021            .collect()
1022    }
1023}
1024
1025fn unknown_availability() -> crate::server::api::LaunchAvailability {
1026    crate::server::api::LaunchAvailability::Unknown
1027}
1028
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030#[serde(deny_unknown_fields)]
1031pub struct ViewerTarget {
1032    pub id: String,
1033    pub kind: String,
1034    /// Target sizing class, used for create validation and the size picker.
1035    #[serde(default)]
1036    pub resource_allocation_kind: ResourceAllocationKind,
1037    pub requires_project_directory: bool,
1038    /// Size last selected for this target's physical container host.
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub remembered_container_size: Option<HostContainerSize>,
1041    /// The latest reported host CPU and memory totals, when available.
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub container_host_limits: Option<HostContainerSize>,
1044    /// Shared default selected for this container target.
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub default_resource_allocation: Option<SessionResourceAllocation>,
1047    /// Whether this target's runtime (Docker, Podman) is not installed on the
1048    /// host running the daemon. That is permanent for the host, so pickers
1049    /// leave the target out and a request that names it is refused. A host
1050    /// that merely did not answer is not this; it stays listed.
1051    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1052    pub runtime_missing: bool,
1053    /// Whether this target is a default candidate that Mjolnir supplies
1054    /// (`Config::with_local_targets`) and the user did not write in
1055    /// `config.toml`. A default candidate whose runtime is missing is listed
1056    /// nowhere; a configured one stays listed as unavailable.
1057    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1058    pub default_candidate: bool,
1059    /// Whether the host's last check answered, from the same classifier as
1060    /// `/api/v1/options`. `Unknown` until the capacity poller has run.
1061    #[serde(default = "unknown_availability")]
1062    pub availability: crate::server::api::LaunchAvailability,
1063    /// A short sentence for a person when `availability` is `Unavailable`.
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub unavailable_reason: Option<String>,
1066    /// Recent raw project directories for this target's physical host. Managed
1067    /// targets intentionally publish an empty list because they select a
1068    /// configured bundle rather than a host checkout.
1069    #[serde(default)]
1070    pub recent_project_directories: Vec<String>,
1071}
1072
1073/// EC2 size choices resolved for one target's launch template.
1074#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1075#[serde(deny_unknown_fields)]
1076pub struct ViewerTargetResourceOptions {
1077    pub options: Vec<SessionResourceAllocation>,
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub default_allocation: Option<SessionResourceAllocation>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub struct ViewerBundle {
1085    pub id: String,
1086    pub primary_repository: String,
1087    pub repositories: Vec<ViewerRepository>,
1088}
1089
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091#[serde(deny_unknown_fields)]
1092pub struct ViewerRepository {
1093    pub id: String,
1094    pub github: Option<String>,
1095    pub destination: String,
1096}
1097
1098/// What a phone may do with one session, as the controller sees it.
1099///
1100/// The viewer renders a control because a flag here is true, and for no other
1101/// reason. Deciding legality in the browser means copying controller policy
1102/// into JavaScript, where it drifts silently: the browser cannot know that a
1103/// session is unmanaged, that a lifecycle operation holds it, or that the
1104/// harness never advertised the option a control would change.
1105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1106#[serde(deny_unknown_fields)]
1107pub struct ViewerSessionCapabilities {
1108    #[serde(default)]
1109    pub clear_context: bool,
1110    pub open: bool,
1111    pub prompt: bool,
1112    pub run_shell: bool,
1113    /// Cancel the turn the agent is working on now, leaving the session alive.
1114    pub interrupt_turn: bool,
1115    /// Cancel the provision, resume or stop currently running.
1116    pub cancel_operation: bool,
1117    pub suspend: bool,
1118    pub destroy: bool,
1119    pub rename: bool,
1120    pub resume: bool,
1121    /// Prepare and confirm a daemon-owned move to a compatible profile or
1122    /// target. The browser must never compose Stop and Resume itself.
1123    #[serde(default)]
1124    pub move_session: bool,
1125    pub set_config: bool,
1126    pub set_plan_mode: bool,
1127    /// Move the session, and the sub-agents under it, to another workspace.
1128    #[serde(default)]
1129    pub change_workspace: bool,
1130    /// Record the session's container size overrides and attached
1131    /// directories. Only a session whose target runs in a container has
1132    /// these, and they apply the next time its container is created.
1133    #[serde(default)]
1134    pub container_settings: bool,
1135    /// Suspend the session if it is live and immediately resume it with the
1136    /// profile, target and mounts it last ran with. A suspended session needs
1137    /// a recovery copy to come back from.
1138    #[serde(default)]
1139    pub restart: bool,
1140    /// Stop the turn of this session and of every sub-agent under it,
1141    /// leaving the sessions alive.
1142    #[serde(default)]
1143    pub interrupt_all: bool,
1144}
1145
1146/// The small set of states a phone reasons about, alongside the precise state.
1147///
1148/// A phone groups and filters by this; it shows the precise `state` string as
1149/// the word it prints. Collapsing here rather than in the browser keeps one
1150/// definition of "live" in the controller.
1151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1152#[serde(rename_all = "kebab-case")]
1153pub enum ViewerLifecycleCategory {
1154    Live,
1155    Starting,
1156    Suspending,
1157    Suspended,
1158    Failed,
1159}
1160
1161/// The published state of a session that has been provisioned and whose
1162/// start is still connecting its worker. Its record says disconnected, which
1163/// reads as a fault while it is only launching (F-12).
1164pub const LAUNCHING_STATE: &str = "launching";
1165
1166/// The name a published session goes by. It is the display title, except
1167/// that a session the harness has not named yet and nobody renamed would
1168/// otherwise be named by its id, which every listing already prints beside
1169/// it (F-12); the title it was created with says more.
1170fn public_title(session: &mj_core::state::SessionRecord) -> String {
1171    session.listed_title().to_owned()
1172}
1173
1174impl ViewerLifecycleCategory {
1175    pub(super) const fn of(state: SessionState) -> Self {
1176        match state {
1177            SessionState::Provisioning => Self::Starting,
1178            // A parked sub-agent stays on the dashboard with its parent.
1179            SessionState::Running
1180            | SessionState::Disconnected
1181            | SessionState::Checkpointing
1182            | SessionState::Parked => Self::Live,
1183            SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1184                Self::Suspending
1185            }
1186            SessionState::Stopped => Self::Suspended,
1187            SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1188                Self::Failed
1189            }
1190        }
1191    }
1192
1193    /// Whether this session belongs on the dashboard. Stopped and failed
1194    /// sessions belong to the resume flow instead, which is where a person can
1195    /// do something about them.
1196    pub const fn is_dashboard_visible(self) -> bool {
1197        matches!(self, Self::Live | Self::Starting | Self::Suspending)
1198    }
1199}
1200
1201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1202#[serde(rename_all = "kebab-case")]
1203pub enum ViewerOperationKind {
1204    Create,
1205    Resume,
1206    Move,
1207    Suspend,
1208    Destroy,
1209    /// A sub-agent stopped because its parent is being suspended.
1210    Stop,
1211    Cleanup,
1212    Checkpoint,
1213}
1214
1215impl ViewerOperationKind {
1216    pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1217        match self {
1218            Self::Create => Some(SessionTransitionKind::Starting),
1219            Self::Resume => Some(SessionTransitionKind::Resuming),
1220            Self::Move => Some(SessionTransitionKind::Moving),
1221            Self::Suspend => Some(SessionTransitionKind::Suspending),
1222            Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1223            Self::Stop => Some(SessionTransitionKind::Stopping),
1224            // Checkpointing is an ordinary live-session operation. It must
1225            // not replace a readable conversation with a placeholder.
1226            Self::Checkpoint => None,
1227        }
1228    }
1229}
1230
1231/// One stage of a running operation, with the clock it started on.
1232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1233#[serde(deny_unknown_fields)]
1234pub struct ViewerOperationStage {
1235    pub label: String,
1236    pub started_at_epoch_seconds: u64,
1237}
1238
1239/// A provision, resume, stop or checkpoint the controller is running now.
1240///
1241/// A phone that asked for one of these got `202 Accepted` and an identifier
1242/// rather than a result, because the work outlives the request. This is how it
1243/// finds out what happened.
1244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1245#[serde(deny_unknown_fields)]
1246pub struct ViewerOperation {
1247    pub id: String,
1248    pub session_id: String,
1249    pub kind: ViewerOperationKind,
1250    pub started_at_epoch_seconds: u64,
1251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1252    pub stages: Vec<ViewerOperationStage>,
1253    /// Controller-authored and already meant for a person to read, unlike the
1254    /// error text this projection keeps on the controller.
1255    #[serde(default, skip_serializing_if = "Option::is_none")]
1256    pub notice: Option<String>,
1257    pub cancellable: bool,
1258}
1259
1260/// What the agent is doing, mirroring `RelayExecutionState`.
1261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "kebab-case")]
1263pub enum ViewerChatPhase {
1264    #[default]
1265    Idle,
1266    Running,
1267    Closing,
1268    Closed,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1272#[serde(deny_unknown_fields)]
1273pub struct ViewerConfigChoice {
1274    pub value: String,
1275    pub name: String,
1276    #[serde(default, skip_serializing_if = "Option::is_none")]
1277    pub description: Option<String>,
1278}
1279
1280/// One setting the harness advertised, with the values it will accept.
1281///
1282/// The browser completes `/model` and `/effort` from this rather than from a
1283/// list of its own, so a harness that offers something new needs no viewer
1284/// change, and a viewer can never offer a value the harness would refuse.
1285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1286#[serde(deny_unknown_fields)]
1287pub struct ViewerConfigOption {
1288    pub key: String,
1289    pub label: String,
1290    #[serde(default, skip_serializing_if = "Option::is_none")]
1291    pub current: Option<String>,
1292    pub choices: Vec<ViewerConfigChoice>,
1293}