1use super::*;
2
3#[cfg(test)]
4thread_local! {
5 static VIEWER_ROW_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
6}
7
8#[cfg(test)]
9pub(crate) fn take_viewer_row_visits() -> usize {
10 VIEWER_ROW_VISITS.with(|visits| visits.replace(0))
11}
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct ViewerSnapshot {
16 #[serde(default)]
17 pub profile_capabilities: mj_core::profile_capabilities::ProfileCapabilitiesSnapshot,
18 #[serde(default)]
19 pub last_subagent_policy: mj_core::subagent::SubagentPolicy,
20 pub revision: u64,
21 pub generated_at: String,
22 #[serde(default)]
25 pub server_time_ms: i64,
26 #[serde(default, skip_serializing_if = "String::is_empty")]
30 pub server_version: String,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub workspaces: Vec<ViewerWorkspace>,
33 pub sessions: ViewerSessions,
34 pub profiles: Vec<ViewerProfile>,
35 pub targets: Vec<ViewerTarget>,
36 pub bundles: Vec<ViewerBundle>,
37 #[serde(default)]
40 pub review_config: ViewerReviewConfig,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub capacity: Vec<ViewerTargetCapacity>,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub launch_failures: Vec<ViewerLaunchFailure>,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
52#[serde(from = "Vec<ViewerSession>", into = "Vec<ViewerSession>")]
53pub struct ViewerSessions(pub(crate) mj_core::snapshot_map::SnapshotMap<String, ViewerSession>);
54
55impl ViewerSessions {
56 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ViewerSession> + ExactSizeIterator {
57 self.0.values()
58 }
59
60 pub fn len(&self) -> usize {
61 self.0.len()
62 }
63 pub fn is_empty(&self) -> bool {
64 self.0.is_empty()
65 }
66 pub fn push(&mut self, session: ViewerSession) {
67 self.0.insert(session.id.clone(), session);
68 }
69}
70
71impl From<Vec<ViewerSession>> for ViewerSessions {
72 fn from(rows: Vec<ViewerSession>) -> Self {
73 rows.into_iter().collect()
74 }
75}
76
77impl From<ViewerSessions> for Vec<ViewerSession> {
78 fn from(rows: ViewerSessions) -> Self {
79 rows.0.into_values().collect()
80 }
81}
82
83impl FromIterator<ViewerSession> for ViewerSessions {
84 fn from_iter<T: IntoIterator<Item = ViewerSession>>(rows: T) -> Self {
85 Self(rows.into_iter().map(|row| (row.id.clone(), row)).collect())
86 }
87}
88
89impl IntoIterator for ViewerSessions {
90 type Item = ViewerSession;
91 type IntoIter = std::vec::IntoIter<ViewerSession>;
92 fn into_iter(self) -> Self::IntoIter {
93 Vec::from(self).into_iter()
94 }
95}
96
97impl std::ops::Index<usize> for ViewerSessions {
98 type Output = ViewerSession;
99 fn index(&self, index: usize) -> &Self::Output {
100 self.iter().nth(index).expect("viewer row index")
101 }
102}
103
104impl std::ops::IndexMut<usize> for ViewerSessions {
105 fn index_mut(&mut self, index: usize) -> &mut Self::Output {
106 let id = self.0.keys().nth(index).expect("viewer row index").clone();
107 self.0.get_mut(&id).expect("viewer row exists")
108 }
109}
110
111pub(crate) type ViewerChildren =
112 mj_core::snapshot_map::SnapshotMap<String, mj_core::snapshot_map::SnapshotMap<String, ()>>;
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ViewerLaunchFailure {
120 pub id: String,
123 pub workspace_id: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub session_id: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub error: Option<String>,
131}
132
133impl ViewerSnapshot {
134 pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
138 let mut children = ViewerChildren::new();
139 for relation in state.subagents.values() {
140 children
141 .entry(relation.parent_session_id.clone())
142 .or_insert_with(Default::default)
143 .insert(relation.child_session_id.clone(), ());
144 }
145 Self::from_config_records(config, state, revision, state.sessions.values(), &children)
146 }
147
148 pub(crate) fn from_config_records<'a>(
149 config: &Config,
150 state: &AppState,
151 revision: u64,
152 records: impl Iterator<Item = &'a mj_core::state::SessionRecord>,
153 children: &ViewerChildren,
154 ) -> Self {
155 let sessions = records
156 .map(|session| {
157 #[cfg(test)]
158 VIEWER_ROW_VISITS.with(|visits| visits.set(visits.get() + 1));
159 let resume_refusals = config
160 .targets
161 .keys()
162 .filter_map(|target_id| {
163 crate::controller::resume_compatibility(session, config, target_id)
164 .err()
165 .map(|reason| (target_id.clone(), reason))
166 })
167 .collect::<BTreeMap<_, _>>();
168 let incompatible = resume_refusals.keys().cloned().collect::<Vec<_>>();
169 let lifecycle = ViewerLifecycleCategory::of(session.state);
170 let project = state.project_identity_session(session);
174 let source = project.project_source(config);
175 let subagent = state.subagents.get(&session.id);
176 let subagent_session_ids = children
177 .get(&session.id)
178 .map(|children| children.keys().cloned().collect())
179 .unwrap_or_default();
180 let start = session.start_selection();
181 ViewerSession {
182 subagents: session.subagents.clone().unwrap_or_default(),
183 at: start.at,
184 branch: start.branch,
185 base: start.base,
186 targeted_turn_control_supported: false,
187 native_subagents: Vec::new(),
188 steering: None,
189 active_prompt_id: None,
190 cancelling_prompt_id: None,
191 capacity_retry: None,
192 retry_assessment_pending: false,
193 quota_recovery: None,
194 id: session.id.clone(),
195 publication_state: session.publication_state(),
196 managed_checkout_kind: session
197 .managed_worktree
198 .as_ref()
199 .map(|owned| owned.kind),
200 workspace_id: session.workspace_id.clone(),
201 title: public_title(session),
202 subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
203 subagent_task_name: subagent.map(|child| child.task_name.clone()),
204 subagent_session_ids,
205 harness_kind: session.harness_kind.id().into(),
206 profile_id: session.last_profile.clone(),
207 bundle_id: session.bundle_id.clone(),
208 target_id: session.target_template_id.clone(),
209 state: match session.state {
210 SessionState::Closing => "suspending",
211 SessionState::Stopped => "suspended",
212 _ => session.state.as_str(),
213 }
214 .into(),
215 created_at: session.created_at.clone(),
216 updated_at: session.updated_at.clone(),
217 has_error: session.last_error.is_some()
218 || session.configuration_issue(config).is_some(),
219 has_checkpoint: session.checkpoint.is_some(),
220 configuration_issue: session.configuration_issue(config),
221 launch_error: matches!(
236 session.state,
237 SessionState::Error | SessionState::Stopped
238 )
239 .then(|| session.last_error.clone())
240 .flatten()
241 .or_else(|| session.public_error().map(str::to_owned)),
242 storage_problem: None,
243 preview: Vec::new(),
244 queued_prompts: Vec::new(),
245 active_user_shells: Vec::new(),
246 background_tasks: Vec::new(),
247 pending_elicitations: Vec::new(),
248 conversation_available: false,
249 prompt_images_supported: false,
250 incompatible_resume_targets: incompatible.clone(),
251 resume_refusals,
252 compatible_resume_targets: config
253 .targets
254 .keys()
255 .filter(|target_id| !incompatible.contains(*target_id))
256 .cloned()
257 .collect(),
258 project_label: source.short,
259 project_key: project_key(&source.key),
260 display_location: project.project_target(config, &session.target_template_id),
261 container_cpus: session.container_cpus.clone(),
262 container_memory: session.container_memory.clone(),
263 additional_mounts: session.additional_mounts.clone(),
264 lifecycle,
265 transitioning: session.state.transition_kind().is_some(),
266 latest_event_ordinal: 0,
267 last_activity_at_ms: None,
268 last_message_at_ms: None,
269 activity_details: None,
270 activity: String::new(),
271 operation: None,
272 move_recovery: None,
273 chat_phase: ViewerChatPhase::default(),
276 is_idle: false,
277 activity_state: None,
278 config_options: Vec::new(),
279 plan_mode_active: None,
280 turn_review: None,
281 available_commands: Vec::new(),
282 capabilities: ViewerSessionCapabilities {
286 clear_context: false,
287 open: false,
288 prompt: false,
289 run_shell: false,
290 interrupt_turn: false,
291 cancel_operation: false,
292 suspend: lifecycle.is_dashboard_visible(),
293 destroy: true,
294 rename: true,
295 resume: !lifecycle.is_dashboard_visible(),
296 move_session: false,
297 set_config: false,
298 set_plan_mode: false,
299 change_workspace: false,
303 container_settings: false,
304 restart: false,
305 interrupt_all: false,
306 },
307 }
308 })
309 .collect();
310 let profiles = config
311 .enabled_profiles()
312 .map(|(id, profile)| ViewerProfile {
313 id: id.to_owned(),
314 harness_kind: profile.kind.id().into(),
315 subagents: profile.subagents.clone(),
316 subagent_discovery_key: config.subagent_discovery_key(id, None),
317 capabilities_key: profile.capabilities_key(id),
318 subagent_profile_ids: config
319 .enabled_profiles()
320 .filter(|(candidate, _)| config.subagents.profile_is_eligible(id, candidate))
321 .map(|(candidate, _)| candidate.to_owned())
322 .collect(),
323 quota: None,
324 })
325 .collect();
326 let targets = config
327 .targets
328 .iter()
329 .map(|(id, target)| ViewerTarget {
330 id: id.clone(),
331 kind: target.kind_name().into(),
332 resource_allocation_kind: target.into(),
333 requires_project_directory: matches!(
334 target,
335 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
336 ),
337 remembered_container_size: mj_core::config::container_size_host(target)
338 .and_then(|host| state.container_sizes.get(host))
339 .copied(),
340 container_host_limits: None,
341 default_resource_allocation: if mj_core::config::is_container_target(target) {
342 let size = mj_core::state::default_container_size(
343 mj_core::config::container_size_host(target)
344 .and_then(|host| state.container_sizes.get(host))
345 .copied(),
346 None,
347 );
348 Some(SessionResourceAllocation::Container {
349 cpus: size.cpus,
350 memory_bytes: size.memory_bytes,
351 })
352 } else {
353 None
354 },
355 runtime_missing: false,
356 default_candidate: config.is_default_target(id),
357 availability: crate::server::api::LaunchAvailability::Unknown,
358 unavailable_reason: None,
359 recent_project_directories: project_history_host(target)
360 .map(|host| {
361 state
362 .project_directories(host)
363 .iter()
364 .map(|directory| directory.to_string_lossy().into_owned())
365 .collect()
366 })
367 .unwrap_or_default(),
368 })
369 .collect();
370 let bundles = config
371 .bundles
372 .iter()
373 .map(|(id, bundle)| ViewerBundle {
374 id: id.clone(),
375 primary_repository: bundle.primary_repo.clone(),
376 repositories: bundle
377 .repositories
378 .iter()
379 .map(|repository| ViewerRepository {
380 id: repository.id.clone(),
381 github: repository.github.clone(),
382 destination: repository.destination.to_string_lossy().into_owned(),
383 })
384 .collect(),
385 })
386 .collect();
387 Self {
388 profile_capabilities: Default::default(),
389 last_subagent_policy: state.last_subagent_policy.clone(),
390 revision,
391 generated_at: now_unix().to_string(),
392 server_time_ms: mj_core::clock::epoch_millis(),
393 server_version: env!("CARGO_PKG_VERSION").to_owned(),
394 workspaces: Vec::new(),
395 sessions,
396 profiles,
397 targets,
398 bundles,
399 review_config: ViewerReviewConfig {
400 enabled: config.review.enabled,
401 profile: config.review.profile.clone(),
402 },
403 capacity: Vec::new(),
404 launch_failures: Vec::new(),
405 }
406 }
407}
408
409pub(super) fn project_key(identity: &str) -> String {
416 use sha2::Digest as _;
417 let digest = Sha256::digest(identity.as_bytes());
418 mj_core::hex::lower_hex(&digest[..8])
419}
420
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct ViewerSession {
424 #[serde(default)]
425 pub subagents: mj_core::subagent::SubagentPolicy,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub at: Option<String>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub branch: Option<String>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub base: Option<String>,
435 #[serde(default)]
436 pub targeted_turn_control_supported: bool,
437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
438 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub steering: Option<mj_core::relay::SteeringOperation>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub active_prompt_id: Option<String>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub cancelling_prompt_id: Option<String>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
447 #[serde(default)]
448 pub retry_assessment_pending: bool,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
451 pub id: String,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub publication_state: Option<mj_core::state::PublicationState>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
456 #[serde(default, skip_serializing_if = "String::is_empty")]
457 pub workspace_id: String,
458 pub title: String,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub subagent_parent_id: Option<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub subagent_task_name: Option<String>,
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 pub subagent_session_ids: Vec<String>,
468 pub harness_kind: String,
469 pub profile_id: String,
470 pub bundle_id: String,
471 pub target_id: String,
472 pub state: String,
473 pub created_at: String,
474 pub updated_at: String,
475 pub has_error: bool,
476 #[serde(default)]
482 pub has_checkpoint: bool,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub configuration_issue: Option<String>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub launch_error: Option<String>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub storage_problem: Option<String>,
497
498 #[serde(default, skip_serializing_if = "Vec::is_empty")]
499 pub preview: Vec<String>,
500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
501 pub queued_prompts: Vec<ViewerQueuedPrompt>,
502 #[serde(default, skip_serializing_if = "Vec::is_empty")]
503 pub active_user_shells: Vec<ViewerUserShell>,
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 pub background_tasks: Vec<ViewerBackgroundTask>,
506 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub pending_elicitations: Vec<ElicitationRequest>,
511 pub conversation_available: bool,
512 #[serde(default)]
516 pub prompt_images_supported: bool,
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
524 pub incompatible_resume_targets: Vec<String>,
525 #[serde(skip)]
530 pub resume_refusals: BTreeMap<String, String>,
531 #[serde(default, skip_serializing_if = "Vec::is_empty")]
534 pub compatible_resume_targets: Vec<String>,
535 #[serde(default, skip_serializing_if = "String::is_empty")]
538 pub project_label: String,
539 #[serde(default, skip_serializing_if = "String::is_empty")]
543 pub project_key: String,
544 #[serde(default)]
547 pub display_location: String,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub container_cpus: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub container_memory: Option<String>,
556 #[serde(default, skip_serializing_if = "Vec::is_empty")]
559 pub additional_mounts: Vec<AdditionalMount>,
560 pub lifecycle: ViewerLifecycleCategory,
561 #[serde(default)]
565 pub transitioning: bool,
566 #[serde(default)]
570 pub latest_event_ordinal: u64,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub last_activity_at_ms: Option<i64>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub last_message_at_ms: Option<i64>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub activity_details: Option<ViewerActivityDetails>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub operation: Option<ViewerOperation>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub move_recovery: Option<ViewerMoveRecovery>,
593 #[serde(default)]
594 pub chat_phase: ViewerChatPhase,
595 #[serde(default)]
598 pub is_idle: bool,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub activity_state: Option<mj_core::activity::ActivityState>,
605 #[serde(default, skip_serializing_if = "String::is_empty")]
608 pub activity: String,
609 #[serde(default, skip_serializing_if = "Vec::is_empty")]
611 pub config_options: Vec<ViewerConfigOption>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub plan_mode_active: Option<bool>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub turn_review: Option<ViewerTurnReview>,
619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
623 pub available_commands: Vec<ViewerMjCommand>,
624 pub capabilities: ViewerSessionCapabilities,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(deny_unknown_fields)]
629pub struct ViewerMoveRecovery {
630 pub operation_id: String,
631 pub source_profile_id: String,
632 pub source_target_template_id: String,
633 pub destination_profile_id: String,
634 pub destination_target_template_id: String,
635 pub phase: String,
636 pub queue: String,
637 pub clear_resource_allocation: bool,
638 #[serde(default)]
641 pub source_additional_mounts: Vec<AdditionalMount>,
642 #[serde(default)]
643 pub source_resource_allocation: Option<SessionResourceAllocation>,
644 #[serde(default)]
647 pub destination_additional_mounts: Vec<AdditionalMount>,
648 #[serde(default)]
649 pub destination_resource_allocation: Option<SessionResourceAllocation>,
650 pub checkpoint_retained: bool,
654 #[serde(default)]
658 pub environment_retained: bool,
659 pub destination_ready: bool,
660 pub queue_admission_started: bool,
661 pub queue_admission_finished: bool,
662}
663
664impl ViewerMoveRecovery {
665 #[must_use]
666 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
667 if matches!(operation.phase, MovePhase::Completed) {
668 return None;
669 }
670 Some(Self {
671 operation_id: operation.operation_id.clone(),
672 source_profile_id: operation.source_profile_id.clone(),
673 source_target_template_id: operation.source_target_template_id.clone(),
674 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
675 destination_target_template_id: operation
676 .selection
677 .target_template_id
678 .clone()
679 .unwrap_or_default(),
680 phase: match operation.phase {
681 MovePhase::Preparing => "preparing",
682 MovePhase::ClosingSource => "closing_source",
683 MovePhase::ResumingDestination => "resuming_destination",
684 MovePhase::StartingQueue => "starting_queue",
685 MovePhase::Completed => "completed",
686 MovePhase::Failed => "failed",
687 MovePhase::Cancelled => "cancelled",
688 }
689 .into(),
690 queue: match operation.queue {
691 ResumeQueueDisposition::Start => "start",
692 ResumeQueueDisposition::Discard => "discard",
693 }
694 .into(),
695 clear_resource_allocation: operation.selection.clear_resource_allocation,
696 source_additional_mounts: operation.source_additional_mounts.clone(),
697 source_resource_allocation: operation.source_resource_allocation.clone(),
698 destination_additional_mounts: operation
699 .selection
700 .additional_mounts
701 .clone()
702 .unwrap_or_default(),
703 destination_resource_allocation: operation.selection.resource_allocation.clone(),
704 checkpoint_retained: operation.checkpoint_retained(),
705 environment_retained: operation.holds_source_environment(),
706 destination_ready: operation.destination_target.is_some()
707 && operation.destination_native_session_id.is_some(),
708 queue_admission_started: operation.queue_admission_started,
709 queue_admission_finished: operation.queue_admission_finished,
710 })
711 }
712}
713
714impl ViewerSession {
715 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
718 self.project_label = source.short.clone();
719 self.project_key = project_key(&source.key);
720 }
721}
722
723pub use crate::database::{
725 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
726};
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(deny_unknown_fields)]
731pub struct ViewerMjCommand {
732 pub name: String,
733 pub description: String,
734 pub source: ViewerCommandSource,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub argument: Option<String>,
740}
741
742#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
743#[serde(rename_all = "snake_case")]
744pub enum ViewerCommandSource {
745 Mj,
746 Agent,
747}
748
749#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(deny_unknown_fields)]
752pub struct ViewerReviewConfig {
753 pub enabled: bool,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub profile: Option<String>,
756}
757
758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct ViewerTurnReview {
762 pub status: String,
764 #[serde(default, skip_serializing_if = "Vec::is_empty")]
766 pub roles: Vec<ViewerReviewRole>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub verdict: Option<ViewerReviewVerdict>,
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct ViewerReviewRole {
775 pub label: String,
776 pub state: String,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[serde(deny_unknown_fields)]
782pub struct ViewerReviewVerdict {
783 pub kind: String,
785 pub text: String,
787 #[serde(default, skip_serializing_if = "Vec::is_empty")]
791 pub allowed: Vec<String>,
792}
793
794impl ViewerTurnReview {
795 #[must_use]
797 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
798 Self {
799 status: review.status.clone(),
800 roles: review
801 .roles
802 .iter()
803 .map(|role| ViewerReviewRole {
804 label: role.label.clone(),
805 state: role.state.label().to_owned(),
806 })
807 .collect(),
808 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
809 kind: match verdict.kind {
810 crate::review_host::VerdictKind::Clean => "clean",
811 crate::review_host::VerdictKind::Findings => "findings",
812 crate::review_host::VerdictKind::Failed => "failed",
813 }
814 .to_owned(),
815 text: verdict.text.clone(),
816 allowed: verdict
817 .allowed
818 .iter()
819 .filter_map(resolution_name)
820 .map(str::to_owned)
821 .collect(),
822 }),
823 }
824 }
825}
826
827#[must_use]
830pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
831 match resolution {
832 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
833 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
834 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
835 mj_core::review::driver::Resolution::NothingToReview
837 | mj_core::review::driver::Resolution::CoverageStarted => None,
838 }
839}
840
841#[must_use]
843pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
844 match name {
845 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
846 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
847 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
848 _ => None,
849 }
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
853#[serde(deny_unknown_fields)]
854pub struct ViewerWorkspace {
855 pub id: String,
856 pub name: String,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
860#[serde(deny_unknown_fields)]
861pub struct ViewerQueuedPrompt {
862 pub id: String,
863 pub text: String,
864 pub created_at: String,
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
868#[serde(deny_unknown_fields)]
869pub struct ViewerUserShell {
870 pub id: String,
871 pub command: String,
872 pub started_at_ms: Option<i64>,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
877#[serde(deny_unknown_fields)]
878pub struct ViewerBackgroundTask {
879 pub id: String,
880 pub command: String,
881 pub started_at_ms: i64,
882 pub can_stop: bool,
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
886#[serde(deny_unknown_fields)]
887pub struct ViewerProfile {
888 pub id: String,
889 #[serde(default)]
890 pub subagent_discovery_key: String,
891 #[serde(default)]
892 pub capabilities_key: String,
893 #[serde(default)]
894 pub subagent_profile_ids: Vec<String>,
895 pub harness_kind: String,
896 #[serde(default)]
897 pub subagents: mj_core::subagent::SubagentPolicy,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub quota: Option<ViewerQuota>,
900}
901
902#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
908#[serde(deny_unknown_fields)]
909pub struct ViewerQuotaWindow {
910 pub label: String,
911 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub resets_at_epoch_seconds: Option<i64>,
913 #[serde(default)]
914 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub banked_resets: Option<u64>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub percent_used: Option<u8>,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
920 pub resets_at: Option<String>,
921 pub projects_exhaustion_before_reset: bool,
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
927#[serde(deny_unknown_fields)]
928pub struct ViewerQuota {
929 pub summary: String,
932 #[serde(default, skip_serializing_if = "Vec::is_empty")]
933 pub windows: Vec<ViewerQuotaWindow>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
935 pub resets_at: Option<String>,
936 pub stale: bool,
937 #[serde(default)]
940 pub refreshed_at_epoch_seconds: u64,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub rate_limited_until_epoch_seconds: Option<u64>,
945 pub has_error: bool,
948}
949
950#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
956#[serde(deny_unknown_fields)]
957pub struct ViewerTargetCapacity {
958 pub id: String,
959 pub label: String,
962 pub target_ids: Vec<String>,
963 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub cpu_percent: Option<u8>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
966 pub memory_used_bytes: Option<u64>,
967 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub memory_total_bytes: Option<u64>,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
970 pub logical_cores: Option<u64>,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
972 pub disk_total_bytes: Option<u64>,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub virtual_machines: Option<u64>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub sampled_at_epoch_seconds: Option<u64>,
978 pub refreshing: bool,
979 pub stale: bool,
980 pub has_error: bool,
983 #[serde(default, skip_serializing_if = "Vec::is_empty")]
986 pub storage: Vec<ViewerTargetStorage>,
987}
988
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
990#[serde(deny_unknown_fields)]
991pub struct ViewerTargetStorage {
992 #[serde(default, skip_serializing_if = "Option::is_none")]
994 pub machine: Option<String>,
995 pub mount: String,
996 pub available_bytes: u64,
997 pub reserved_bytes: u64,
999 pub condition: mj_core::targets::storage::StorageCondition,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub detail: Option<String>,
1003}
1004
1005impl ViewerTargetStorage {
1006 pub fn list(views: &[&mj_core::targets::storage::TargetStorageView], fleet: bool) -> Vec<Self> {
1007 use mj_core::targets::storage::StorageCondition;
1008 views
1009 .iter()
1010 .flat_map(|view| {
1011 view.filesystems.iter().map(move |filesystem| Self {
1012 machine: fleet.then(|| view.host.clone()),
1013 mount: filesystem.space.mount.clone(),
1014 available_bytes: filesystem.space.available_bytes,
1015 reserved_bytes: filesystem.space.reserved_bytes,
1016 condition: filesystem.condition,
1017 detail: (filesystem.condition != StorageCondition::Ok)
1018 .then(|| view.explanation(filesystem)),
1019 })
1020 })
1021 .collect()
1022 }
1023}
1024
1025fn unknown_availability() -> crate::server::api::LaunchAvailability {
1026 crate::server::api::LaunchAvailability::Unknown
1027}
1028
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030#[serde(deny_unknown_fields)]
1031pub struct ViewerTarget {
1032 pub id: String,
1033 pub kind: String,
1034 #[serde(default)]
1036 pub resource_allocation_kind: ResourceAllocationKind,
1037 pub requires_project_directory: bool,
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1040 pub remembered_container_size: Option<HostContainerSize>,
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub container_host_limits: Option<HostContainerSize>,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub default_resource_allocation: Option<SessionResourceAllocation>,
1047 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1052 pub runtime_missing: bool,
1053 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1058 pub default_candidate: bool,
1059 #[serde(default = "unknown_availability")]
1062 pub availability: crate::server::api::LaunchAvailability,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub unavailable_reason: Option<String>,
1066 #[serde(default)]
1070 pub recent_project_directories: Vec<String>,
1071}
1072
1073#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1075#[serde(deny_unknown_fields)]
1076pub struct ViewerTargetResourceOptions {
1077 pub options: Vec<SessionResourceAllocation>,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub default_allocation: Option<SessionResourceAllocation>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub struct ViewerBundle {
1085 pub id: String,
1086 pub primary_repository: String,
1087 pub repositories: Vec<ViewerRepository>,
1088}
1089
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091#[serde(deny_unknown_fields)]
1092pub struct ViewerRepository {
1093 pub id: String,
1094 pub github: Option<String>,
1095 pub destination: String,
1096}
1097
1098#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1106#[serde(deny_unknown_fields)]
1107pub struct ViewerSessionCapabilities {
1108 #[serde(default)]
1109 pub clear_context: bool,
1110 pub open: bool,
1111 pub prompt: bool,
1112 pub run_shell: bool,
1113 pub interrupt_turn: bool,
1115 pub cancel_operation: bool,
1117 pub suspend: bool,
1118 pub destroy: bool,
1119 pub rename: bool,
1120 pub resume: bool,
1121 #[serde(default)]
1124 pub move_session: bool,
1125 pub set_config: bool,
1126 pub set_plan_mode: bool,
1127 #[serde(default)]
1129 pub change_workspace: bool,
1130 #[serde(default)]
1134 pub container_settings: bool,
1135 #[serde(default)]
1139 pub restart: bool,
1140 #[serde(default)]
1143 pub interrupt_all: bool,
1144}
1145
1146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1152#[serde(rename_all = "kebab-case")]
1153pub enum ViewerLifecycleCategory {
1154 Live,
1155 Starting,
1156 Suspending,
1157 Suspended,
1158 Failed,
1159}
1160
1161pub const LAUNCHING_STATE: &str = "launching";
1165
1166fn public_title(session: &mj_core::state::SessionRecord) -> String {
1171 session.listed_title().to_owned()
1172}
1173
1174impl ViewerLifecycleCategory {
1175 pub(super) const fn of(state: SessionState) -> Self {
1176 match state {
1177 SessionState::Provisioning => Self::Starting,
1178 SessionState::Running
1180 | SessionState::Disconnected
1181 | SessionState::Checkpointing
1182 | SessionState::Parked => Self::Live,
1183 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1184 Self::Suspending
1185 }
1186 SessionState::Stopped => Self::Suspended,
1187 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1188 Self::Failed
1189 }
1190 }
1191 }
1192
1193 pub const fn is_dashboard_visible(self) -> bool {
1197 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1198 }
1199}
1200
1201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1202#[serde(rename_all = "kebab-case")]
1203pub enum ViewerOperationKind {
1204 Create,
1205 Resume,
1206 Move,
1207 Suspend,
1208 Destroy,
1209 Stop,
1211 Cleanup,
1212 Checkpoint,
1213}
1214
1215impl ViewerOperationKind {
1216 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1217 match self {
1218 Self::Create => Some(SessionTransitionKind::Starting),
1219 Self::Resume => Some(SessionTransitionKind::Resuming),
1220 Self::Move => Some(SessionTransitionKind::Moving),
1221 Self::Suspend => Some(SessionTransitionKind::Suspending),
1222 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1223 Self::Stop => Some(SessionTransitionKind::Stopping),
1224 Self::Checkpoint => None,
1227 }
1228 }
1229}
1230
1231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1233#[serde(deny_unknown_fields)]
1234pub struct ViewerOperationStage {
1235 pub label: String,
1236 pub started_at_epoch_seconds: u64,
1237}
1238
1239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1245#[serde(deny_unknown_fields)]
1246pub struct ViewerOperation {
1247 pub id: String,
1248 pub session_id: String,
1249 pub kind: ViewerOperationKind,
1250 pub started_at_epoch_seconds: u64,
1251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1252 pub stages: Vec<ViewerOperationStage>,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub notice: Option<String>,
1257 pub cancellable: bool,
1258}
1259
1260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "kebab-case")]
1263pub enum ViewerChatPhase {
1264 #[default]
1265 Idle,
1266 Running,
1267 Closing,
1268 Closed,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1272#[serde(deny_unknown_fields)]
1273pub struct ViewerConfigChoice {
1274 pub value: String,
1275 pub name: String,
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1277 pub description: Option<String>,
1278}
1279
1280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1286#[serde(deny_unknown_fields)]
1287pub struct ViewerConfigOption {
1288 pub key: String,
1289 pub label: String,
1290 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub current: Option<String>,
1292 pub choices: Vec<ViewerConfigChoice>,
1293}