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