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 checkout = state
160 .checkout(&session.id)
161 .expect("viewer session has a valid checkout owner");
162 let resume_refusals = config
163 .targets
164 .keys()
165 .filter_map(|target_id| {
166 crate::controller::resume_compatibility_with_checkout(
167 session, &checkout, config, target_id,
168 )
169 .err()
170 .map(|reason| (target_id.clone(), reason))
171 })
172 .collect::<BTreeMap<_, _>>();
173 let incompatible = resume_refusals.keys().cloned().collect::<Vec<_>>();
174 let lifecycle = ViewerLifecycleCategory::of(session.state);
175 let project = state.project_identity_session(session);
179 let source = project.project_source(config);
180 let subagent = state.subagents.get(&session.id);
181 let subagent_session_ids = children
182 .get(&session.id)
183 .map(|children| children.keys().cloned().collect())
184 .unwrap_or_default();
185 let start = session.start_selection();
186 ViewerSession {
187 subagents: session.subagents.clone().unwrap_or_default(),
188 at: start.at,
189 branch: start.branch,
190 base: start.base,
191 targeted_turn_control_supported: false,
192 native_subagents: Vec::new(),
193 steering: None,
194 active_prompt_id: None,
195 cancelling_prompt_id: None,
196 capacity_retry: None,
197 retry_assessment_pending: false,
198 quota_recovery: None,
199 id: session.id.clone(),
200 publication_state: session.publication_state(),
201 managed_checkout_kind: checkout.managed_worktree().map(|owned| owned.kind),
202 workspace_id: session.workspace_id.clone(),
203 title: public_title(session),
204 subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
205 subagent_task_name: subagent.map(|child| child.task_name.clone()),
206 subagent_session_ids,
207 harness_kind: session.harness_kind.id().into(),
208 profile_id: session.last_profile.clone(),
209 bundle_id: session.bundle_id.clone(),
210 target_id: session.target_template_id.clone(),
211 state: match session.state {
212 SessionState::Closing => "suspending",
213 SessionState::Stopped => "suspended",
214 _ => session.state.as_str(),
215 }
216 .into(),
217 created_at: session.created_at.clone(),
218 updated_at: session.updated_at.clone(),
219 has_error: session.last_error.is_some()
220 || session.configuration_issue(config).is_some(),
221 has_checkpoint: session.checkpoint.is_some(),
222 configuration_issue: session.configuration_issue(config),
223 launch_error: matches!(
238 session.state,
239 SessionState::Error | SessionState::Stopped
240 )
241 .then(|| session.last_error.clone())
242 .flatten()
243 .or_else(|| session.public_error().map(str::to_owned)),
244 storage_problem: None,
245 preview: Vec::new(),
246 queued_prompts: Vec::new(),
247 active_user_shells: Vec::new(),
248 background_tasks: Vec::new(),
249 pending_elicitations: Vec::new(),
250 conversation_available: false,
251 prompt_images_supported: false,
252 incompatible_resume_targets: incompatible.clone(),
253 resume_refusals,
254 compatible_resume_targets: config
255 .targets
256 .keys()
257 .filter(|target_id| !incompatible.contains(*target_id))
258 .cloned()
259 .collect(),
260 project_label: source.short,
261 project_key: project_key(&source.key),
262 display_location: project.project_target(config, &session.target_template_id),
263 container_cpus: session.container_cpus.clone(),
264 container_memory: session.container_memory.clone(),
265 additional_mounts: session.additional_mounts.clone(),
266 lifecycle,
267 transitioning: session.state.transition_kind().is_some(),
268 latest_event_ordinal: 0,
269 last_activity_at_ms: None,
270 last_message_at_ms: None,
271 activity_details: None,
272 activity: String::new(),
273 operation: None,
274 move_recovery: None,
275 chat_phase: ViewerChatPhase::default(),
278 is_idle: false,
279 activity_state: None,
280 config_options: Vec::new(),
281 plan_mode_active: None,
282 turn_review: None,
283 available_commands: Vec::new(),
284 capabilities: ViewerSessionCapabilities {
288 clear_context: false,
289 open: false,
290 prompt: false,
291 run_shell: false,
292 interrupt_turn: false,
293 cancel_operation: false,
294 suspend: lifecycle.is_dashboard_visible(),
295 destroy: true,
296 rename: true,
297 resume: !lifecycle.is_dashboard_visible(),
298 move_session: false,
299 set_config: false,
300 set_plan_mode: false,
301 change_workspace: false,
305 container_settings: false,
306 restart: false,
307 interrupt_all: false,
308 },
309 }
310 })
311 .collect();
312 let profiles = config
313 .enabled_profiles()
314 .map(|(id, profile)| ViewerProfile {
315 id: id.to_owned(),
316 harness_kind: profile.kind.id().into(),
317 subagents: profile.subagents.clone(),
318 subagent_discovery_key: config.subagent_discovery_key(id, None),
319 capabilities_key: profile.capabilities_key(id),
320 subagent_profile_ids: config
321 .enabled_profiles()
322 .filter(|(candidate, _)| config.subagents.profile_is_eligible(id, candidate))
323 .map(|(candidate, _)| candidate.to_owned())
324 .collect(),
325 quota: None,
326 })
327 .collect();
328 let targets = config
329 .targets
330 .iter()
331 .map(|(id, target)| ViewerTarget {
332 id: id.clone(),
333 kind: target.kind_name().into(),
334 resource_allocation_kind: target.into(),
335 requires_project_directory: matches!(
336 target,
337 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
338 ),
339 remembered_container_size: mj_core::config::container_size_host(target)
340 .and_then(|host| state.container_sizes.get(host))
341 .copied(),
342 container_host_limits: None,
343 default_resource_allocation: if mj_core::config::is_container_target(target) {
344 let size = mj_core::state::default_container_size(
345 mj_core::config::container_size_host(target)
346 .and_then(|host| state.container_sizes.get(host))
347 .copied(),
348 None,
349 );
350 Some(SessionResourceAllocation::Container {
351 cpus: size.cpus,
352 memory_bytes: size.memory_bytes,
353 })
354 } else {
355 None
356 },
357 runtime_missing: false,
358 default_candidate: config.is_default_target(id),
359 availability: crate::server::api::LaunchAvailability::Unknown,
360 unavailable_reason: None,
361 recent_project_directories: project_history_host(target)
362 .map(|host| {
363 state
364 .project_directories(host)
365 .iter()
366 .map(|directory| directory.to_string_lossy().into_owned())
367 .collect()
368 })
369 .unwrap_or_default(),
370 })
371 .collect();
372 let bundles = config
373 .bundles
374 .iter()
375 .map(|(id, bundle)| ViewerBundle {
376 id: id.clone(),
377 primary_repository: bundle.primary_repo.clone(),
378 repositories: bundle
379 .repositories
380 .iter()
381 .map(|repository| ViewerRepository {
382 id: repository.id.clone(),
383 github: repository.github.clone(),
384 destination: repository.destination.to_string_lossy().into_owned(),
385 })
386 .collect(),
387 })
388 .collect();
389 Self {
390 profile_capabilities: Default::default(),
391 last_subagent_policy: state.last_subagent_policy.clone(),
392 revision,
393 generated_at: now_unix().to_string(),
394 server_time_ms: mj_core::clock::epoch_millis(),
395 server_version: env!("CARGO_PKG_VERSION").to_owned(),
396 workspaces: Vec::new(),
397 sessions,
398 profiles,
399 targets,
400 bundles,
401 review_config: ViewerReviewConfig {
402 enabled: config.review.enabled,
403 profile: config.review.profile.clone(),
404 },
405 capacity: Vec::new(),
406 launch_failures: Vec::new(),
407 }
408 }
409}
410
411pub(super) fn project_key(identity: &str) -> String {
418 use sha2::Digest as _;
419 let digest = Sha256::digest(identity.as_bytes());
420 mj_core::hex::lower_hex(&digest[..8])
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424#[serde(deny_unknown_fields)]
425pub struct ViewerSession {
426 #[serde(default)]
427 pub subagents: mj_core::subagent::SubagentPolicy,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub at: Option<String>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub branch: Option<String>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub base: Option<String>,
437 #[serde(default)]
438 pub targeted_turn_control_supported: bool,
439 #[serde(default, skip_serializing_if = "Vec::is_empty")]
440 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub steering: Option<mj_core::relay::SteeringOperation>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub active_prompt_id: Option<String>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub cancelling_prompt_id: Option<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
449 #[serde(default)]
450 pub retry_assessment_pending: bool,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
453 pub id: String,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub publication_state: Option<mj_core::state::PublicationState>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
458 #[serde(default, skip_serializing_if = "String::is_empty")]
459 pub workspace_id: String,
460 pub title: String,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub subagent_parent_id: Option<String>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub subagent_task_name: Option<String>,
467 #[serde(default, skip_serializing_if = "Vec::is_empty")]
469 pub subagent_session_ids: Vec<String>,
470 pub harness_kind: String,
471 pub profile_id: String,
472 pub bundle_id: String,
473 pub target_id: String,
474 pub state: String,
475 pub created_at: String,
476 pub updated_at: String,
477 pub has_error: bool,
478 #[serde(default)]
484 pub has_checkpoint: bool,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub configuration_issue: Option<String>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub launch_error: Option<String>,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub storage_problem: Option<String>,
499
500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
501 pub preview: Vec<String>,
502 #[serde(default, skip_serializing_if = "Vec::is_empty")]
503 pub queued_prompts: Vec<ViewerQueuedPrompt>,
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 pub active_user_shells: Vec<ViewerUserShell>,
506 #[serde(default, skip_serializing_if = "Vec::is_empty")]
507 pub background_tasks: Vec<ViewerBackgroundTask>,
508 #[serde(default, skip_serializing_if = "Vec::is_empty")]
512 pub pending_elicitations: Vec<ElicitationRequest>,
513 pub conversation_available: bool,
514 #[serde(default)]
518 pub prompt_images_supported: bool,
519 #[serde(default, skip_serializing_if = "Vec::is_empty")]
526 pub incompatible_resume_targets: Vec<String>,
527 #[serde(skip)]
532 pub resume_refusals: BTreeMap<String, String>,
533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
536 pub compatible_resume_targets: Vec<String>,
537 #[serde(default, skip_serializing_if = "String::is_empty")]
540 pub project_label: String,
541 #[serde(default, skip_serializing_if = "String::is_empty")]
545 pub project_key: String,
546 #[serde(default)]
549 pub display_location: String,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub container_cpus: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub container_memory: Option<String>,
558 #[serde(default, skip_serializing_if = "Vec::is_empty")]
561 pub additional_mounts: Vec<AdditionalMount>,
562 pub lifecycle: ViewerLifecycleCategory,
563 #[serde(default)]
567 pub transitioning: bool,
568 #[serde(default)]
572 pub latest_event_ordinal: u64,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub last_activity_at_ms: Option<i64>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub last_message_at_ms: Option<i64>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub activity_details: Option<ViewerActivityDetails>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub operation: Option<ViewerOperation>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub move_recovery: Option<ViewerMoveRecovery>,
595 #[serde(default)]
596 pub chat_phase: ViewerChatPhase,
597 #[serde(default)]
600 pub is_idle: bool,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub activity_state: Option<mj_core::activity::ActivityState>,
607 #[serde(default, skip_serializing_if = "String::is_empty")]
610 pub activity: String,
611 #[serde(default, skip_serializing_if = "Vec::is_empty")]
613 pub config_options: Vec<ViewerConfigOption>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub plan_mode_active: Option<bool>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub turn_review: Option<ViewerTurnReview>,
621 #[serde(default, skip_serializing_if = "Vec::is_empty")]
625 pub available_commands: Vec<ViewerMjCommand>,
626 pub capabilities: ViewerSessionCapabilities,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(deny_unknown_fields)]
631pub struct ViewerMoveRecovery {
632 pub operation_id: String,
633 pub source_profile_id: String,
634 pub source_target_template_id: String,
635 pub destination_profile_id: String,
636 pub destination_target_template_id: String,
637 pub phase: String,
638 pub queue: String,
639 pub clear_resource_allocation: bool,
640 #[serde(default)]
643 pub source_additional_mounts: Vec<AdditionalMount>,
644 #[serde(default)]
645 pub source_resource_allocation: Option<SessionResourceAllocation>,
646 #[serde(default)]
649 pub destination_additional_mounts: Vec<AdditionalMount>,
650 #[serde(default)]
651 pub destination_resource_allocation: Option<SessionResourceAllocation>,
652 pub checkpoint_retained: bool,
656 #[serde(default)]
660 pub environment_retained: bool,
661 pub destination_ready: bool,
662 pub queue_admission_started: bool,
663 pub queue_admission_finished: bool,
664}
665
666impl ViewerMoveRecovery {
667 #[must_use]
668 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
669 if matches!(operation.phase, MovePhase::Completed) {
670 return None;
671 }
672 Some(Self {
673 operation_id: operation.operation_id.clone(),
674 source_profile_id: operation.source_profile_id.clone(),
675 source_target_template_id: operation.source_target_template_id.clone(),
676 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
677 destination_target_template_id: operation
678 .selection
679 .target_template_id
680 .clone()
681 .unwrap_or_default(),
682 phase: match operation.phase {
683 MovePhase::Preparing => "preparing",
684 MovePhase::ClosingSource => "closing_source",
685 MovePhase::ResumingDestination => "resuming_destination",
686 MovePhase::StartingQueue => "starting_queue",
687 MovePhase::Completed => "completed",
688 MovePhase::Failed => "failed",
689 MovePhase::Cancelled => "cancelled",
690 }
691 .into(),
692 queue: match operation.queue {
693 ResumeQueueDisposition::Start => "start",
694 ResumeQueueDisposition::Discard => "discard",
695 }
696 .into(),
697 clear_resource_allocation: operation.selection.clear_resource_allocation,
698 source_additional_mounts: operation.source_additional_mounts.clone(),
699 source_resource_allocation: operation.source_resource_allocation.clone(),
700 destination_additional_mounts: operation
701 .selection
702 .additional_mounts
703 .clone()
704 .unwrap_or_default(),
705 destination_resource_allocation: operation.selection.resource_allocation.clone(),
706 checkpoint_retained: operation.checkpoint_retained(),
707 environment_retained: operation.holds_source_environment(),
708 destination_ready: operation.destination_target.is_some()
709 && operation.destination_native_session_id.is_some(),
710 queue_admission_started: operation.queue_admission_started,
711 queue_admission_finished: operation.queue_admission_finished,
712 })
713 }
714}
715
716impl ViewerSession {
717 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
720 self.project_label = source.short.clone();
721 self.project_key = project_key(&source.key);
722 }
723}
724
725pub use crate::database::{
727 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
728};
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(deny_unknown_fields)]
733pub struct ViewerMjCommand {
734 pub name: String,
735 pub description: String,
736 pub source: ViewerCommandSource,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub argument: Option<String>,
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
745#[serde(rename_all = "snake_case")]
746pub enum ViewerCommandSource {
747 Mj,
748 Agent,
749}
750
751#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(deny_unknown_fields)]
754pub struct ViewerReviewConfig {
755 pub enabled: bool,
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 status: String,
766 #[serde(default, skip_serializing_if = "Vec::is_empty")]
768 pub roles: Vec<ViewerReviewRole>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub verdict: Option<ViewerReviewVerdict>,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
775#[serde(deny_unknown_fields)]
776pub struct ViewerReviewRole {
777 pub label: String,
778 pub state: String,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub struct ViewerReviewVerdict {
785 pub kind: String,
787 pub text: String,
789 #[serde(default, skip_serializing_if = "Vec::is_empty")]
793 pub allowed: Vec<String>,
794}
795
796impl ViewerTurnReview {
797 #[must_use]
799 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
800 Self {
801 status: review.status.clone(),
802 roles: review
803 .roles
804 .iter()
805 .map(|role| ViewerReviewRole {
806 label: role.label.clone(),
807 state: role.state.label().to_owned(),
808 })
809 .collect(),
810 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
811 kind: match verdict.kind {
812 crate::review_host::VerdictKind::Clean => "clean",
813 crate::review_host::VerdictKind::Findings => "findings",
814 crate::review_host::VerdictKind::Failed => "failed",
815 }
816 .to_owned(),
817 text: verdict.text.clone(),
818 allowed: verdict
819 .allowed
820 .iter()
821 .filter_map(resolution_name)
822 .map(str::to_owned)
823 .collect(),
824 }),
825 }
826 }
827}
828
829#[must_use]
832pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
833 match resolution {
834 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
835 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
836 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
837 mj_core::review::driver::Resolution::NothingToReview
839 | mj_core::review::driver::Resolution::CoverageStarted => None,
840 }
841}
842
843#[must_use]
845pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
846 match name {
847 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
848 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
849 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
850 _ => None,
851 }
852}
853
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855#[serde(deny_unknown_fields)]
856pub struct ViewerWorkspace {
857 pub id: String,
858 pub name: String,
859}
860
861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862#[serde(deny_unknown_fields)]
863pub struct ViewerQueuedPrompt {
864 pub id: String,
865 pub text: String,
866 pub created_at: String,
867}
868
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870#[serde(deny_unknown_fields)]
871pub struct ViewerUserShell {
872 pub id: String,
873 pub command: String,
874 pub started_at_ms: Option<i64>,
875}
876
877#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
879#[serde(deny_unknown_fields)]
880pub struct ViewerBackgroundTask {
881 pub id: String,
882 pub command: String,
883 pub started_at_ms: i64,
884 pub can_stop: bool,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct ViewerProfile {
890 pub id: String,
891 #[serde(default)]
892 pub subagent_discovery_key: String,
893 #[serde(default)]
894 pub capabilities_key: String,
895 #[serde(default)]
896 pub subagent_profile_ids: Vec<String>,
897 pub harness_kind: String,
898 #[serde(default)]
899 pub subagents: mj_core::subagent::SubagentPolicy,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub quota: Option<ViewerQuota>,
902}
903
904#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(deny_unknown_fields)]
911pub struct ViewerQuotaWindow {
912 pub label: String,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub resets_at_epoch_seconds: Option<i64>,
915 #[serde(default)]
916 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub banked_resets: Option<u64>,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
920 pub percent_used: Option<u8>,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub resets_at: Option<String>,
923 pub projects_exhaustion_before_reset: bool,
926}
927
928#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929#[serde(deny_unknown_fields)]
930pub struct ViewerQuota {
931 pub summary: String,
934 #[serde(default, skip_serializing_if = "Vec::is_empty")]
935 pub windows: Vec<ViewerQuotaWindow>,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub resets_at: Option<String>,
938 pub stale: bool,
939 #[serde(default)]
942 pub refreshed_at_epoch_seconds: u64,
943 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub rate_limited_until_epoch_seconds: Option<u64>,
947 pub has_error: bool,
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
958#[serde(deny_unknown_fields)]
959pub struct ViewerTargetCapacity {
960 pub id: String,
961 pub label: String,
964 pub target_ids: Vec<String>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
966 pub cpu_percent: Option<u8>,
967 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub memory_used_bytes: Option<u64>,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
970 pub memory_total_bytes: Option<u64>,
971 #[serde(default, skip_serializing_if = "Option::is_none")]
972 pub logical_cores: Option<u64>,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub disk_total_bytes: Option<u64>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub virtual_machines: Option<u64>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub sampled_at_epoch_seconds: Option<u64>,
980 pub refreshing: bool,
981 pub stale: bool,
982 pub has_error: bool,
985 #[serde(default, skip_serializing_if = "Vec::is_empty")]
988 pub storage: Vec<ViewerTargetStorage>,
989}
990
991#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992#[serde(deny_unknown_fields)]
993pub struct ViewerTargetStorage {
994 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub machine: Option<String>,
997 pub mount: String,
998 pub available_bytes: u64,
999 pub reserved_bytes: u64,
1001 pub condition: mj_core::targets::storage::StorageCondition,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub detail: Option<String>,
1005}
1006
1007impl ViewerTargetStorage {
1008 pub fn list(views: &[&mj_core::targets::storage::TargetStorageView], fleet: bool) -> Vec<Self> {
1009 use mj_core::targets::storage::StorageCondition;
1010 views
1011 .iter()
1012 .flat_map(|view| {
1013 view.filesystems.iter().map(move |filesystem| Self {
1014 machine: fleet.then(|| view.host.clone()),
1015 mount: filesystem.space.mount.clone(),
1016 available_bytes: filesystem.space.available_bytes,
1017 reserved_bytes: filesystem.space.reserved_bytes,
1018 condition: filesystem.condition,
1019 detail: (filesystem.condition != StorageCondition::Ok)
1020 .then(|| view.explanation(filesystem)),
1021 })
1022 })
1023 .collect()
1024 }
1025}
1026
1027fn unknown_availability() -> crate::server::api::LaunchAvailability {
1028 crate::server::api::LaunchAvailability::Unknown
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1032#[serde(deny_unknown_fields)]
1033pub struct ViewerTarget {
1034 pub id: String,
1035 pub kind: String,
1036 #[serde(default)]
1038 pub resource_allocation_kind: ResourceAllocationKind,
1039 pub requires_project_directory: bool,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub remembered_container_size: Option<HostContainerSize>,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1045 pub container_host_limits: Option<HostContainerSize>,
1046 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub default_resource_allocation: Option<SessionResourceAllocation>,
1049 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1054 pub runtime_missing: bool,
1055 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1060 pub default_candidate: bool,
1061 #[serde(default = "unknown_availability")]
1064 pub availability: crate::server::api::LaunchAvailability,
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub unavailable_reason: Option<String>,
1068 #[serde(default)]
1072 pub recent_project_directories: Vec<String>,
1073}
1074
1075#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1077#[serde(deny_unknown_fields)]
1078pub struct ViewerTargetResourceOptions {
1079 pub options: Vec<SessionResourceAllocation>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub default_allocation: Option<SessionResourceAllocation>,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1085#[serde(deny_unknown_fields)]
1086pub struct ViewerBundle {
1087 pub id: String,
1088 pub primary_repository: String,
1089 pub repositories: Vec<ViewerRepository>,
1090}
1091
1092#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093#[serde(deny_unknown_fields)]
1094pub struct ViewerRepository {
1095 pub id: String,
1096 pub github: Option<String>,
1097 pub destination: String,
1098}
1099
1100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1108#[serde(deny_unknown_fields)]
1109pub struct ViewerSessionCapabilities {
1110 #[serde(default)]
1111 pub clear_context: bool,
1112 pub open: bool,
1113 pub prompt: bool,
1114 pub run_shell: bool,
1115 pub interrupt_turn: bool,
1117 pub cancel_operation: bool,
1119 pub suspend: bool,
1120 pub destroy: bool,
1121 pub rename: bool,
1122 pub resume: bool,
1123 #[serde(default)]
1126 pub move_session: bool,
1127 pub set_config: bool,
1128 pub set_plan_mode: bool,
1129 #[serde(default)]
1131 pub change_workspace: bool,
1132 #[serde(default)]
1136 pub container_settings: bool,
1137 #[serde(default)]
1141 pub restart: bool,
1142 #[serde(default)]
1145 pub interrupt_all: bool,
1146}
1147
1148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1154#[serde(rename_all = "kebab-case")]
1155pub enum ViewerLifecycleCategory {
1156 Live,
1157 Starting,
1158 Suspending,
1159 Suspended,
1160 Failed,
1161}
1162
1163pub const LAUNCHING_STATE: &str = "launching";
1167
1168fn public_title(session: &mj_core::state::SessionRecord) -> String {
1173 session.listed_title().to_owned()
1174}
1175
1176impl ViewerLifecycleCategory {
1177 pub(super) const fn of(state: SessionState) -> Self {
1178 match state {
1179 SessionState::Provisioning => Self::Starting,
1180 SessionState::Running
1182 | SessionState::Disconnected
1183 | SessionState::Checkpointing
1184 | SessionState::Parked => Self::Live,
1185 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1186 Self::Suspending
1187 }
1188 SessionState::Stopped => Self::Suspended,
1189 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1190 Self::Failed
1191 }
1192 }
1193 }
1194
1195 pub const fn is_dashboard_visible(self) -> bool {
1199 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1200 }
1201}
1202
1203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1204#[serde(rename_all = "kebab-case")]
1205pub enum ViewerOperationKind {
1206 Create,
1207 Resume,
1208 Move,
1209 Suspend,
1210 Destroy,
1211 Stop,
1213 Cleanup,
1214 Checkpoint,
1215}
1216
1217impl ViewerOperationKind {
1218 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1219 match self {
1220 Self::Create => Some(SessionTransitionKind::Starting),
1221 Self::Resume => Some(SessionTransitionKind::Resuming),
1222 Self::Move => Some(SessionTransitionKind::Moving),
1223 Self::Suspend => Some(SessionTransitionKind::Suspending),
1224 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1225 Self::Stop => Some(SessionTransitionKind::Stopping),
1226 Self::Checkpoint => None,
1229 }
1230 }
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1235#[serde(deny_unknown_fields)]
1236pub struct ViewerOperationStage {
1237 pub label: String,
1238 pub started_at_epoch_seconds: u64,
1239}
1240
1241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1247#[serde(deny_unknown_fields)]
1248pub struct ViewerOperation {
1249 pub id: String,
1250 pub session_id: String,
1251 pub kind: ViewerOperationKind,
1252 pub started_at_epoch_seconds: u64,
1253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1254 pub stages: Vec<ViewerOperationStage>,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1258 pub notice: Option<String>,
1259 pub cancellable: bool,
1260}
1261
1262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1264#[serde(rename_all = "kebab-case")]
1265pub enum ViewerChatPhase {
1266 #[default]
1267 Idle,
1268 Running,
1269 Closing,
1270 Closed,
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1274#[serde(deny_unknown_fields)]
1275pub struct ViewerConfigChoice {
1276 pub value: String,
1277 pub name: String,
1278 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub description: Option<String>,
1280}
1281
1282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1288#[serde(deny_unknown_fields)]
1289pub struct ViewerConfigOption {
1290 pub key: String,
1291 pub label: String,
1292 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub current: Option<String>,
1294 pub choices: Vec<ViewerConfigChoice>,
1295}