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