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