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