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