Skip to main content

mj_controller/server/
viewer_types.rs

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