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 preview: Vec::new(),
243 queued_prompts: Vec::new(),
244 active_user_shells: Vec::new(),
245 background_tasks: Vec::new(),
246 pending_elicitations: Vec::new(),
247 conversation_available: false,
248 prompt_images_supported: false,
249 incompatible_resume_targets: incompatible.clone(),
250 resume_refusals,
251 compatible_resume_targets: config
252 .targets
253 .keys()
254 .filter(|target_id| !incompatible.contains(*target_id))
255 .cloned()
256 .collect(),
257 project_label: source.short,
258 project_key: project_key(&source.key),
259 display_location: project.project_target(config, &session.target_template_id),
260 container_cpus: session.container_cpus.clone(),
261 container_memory: session.container_memory.clone(),
262 additional_mounts: session.additional_mounts.clone(),
263 lifecycle,
264 transitioning: session.state.transition_kind().is_some(),
265 latest_event_ordinal: 0,
266 last_activity_at_ms: None,
267 last_message_at_ms: None,
268 activity_details: None,
269 activity: String::new(),
270 operation: None,
271 move_recovery: None,
272 chat_phase: ViewerChatPhase::default(),
275 is_idle: false,
276 activity_state: None,
277 config_options: Vec::new(),
278 plan_mode_active: None,
279 turn_review: None,
280 available_commands: Vec::new(),
281 capabilities: ViewerSessionCapabilities {
285 clear_context: false,
286 open: false,
287 prompt: false,
288 run_shell: false,
289 interrupt_turn: false,
290 cancel_operation: false,
291 suspend: lifecycle.is_dashboard_visible(),
292 destroy: true,
293 rename: true,
294 resume: !lifecycle.is_dashboard_visible(),
295 move_session: false,
296 set_config: false,
297 set_plan_mode: false,
298 change_workspace: false,
302 container_settings: false,
303 restart: false,
304 interrupt_all: false,
305 },
306 }
307 })
308 .collect();
309 let profiles = config
310 .enabled_profiles()
311 .map(|(id, profile)| ViewerProfile {
312 id: id.to_owned(),
313 harness_kind: profile.kind.id().into(),
314 subagents: profile.subagents.clone(),
315 subagent_discovery_key: config.subagent_discovery_key(id, None),
316 capabilities_key: profile.capabilities_key(id),
317 subagent_profile_ids: config
318 .enabled_profiles()
319 .filter(|(candidate, _)| config.subagents.profile_is_eligible(id, candidate))
320 .map(|(candidate, _)| candidate.to_owned())
321 .collect(),
322 quota: None,
323 })
324 .collect();
325 let targets = config
326 .targets
327 .iter()
328 .map(|(id, target)| ViewerTarget {
329 id: id.clone(),
330 kind: target.kind_name().into(),
331 requires_project_directory: matches!(
332 target,
333 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
334 ),
335 runtime_missing: false,
336 default_candidate: config.is_default_target(id),
337 availability: crate::server::api::LaunchAvailability::Unknown,
338 unavailable_reason: None,
339 recent_project_directories: project_history_host(target)
340 .map(|host| {
341 state
342 .project_directories(host)
343 .iter()
344 .map(|directory| directory.to_string_lossy().into_owned())
345 .collect()
346 })
347 .unwrap_or_default(),
348 })
349 .collect();
350 let bundles = config
351 .bundles
352 .iter()
353 .map(|(id, bundle)| ViewerBundle {
354 id: id.clone(),
355 primary_repository: bundle.primary_repo.clone(),
356 repositories: bundle
357 .repositories
358 .iter()
359 .map(|repository| ViewerRepository {
360 id: repository.id.clone(),
361 github: repository.github.clone(),
362 destination: repository.destination.to_string_lossy().into_owned(),
363 })
364 .collect(),
365 })
366 .collect();
367 Self {
368 profile_capabilities: Default::default(),
369 last_subagent_policy: state.last_subagent_policy.clone(),
370 revision,
371 generated_at: now_unix().to_string(),
372 server_time_ms: mj_core::clock::epoch_millis(),
373 server_version: env!("CARGO_PKG_VERSION").to_owned(),
374 workspaces: Vec::new(),
375 sessions,
376 profiles,
377 targets,
378 bundles,
379 review_config: ViewerReviewConfig {
380 enabled: config.review.enabled,
381 tier: config.review.tier.label().to_owned(),
382 profile: config.review.profile.clone(),
383 },
384 capacity: Vec::new(),
385 launch_failures: Vec::new(),
386 }
387 }
388}
389
390pub(super) fn project_key(identity: &str) -> String {
397 use sha2::Digest as _;
398 let digest = Sha256::digest(identity.as_bytes());
399 mj_core::hex::lower_hex(&digest[..8])
400}
401
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403#[serde(deny_unknown_fields)]
404pub struct ViewerSession {
405 #[serde(default)]
406 pub subagents: mj_core::subagent::SubagentPolicy,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub at: Option<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub branch: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub base: Option<String>,
416 #[serde(default)]
417 pub targeted_turn_control_supported: bool,
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub steering: Option<mj_core::relay::SteeringOperation>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub active_prompt_id: Option<String>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub cancelling_prompt_id: Option<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
428 #[serde(default)]
429 pub retry_assessment_pending: bool,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
432 pub id: String,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub publication_state: Option<mj_core::state::PublicationState>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
437 #[serde(default, skip_serializing_if = "String::is_empty")]
438 pub workspace_id: String,
439 pub title: String,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub subagent_parent_id: Option<String>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub subagent_task_name: Option<String>,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
448 pub subagent_session_ids: Vec<String>,
449 pub harness_kind: String,
450 pub profile_id: String,
451 pub bundle_id: String,
452 pub target_id: String,
453 pub state: String,
454 pub created_at: String,
455 pub updated_at: String,
456 pub has_error: bool,
457 #[serde(default)]
463 pub has_checkpoint: bool,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub configuration_issue: Option<String>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub launch_error: Option<String>,
473
474 #[serde(default, skip_serializing_if = "Vec::is_empty")]
475 pub preview: Vec<String>,
476 #[serde(default, skip_serializing_if = "Vec::is_empty")]
477 pub queued_prompts: Vec<ViewerQueuedPrompt>,
478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
479 pub active_user_shells: Vec<ViewerUserShell>,
480 #[serde(default, skip_serializing_if = "Vec::is_empty")]
481 pub background_tasks: Vec<ViewerBackgroundTask>,
482 #[serde(default, skip_serializing_if = "Vec::is_empty")]
486 pub pending_elicitations: Vec<ElicitationRequest>,
487 pub conversation_available: bool,
488 #[serde(default)]
492 pub prompt_images_supported: bool,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
500 pub incompatible_resume_targets: Vec<String>,
501 #[serde(skip)]
506 pub resume_refusals: BTreeMap<String, String>,
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub compatible_resume_targets: Vec<String>,
511 #[serde(default, skip_serializing_if = "String::is_empty")]
514 pub project_label: String,
515 #[serde(default, skip_serializing_if = "String::is_empty")]
519 pub project_key: String,
520 #[serde(default)]
523 pub display_location: String,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub container_cpus: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub container_memory: Option<String>,
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub additional_mounts: Vec<AdditionalMount>,
536 pub lifecycle: ViewerLifecycleCategory,
537 #[serde(default)]
541 pub transitioning: bool,
542 #[serde(default)]
546 pub latest_event_ordinal: u64,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub last_activity_at_ms: Option<i64>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub last_message_at_ms: Option<i64>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub activity_details: Option<ViewerActivityDetails>,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub operation: Option<ViewerOperation>,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub move_recovery: Option<ViewerMoveRecovery>,
569 #[serde(default)]
570 pub chat_phase: ViewerChatPhase,
571 #[serde(default)]
574 pub is_idle: bool,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub activity_state: Option<mj_core::activity::ActivityState>,
581 #[serde(default, skip_serializing_if = "String::is_empty")]
584 pub activity: String,
585 #[serde(default, skip_serializing_if = "Vec::is_empty")]
587 pub config_options: Vec<ViewerConfigOption>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub plan_mode_active: Option<bool>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub turn_review: Option<ViewerTurnReview>,
595 #[serde(default, skip_serializing_if = "Vec::is_empty")]
599 pub available_commands: Vec<ViewerMjCommand>,
600 pub capabilities: ViewerSessionCapabilities,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604#[serde(deny_unknown_fields)]
605pub struct ViewerMoveRecovery {
606 pub operation_id: String,
607 pub source_profile_id: String,
608 pub source_target_template_id: String,
609 pub destination_profile_id: String,
610 pub destination_target_template_id: String,
611 pub phase: String,
612 pub queue: String,
613 pub clear_resource_allocation: bool,
614 #[serde(default)]
617 pub source_additional_mounts: Vec<AdditionalMount>,
618 #[serde(default)]
619 pub source_resource_allocation: Option<SessionResourceAllocation>,
620 #[serde(default)]
623 pub destination_additional_mounts: Vec<AdditionalMount>,
624 #[serde(default)]
625 pub destination_resource_allocation: Option<SessionResourceAllocation>,
626 pub checkpoint_retained: bool,
630 #[serde(default)]
634 pub environment_retained: bool,
635 pub destination_ready: bool,
636 pub queue_admission_started: bool,
637 pub queue_admission_finished: bool,
638}
639
640impl ViewerMoveRecovery {
641 #[must_use]
642 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
643 if matches!(operation.phase, MovePhase::Completed) {
644 return None;
645 }
646 Some(Self {
647 operation_id: operation.operation_id.clone(),
648 source_profile_id: operation.source_profile_id.clone(),
649 source_target_template_id: operation.source_target_template_id.clone(),
650 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
651 destination_target_template_id: operation
652 .selection
653 .target_template_id
654 .clone()
655 .unwrap_or_default(),
656 phase: match operation.phase {
657 MovePhase::Preparing => "preparing",
658 MovePhase::ClosingSource => "closing_source",
659 MovePhase::ResumingDestination => "resuming_destination",
660 MovePhase::StartingQueue => "starting_queue",
661 MovePhase::Completed => "completed",
662 MovePhase::Failed => "failed",
663 MovePhase::Cancelled => "cancelled",
664 }
665 .into(),
666 queue: match operation.queue {
667 ResumeQueueDisposition::Start => "start",
668 ResumeQueueDisposition::Discard => "discard",
669 }
670 .into(),
671 clear_resource_allocation: operation.selection.clear_resource_allocation,
672 source_additional_mounts: operation.source_additional_mounts.clone(),
673 source_resource_allocation: operation.source_resource_allocation.clone(),
674 destination_additional_mounts: operation
675 .selection
676 .additional_mounts
677 .clone()
678 .unwrap_or_default(),
679 destination_resource_allocation: operation.selection.resource_allocation.clone(),
680 checkpoint_retained: operation.checkpoint_retained(),
681 environment_retained: operation.holds_source_environment(),
682 destination_ready: operation.destination_target.is_some()
683 && operation.destination_native_session_id.is_some(),
684 queue_admission_started: operation.queue_admission_started,
685 queue_admission_finished: operation.queue_admission_finished,
686 })
687 }
688}
689
690impl ViewerSession {
691 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
694 self.project_label = source.short.clone();
695 self.project_key = project_key(&source.key);
696 }
697}
698
699pub use crate::database::{
701 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
702};
703
704#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
706#[serde(deny_unknown_fields)]
707pub struct ViewerMjCommand {
708 pub name: String,
709 pub description: String,
710 pub source: ViewerCommandSource,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub argument: Option<String>,
716}
717
718#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
719#[serde(rename_all = "snake_case")]
720pub enum ViewerCommandSource {
721 Mj,
722 Agent,
723}
724
725#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(deny_unknown_fields)]
728pub struct ViewerReviewConfig {
729 pub enabled: bool,
730 pub tier: String,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub profile: Option<String>,
733}
734
735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
737#[serde(deny_unknown_fields)]
738pub struct ViewerTurnReview {
739 pub tier: String,
741 pub status: String,
743 #[serde(default, skip_serializing_if = "Vec::is_empty")]
745 pub roles: Vec<ViewerReviewRole>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub verdict: Option<ViewerReviewVerdict>,
749}
750
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
752#[serde(deny_unknown_fields)]
753pub struct ViewerReviewRole {
754 pub label: String,
755 pub state: String,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct ViewerReviewVerdict {
762 pub kind: String,
764 pub text: String,
766 #[serde(default, skip_serializing_if = "Vec::is_empty")]
770 pub allowed: Vec<String>,
771}
772
773impl ViewerTurnReview {
774 #[must_use]
776 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
777 Self {
778 tier: review.tier.label().to_owned(),
779 status: review.status.clone(),
780 roles: review
781 .roles
782 .iter()
783 .map(|role| ViewerReviewRole {
784 label: role.label.clone(),
785 state: role.state.label().to_owned(),
786 })
787 .collect(),
788 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
789 kind: match verdict.kind {
790 crate::review_host::VerdictKind::Clean => "clean",
791 crate::review_host::VerdictKind::Findings => "findings",
792 crate::review_host::VerdictKind::Failed => "failed",
793 }
794 .to_owned(),
795 text: verdict.text.clone(),
796 allowed: verdict
797 .allowed
798 .iter()
799 .filter_map(resolution_name)
800 .map(str::to_owned)
801 .collect(),
802 }),
803 }
804 }
805}
806
807#[must_use]
810pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
811 match resolution {
812 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
813 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
814 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
815 mj_core::review::driver::Resolution::NothingToReview
817 | mj_core::review::driver::Resolution::CoverageStarted => None,
818 }
819}
820
821#[must_use]
823pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
824 match name {
825 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
826 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
827 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
828 _ => None,
829 }
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
833#[serde(deny_unknown_fields)]
834pub struct ViewerWorkspace {
835 pub id: String,
836 pub name: String,
837}
838
839#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
840#[serde(deny_unknown_fields)]
841pub struct ViewerQueuedPrompt {
842 pub id: String,
843 pub text: String,
844 pub created_at: String,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(deny_unknown_fields)]
849pub struct ViewerUserShell {
850 pub id: String,
851 pub command: String,
852 pub started_at_ms: Option<i64>,
853}
854
855#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857#[serde(deny_unknown_fields)]
858pub struct ViewerBackgroundTask {
859 pub id: String,
860 pub command: String,
861 pub started_at_ms: i64,
862 pub can_stop: bool,
863}
864
865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
866#[serde(deny_unknown_fields)]
867pub struct ViewerProfile {
868 pub id: String,
869 #[serde(default)]
870 pub subagent_discovery_key: String,
871 #[serde(default)]
872 pub capabilities_key: String,
873 #[serde(default)]
874 pub subagent_profile_ids: Vec<String>,
875 pub harness_kind: String,
876 #[serde(default)]
877 pub subagents: mj_core::subagent::SubagentPolicy,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub quota: Option<ViewerQuota>,
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct ViewerQuotaWindow {
890 pub label: String,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
892 pub resets_at_epoch_seconds: Option<i64>,
893 #[serde(default)]
894 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
895 #[serde(default, skip_serializing_if = "Option::is_none")]
896 pub banked_resets: Option<u64>,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub percent_used: Option<u8>,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub resets_at: Option<String>,
901 pub projects_exhaustion_before_reset: bool,
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907#[serde(deny_unknown_fields)]
908pub struct ViewerQuota {
909 pub summary: String,
912 #[serde(default, skip_serializing_if = "Vec::is_empty")]
913 pub windows: Vec<ViewerQuotaWindow>,
914 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub resets_at: Option<String>,
916 pub stale: bool,
917 #[serde(default)]
920 pub refreshed_at_epoch_seconds: u64,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub rate_limited_until_epoch_seconds: Option<u64>,
925 pub has_error: bool,
928}
929
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936#[serde(deny_unknown_fields)]
937pub struct ViewerTargetCapacity {
938 pub id: String,
939 pub label: String,
942 pub target_ids: Vec<String>,
943 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub cpu_percent: Option<u8>,
945 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub memory_used_bytes: Option<u64>,
947 #[serde(default, skip_serializing_if = "Option::is_none")]
948 pub memory_total_bytes: Option<u64>,
949 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub logical_cores: Option<u64>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub disk_total_bytes: Option<u64>,
953 #[serde(default, skip_serializing_if = "Option::is_none")]
955 pub virtual_machines: Option<u64>,
956 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub sampled_at_epoch_seconds: Option<u64>,
958 pub refreshing: bool,
959 pub stale: bool,
960 pub has_error: bool,
963}
964
965fn unknown_availability() -> crate::server::api::LaunchAvailability {
966 crate::server::api::LaunchAvailability::Unknown
967}
968
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970#[serde(deny_unknown_fields)]
971pub struct ViewerTarget {
972 pub id: String,
973 pub kind: String,
974 pub requires_project_directory: bool,
975 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
980 pub runtime_missing: bool,
981 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
986 pub default_candidate: bool,
987 #[serde(default = "unknown_availability")]
990 pub availability: crate::server::api::LaunchAvailability,
991 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub unavailable_reason: Option<String>,
994 #[serde(default)]
998 pub recent_project_directories: Vec<String>,
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1002#[serde(deny_unknown_fields)]
1003pub struct ViewerBundle {
1004 pub id: String,
1005 pub primary_repository: String,
1006 pub repositories: Vec<ViewerRepository>,
1007}
1008
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1010#[serde(deny_unknown_fields)]
1011pub struct ViewerRepository {
1012 pub id: String,
1013 pub github: Option<String>,
1014 pub destination: String,
1015}
1016
1017#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1025#[serde(deny_unknown_fields)]
1026pub struct ViewerSessionCapabilities {
1027 #[serde(default)]
1028 pub clear_context: bool,
1029 pub open: bool,
1030 pub prompt: bool,
1031 pub run_shell: bool,
1032 pub interrupt_turn: bool,
1034 pub cancel_operation: bool,
1036 pub suspend: bool,
1037 pub destroy: bool,
1038 pub rename: bool,
1039 pub resume: bool,
1040 #[serde(default)]
1043 pub move_session: bool,
1044 pub set_config: bool,
1045 pub set_plan_mode: bool,
1046 #[serde(default)]
1048 pub change_workspace: bool,
1049 #[serde(default)]
1053 pub container_settings: bool,
1054 #[serde(default)]
1058 pub restart: bool,
1059 #[serde(default)]
1062 pub interrupt_all: bool,
1063}
1064
1065#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1071#[serde(rename_all = "kebab-case")]
1072pub enum ViewerLifecycleCategory {
1073 Live,
1074 Starting,
1075 Suspending,
1076 Suspended,
1077 Failed,
1078}
1079
1080pub const LAUNCHING_STATE: &str = "launching";
1084
1085fn public_title(session: &mj_core::state::SessionRecord) -> String {
1090 session.listed_title().to_owned()
1091}
1092
1093impl ViewerLifecycleCategory {
1094 pub(super) const fn of(state: SessionState) -> Self {
1095 match state {
1096 SessionState::Provisioning => Self::Starting,
1097 SessionState::Running
1099 | SessionState::Disconnected
1100 | SessionState::Checkpointing
1101 | SessionState::Parked => Self::Live,
1102 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1103 Self::Suspending
1104 }
1105 SessionState::Stopped => Self::Suspended,
1106 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1107 Self::Failed
1108 }
1109 }
1110 }
1111
1112 pub const fn is_dashboard_visible(self) -> bool {
1116 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1117 }
1118}
1119
1120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1121#[serde(rename_all = "kebab-case")]
1122pub enum ViewerOperationKind {
1123 Create,
1124 Resume,
1125 Move,
1126 Suspend,
1127 Destroy,
1128 Stop,
1130 Cleanup,
1131 Checkpoint,
1132}
1133
1134impl ViewerOperationKind {
1135 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1136 match self {
1137 Self::Create => Some(SessionTransitionKind::Starting),
1138 Self::Resume => Some(SessionTransitionKind::Resuming),
1139 Self::Move => Some(SessionTransitionKind::Moving),
1140 Self::Suspend => Some(SessionTransitionKind::Suspending),
1141 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1142 Self::Stop => Some(SessionTransitionKind::Stopping),
1143 Self::Checkpoint => None,
1146 }
1147 }
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1152#[serde(deny_unknown_fields)]
1153pub struct ViewerOperationStage {
1154 pub label: String,
1155 pub started_at_epoch_seconds: u64,
1156}
1157
1158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1164#[serde(deny_unknown_fields)]
1165pub struct ViewerOperation {
1166 pub id: String,
1167 pub session_id: String,
1168 pub kind: ViewerOperationKind,
1169 pub started_at_epoch_seconds: u64,
1170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1171 pub stages: Vec<ViewerOperationStage>,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub notice: Option<String>,
1176 pub cancellable: bool,
1177}
1178
1179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1181#[serde(rename_all = "kebab-case")]
1182pub enum ViewerChatPhase {
1183 #[default]
1184 Idle,
1185 Running,
1186 Closing,
1187 Closed,
1188}
1189
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191#[serde(deny_unknown_fields)]
1192pub struct ViewerConfigChoice {
1193 pub value: String,
1194 pub name: String,
1195 #[serde(default, skip_serializing_if = "Option::is_none")]
1196 pub description: Option<String>,
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1205#[serde(deny_unknown_fields)]
1206pub struct ViewerConfigOption {
1207 pub key: String,
1208 pub label: String,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub current: Option<String>,
1211 pub choices: Vec<ViewerConfigChoice>,
1212}