Skip to main content

mj_controller/server/
viewer_types.rs

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