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(skip, default = "default_agent_mailboxes_enabled")]
19 pub(crate) agent_mailboxes_enabled: bool,
20 #[serde(default)]
21 pub profile_capabilities: mj_core::profile_capabilities::ProfileCapabilitiesSnapshot,
22 #[serde(
23 default,
24 deserialize_with = "mj_core::subagent::deserialize_launch_policy"
25 )]
26 pub last_subagent_policy: mj_core::subagent::SubagentPolicy,
27 #[serde(default)]
28 pub subagents_configured: bool,
29 pub revision: u64,
30 pub generated_at: String,
31 #[serde(default)]
34 pub server_time_ms: i64,
35 #[serde(default, skip_serializing_if = "String::is_empty")]
39 pub server_version: String,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub workspaces: Vec<ViewerWorkspace>,
42 pub sessions: ViewerSessions,
43 pub profiles: Vec<ViewerProfile>,
44 pub targets: Vec<ViewerTarget>,
45 pub bundles: Vec<ViewerBundle>,
46 #[serde(default)]
49 pub review_config: ViewerReviewConfig,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub capacity: Vec<ViewerTargetCapacity>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub launch_failures: Vec<ViewerLaunchFailure>,
57}
58
59fn default_agent_mailboxes_enabled() -> bool {
60 true
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
65#[serde(from = "Vec<ViewerSession>", into = "Vec<ViewerSession>")]
66pub struct ViewerSessions(pub(crate) mj_core::snapshot_map::SnapshotMap<String, ViewerSession>);
67
68impl ViewerSessions {
69 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ViewerSession> + ExactSizeIterator {
70 self.0.values()
71 }
72
73 pub fn len(&self) -> usize {
74 self.0.len()
75 }
76 pub fn is_empty(&self) -> bool {
77 self.0.is_empty()
78 }
79 pub fn push(&mut self, session: ViewerSession) {
80 self.0.insert(session.id.clone(), session);
81 }
82}
83
84impl From<Vec<ViewerSession>> for ViewerSessions {
85 fn from(rows: Vec<ViewerSession>) -> Self {
86 rows.into_iter().collect()
87 }
88}
89
90impl From<ViewerSessions> for Vec<ViewerSession> {
91 fn from(rows: ViewerSessions) -> Self {
92 rows.0.into_values().collect()
93 }
94}
95
96impl FromIterator<ViewerSession> for ViewerSessions {
97 fn from_iter<T: IntoIterator<Item = ViewerSession>>(rows: T) -> Self {
98 Self(rows.into_iter().map(|row| (row.id.clone(), row)).collect())
99 }
100}
101
102impl IntoIterator for ViewerSessions {
103 type Item = ViewerSession;
104 type IntoIter = std::vec::IntoIter<ViewerSession>;
105 fn into_iter(self) -> Self::IntoIter {
106 Vec::from(self).into_iter()
107 }
108}
109
110impl std::ops::Index<usize> for ViewerSessions {
111 type Output = ViewerSession;
112 fn index(&self, index: usize) -> &Self::Output {
113 self.iter().nth(index).expect("viewer row index")
114 }
115}
116
117impl std::ops::IndexMut<usize> for ViewerSessions {
118 fn index_mut(&mut self, index: usize) -> &mut Self::Output {
119 let id = self.0.keys().nth(index).expect("viewer row index").clone();
120 self.0.get_mut(&id).expect("viewer row exists")
121 }
122}
123
124pub(crate) type ViewerChildren =
125 mj_core::snapshot_map::SnapshotMap<String, mj_core::snapshot_map::SnapshotMap<String, ()>>;
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct ViewerLaunchFailure {
133 pub id: String,
136 pub workspace_id: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub session_id: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub error: Option<String>,
144}
145
146impl ViewerSnapshot {
147 pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
151 let mut children = ViewerChildren::new();
152 for relation in state.subagents.values() {
153 children
154 .entry(relation.parent_session_id.clone())
155 .or_insert_with(Default::default)
156 .insert(relation.child_session_id.clone(), ());
157 }
158 Self::from_config_records(config, state, revision, state.sessions.values(), &children)
159 }
160
161 pub(crate) fn from_config_records<'a>(
162 config: &Config,
163 state: &AppState,
164 revision: u64,
165 records: impl Iterator<Item = &'a mj_core::state::SessionRecord>,
166 children: &ViewerChildren,
167 ) -> Self {
168 let sessions = records
169 .map(|session| {
170 #[cfg(test)]
171 VIEWER_ROW_VISITS.with(|visits| visits.set(visits.get() + 1));
172 let checkout = state
173 .checkout(&session.id)
174 .expect("viewer session has a valid checkout owner");
175 let resume_refusals = config
176 .targets
177 .keys()
178 .filter_map(|target_id| {
179 crate::controller::resume_compatibility_with_checkout(
180 session, &checkout, config, target_id,
181 )
182 .err()
183 .map(|reason| (target_id.clone(), reason))
184 })
185 .collect::<BTreeMap<_, _>>();
186 let incompatible = resume_refusals.keys().cloned().collect::<Vec<_>>();
187 let lifecycle = ViewerLifecycleCategory::of(session.state);
188 let project = state.project_identity_session(session);
192 let source = project.project_source(config);
193 let subagent = state.subagents.get(&session.id);
194 let subagent_session_ids = children
195 .get(&session.id)
196 .map(|children| children.keys().cloned().collect())
197 .unwrap_or_default();
198 let start = session.start_selection();
199 ViewerSession {
200 subagents: session.subagents.clone().unwrap_or_default(),
201 at: start.at,
202 branch: start.branch,
203 base: start.base,
204 targeted_turn_control_supported: false,
205 native_subagents: Vec::new(),
206 steering: None,
207 active_prompt_id: None,
208 cancelling_prompt_id: None,
209 capacity_retry: None,
210 retry_assessment_pending: false,
211 quota_recovery: None,
212 is_subagent_session: state.is_subagent_session(&session.id),
213 no_mailbox: session.no_mailbox,
214 id: session.id.clone(),
215 publication_state: session.publication_state(),
216 managed_checkout_kind: checkout.managed_worktree().map(|owned| owned.kind),
217 workspace_id: session.workspace_id.clone(),
218 title: public_title(session),
219 subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
220 subagent_task_name: subagent.map(|child| child.task_name.clone()),
221 subagent_session_ids,
222 harness_kind: session.harness_kind.id().into(),
223 profile_id: session.last_profile.clone(),
224 bundle_id: session.bundle_id.clone(),
225 target_id: session.target_template_id.clone(),
226 state: match session.state {
227 SessionState::Closing => "suspending",
228 SessionState::Stopped => "suspended",
229 _ => session.state.as_str(),
230 }
231 .into(),
232 created_at: session.created_at.clone(),
233 updated_at: session.updated_at.clone(),
234 has_error: session.last_error.is_some()
235 || session.configuration_issue(config).is_some(),
236 has_checkpoint: session.checkpoint.is_some(),
237 configuration_issue: session.configuration_issue(config),
238 launch_error: matches!(
253 session.state,
254 SessionState::Error | SessionState::Stopped
255 )
256 .then(|| session.last_error.clone())
257 .flatten()
258 .or_else(|| session.public_error().map(str::to_owned)),
259 storage_problem: None,
260 preview: Vec::new(),
261 queued_prompts: Vec::new(),
262 active_user_shells: Vec::new(),
263 background_tasks: Vec::new(),
264 pending_elicitations: Vec::new(),
265 conversation_available: false,
266 prompt_images_supported: false,
267 incompatible_resume_targets: incompatible.clone(),
268 resume_refusals,
269 compatible_resume_targets: config
270 .targets
271 .keys()
272 .filter(|target_id| !incompatible.contains(*target_id))
273 .cloned()
274 .collect(),
275 project_label: source.short,
276 project_key: project_key(&source.key),
277 display_location: project.project_target(config, &session.target_template_id),
278 container_cpus: session.container_cpus.clone(),
279 container_memory: session.container_memory.clone(),
280 additional_mounts: session.additional_mounts.clone(),
281 lifecycle,
282 transitioning: session.state.transition_kind().is_some(),
283 latest_event_ordinal: 0,
284 last_activity_at_ms: None,
285 last_message_at_ms: None,
286 activity_details: None,
287 activity: String::new(),
288 operation: None,
289 move_recovery: None,
290 chat_phase: ViewerChatPhase::default(),
293 is_idle: false,
294 activity_state: None,
295 config_options: Vec::new(),
296 plan_mode_active: None,
297 turn_review: None,
298 available_commands: Vec::new(),
299 capabilities: ViewerSessionCapabilities {
303 clear_context: false,
304 open: false,
305 prompt: false,
306 run_shell: false,
307 interrupt_turn: false,
308 cancel_operation: false,
309 suspend: lifecycle.is_dashboard_visible(),
310 destroy: true,
311 rename: true,
312 resume: !lifecycle.is_dashboard_visible(),
313 move_session: false,
314 set_config: false,
315 set_plan_mode: false,
316 change_workspace: false,
320 container_settings: false,
321 restart: false,
322 interrupt_all: false,
323 },
324 }
325 })
326 .collect();
327 let profiles = config
328 .enabled_profiles()
329 .map(|(id, profile)| ViewerProfile {
330 id: id.to_owned(),
331 harness_kind: profile.kind.id().into(),
332 capabilities_key: profile.capabilities_key(id),
333 quota: None,
334 })
335 .collect();
336 let targets = config
337 .targets
338 .iter()
339 .map(|(id, target)| ViewerTarget {
340 id: id.clone(),
341 kind: target.kind_name().into(),
342 resource_allocation_kind: target.into(),
343 requires_project_directory: matches!(
344 target,
345 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
346 ),
347 remembered_container_size: mj_core::config::container_size_host(target)
348 .and_then(|host| state.container_sizes.get(host))
349 .copied(),
350 container_host_limits: None,
351 default_resource_allocation: if mj_core::config::is_container_target(target) {
352 let size = mj_core::state::default_container_size(
353 mj_core::config::container_size_host(target)
354 .and_then(|host| state.container_sizes.get(host))
355 .copied(),
356 None,
357 );
358 Some(SessionResourceAllocation::Container {
359 cpus: size.cpus,
360 memory_bytes: size.memory_bytes,
361 })
362 } else {
363 None
364 },
365 runtime_missing: false,
366 default_candidate: config.is_default_target(id),
367 availability: crate::server::api::LaunchAvailability::Unknown,
368 unavailable_reason: None,
369 recent_project_directories: project_history_host(target)
370 .map(|host| {
371 state
372 .project_directories(host)
373 .iter()
374 .map(|directory| directory.to_string_lossy().into_owned())
375 .collect()
376 })
377 .unwrap_or_default(),
378 })
379 .collect();
380 let bundles = config
381 .bundles
382 .iter()
383 .map(|(id, bundle)| ViewerBundle {
384 id: id.clone(),
385 primary_repository: bundle.primary_repo.clone(),
386 repositories: bundle
387 .repositories
388 .iter()
389 .map(|repository| ViewerRepository {
390 id: repository.id.clone(),
391 github: repository.github.clone(),
392 destination: repository.destination.to_string_lossy().into_owned(),
393 })
394 .collect(),
395 })
396 .collect();
397 Self {
398 agent_mailboxes_enabled: config.agent_mailboxes_enabled(),
399 profile_capabilities: Default::default(),
400 last_subagent_policy: state.last_subagent_policy.clone(),
401 subagents_configured: config.subagents.is_configured(),
402 revision,
403 generated_at: now_unix().to_string(),
404 server_time_ms: mj_core::clock::epoch_millis(),
405 server_version: env!("CARGO_PKG_VERSION").to_owned(),
406 workspaces: Vec::new(),
407 sessions,
408 profiles,
409 targets,
410 bundles,
411 review_config: ViewerReviewConfig {
412 enabled: config.review.enabled,
413 profile: config.review.profile.clone(),
414 },
415 capacity: Vec::new(),
416 launch_failures: Vec::new(),
417 }
418 }
419}
420
421pub(super) fn project_key(identity: &str) -> String {
428 use sha2::Digest as _;
429 let digest = Sha256::digest(identity.as_bytes());
430 mj_core::hex::lower_hex(&digest[..8])
431}
432
433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
434#[serde(deny_unknown_fields)]
435pub struct ViewerSession {
436 #[serde(skip, default)]
439 pub(crate) is_subagent_session: bool,
440 #[serde(skip, default)]
443 pub(crate) no_mailbox: bool,
444 #[serde(default)]
445 pub subagents: mj_core::subagent::SubagentPolicy,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub at: Option<String>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub branch: Option<String>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub base: Option<String>,
455 #[serde(default)]
456 pub targeted_turn_control_supported: bool,
457 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub steering: Option<mj_core::relay::SteeringOperation>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub active_prompt_id: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub cancelling_prompt_id: Option<String>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
467 #[serde(default)]
468 pub retry_assessment_pending: bool,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
471 pub id: String,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub publication_state: Option<mj_core::state::PublicationState>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
476 #[serde(default, skip_serializing_if = "String::is_empty")]
477 pub workspace_id: String,
478 pub title: String,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub subagent_parent_id: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub subagent_task_name: Option<String>,
485 #[serde(default, skip_serializing_if = "Vec::is_empty")]
487 pub subagent_session_ids: Vec<String>,
488 pub harness_kind: String,
489 pub profile_id: String,
490 pub bundle_id: String,
491 pub target_id: String,
492 pub state: String,
493 pub created_at: String,
494 pub updated_at: String,
495 pub has_error: bool,
496 #[serde(default)]
502 pub has_checkpoint: bool,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub configuration_issue: Option<String>,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub launch_error: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub storage_problem: Option<String>,
517
518 #[serde(default, skip_serializing_if = "Vec::is_empty")]
519 pub preview: Vec<String>,
520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
521 pub queued_prompts: Vec<ViewerQueuedPrompt>,
522 #[serde(default, skip_serializing_if = "Vec::is_empty")]
523 pub active_user_shells: Vec<ViewerUserShell>,
524 #[serde(default, skip_serializing_if = "Vec::is_empty")]
525 pub background_tasks: Vec<ViewerBackgroundTask>,
526 #[serde(default, skip_serializing_if = "Vec::is_empty")]
530 pub pending_elicitations: Vec<ElicitationRequest>,
531 pub conversation_available: bool,
532 #[serde(default)]
536 pub prompt_images_supported: bool,
537 #[serde(default, skip_serializing_if = "Vec::is_empty")]
544 pub incompatible_resume_targets: Vec<String>,
545 #[serde(skip)]
550 pub resume_refusals: BTreeMap<String, String>,
551 #[serde(default, skip_serializing_if = "Vec::is_empty")]
554 pub compatible_resume_targets: Vec<String>,
555 #[serde(default, skip_serializing_if = "String::is_empty")]
558 pub project_label: String,
559 #[serde(default, skip_serializing_if = "String::is_empty")]
563 pub project_key: String,
564 #[serde(default)]
567 pub display_location: String,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub container_cpus: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub container_memory: Option<String>,
576 #[serde(default, skip_serializing_if = "Vec::is_empty")]
579 pub additional_mounts: Vec<AdditionalMount>,
580 pub lifecycle: ViewerLifecycleCategory,
581 #[serde(default)]
585 pub transitioning: bool,
586 #[serde(default)]
590 pub latest_event_ordinal: u64,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub last_activity_at_ms: Option<i64>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub last_message_at_ms: Option<i64>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub activity_details: Option<ViewerActivityDetails>,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub operation: Option<ViewerOperation>,
608 #[serde(default, skip_serializing_if = "Option::is_none")]
612 pub move_recovery: Option<ViewerMoveRecovery>,
613 #[serde(default)]
614 pub chat_phase: ViewerChatPhase,
615 #[serde(default)]
618 pub is_idle: bool,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub activity_state: Option<mj_core::activity::ActivityState>,
625 #[serde(default, skip_serializing_if = "String::is_empty")]
628 pub activity: String,
629 #[serde(default, skip_serializing_if = "Vec::is_empty")]
631 pub config_options: Vec<ViewerConfigOption>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub plan_mode_active: Option<bool>,
635 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub turn_review: Option<ViewerTurnReview>,
639 #[serde(default, skip_serializing_if = "Vec::is_empty")]
643 pub available_commands: Vec<ViewerMjCommand>,
644 pub capabilities: ViewerSessionCapabilities,
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648#[serde(deny_unknown_fields)]
649pub struct ViewerMoveRecovery {
650 pub operation_id: String,
651 pub source_profile_id: String,
652 pub source_target_template_id: String,
653 pub destination_profile_id: String,
654 pub destination_target_template_id: String,
655 pub phase: String,
656 pub queue: String,
657 pub clear_resource_allocation: bool,
658 #[serde(default)]
661 pub source_additional_mounts: Vec<AdditionalMount>,
662 #[serde(default)]
663 pub source_resource_allocation: Option<SessionResourceAllocation>,
664 #[serde(default)]
667 pub destination_additional_mounts: Vec<AdditionalMount>,
668 #[serde(default)]
669 pub destination_resource_allocation: Option<SessionResourceAllocation>,
670 pub checkpoint_retained: bool,
674 #[serde(default)]
678 pub environment_retained: bool,
679 pub destination_ready: bool,
680 pub queue_admission_started: bool,
681 pub queue_admission_finished: bool,
682}
683
684impl ViewerMoveRecovery {
685 #[must_use]
686 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
687 if matches!(operation.phase, MovePhase::Completed) {
688 return None;
689 }
690 Some(Self {
691 operation_id: operation.operation_id.clone(),
692 source_profile_id: operation.source_profile_id.clone(),
693 source_target_template_id: operation.source_target_template_id.clone(),
694 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
695 destination_target_template_id: operation
696 .selection
697 .target_template_id
698 .clone()
699 .unwrap_or_default(),
700 phase: match operation.phase {
701 MovePhase::Preparing => "preparing",
702 MovePhase::ClosingSource => "closing_source",
703 MovePhase::ResumingDestination => "resuming_destination",
704 MovePhase::StartingQueue => "starting_queue",
705 MovePhase::Completed => "completed",
706 MovePhase::Failed => "failed",
707 MovePhase::Cancelled => "cancelled",
708 }
709 .into(),
710 queue: match operation.queue {
711 ResumeQueueDisposition::Start => "start",
712 ResumeQueueDisposition::Discard => "discard",
713 }
714 .into(),
715 clear_resource_allocation: operation.selection.clear_resource_allocation,
716 source_additional_mounts: operation.source_additional_mounts.clone(),
717 source_resource_allocation: operation.source_resource_allocation.clone(),
718 destination_additional_mounts: operation
719 .selection
720 .additional_mounts
721 .clone()
722 .unwrap_or_default(),
723 destination_resource_allocation: operation.selection.resource_allocation.clone(),
724 checkpoint_retained: operation.checkpoint_retained(),
725 environment_retained: operation.holds_source_environment(),
726 destination_ready: operation.destination_target.is_some()
727 && operation.destination_native_session_id.is_some(),
728 queue_admission_started: operation.queue_admission_started,
729 queue_admission_finished: operation.queue_admission_finished,
730 })
731 }
732}
733
734impl ViewerSession {
735 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
738 self.project_label = source.short.clone();
739 self.project_key = project_key(&source.key);
740 }
741}
742
743pub use crate::database::{
745 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
746};
747
748#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct ViewerMjCommand {
752 pub name: String,
753 pub description: String,
754 pub source: ViewerCommandSource,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub argument: Option<String>,
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
763#[serde(rename_all = "snake_case")]
764pub enum ViewerCommandSource {
765 Mj,
766 Agent,
767}
768
769#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(deny_unknown_fields)]
772pub struct ViewerReviewConfig {
773 pub enabled: bool,
774 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub profile: Option<String>,
776}
777
778#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
780#[serde(deny_unknown_fields)]
781pub struct ViewerTurnReview {
782 pub status: String,
784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
786 pub roles: Vec<ViewerReviewRole>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub verdict: Option<ViewerReviewVerdict>,
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(deny_unknown_fields)]
794pub struct ViewerReviewRole {
795 pub label: String,
796 pub state: String,
798}
799
800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801#[serde(deny_unknown_fields)]
802pub struct ViewerReviewVerdict {
803 pub kind: String,
805 pub text: String,
807 #[serde(default, skip_serializing_if = "Vec::is_empty")]
811 pub allowed: Vec<String>,
812}
813
814impl ViewerTurnReview {
815 #[must_use]
817 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
818 Self {
819 status: review.status.clone(),
820 roles: review
821 .roles
822 .iter()
823 .map(|role| ViewerReviewRole {
824 label: role.label.clone(),
825 state: role.state.label().to_owned(),
826 })
827 .collect(),
828 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
829 kind: match verdict.kind {
830 crate::review_host::VerdictKind::Clean => "clean",
831 crate::review_host::VerdictKind::Findings => "findings",
832 crate::review_host::VerdictKind::Failed => "failed",
833 }
834 .to_owned(),
835 text: verdict.text.clone(),
836 allowed: verdict
837 .allowed
838 .iter()
839 .filter_map(resolution_name)
840 .map(str::to_owned)
841 .collect(),
842 }),
843 }
844 }
845}
846
847#[must_use]
850pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
851 match resolution {
852 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
853 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
854 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
855 mj_core::review::driver::Resolution::NothingToReview
857 | mj_core::review::driver::Resolution::CoverageStarted => None,
858 }
859}
860
861#[must_use]
863pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
864 match name {
865 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
866 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
867 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
868 _ => None,
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(deny_unknown_fields)]
874pub struct ViewerWorkspace {
875 pub id: String,
876 pub name: String,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880#[serde(deny_unknown_fields)]
881pub struct ViewerQueuedPrompt {
882 pub id: String,
883 pub text: String,
884 pub created_at: String,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct ViewerUserShell {
890 pub id: String,
891 pub command: String,
892 pub started_at_ms: Option<i64>,
893}
894
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
897#[serde(deny_unknown_fields)]
898pub struct ViewerBackgroundTask {
899 pub id: String,
900 pub command: String,
901 pub started_at_ms: i64,
902 pub can_stop: bool,
903}
904
905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
906#[serde(deny_unknown_fields)]
907pub struct ViewerProfile {
908 pub id: String,
909 #[serde(default)]
910 pub capabilities_key: String,
911 pub harness_kind: String,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
913 pub quota: Option<ViewerQuota>,
914}
915
916#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
922#[serde(deny_unknown_fields)]
923pub struct ViewerQuotaWindow {
924 pub label: String,
925 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub resets_at_epoch_seconds: Option<i64>,
927 #[serde(default)]
928 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
929 #[serde(default, skip_serializing_if = "Option::is_none")]
930 pub banked_resets: Option<u64>,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub percent_used: Option<u8>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub resets_at: Option<String>,
935 pub projects_exhaustion_before_reset: bool,
938}
939
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(deny_unknown_fields)]
942pub struct ViewerQuota {
943 pub summary: String,
946 #[serde(default, skip_serializing_if = "Vec::is_empty")]
947 pub windows: Vec<ViewerQuotaWindow>,
948 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub resets_at: Option<String>,
950 pub stale: bool,
951 #[serde(default)]
954 pub refreshed_at_epoch_seconds: u64,
955 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub rate_limited_until_epoch_seconds: Option<u64>,
959 pub has_error: bool,
962}
963
964#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970#[serde(deny_unknown_fields)]
971pub struct ViewerTargetCapacity {
972 pub id: String,
973 pub label: String,
976 pub target_ids: Vec<String>,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub cpu_percent: Option<u8>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub memory_used_bytes: Option<u64>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub memory_total_bytes: Option<u64>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub logical_cores: Option<u64>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
986 pub disk_total_bytes: Option<u64>,
987 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub virtual_machines: Option<u64>,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub sampled_at_epoch_seconds: Option<u64>,
992 pub refreshing: bool,
993 pub stale: bool,
994 pub has_error: bool,
997 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1000 pub storage: Vec<ViewerTargetStorage>,
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(deny_unknown_fields)]
1005pub struct ViewerTargetStorage {
1006 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub machine: Option<String>,
1009 pub mount: String,
1010 pub available_bytes: u64,
1011 pub reserved_bytes: u64,
1013 pub condition: mj_core::targets::storage::StorageCondition,
1014 #[serde(default, skip_serializing_if = "Option::is_none")]
1016 pub detail: Option<String>,
1017}
1018
1019impl ViewerTargetStorage {
1020 pub fn list(views: &[&mj_core::targets::storage::TargetStorageView], fleet: bool) -> Vec<Self> {
1021 use mj_core::targets::storage::StorageCondition;
1022 views
1023 .iter()
1024 .flat_map(|view| {
1025 view.filesystems.iter().map(move |filesystem| Self {
1026 machine: fleet.then(|| view.host.clone()),
1027 mount: filesystem.space.mount.clone(),
1028 available_bytes: filesystem.space.available_bytes,
1029 reserved_bytes: filesystem.space.reserved_bytes,
1030 condition: filesystem.condition,
1031 detail: (filesystem.condition != StorageCondition::Ok)
1032 .then(|| view.explanation(filesystem)),
1033 })
1034 })
1035 .collect()
1036 }
1037}
1038
1039fn unknown_availability() -> crate::server::api::LaunchAvailability {
1040 crate::server::api::LaunchAvailability::Unknown
1041}
1042
1043#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1044#[serde(deny_unknown_fields)]
1045pub struct ViewerTarget {
1046 pub id: String,
1047 pub kind: String,
1048 #[serde(default)]
1050 pub resource_allocation_kind: ResourceAllocationKind,
1051 pub requires_project_directory: bool,
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub remembered_container_size: Option<HostContainerSize>,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub container_host_limits: Option<HostContainerSize>,
1058 #[serde(default, skip_serializing_if = "Option::is_none")]
1060 pub default_resource_allocation: Option<SessionResourceAllocation>,
1061 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1066 pub runtime_missing: bool,
1067 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1072 pub default_candidate: bool,
1073 #[serde(default = "unknown_availability")]
1076 pub availability: crate::server::api::LaunchAvailability,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub unavailable_reason: Option<String>,
1080 #[serde(default)]
1084 pub recent_project_directories: Vec<String>,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1089#[serde(deny_unknown_fields)]
1090pub struct ViewerTargetResourceOptions {
1091 pub options: Vec<SessionResourceAllocation>,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub default_allocation: Option<SessionResourceAllocation>,
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1097#[serde(deny_unknown_fields)]
1098pub struct ViewerBundle {
1099 pub id: String,
1100 pub primary_repository: String,
1101 pub repositories: Vec<ViewerRepository>,
1102}
1103
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1105#[serde(deny_unknown_fields)]
1106pub struct ViewerRepository {
1107 pub id: String,
1108 pub github: Option<String>,
1109 pub destination: String,
1110}
1111
1112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1120#[serde(deny_unknown_fields)]
1121pub struct ViewerSessionCapabilities {
1122 #[serde(default)]
1123 pub clear_context: bool,
1124 pub open: bool,
1125 pub prompt: bool,
1126 pub run_shell: bool,
1127 pub interrupt_turn: bool,
1129 pub cancel_operation: bool,
1131 pub suspend: bool,
1132 pub destroy: bool,
1133 pub rename: bool,
1134 pub resume: bool,
1135 #[serde(default)]
1138 pub move_session: bool,
1139 pub set_config: bool,
1140 pub set_plan_mode: bool,
1141 #[serde(default)]
1143 pub change_workspace: bool,
1144 #[serde(default)]
1148 pub container_settings: bool,
1149 #[serde(default)]
1153 pub restart: bool,
1154 #[serde(default)]
1157 pub interrupt_all: bool,
1158}
1159
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1166#[serde(rename_all = "kebab-case")]
1167pub enum ViewerLifecycleCategory {
1168 Live,
1169 Starting,
1170 Suspending,
1171 Suspended,
1172 Failed,
1173}
1174
1175pub const LAUNCHING_STATE: &str = "launching";
1179
1180fn public_title(session: &mj_core::state::SessionRecord) -> String {
1185 session.listed_title().to_owned()
1186}
1187
1188impl ViewerLifecycleCategory {
1189 pub(super) const fn of(state: SessionState) -> Self {
1190 match state {
1191 SessionState::Provisioning => Self::Starting,
1192 SessionState::Running
1194 | SessionState::Disconnected
1195 | SessionState::Checkpointing
1196 | SessionState::Parked => Self::Live,
1197 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1198 Self::Suspending
1199 }
1200 SessionState::Stopped => Self::Suspended,
1201 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1202 Self::Failed
1203 }
1204 }
1205 }
1206
1207 pub const fn is_dashboard_visible(self) -> bool {
1211 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1212 }
1213}
1214
1215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1216#[serde(rename_all = "kebab-case")]
1217pub enum ViewerOperationKind {
1218 Create,
1219 Resume,
1220 Move,
1221 Suspend,
1222 Destroy,
1223 Stop,
1225 Cleanup,
1226 Checkpoint,
1227}
1228
1229impl ViewerOperationKind {
1230 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1231 match self {
1232 Self::Create => Some(SessionTransitionKind::Starting),
1233 Self::Resume => Some(SessionTransitionKind::Resuming),
1234 Self::Move => Some(SessionTransitionKind::Moving),
1235 Self::Suspend => Some(SessionTransitionKind::Suspending),
1236 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1237 Self::Stop => Some(SessionTransitionKind::Stopping),
1238 Self::Checkpoint => None,
1241 }
1242 }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1247#[serde(deny_unknown_fields)]
1248pub struct ViewerOperationStage {
1249 pub label: String,
1250 pub started_at_epoch_seconds: u64,
1251}
1252
1253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1259#[serde(deny_unknown_fields)]
1260pub struct ViewerOperation {
1261 pub id: String,
1262 pub session_id: String,
1263 pub kind: ViewerOperationKind,
1264 pub started_at_epoch_seconds: u64,
1265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1266 pub stages: Vec<ViewerOperationStage>,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub notice: Option<String>,
1271 pub cancellable: bool,
1272}
1273
1274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1276#[serde(rename_all = "kebab-case")]
1277pub enum ViewerChatPhase {
1278 #[default]
1279 Idle,
1280 Running,
1281 Closing,
1282 Closed,
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1286#[serde(deny_unknown_fields)]
1287pub struct ViewerConfigChoice {
1288 pub value: String,
1289 pub name: String,
1290 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub description: Option<String>,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1300#[serde(deny_unknown_fields)]
1301pub struct ViewerConfigOption {
1302 pub key: String,
1303 pub label: String,
1304 #[serde(default, skip_serializing_if = "Option::is_none")]
1305 pub current: Option<String>,
1306 pub choices: Vec<ViewerConfigChoice>,
1307}