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(default)]
23 pub last_subagent_policy: mj_core::subagent::SubagentPolicy,
24 pub revision: u64,
25 pub generated_at: String,
26 #[serde(default)]
29 pub server_time_ms: i64,
30 #[serde(default, skip_serializing_if = "String::is_empty")]
34 pub server_version: String,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub workspaces: Vec<ViewerWorkspace>,
37 pub sessions: ViewerSessions,
38 pub profiles: Vec<ViewerProfile>,
39 pub targets: Vec<ViewerTarget>,
40 pub bundles: Vec<ViewerBundle>,
41 #[serde(default)]
44 pub review_config: ViewerReviewConfig,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub capacity: Vec<ViewerTargetCapacity>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub launch_failures: Vec<ViewerLaunchFailure>,
52}
53
54fn default_agent_mailboxes_enabled() -> bool {
55 true
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
60#[serde(from = "Vec<ViewerSession>", into = "Vec<ViewerSession>")]
61pub struct ViewerSessions(pub(crate) mj_core::snapshot_map::SnapshotMap<String, ViewerSession>);
62
63impl ViewerSessions {
64 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ViewerSession> + ExactSizeIterator {
65 self.0.values()
66 }
67
68 pub fn len(&self) -> usize {
69 self.0.len()
70 }
71 pub fn is_empty(&self) -> bool {
72 self.0.is_empty()
73 }
74 pub fn push(&mut self, session: ViewerSession) {
75 self.0.insert(session.id.clone(), session);
76 }
77}
78
79impl From<Vec<ViewerSession>> for ViewerSessions {
80 fn from(rows: Vec<ViewerSession>) -> Self {
81 rows.into_iter().collect()
82 }
83}
84
85impl From<ViewerSessions> for Vec<ViewerSession> {
86 fn from(rows: ViewerSessions) -> Self {
87 rows.0.into_values().collect()
88 }
89}
90
91impl FromIterator<ViewerSession> for ViewerSessions {
92 fn from_iter<T: IntoIterator<Item = ViewerSession>>(rows: T) -> Self {
93 Self(rows.into_iter().map(|row| (row.id.clone(), row)).collect())
94 }
95}
96
97impl IntoIterator for ViewerSessions {
98 type Item = ViewerSession;
99 type IntoIter = std::vec::IntoIter<ViewerSession>;
100 fn into_iter(self) -> Self::IntoIter {
101 Vec::from(self).into_iter()
102 }
103}
104
105impl std::ops::Index<usize> for ViewerSessions {
106 type Output = ViewerSession;
107 fn index(&self, index: usize) -> &Self::Output {
108 self.iter().nth(index).expect("viewer row index")
109 }
110}
111
112impl std::ops::IndexMut<usize> for ViewerSessions {
113 fn index_mut(&mut self, index: usize) -> &mut Self::Output {
114 let id = self.0.keys().nth(index).expect("viewer row index").clone();
115 self.0.get_mut(&id).expect("viewer row exists")
116 }
117}
118
119pub(crate) type ViewerChildren =
120 mj_core::snapshot_map::SnapshotMap<String, mj_core::snapshot_map::SnapshotMap<String, ()>>;
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct ViewerLaunchFailure {
128 pub id: String,
131 pub workspace_id: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub session_id: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub error: Option<String>,
139}
140
141impl ViewerSnapshot {
142 pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
146 let mut children = ViewerChildren::new();
147 for relation in state.subagents.values() {
148 children
149 .entry(relation.parent_session_id.clone())
150 .or_insert_with(Default::default)
151 .insert(relation.child_session_id.clone(), ());
152 }
153 Self::from_config_records(config, state, revision, state.sessions.values(), &children)
154 }
155
156 pub(crate) fn from_config_records<'a>(
157 config: &Config,
158 state: &AppState,
159 revision: u64,
160 records: impl Iterator<Item = &'a mj_core::state::SessionRecord>,
161 children: &ViewerChildren,
162 ) -> Self {
163 let sessions = records
164 .map(|session| {
165 #[cfg(test)]
166 VIEWER_ROW_VISITS.with(|visits| visits.set(visits.get() + 1));
167 let checkout = state
168 .checkout(&session.id)
169 .expect("viewer session has a valid checkout owner");
170 let resume_refusals = config
171 .targets
172 .keys()
173 .filter_map(|target_id| {
174 crate::controller::resume_compatibility_with_checkout(
175 session, &checkout, config, target_id,
176 )
177 .err()
178 .map(|reason| (target_id.clone(), reason))
179 })
180 .collect::<BTreeMap<_, _>>();
181 let incompatible = resume_refusals.keys().cloned().collect::<Vec<_>>();
182 let lifecycle = ViewerLifecycleCategory::of(session.state);
183 let project = state.project_identity_session(session);
187 let source = project.project_source(config);
188 let subagent = state.subagents.get(&session.id);
189 let subagent_session_ids = children
190 .get(&session.id)
191 .map(|children| children.keys().cloned().collect())
192 .unwrap_or_default();
193 let start = session.start_selection();
194 ViewerSession {
195 subagents: session.subagents.clone().unwrap_or_default(),
196 at: start.at,
197 branch: start.branch,
198 base: start.base,
199 targeted_turn_control_supported: false,
200 native_subagents: Vec::new(),
201 steering: None,
202 active_prompt_id: None,
203 cancelling_prompt_id: None,
204 capacity_retry: None,
205 retry_assessment_pending: false,
206 quota_recovery: None,
207 is_subagent_session: state.is_subagent_session(&session.id),
208 id: session.id.clone(),
209 publication_state: session.publication_state(),
210 managed_checkout_kind: checkout.managed_worktree().map(|owned| owned.kind),
211 workspace_id: session.workspace_id.clone(),
212 title: public_title(session),
213 subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
214 subagent_task_name: subagent.map(|child| child.task_name.clone()),
215 subagent_session_ids,
216 harness_kind: session.harness_kind.id().into(),
217 profile_id: session.last_profile.clone(),
218 bundle_id: session.bundle_id.clone(),
219 target_id: session.target_template_id.clone(),
220 state: match session.state {
221 SessionState::Closing => "suspending",
222 SessionState::Stopped => "suspended",
223 _ => session.state.as_str(),
224 }
225 .into(),
226 created_at: session.created_at.clone(),
227 updated_at: session.updated_at.clone(),
228 has_error: session.last_error.is_some()
229 || session.configuration_issue(config).is_some(),
230 has_checkpoint: session.checkpoint.is_some(),
231 configuration_issue: session.configuration_issue(config),
232 launch_error: matches!(
247 session.state,
248 SessionState::Error | SessionState::Stopped
249 )
250 .then(|| session.last_error.clone())
251 .flatten()
252 .or_else(|| session.public_error().map(str::to_owned)),
253 storage_problem: None,
254 preview: Vec::new(),
255 queued_prompts: Vec::new(),
256 active_user_shells: Vec::new(),
257 background_tasks: Vec::new(),
258 pending_elicitations: Vec::new(),
259 conversation_available: false,
260 prompt_images_supported: false,
261 incompatible_resume_targets: incompatible.clone(),
262 resume_refusals,
263 compatible_resume_targets: config
264 .targets
265 .keys()
266 .filter(|target_id| !incompatible.contains(*target_id))
267 .cloned()
268 .collect(),
269 project_label: source.short,
270 project_key: project_key(&source.key),
271 display_location: project.project_target(config, &session.target_template_id),
272 container_cpus: session.container_cpus.clone(),
273 container_memory: session.container_memory.clone(),
274 additional_mounts: session.additional_mounts.clone(),
275 lifecycle,
276 transitioning: session.state.transition_kind().is_some(),
277 latest_event_ordinal: 0,
278 last_activity_at_ms: None,
279 last_message_at_ms: None,
280 activity_details: None,
281 activity: String::new(),
282 operation: None,
283 move_recovery: None,
284 chat_phase: ViewerChatPhase::default(),
287 is_idle: false,
288 activity_state: None,
289 config_options: Vec::new(),
290 plan_mode_active: None,
291 turn_review: None,
292 available_commands: Vec::new(),
293 capabilities: ViewerSessionCapabilities {
297 clear_context: false,
298 open: false,
299 prompt: false,
300 run_shell: false,
301 interrupt_turn: false,
302 cancel_operation: false,
303 suspend: lifecycle.is_dashboard_visible(),
304 destroy: true,
305 rename: true,
306 resume: !lifecycle.is_dashboard_visible(),
307 move_session: false,
308 set_config: false,
309 set_plan_mode: false,
310 change_workspace: false,
314 container_settings: false,
315 restart: false,
316 interrupt_all: false,
317 },
318 }
319 })
320 .collect();
321 let profiles = config
322 .enabled_profiles()
323 .map(|(id, profile)| ViewerProfile {
324 id: id.to_owned(),
325 harness_kind: profile.kind.id().into(),
326 subagents: profile.subagents.clone(),
327 subagent_discovery_key: config.subagent_discovery_key(id, None),
328 capabilities_key: profile.capabilities_key(id),
329 subagent_profile_ids: config
330 .enabled_profiles()
331 .filter(|(candidate, _)| config.subagents.profile_is_eligible(id, candidate))
332 .map(|(candidate, _)| candidate.to_owned())
333 .collect(),
334 quota: None,
335 })
336 .collect();
337 let targets = config
338 .targets
339 .iter()
340 .map(|(id, target)| ViewerTarget {
341 id: id.clone(),
342 kind: target.kind_name().into(),
343 resource_allocation_kind: target.into(),
344 requires_project_directory: matches!(
345 target,
346 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
347 ),
348 remembered_container_size: mj_core::config::container_size_host(target)
349 .and_then(|host| state.container_sizes.get(host))
350 .copied(),
351 container_host_limits: None,
352 default_resource_allocation: if mj_core::config::is_container_target(target) {
353 let size = mj_core::state::default_container_size(
354 mj_core::config::container_size_host(target)
355 .and_then(|host| state.container_sizes.get(host))
356 .copied(),
357 None,
358 );
359 Some(SessionResourceAllocation::Container {
360 cpus: size.cpus,
361 memory_bytes: size.memory_bytes,
362 })
363 } else {
364 None
365 },
366 runtime_missing: false,
367 default_candidate: config.is_default_target(id),
368 availability: crate::server::api::LaunchAvailability::Unknown,
369 unavailable_reason: None,
370 recent_project_directories: project_history_host(target)
371 .map(|host| {
372 state
373 .project_directories(host)
374 .iter()
375 .map(|directory| directory.to_string_lossy().into_owned())
376 .collect()
377 })
378 .unwrap_or_default(),
379 })
380 .collect();
381 let bundles = config
382 .bundles
383 .iter()
384 .map(|(id, bundle)| ViewerBundle {
385 id: id.clone(),
386 primary_repository: bundle.primary_repo.clone(),
387 repositories: bundle
388 .repositories
389 .iter()
390 .map(|repository| ViewerRepository {
391 id: repository.id.clone(),
392 github: repository.github.clone(),
393 destination: repository.destination.to_string_lossy().into_owned(),
394 })
395 .collect(),
396 })
397 .collect();
398 Self {
399 agent_mailboxes_enabled: config.agent_mailboxes_enabled(),
400 profile_capabilities: Default::default(),
401 last_subagent_policy: state.last_subagent_policy.clone(),
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(default)]
441 pub subagents: mj_core::subagent::SubagentPolicy,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub at: Option<String>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub branch: Option<String>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub base: Option<String>,
451 #[serde(default)]
452 pub targeted_turn_control_supported: bool,
453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub steering: Option<mj_core::relay::SteeringOperation>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub active_prompt_id: Option<String>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub cancelling_prompt_id: Option<String>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
463 #[serde(default)]
464 pub retry_assessment_pending: bool,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
467 pub id: String,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub publication_state: Option<mj_core::state::PublicationState>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
472 #[serde(default, skip_serializing_if = "String::is_empty")]
473 pub workspace_id: String,
474 pub title: String,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub subagent_parent_id: Option<String>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub subagent_task_name: Option<String>,
481 #[serde(default, skip_serializing_if = "Vec::is_empty")]
483 pub subagent_session_ids: Vec<String>,
484 pub harness_kind: String,
485 pub profile_id: String,
486 pub bundle_id: String,
487 pub target_id: String,
488 pub state: String,
489 pub created_at: String,
490 pub updated_at: String,
491 pub has_error: bool,
492 #[serde(default)]
498 pub has_checkpoint: bool,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub configuration_issue: Option<String>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub launch_error: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub storage_problem: Option<String>,
513
514 #[serde(default, skip_serializing_if = "Vec::is_empty")]
515 pub preview: Vec<String>,
516 #[serde(default, skip_serializing_if = "Vec::is_empty")]
517 pub queued_prompts: Vec<ViewerQueuedPrompt>,
518 #[serde(default, skip_serializing_if = "Vec::is_empty")]
519 pub active_user_shells: Vec<ViewerUserShell>,
520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
521 pub background_tasks: Vec<ViewerBackgroundTask>,
522 #[serde(default, skip_serializing_if = "Vec::is_empty")]
526 pub pending_elicitations: Vec<ElicitationRequest>,
527 pub conversation_available: bool,
528 #[serde(default)]
532 pub prompt_images_supported: bool,
533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
540 pub incompatible_resume_targets: Vec<String>,
541 #[serde(skip)]
546 pub resume_refusals: BTreeMap<String, String>,
547 #[serde(default, skip_serializing_if = "Vec::is_empty")]
550 pub compatible_resume_targets: Vec<String>,
551 #[serde(default, skip_serializing_if = "String::is_empty")]
554 pub project_label: String,
555 #[serde(default, skip_serializing_if = "String::is_empty")]
559 pub project_key: String,
560 #[serde(default)]
563 pub display_location: String,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub container_cpus: Option<String>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub container_memory: Option<String>,
572 #[serde(default, skip_serializing_if = "Vec::is_empty")]
575 pub additional_mounts: Vec<AdditionalMount>,
576 pub lifecycle: ViewerLifecycleCategory,
577 #[serde(default)]
581 pub transitioning: bool,
582 #[serde(default)]
586 pub latest_event_ordinal: u64,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub last_activity_at_ms: Option<i64>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub last_message_at_ms: Option<i64>,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub activity_details: Option<ViewerActivityDetails>,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub operation: Option<ViewerOperation>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub move_recovery: Option<ViewerMoveRecovery>,
609 #[serde(default)]
610 pub chat_phase: ViewerChatPhase,
611 #[serde(default)]
614 pub is_idle: bool,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub activity_state: Option<mj_core::activity::ActivityState>,
621 #[serde(default, skip_serializing_if = "String::is_empty")]
624 pub activity: String,
625 #[serde(default, skip_serializing_if = "Vec::is_empty")]
627 pub config_options: Vec<ViewerConfigOption>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub plan_mode_active: Option<bool>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub turn_review: Option<ViewerTurnReview>,
635 #[serde(default, skip_serializing_if = "Vec::is_empty")]
639 pub available_commands: Vec<ViewerMjCommand>,
640 pub capabilities: ViewerSessionCapabilities,
641}
642
643#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
644#[serde(deny_unknown_fields)]
645pub struct ViewerMoveRecovery {
646 pub operation_id: String,
647 pub source_profile_id: String,
648 pub source_target_template_id: String,
649 pub destination_profile_id: String,
650 pub destination_target_template_id: String,
651 pub phase: String,
652 pub queue: String,
653 pub clear_resource_allocation: bool,
654 #[serde(default)]
657 pub source_additional_mounts: Vec<AdditionalMount>,
658 #[serde(default)]
659 pub source_resource_allocation: Option<SessionResourceAllocation>,
660 #[serde(default)]
663 pub destination_additional_mounts: Vec<AdditionalMount>,
664 #[serde(default)]
665 pub destination_resource_allocation: Option<SessionResourceAllocation>,
666 pub checkpoint_retained: bool,
670 #[serde(default)]
674 pub environment_retained: bool,
675 pub destination_ready: bool,
676 pub queue_admission_started: bool,
677 pub queue_admission_finished: bool,
678}
679
680impl ViewerMoveRecovery {
681 #[must_use]
682 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
683 if matches!(operation.phase, MovePhase::Completed) {
684 return None;
685 }
686 Some(Self {
687 operation_id: operation.operation_id.clone(),
688 source_profile_id: operation.source_profile_id.clone(),
689 source_target_template_id: operation.source_target_template_id.clone(),
690 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
691 destination_target_template_id: operation
692 .selection
693 .target_template_id
694 .clone()
695 .unwrap_or_default(),
696 phase: match operation.phase {
697 MovePhase::Preparing => "preparing",
698 MovePhase::ClosingSource => "closing_source",
699 MovePhase::ResumingDestination => "resuming_destination",
700 MovePhase::StartingQueue => "starting_queue",
701 MovePhase::Completed => "completed",
702 MovePhase::Failed => "failed",
703 MovePhase::Cancelled => "cancelled",
704 }
705 .into(),
706 queue: match operation.queue {
707 ResumeQueueDisposition::Start => "start",
708 ResumeQueueDisposition::Discard => "discard",
709 }
710 .into(),
711 clear_resource_allocation: operation.selection.clear_resource_allocation,
712 source_additional_mounts: operation.source_additional_mounts.clone(),
713 source_resource_allocation: operation.source_resource_allocation.clone(),
714 destination_additional_mounts: operation
715 .selection
716 .additional_mounts
717 .clone()
718 .unwrap_or_default(),
719 destination_resource_allocation: operation.selection.resource_allocation.clone(),
720 checkpoint_retained: operation.checkpoint_retained(),
721 environment_retained: operation.holds_source_environment(),
722 destination_ready: operation.destination_target.is_some()
723 && operation.destination_native_session_id.is_some(),
724 queue_admission_started: operation.queue_admission_started,
725 queue_admission_finished: operation.queue_admission_finished,
726 })
727 }
728}
729
730impl ViewerSession {
731 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
734 self.project_label = source.short.clone();
735 self.project_key = project_key(&source.key);
736 }
737}
738
739pub use crate::database::{
741 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
742};
743
744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
746#[serde(deny_unknown_fields)]
747pub struct ViewerMjCommand {
748 pub name: String,
749 pub description: String,
750 pub source: ViewerCommandSource,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub argument: Option<String>,
756}
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(rename_all = "snake_case")]
760pub enum ViewerCommandSource {
761 Mj,
762 Agent,
763}
764
765#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
767#[serde(deny_unknown_fields)]
768pub struct ViewerReviewConfig {
769 pub enabled: bool,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub profile: Option<String>,
772}
773
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
776#[serde(deny_unknown_fields)]
777pub struct ViewerTurnReview {
778 pub status: String,
780 #[serde(default, skip_serializing_if = "Vec::is_empty")]
782 pub roles: Vec<ViewerReviewRole>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub verdict: Option<ViewerReviewVerdict>,
786}
787
788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(deny_unknown_fields)]
790pub struct ViewerReviewRole {
791 pub label: String,
792 pub state: String,
794}
795
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
797#[serde(deny_unknown_fields)]
798pub struct ViewerReviewVerdict {
799 pub kind: String,
801 pub text: String,
803 #[serde(default, skip_serializing_if = "Vec::is_empty")]
807 pub allowed: Vec<String>,
808}
809
810impl ViewerTurnReview {
811 #[must_use]
813 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
814 Self {
815 status: review.status.clone(),
816 roles: review
817 .roles
818 .iter()
819 .map(|role| ViewerReviewRole {
820 label: role.label.clone(),
821 state: role.state.label().to_owned(),
822 })
823 .collect(),
824 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
825 kind: match verdict.kind {
826 crate::review_host::VerdictKind::Clean => "clean",
827 crate::review_host::VerdictKind::Findings => "findings",
828 crate::review_host::VerdictKind::Failed => "failed",
829 }
830 .to_owned(),
831 text: verdict.text.clone(),
832 allowed: verdict
833 .allowed
834 .iter()
835 .filter_map(resolution_name)
836 .map(str::to_owned)
837 .collect(),
838 }),
839 }
840 }
841}
842
843#[must_use]
846pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
847 match resolution {
848 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
849 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
850 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
851 mj_core::review::driver::Resolution::NothingToReview
853 | mj_core::review::driver::Resolution::CoverageStarted => None,
854 }
855}
856
857#[must_use]
859pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
860 match name {
861 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
862 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
863 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
864 _ => None,
865 }
866}
867
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869#[serde(deny_unknown_fields)]
870pub struct ViewerWorkspace {
871 pub id: String,
872 pub name: String,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
876#[serde(deny_unknown_fields)]
877pub struct ViewerQueuedPrompt {
878 pub id: String,
879 pub text: String,
880 pub created_at: String,
881}
882
883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(deny_unknown_fields)]
885pub struct ViewerUserShell {
886 pub id: String,
887 pub command: String,
888 pub started_at_ms: Option<i64>,
889}
890
891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(deny_unknown_fields)]
894pub struct ViewerBackgroundTask {
895 pub id: String,
896 pub command: String,
897 pub started_at_ms: i64,
898 pub can_stop: bool,
899}
900
901#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
902#[serde(deny_unknown_fields)]
903pub struct ViewerProfile {
904 pub id: String,
905 #[serde(default)]
906 pub subagent_discovery_key: String,
907 #[serde(default)]
908 pub capabilities_key: String,
909 #[serde(default)]
910 pub subagent_profile_ids: Vec<String>,
911 pub harness_kind: String,
912 #[serde(default)]
913 pub subagents: mj_core::subagent::SubagentPolicy,
914 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub quota: Option<ViewerQuota>,
916}
917
918#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
924#[serde(deny_unknown_fields)]
925pub struct ViewerQuotaWindow {
926 pub label: String,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub resets_at_epoch_seconds: Option<i64>,
929 #[serde(default)]
930 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub banked_resets: Option<u64>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub percent_used: Option<u8>,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub resets_at: Option<String>,
937 pub projects_exhaustion_before_reset: bool,
940}
941
942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
943#[serde(deny_unknown_fields)]
944pub struct ViewerQuota {
945 pub summary: String,
948 #[serde(default, skip_serializing_if = "Vec::is_empty")]
949 pub windows: Vec<ViewerQuotaWindow>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub resets_at: Option<String>,
952 pub stale: bool,
953 #[serde(default)]
956 pub refreshed_at_epoch_seconds: u64,
957 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub rate_limited_until_epoch_seconds: Option<u64>,
961 pub has_error: bool,
964}
965
966#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(deny_unknown_fields)]
973pub struct ViewerTargetCapacity {
974 pub id: String,
975 pub label: String,
978 pub target_ids: Vec<String>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub cpu_percent: Option<u8>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub memory_used_bytes: Option<u64>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub memory_total_bytes: Option<u64>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
986 pub logical_cores: Option<u64>,
987 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub disk_total_bytes: Option<u64>,
989 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub virtual_machines: Option<u64>,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub sampled_at_epoch_seconds: Option<u64>,
994 pub refreshing: bool,
995 pub stale: bool,
996 pub has_error: bool,
999 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1002 pub storage: Vec<ViewerTargetStorage>,
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1006#[serde(deny_unknown_fields)]
1007pub struct ViewerTargetStorage {
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub machine: Option<String>,
1011 pub mount: String,
1012 pub available_bytes: u64,
1013 pub reserved_bytes: u64,
1015 pub condition: mj_core::targets::storage::StorageCondition,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub detail: Option<String>,
1019}
1020
1021impl ViewerTargetStorage {
1022 pub fn list(views: &[&mj_core::targets::storage::TargetStorageView], fleet: bool) -> Vec<Self> {
1023 use mj_core::targets::storage::StorageCondition;
1024 views
1025 .iter()
1026 .flat_map(|view| {
1027 view.filesystems.iter().map(move |filesystem| Self {
1028 machine: fleet.then(|| view.host.clone()),
1029 mount: filesystem.space.mount.clone(),
1030 available_bytes: filesystem.space.available_bytes,
1031 reserved_bytes: filesystem.space.reserved_bytes,
1032 condition: filesystem.condition,
1033 detail: (filesystem.condition != StorageCondition::Ok)
1034 .then(|| view.explanation(filesystem)),
1035 })
1036 })
1037 .collect()
1038 }
1039}
1040
1041fn unknown_availability() -> crate::server::api::LaunchAvailability {
1042 crate::server::api::LaunchAvailability::Unknown
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1046#[serde(deny_unknown_fields)]
1047pub struct ViewerTarget {
1048 pub id: String,
1049 pub kind: String,
1050 #[serde(default)]
1052 pub resource_allocation_kind: ResourceAllocationKind,
1053 pub requires_project_directory: bool,
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub remembered_container_size: Option<HostContainerSize>,
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub container_host_limits: Option<HostContainerSize>,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub default_resource_allocation: Option<SessionResourceAllocation>,
1063 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1068 pub runtime_missing: bool,
1069 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1074 pub default_candidate: bool,
1075 #[serde(default = "unknown_availability")]
1078 pub availability: crate::server::api::LaunchAvailability,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub unavailable_reason: Option<String>,
1082 #[serde(default)]
1086 pub recent_project_directories: Vec<String>,
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091#[serde(deny_unknown_fields)]
1092pub struct ViewerTargetResourceOptions {
1093 pub options: Vec<SessionResourceAllocation>,
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub default_allocation: Option<SessionResourceAllocation>,
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1099#[serde(deny_unknown_fields)]
1100pub struct ViewerBundle {
1101 pub id: String,
1102 pub primary_repository: String,
1103 pub repositories: Vec<ViewerRepository>,
1104}
1105
1106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1107#[serde(deny_unknown_fields)]
1108pub struct ViewerRepository {
1109 pub id: String,
1110 pub github: Option<String>,
1111 pub destination: String,
1112}
1113
1114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1122#[serde(deny_unknown_fields)]
1123pub struct ViewerSessionCapabilities {
1124 #[serde(default)]
1125 pub clear_context: bool,
1126 pub open: bool,
1127 pub prompt: bool,
1128 pub run_shell: bool,
1129 pub interrupt_turn: bool,
1131 pub cancel_operation: bool,
1133 pub suspend: bool,
1134 pub destroy: bool,
1135 pub rename: bool,
1136 pub resume: bool,
1137 #[serde(default)]
1140 pub move_session: bool,
1141 pub set_config: bool,
1142 pub set_plan_mode: bool,
1143 #[serde(default)]
1145 pub change_workspace: bool,
1146 #[serde(default)]
1150 pub container_settings: bool,
1151 #[serde(default)]
1155 pub restart: bool,
1156 #[serde(default)]
1159 pub interrupt_all: bool,
1160}
1161
1162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1168#[serde(rename_all = "kebab-case")]
1169pub enum ViewerLifecycleCategory {
1170 Live,
1171 Starting,
1172 Suspending,
1173 Suspended,
1174 Failed,
1175}
1176
1177pub const LAUNCHING_STATE: &str = "launching";
1181
1182fn public_title(session: &mj_core::state::SessionRecord) -> String {
1187 session.listed_title().to_owned()
1188}
1189
1190impl ViewerLifecycleCategory {
1191 pub(super) const fn of(state: SessionState) -> Self {
1192 match state {
1193 SessionState::Provisioning => Self::Starting,
1194 SessionState::Running
1196 | SessionState::Disconnected
1197 | SessionState::Checkpointing
1198 | SessionState::Parked => Self::Live,
1199 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1200 Self::Suspending
1201 }
1202 SessionState::Stopped => Self::Suspended,
1203 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1204 Self::Failed
1205 }
1206 }
1207 }
1208
1209 pub const fn is_dashboard_visible(self) -> bool {
1213 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1214 }
1215}
1216
1217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1218#[serde(rename_all = "kebab-case")]
1219pub enum ViewerOperationKind {
1220 Create,
1221 Resume,
1222 Move,
1223 Suspend,
1224 Destroy,
1225 Stop,
1227 Cleanup,
1228 Checkpoint,
1229}
1230
1231impl ViewerOperationKind {
1232 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1233 match self {
1234 Self::Create => Some(SessionTransitionKind::Starting),
1235 Self::Resume => Some(SessionTransitionKind::Resuming),
1236 Self::Move => Some(SessionTransitionKind::Moving),
1237 Self::Suspend => Some(SessionTransitionKind::Suspending),
1238 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1239 Self::Stop => Some(SessionTransitionKind::Stopping),
1240 Self::Checkpoint => None,
1243 }
1244 }
1245}
1246
1247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1249#[serde(deny_unknown_fields)]
1250pub struct ViewerOperationStage {
1251 pub label: String,
1252 pub started_at_epoch_seconds: u64,
1253}
1254
1255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1261#[serde(deny_unknown_fields)]
1262pub struct ViewerOperation {
1263 pub id: String,
1264 pub session_id: String,
1265 pub kind: ViewerOperationKind,
1266 pub started_at_epoch_seconds: u64,
1267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1268 pub stages: Vec<ViewerOperationStage>,
1269 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub notice: Option<String>,
1273 pub cancellable: bool,
1274}
1275
1276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1278#[serde(rename_all = "kebab-case")]
1279pub enum ViewerChatPhase {
1280 #[default]
1281 Idle,
1282 Running,
1283 Closing,
1284 Closed,
1285}
1286
1287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1288#[serde(deny_unknown_fields)]
1289pub struct ViewerConfigChoice {
1290 pub value: String,
1291 pub name: String,
1292 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub description: Option<String>,
1294}
1295
1296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1302#[serde(deny_unknown_fields)]
1303pub struct ViewerConfigOption {
1304 pub key: String,
1305 pub label: String,
1306 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub current: Option<String>,
1308 pub choices: Vec<ViewerConfigChoice>,
1309}