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 tier: config.review.tier.label().to_owned(),
402 profile: config.review.profile.clone(),
403 },
404 capacity: Vec::new(),
405 launch_failures: Vec::new(),
406 }
407 }
408}
409
410pub(super) fn project_key(identity: &str) -> String {
417 use sha2::Digest as _;
418 let digest = Sha256::digest(identity.as_bytes());
419 mj_core::hex::lower_hex(&digest[..8])
420}
421
422#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct ViewerSession {
425 #[serde(default)]
426 pub subagents: mj_core::subagent::SubagentPolicy,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub at: Option<String>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub branch: Option<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub base: Option<String>,
436 #[serde(default)]
437 pub targeted_turn_control_supported: bool,
438 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub steering: Option<mj_core::relay::SteeringOperation>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub active_prompt_id: Option<String>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub cancelling_prompt_id: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
448 #[serde(default)]
449 pub retry_assessment_pending: bool,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
452 pub id: String,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub publication_state: Option<mj_core::state::PublicationState>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
457 #[serde(default, skip_serializing_if = "String::is_empty")]
458 pub workspace_id: String,
459 pub title: String,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub subagent_parent_id: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub subagent_task_name: Option<String>,
466 #[serde(default, skip_serializing_if = "Vec::is_empty")]
468 pub subagent_session_ids: Vec<String>,
469 pub harness_kind: String,
470 pub profile_id: String,
471 pub bundle_id: String,
472 pub target_id: String,
473 pub state: String,
474 pub created_at: String,
475 pub updated_at: String,
476 pub has_error: bool,
477 #[serde(default)]
483 pub has_checkpoint: bool,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub configuration_issue: Option<String>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub launch_error: Option<String>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub storage_problem: Option<String>,
498
499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
500 pub preview: Vec<String>,
501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 pub queued_prompts: Vec<ViewerQueuedPrompt>,
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 pub active_user_shells: Vec<ViewerUserShell>,
505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
506 pub background_tasks: Vec<ViewerBackgroundTask>,
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
511 pub pending_elicitations: Vec<ElicitationRequest>,
512 pub conversation_available: bool,
513 #[serde(default)]
517 pub prompt_images_supported: bool,
518 #[serde(default, skip_serializing_if = "Vec::is_empty")]
525 pub incompatible_resume_targets: Vec<String>,
526 #[serde(skip)]
531 pub resume_refusals: BTreeMap<String, String>,
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub compatible_resume_targets: Vec<String>,
536 #[serde(default, skip_serializing_if = "String::is_empty")]
539 pub project_label: String,
540 #[serde(default, skip_serializing_if = "String::is_empty")]
544 pub project_key: String,
545 #[serde(default)]
548 pub display_location: String,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub container_cpus: Option<String>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub container_memory: Option<String>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
560 pub additional_mounts: Vec<AdditionalMount>,
561 pub lifecycle: ViewerLifecycleCategory,
562 #[serde(default)]
566 pub transitioning: bool,
567 #[serde(default)]
571 pub latest_event_ordinal: u64,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub last_activity_at_ms: Option<i64>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub last_message_at_ms: Option<i64>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub activity_details: Option<ViewerActivityDetails>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub operation: Option<ViewerOperation>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub move_recovery: Option<ViewerMoveRecovery>,
594 #[serde(default)]
595 pub chat_phase: ViewerChatPhase,
596 #[serde(default)]
599 pub is_idle: bool,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub activity_state: Option<mj_core::activity::ActivityState>,
606 #[serde(default, skip_serializing_if = "String::is_empty")]
609 pub activity: String,
610 #[serde(default, skip_serializing_if = "Vec::is_empty")]
612 pub config_options: Vec<ViewerConfigOption>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub plan_mode_active: Option<bool>,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub turn_review: Option<ViewerTurnReview>,
620 #[serde(default, skip_serializing_if = "Vec::is_empty")]
624 pub available_commands: Vec<ViewerMjCommand>,
625 pub capabilities: ViewerSessionCapabilities,
626}
627
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
629#[serde(deny_unknown_fields)]
630pub struct ViewerMoveRecovery {
631 pub operation_id: String,
632 pub source_profile_id: String,
633 pub source_target_template_id: String,
634 pub destination_profile_id: String,
635 pub destination_target_template_id: String,
636 pub phase: String,
637 pub queue: String,
638 pub clear_resource_allocation: bool,
639 #[serde(default)]
642 pub source_additional_mounts: Vec<AdditionalMount>,
643 #[serde(default)]
644 pub source_resource_allocation: Option<SessionResourceAllocation>,
645 #[serde(default)]
648 pub destination_additional_mounts: Vec<AdditionalMount>,
649 #[serde(default)]
650 pub destination_resource_allocation: Option<SessionResourceAllocation>,
651 pub checkpoint_retained: bool,
655 #[serde(default)]
659 pub environment_retained: bool,
660 pub destination_ready: bool,
661 pub queue_admission_started: bool,
662 pub queue_admission_finished: bool,
663}
664
665impl ViewerMoveRecovery {
666 #[must_use]
667 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
668 if matches!(operation.phase, MovePhase::Completed) {
669 return None;
670 }
671 Some(Self {
672 operation_id: operation.operation_id.clone(),
673 source_profile_id: operation.source_profile_id.clone(),
674 source_target_template_id: operation.source_target_template_id.clone(),
675 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
676 destination_target_template_id: operation
677 .selection
678 .target_template_id
679 .clone()
680 .unwrap_or_default(),
681 phase: match operation.phase {
682 MovePhase::Preparing => "preparing",
683 MovePhase::ClosingSource => "closing_source",
684 MovePhase::ResumingDestination => "resuming_destination",
685 MovePhase::StartingQueue => "starting_queue",
686 MovePhase::Completed => "completed",
687 MovePhase::Failed => "failed",
688 MovePhase::Cancelled => "cancelled",
689 }
690 .into(),
691 queue: match operation.queue {
692 ResumeQueueDisposition::Start => "start",
693 ResumeQueueDisposition::Discard => "discard",
694 }
695 .into(),
696 clear_resource_allocation: operation.selection.clear_resource_allocation,
697 source_additional_mounts: operation.source_additional_mounts.clone(),
698 source_resource_allocation: operation.source_resource_allocation.clone(),
699 destination_additional_mounts: operation
700 .selection
701 .additional_mounts
702 .clone()
703 .unwrap_or_default(),
704 destination_resource_allocation: operation.selection.resource_allocation.clone(),
705 checkpoint_retained: operation.checkpoint_retained(),
706 environment_retained: operation.holds_source_environment(),
707 destination_ready: operation.destination_target.is_some()
708 && operation.destination_native_session_id.is_some(),
709 queue_admission_started: operation.queue_admission_started,
710 queue_admission_finished: operation.queue_admission_finished,
711 })
712 }
713}
714
715impl ViewerSession {
716 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
719 self.project_label = source.short.clone();
720 self.project_key = project_key(&source.key);
721 }
722}
723
724pub use crate::database::{
726 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
727};
728
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(deny_unknown_fields)]
732pub struct ViewerMjCommand {
733 pub name: String,
734 pub description: String,
735 pub source: ViewerCommandSource,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub argument: Option<String>,
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
744#[serde(rename_all = "snake_case")]
745pub enum ViewerCommandSource {
746 Mj,
747 Agent,
748}
749
750#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
752#[serde(deny_unknown_fields)]
753pub struct ViewerReviewConfig {
754 pub enabled: bool,
755 pub tier: String,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub profile: Option<String>,
758}
759
760#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
762#[serde(deny_unknown_fields)]
763pub struct ViewerTurnReview {
764 pub tier: String,
766 pub status: String,
768 #[serde(default, skip_serializing_if = "Vec::is_empty")]
770 pub roles: Vec<ViewerReviewRole>,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub verdict: Option<ViewerReviewVerdict>,
774}
775
776#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
777#[serde(deny_unknown_fields)]
778pub struct ViewerReviewRole {
779 pub label: String,
780 pub state: String,
782}
783
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785#[serde(deny_unknown_fields)]
786pub struct ViewerReviewVerdict {
787 pub kind: String,
789 pub text: String,
791 #[serde(default, skip_serializing_if = "Vec::is_empty")]
795 pub allowed: Vec<String>,
796}
797
798impl ViewerTurnReview {
799 #[must_use]
801 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
802 Self {
803 tier: review.tier.label().to_owned(),
804 status: review.status.clone(),
805 roles: review
806 .roles
807 .iter()
808 .map(|role| ViewerReviewRole {
809 label: role.label.clone(),
810 state: role.state.label().to_owned(),
811 })
812 .collect(),
813 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
814 kind: match verdict.kind {
815 crate::review_host::VerdictKind::Clean => "clean",
816 crate::review_host::VerdictKind::Findings => "findings",
817 crate::review_host::VerdictKind::Failed => "failed",
818 }
819 .to_owned(),
820 text: verdict.text.clone(),
821 allowed: verdict
822 .allowed
823 .iter()
824 .filter_map(resolution_name)
825 .map(str::to_owned)
826 .collect(),
827 }),
828 }
829 }
830}
831
832#[must_use]
835pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
836 match resolution {
837 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
838 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
839 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
840 mj_core::review::driver::Resolution::NothingToReview
842 | mj_core::review::driver::Resolution::CoverageStarted => None,
843 }
844}
845
846#[must_use]
848pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
849 match name {
850 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
851 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
852 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
853 _ => None,
854 }
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
858#[serde(deny_unknown_fields)]
859pub struct ViewerWorkspace {
860 pub id: String,
861 pub name: String,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
865#[serde(deny_unknown_fields)]
866pub struct ViewerQueuedPrompt {
867 pub id: String,
868 pub text: String,
869 pub created_at: String,
870}
871
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(deny_unknown_fields)]
874pub struct ViewerUserShell {
875 pub id: String,
876 pub command: String,
877 pub started_at_ms: Option<i64>,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(deny_unknown_fields)]
883pub struct ViewerBackgroundTask {
884 pub id: String,
885 pub command: String,
886 pub started_at_ms: i64,
887 pub can_stop: bool,
888}
889
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[serde(deny_unknown_fields)]
892pub struct ViewerProfile {
893 pub id: String,
894 #[serde(default)]
895 pub subagent_discovery_key: String,
896 #[serde(default)]
897 pub capabilities_key: String,
898 #[serde(default)]
899 pub subagent_profile_ids: Vec<String>,
900 pub harness_kind: String,
901 #[serde(default)]
902 pub subagents: mj_core::subagent::SubagentPolicy,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub quota: Option<ViewerQuota>,
905}
906
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
913#[serde(deny_unknown_fields)]
914pub struct ViewerQuotaWindow {
915 pub label: String,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub resets_at_epoch_seconds: Option<i64>,
918 #[serde(default)]
919 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
921 pub banked_resets: Option<u64>,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub percent_used: Option<u8>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
925 pub resets_at: Option<String>,
926 pub projects_exhaustion_before_reset: bool,
929}
930
931#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
932#[serde(deny_unknown_fields)]
933pub struct ViewerQuota {
934 pub summary: String,
937 #[serde(default, skip_serializing_if = "Vec::is_empty")]
938 pub windows: Vec<ViewerQuotaWindow>,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub resets_at: Option<String>,
941 pub stale: bool,
942 #[serde(default)]
945 pub refreshed_at_epoch_seconds: u64,
946 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub rate_limited_until_epoch_seconds: Option<u64>,
950 pub has_error: bool,
953}
954
955#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
961#[serde(deny_unknown_fields)]
962pub struct ViewerTargetCapacity {
963 pub id: String,
964 pub label: String,
967 pub target_ids: Vec<String>,
968 #[serde(default, skip_serializing_if = "Option::is_none")]
969 pub cpu_percent: Option<u8>,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub memory_used_bytes: Option<u64>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub memory_total_bytes: Option<u64>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub logical_cores: Option<u64>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub disk_total_bytes: Option<u64>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub virtual_machines: Option<u64>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub sampled_at_epoch_seconds: Option<u64>,
983 pub refreshing: bool,
984 pub stale: bool,
985 pub has_error: bool,
988 #[serde(default, skip_serializing_if = "Vec::is_empty")]
991 pub storage: Vec<ViewerTargetStorage>,
992}
993
994#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
995#[serde(deny_unknown_fields)]
996pub struct ViewerTargetStorage {
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub machine: Option<String>,
1000 pub mount: String,
1001 pub available_bytes: u64,
1002 pub reserved_bytes: u64,
1004 pub condition: mj_core::targets::storage::StorageCondition,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub detail: Option<String>,
1008}
1009
1010impl ViewerTargetStorage {
1011 pub fn list(views: &[&mj_core::targets::storage::TargetStorageView], fleet: bool) -> Vec<Self> {
1012 use mj_core::targets::storage::StorageCondition;
1013 views
1014 .iter()
1015 .flat_map(|view| {
1016 view.filesystems.iter().map(move |filesystem| Self {
1017 machine: fleet.then(|| view.host.clone()),
1018 mount: filesystem.space.mount.clone(),
1019 available_bytes: filesystem.space.available_bytes,
1020 reserved_bytes: filesystem.space.reserved_bytes,
1021 condition: filesystem.condition,
1022 detail: (filesystem.condition != StorageCondition::Ok)
1023 .then(|| view.explanation(filesystem)),
1024 })
1025 })
1026 .collect()
1027 }
1028}
1029
1030fn unknown_availability() -> crate::server::api::LaunchAvailability {
1031 crate::server::api::LaunchAvailability::Unknown
1032}
1033
1034#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1035#[serde(deny_unknown_fields)]
1036pub struct ViewerTarget {
1037 pub id: String,
1038 pub kind: String,
1039 #[serde(default)]
1041 pub resource_allocation_kind: ResourceAllocationKind,
1042 pub requires_project_directory: bool,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1045 pub remembered_container_size: Option<HostContainerSize>,
1046 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub container_host_limits: Option<HostContainerSize>,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub default_resource_allocation: Option<SessionResourceAllocation>,
1052 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1057 pub runtime_missing: bool,
1058 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1063 pub default_candidate: bool,
1064 #[serde(default = "unknown_availability")]
1067 pub availability: crate::server::api::LaunchAvailability,
1068 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub unavailable_reason: Option<String>,
1071 #[serde(default)]
1075 pub recent_project_directories: Vec<String>,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1080#[serde(deny_unknown_fields)]
1081pub struct ViewerTargetResourceOptions {
1082 pub options: Vec<SessionResourceAllocation>,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub default_allocation: Option<SessionResourceAllocation>,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1088#[serde(deny_unknown_fields)]
1089pub struct ViewerBundle {
1090 pub id: String,
1091 pub primary_repository: String,
1092 pub repositories: Vec<ViewerRepository>,
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1096#[serde(deny_unknown_fields)]
1097pub struct ViewerRepository {
1098 pub id: String,
1099 pub github: Option<String>,
1100 pub destination: String,
1101}
1102
1103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1111#[serde(deny_unknown_fields)]
1112pub struct ViewerSessionCapabilities {
1113 #[serde(default)]
1114 pub clear_context: bool,
1115 pub open: bool,
1116 pub prompt: bool,
1117 pub run_shell: bool,
1118 pub interrupt_turn: bool,
1120 pub cancel_operation: bool,
1122 pub suspend: bool,
1123 pub destroy: bool,
1124 pub rename: bool,
1125 pub resume: bool,
1126 #[serde(default)]
1129 pub move_session: bool,
1130 pub set_config: bool,
1131 pub set_plan_mode: bool,
1132 #[serde(default)]
1134 pub change_workspace: bool,
1135 #[serde(default)]
1139 pub container_settings: bool,
1140 #[serde(default)]
1144 pub restart: bool,
1145 #[serde(default)]
1148 pub interrupt_all: bool,
1149}
1150
1151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1157#[serde(rename_all = "kebab-case")]
1158pub enum ViewerLifecycleCategory {
1159 Live,
1160 Starting,
1161 Suspending,
1162 Suspended,
1163 Failed,
1164}
1165
1166pub const LAUNCHING_STATE: &str = "launching";
1170
1171fn public_title(session: &mj_core::state::SessionRecord) -> String {
1176 session.listed_title().to_owned()
1177}
1178
1179impl ViewerLifecycleCategory {
1180 pub(super) const fn of(state: SessionState) -> Self {
1181 match state {
1182 SessionState::Provisioning => Self::Starting,
1183 SessionState::Running
1185 | SessionState::Disconnected
1186 | SessionState::Checkpointing
1187 | SessionState::Parked => Self::Live,
1188 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1189 Self::Suspending
1190 }
1191 SessionState::Stopped => Self::Suspended,
1192 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1193 Self::Failed
1194 }
1195 }
1196 }
1197
1198 pub const fn is_dashboard_visible(self) -> bool {
1202 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1203 }
1204}
1205
1206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1207#[serde(rename_all = "kebab-case")]
1208pub enum ViewerOperationKind {
1209 Create,
1210 Resume,
1211 Move,
1212 Suspend,
1213 Destroy,
1214 Stop,
1216 Cleanup,
1217 Checkpoint,
1218}
1219
1220impl ViewerOperationKind {
1221 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1222 match self {
1223 Self::Create => Some(SessionTransitionKind::Starting),
1224 Self::Resume => Some(SessionTransitionKind::Resuming),
1225 Self::Move => Some(SessionTransitionKind::Moving),
1226 Self::Suspend => Some(SessionTransitionKind::Suspending),
1227 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1228 Self::Stop => Some(SessionTransitionKind::Stopping),
1229 Self::Checkpoint => None,
1232 }
1233 }
1234}
1235
1236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1238#[serde(deny_unknown_fields)]
1239pub struct ViewerOperationStage {
1240 pub label: String,
1241 pub started_at_epoch_seconds: u64,
1242}
1243
1244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1250#[serde(deny_unknown_fields)]
1251pub struct ViewerOperation {
1252 pub id: String,
1253 pub session_id: String,
1254 pub kind: ViewerOperationKind,
1255 pub started_at_epoch_seconds: u64,
1256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1257 pub stages: Vec<ViewerOperationStage>,
1258 #[serde(default, skip_serializing_if = "Option::is_none")]
1261 pub notice: Option<String>,
1262 pub cancellable: bool,
1263}
1264
1265#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1267#[serde(rename_all = "kebab-case")]
1268pub enum ViewerChatPhase {
1269 #[default]
1270 Idle,
1271 Running,
1272 Closing,
1273 Closed,
1274}
1275
1276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1277#[serde(deny_unknown_fields)]
1278pub struct ViewerConfigChoice {
1279 pub value: String,
1280 pub name: String,
1281 #[serde(default, skip_serializing_if = "Option::is_none")]
1282 pub description: Option<String>,
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1291#[serde(deny_unknown_fields)]
1292pub struct ViewerConfigOption {
1293 pub key: String,
1294 pub label: String,
1295 #[serde(default, skip_serializing_if = "Option::is_none")]
1296 pub current: Option<String>,
1297 pub choices: Vec<ViewerConfigChoice>,
1298}