Skip to main content

mj_controller/server/
viewer_types.rs

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