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 last_subagent_policy: mj_core::subagent::SubagentPolicy,
18 pub revision: u64,
19 pub generated_at: String,
20 #[serde(default)]
23 pub server_time_ms: i64,
24 #[serde(default, skip_serializing_if = "String::is_empty")]
28 pub server_version: String,
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub workspaces: Vec<ViewerWorkspace>,
31 pub sessions: ViewerSessions,
32 pub profiles: Vec<ViewerProfile>,
33 pub targets: Vec<ViewerTarget>,
34 pub bundles: Vec<ViewerBundle>,
35 #[serde(default)]
38 pub review_config: ViewerReviewConfig,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub capacity: Vec<ViewerTargetCapacity>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub launch_failures: Vec<ViewerLaunchFailure>,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
50#[serde(from = "Vec<ViewerSession>", into = "Vec<ViewerSession>")]
51pub struct ViewerSessions(pub(crate) mj_core::snapshot_map::SnapshotMap<String, ViewerSession>);
52
53impl ViewerSessions {
54 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ViewerSession> + ExactSizeIterator {
55 self.0.values()
56 }
57
58 pub fn len(&self) -> usize {
59 self.0.len()
60 }
61 pub fn is_empty(&self) -> bool {
62 self.0.is_empty()
63 }
64 pub fn push(&mut self, session: ViewerSession) {
65 self.0.insert(session.id.clone(), session);
66 }
67}
68
69impl From<Vec<ViewerSession>> for ViewerSessions {
70 fn from(rows: Vec<ViewerSession>) -> Self {
71 rows.into_iter().collect()
72 }
73}
74
75impl From<ViewerSessions> for Vec<ViewerSession> {
76 fn from(rows: ViewerSessions) -> Self {
77 rows.0.into_values().collect()
78 }
79}
80
81impl FromIterator<ViewerSession> for ViewerSessions {
82 fn from_iter<T: IntoIterator<Item = ViewerSession>>(rows: T) -> Self {
83 Self(rows.into_iter().map(|row| (row.id.clone(), row)).collect())
84 }
85}
86
87impl IntoIterator for ViewerSessions {
88 type Item = ViewerSession;
89 type IntoIter = std::vec::IntoIter<ViewerSession>;
90 fn into_iter(self) -> Self::IntoIter {
91 Vec::from(self).into_iter()
92 }
93}
94
95impl std::ops::Index<usize> for ViewerSessions {
96 type Output = ViewerSession;
97 fn index(&self, index: usize) -> &Self::Output {
98 self.iter().nth(index).expect("viewer row index")
99 }
100}
101
102impl std::ops::IndexMut<usize> for ViewerSessions {
103 fn index_mut(&mut self, index: usize) -> &mut Self::Output {
104 let id = self.0.keys().nth(index).expect("viewer row index").clone();
105 self.0.get_mut(&id).expect("viewer row exists")
106 }
107}
108
109pub(crate) type ViewerChildren =
110 mj_core::snapshot_map::SnapshotMap<String, mj_core::snapshot_map::SnapshotMap<String, ()>>;
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ViewerLaunchFailure {
118 pub id: String,
121 pub workspace_id: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub session_id: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub error: Option<String>,
129}
130
131impl ViewerSnapshot {
132 pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
136 let mut children = ViewerChildren::new();
137 for relation in state.subagents.values() {
138 children
139 .entry(relation.parent_session_id.clone())
140 .or_insert_with(Default::default)
141 .insert(relation.child_session_id.clone(), ());
142 }
143 Self::from_config_records(config, state, revision, state.sessions.values(), &children)
144 }
145
146 pub(crate) fn from_config_records<'a>(
147 config: &Config,
148 state: &AppState,
149 revision: u64,
150 records: impl Iterator<Item = &'a mj_core::state::SessionRecord>,
151 children: &ViewerChildren,
152 ) -> Self {
153 let sessions = records
154 .map(|session| {
155 #[cfg(test)]
156 VIEWER_ROW_VISITS.with(|visits| visits.set(visits.get() + 1));
157 let resume_refusals = config
158 .targets
159 .keys()
160 .filter_map(|target_id| {
161 crate::controller::resume_compatibility(session, config, target_id)
162 .err()
163 .map(|reason| (target_id.clone(), reason))
164 })
165 .collect::<BTreeMap<_, _>>();
166 let incompatible = resume_refusals.keys().cloned().collect::<Vec<_>>();
167 let lifecycle = ViewerLifecycleCategory::of(session.state);
168 let project = state.project_identity_session(session);
172 let source = project.project_source(config);
173 let subagent = state.subagents.get(&session.id);
174 let subagent_session_ids = children
175 .get(&session.id)
176 .map(|children| children.keys().cloned().collect())
177 .unwrap_or_default();
178 let start = session.start_selection();
179 ViewerSession {
180 subagents: session.subagents.clone().unwrap_or_default(),
181 at: start.at,
182 branch: start.branch,
183 base: start.base,
184 targeted_turn_control_supported: false,
185 native_subagents: Vec::new(),
186 steering: None,
187 active_prompt_id: None,
188 cancelling_prompt_id: None,
189 capacity_retry: None,
190 retry_assessment_pending: false,
191 quota_recovery: None,
192 id: session.id.clone(),
193 publication_state: session.publication_state(),
194 managed_checkout_kind: session
195 .managed_worktree
196 .as_ref()
197 .map(|owned| owned.kind),
198 workspace_id: session.workspace_id.clone(),
199 title: public_title(session),
200 subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
201 subagent_task_name: subagent.map(|child| child.task_name.clone()),
202 subagent_session_ids,
203 harness_kind: session.harness_kind.id().into(),
204 profile_id: session.last_profile.clone(),
205 bundle_id: session.bundle_id.clone(),
206 target_id: session.target_template_id.clone(),
207 state: match session.state {
208 SessionState::Closing => "suspending",
209 SessionState::Stopped => "suspended",
210 _ => session.state.as_str(),
211 }
212 .into(),
213 created_at: session.created_at.clone(),
214 updated_at: session.updated_at.clone(),
215 has_error: session.last_error.is_some()
216 || session.configuration_issue(config).is_some(),
217 has_checkpoint: session.checkpoint.is_some(),
218 configuration_issue: session.configuration_issue(config),
219 launch_error: matches!(
234 session.state,
235 SessionState::Error | SessionState::Stopped
236 )
237 .then(|| session.last_error.clone())
238 .flatten()
239 .or_else(|| session.public_error().map(str::to_owned)),
240 preview: Vec::new(),
241 queued_prompts: Vec::new(),
242 active_user_shells: Vec::new(),
243 background_tasks: Vec::new(),
244 pending_elicitations: Vec::new(),
245 conversation_available: false,
246 prompt_images_supported: false,
247 incompatible_resume_targets: incompatible.clone(),
248 resume_refusals,
249 compatible_resume_targets: config
250 .targets
251 .keys()
252 .filter(|target_id| !incompatible.contains(*target_id))
253 .cloned()
254 .collect(),
255 project_label: source.short,
256 project_key: project_key(&source.key),
257 display_location: project.project_target(config, &session.target_template_id),
258 lifecycle,
259 transitioning: session.state.transition_kind().is_some(),
260 latest_event_ordinal: 0,
261 last_activity_at_ms: None,
262 activity_details: None,
263 activity: String::new(),
264 operation: None,
265 move_recovery: None,
266 chat_phase: ViewerChatPhase::default(),
269 is_idle: false,
270 activity_state: None,
271 config_options: Vec::new(),
272 plan_mode_active: None,
273 turn_review: None,
274 available_commands: Vec::new(),
275 capabilities: ViewerSessionCapabilities {
279 clear_context: false,
280 open: false,
281 prompt: false,
282 run_shell: false,
283 interrupt_turn: false,
284 cancel_operation: false,
285 suspend: lifecycle.is_dashboard_visible(),
286 destroy: true,
287 rename: true,
288 resume: !lifecycle.is_dashboard_visible(),
289 move_session: false,
290 set_config: false,
291 set_plan_mode: false,
292 },
293 }
294 })
295 .collect();
296 let profiles = config
297 .enabled_profiles()
298 .map(|(id, profile)| ViewerProfile {
299 id: id.to_owned(),
300 harness_kind: profile.kind.id().into(),
301 subagents: profile.subagents.clone(),
302 subagent_discovery_key: config.subagent_discovery_key(id, None),
303 quota: None,
304 })
305 .collect();
306 let targets = config
307 .targets
308 .iter()
309 .map(|(id, target)| ViewerTarget {
310 id: id.clone(),
311 kind: target.kind_name().into(),
312 requires_project_directory: matches!(
313 target,
314 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
315 ),
316 runtime_missing: false,
317 default_candidate: config.is_default_target(id),
318 availability: crate::server::api::LaunchAvailability::Unknown,
319 unavailable_reason: None,
320 recent_project_directories: project_history_host(target)
321 .map(|host| {
322 state
323 .project_directories(host)
324 .iter()
325 .map(|directory| directory.to_string_lossy().into_owned())
326 .collect()
327 })
328 .unwrap_or_default(),
329 })
330 .collect();
331 let bundles = config
332 .bundles
333 .iter()
334 .map(|(id, bundle)| ViewerBundle {
335 id: id.clone(),
336 primary_repository: bundle.primary_repo.clone(),
337 repositories: bundle
338 .repositories
339 .iter()
340 .map(|repository| ViewerRepository {
341 id: repository.id.clone(),
342 github: repository.github.clone(),
343 destination: repository.destination.to_string_lossy().into_owned(),
344 })
345 .collect(),
346 })
347 .collect();
348 Self {
349 last_subagent_policy: state.last_subagent_policy.clone(),
350 revision,
351 generated_at: now_unix().to_string(),
352 server_time_ms: mj_core::clock::epoch_millis(),
353 server_version: env!("CARGO_PKG_VERSION").to_owned(),
354 workspaces: Vec::new(),
355 sessions,
356 profiles,
357 targets,
358 bundles,
359 review_config: ViewerReviewConfig {
360 enabled: config.review.enabled,
361 tier: config.review.tier.label().to_owned(),
362 profile: config.review.profile.clone(),
363 },
364 capacity: Vec::new(),
365 launch_failures: Vec::new(),
366 }
367 }
368}
369
370pub(super) fn project_key(identity: &str) -> String {
377 use sha2::Digest as _;
378 let digest = Sha256::digest(identity.as_bytes());
379 mj_core::hex::lower_hex(&digest[..8])
380}
381
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct ViewerSession {
385 #[serde(default)]
386 pub subagents: mj_core::subagent::SubagentPolicy,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub at: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub branch: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub base: Option<String>,
396 #[serde(default)]
397 pub targeted_turn_control_supported: bool,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub native_subagents: Vec<mj_core::native_agent::NativeAgent>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub steering: Option<mj_core::relay::SteeringOperation>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub active_prompt_id: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub cancelling_prompt_id: Option<String>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
408 #[serde(default)]
409 pub retry_assessment_pending: bool,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
412 pub id: String,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub publication_state: Option<mj_core::state::PublicationState>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub managed_checkout_kind: Option<mj_core::state::ManagedCheckoutKind>,
417 #[serde(default, skip_serializing_if = "String::is_empty")]
418 pub workspace_id: String,
419 pub title: String,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub subagent_parent_id: Option<String>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub subagent_task_name: Option<String>,
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub subagent_session_ids: Vec<String>,
429 pub harness_kind: String,
430 pub profile_id: String,
431 pub bundle_id: String,
432 pub target_id: String,
433 pub state: String,
434 pub created_at: String,
435 pub updated_at: String,
436 pub has_error: bool,
437 #[serde(default)]
443 pub has_checkpoint: bool,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub configuration_issue: Option<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub launch_error: Option<String>,
453
454 #[serde(default, skip_serializing_if = "Vec::is_empty")]
455 pub preview: Vec<String>,
456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub queued_prompts: Vec<ViewerQueuedPrompt>,
458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
459 pub active_user_shells: Vec<ViewerUserShell>,
460 #[serde(default, skip_serializing_if = "Vec::is_empty")]
461 pub background_tasks: Vec<ViewerBackgroundTask>,
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
466 pub pending_elicitations: Vec<ElicitationRequest>,
467 pub conversation_available: bool,
468 #[serde(default)]
472 pub prompt_images_supported: bool,
473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub incompatible_resume_targets: Vec<String>,
481 #[serde(skip)]
486 pub resume_refusals: BTreeMap<String, String>,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
490 pub compatible_resume_targets: Vec<String>,
491 #[serde(default, skip_serializing_if = "String::is_empty")]
494 pub project_label: String,
495 #[serde(default, skip_serializing_if = "String::is_empty")]
499 pub project_key: String,
500 #[serde(default)]
503 pub display_location: String,
504 pub lifecycle: ViewerLifecycleCategory,
505 #[serde(default)]
509 pub transitioning: bool,
510 #[serde(default)]
514 pub latest_event_ordinal: u64,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub last_activity_at_ms: Option<i64>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub activity_details: Option<ViewerActivityDetails>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub operation: Option<ViewerOperation>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub move_recovery: Option<ViewerMoveRecovery>,
531 #[serde(default)]
532 pub chat_phase: ViewerChatPhase,
533 #[serde(default)]
536 pub is_idle: bool,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub activity_state: Option<mj_core::activity::ActivityState>,
543 #[serde(default, skip_serializing_if = "String::is_empty")]
546 pub activity: String,
547 #[serde(default, skip_serializing_if = "Vec::is_empty")]
549 pub config_options: Vec<ViewerConfigOption>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub plan_mode_active: Option<bool>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub turn_review: Option<ViewerTurnReview>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
561 pub available_commands: Vec<ViewerMjCommand>,
562 pub capabilities: ViewerSessionCapabilities,
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(deny_unknown_fields)]
567pub struct ViewerMoveRecovery {
568 pub operation_id: String,
569 pub source_profile_id: String,
570 pub source_target_template_id: String,
571 pub destination_profile_id: String,
572 pub destination_target_template_id: String,
573 pub phase: String,
574 pub queue: String,
575 pub clear_resource_allocation: bool,
576 #[serde(default)]
579 pub source_additional_mounts: Vec<AdditionalMount>,
580 #[serde(default)]
581 pub source_resource_allocation: Option<SessionResourceAllocation>,
582 #[serde(default)]
585 pub destination_additional_mounts: Vec<AdditionalMount>,
586 #[serde(default)]
587 pub destination_resource_allocation: Option<SessionResourceAllocation>,
588 pub checkpoint_retained: bool,
592 #[serde(default)]
596 pub environment_retained: bool,
597 pub destination_ready: bool,
598 pub queue_admission_started: bool,
599 pub queue_admission_finished: bool,
600}
601
602impl ViewerMoveRecovery {
603 #[must_use]
604 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
605 if matches!(operation.phase, MovePhase::Completed) {
606 return None;
607 }
608 Some(Self {
609 operation_id: operation.operation_id.clone(),
610 source_profile_id: operation.source_profile_id.clone(),
611 source_target_template_id: operation.source_target_template_id.clone(),
612 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
613 destination_target_template_id: operation
614 .selection
615 .target_template_id
616 .clone()
617 .unwrap_or_default(),
618 phase: match operation.phase {
619 MovePhase::Preparing => "preparing",
620 MovePhase::ClosingSource => "closing_source",
621 MovePhase::ResumingDestination => "resuming_destination",
622 MovePhase::StartingQueue => "starting_queue",
623 MovePhase::Completed => "completed",
624 MovePhase::Failed => "failed",
625 MovePhase::Cancelled => "cancelled",
626 }
627 .into(),
628 queue: match operation.queue {
629 ResumeQueueDisposition::Start => "start",
630 ResumeQueueDisposition::Discard => "discard",
631 }
632 .into(),
633 clear_resource_allocation: operation.selection.clear_resource_allocation,
634 source_additional_mounts: operation.source_additional_mounts.clone(),
635 source_resource_allocation: operation.source_resource_allocation.clone(),
636 destination_additional_mounts: operation
637 .selection
638 .additional_mounts
639 .clone()
640 .unwrap_or_default(),
641 destination_resource_allocation: operation.selection.resource_allocation.clone(),
642 checkpoint_retained: operation.checkpoint_retained(),
643 environment_retained: operation.holds_source_environment(),
644 destination_ready: operation.destination_target.is_some()
645 && operation.destination_native_session_id.is_some(),
646 queue_admission_started: operation.queue_admission_started,
647 queue_admission_finished: operation.queue_admission_finished,
648 })
649 }
650}
651
652impl ViewerSession {
653 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
656 self.project_label = source.short.clone();
657 self.project_key = project_key(&source.key);
658 }
659}
660
661pub use crate::database::{
663 ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
664};
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668#[serde(deny_unknown_fields)]
669pub struct ViewerMjCommand {
670 pub name: String,
671 pub description: String,
672 pub source: ViewerCommandSource,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub argument: Option<String>,
678}
679
680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
681#[serde(rename_all = "snake_case")]
682pub enum ViewerCommandSource {
683 Mj,
684 Agent,
685}
686
687#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(deny_unknown_fields)]
690pub struct ViewerReviewConfig {
691 pub enabled: bool,
692 pub tier: String,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub profile: Option<String>,
695}
696
697#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
699#[serde(deny_unknown_fields)]
700pub struct ViewerTurnReview {
701 pub tier: String,
703 pub status: String,
705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 pub roles: Vec<ViewerReviewRole>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub verdict: Option<ViewerReviewVerdict>,
711}
712
713#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
714#[serde(deny_unknown_fields)]
715pub struct ViewerReviewRole {
716 pub label: String,
717 pub state: String,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(deny_unknown_fields)]
723pub struct ViewerReviewVerdict {
724 pub kind: String,
726 pub text: String,
728 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub allowed: Vec<String>,
733}
734
735impl ViewerTurnReview {
736 #[must_use]
738 pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
739 Self {
740 tier: review.tier.label().to_owned(),
741 status: review.status.clone(),
742 roles: review
743 .roles
744 .iter()
745 .map(|role| ViewerReviewRole {
746 label: role.label.clone(),
747 state: role.state.label().to_owned(),
748 })
749 .collect(),
750 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
751 kind: match verdict.kind {
752 crate::review_host::VerdictKind::Clean => "clean",
753 crate::review_host::VerdictKind::Findings => "findings",
754 crate::review_host::VerdictKind::Failed => "failed",
755 }
756 .to_owned(),
757 text: verdict.text.clone(),
758 allowed: verdict
759 .allowed
760 .iter()
761 .filter_map(resolution_name)
762 .map(str::to_owned)
763 .collect(),
764 }),
765 }
766 }
767}
768
769#[must_use]
772pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
773 match resolution {
774 mj_core::review::driver::Resolution::Forwarded => Some("forward"),
775 mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
776 mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
777 mj_core::review::driver::Resolution::NothingToReview
779 | mj_core::review::driver::Resolution::CoverageStarted => None,
780 }
781}
782
783#[must_use]
785pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
786 match name {
787 "forward" => Some(mj_core::review::driver::Resolution::Forwarded),
788 "dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
789 "cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
790 _ => None,
791 }
792}
793
794#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct ViewerWorkspace {
797 pub id: String,
798 pub name: String,
799}
800
801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
802#[serde(deny_unknown_fields)]
803pub struct ViewerQueuedPrompt {
804 pub id: String,
805 pub text: String,
806 pub created_at: String,
807}
808
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
810#[serde(deny_unknown_fields)]
811pub struct ViewerUserShell {
812 pub id: String,
813 pub command: String,
814 pub started_at_ms: Option<i64>,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(deny_unknown_fields)]
820pub struct ViewerBackgroundTask {
821 pub id: String,
822 pub command: String,
823 pub started_at_ms: i64,
824 pub can_stop: bool,
825}
826
827#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
828#[serde(deny_unknown_fields)]
829pub struct ViewerProfile {
830 pub id: String,
831 #[serde(default)]
832 pub subagent_discovery_key: String,
833 pub harness_kind: String,
834 #[serde(default)]
835 pub subagents: mj_core::subagent::SubagentPolicy,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub quota: Option<ViewerQuota>,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846#[serde(deny_unknown_fields)]
847pub struct ViewerQuotaWindow {
848 pub label: String,
849 #[serde(default, skip_serializing_if = "Option::is_none")]
850 pub resets_at_epoch_seconds: Option<i64>,
851 #[serde(default)]
852 pub reset_countdown_style: mj_client::quota::ResetCountdownStyle,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
854 pub banked_resets: Option<u64>,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub percent_used: Option<u8>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub resets_at: Option<String>,
859 pub projects_exhaustion_before_reset: bool,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
865#[serde(deny_unknown_fields)]
866pub struct ViewerQuota {
867 pub summary: String,
870 #[serde(default, skip_serializing_if = "Vec::is_empty")]
871 pub windows: Vec<ViewerQuotaWindow>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub resets_at: Option<String>,
874 pub stale: bool,
875 #[serde(default)]
878 pub refreshed_at_epoch_seconds: u64,
879 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub rate_limited_until_epoch_seconds: Option<u64>,
883 pub has_error: bool,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
894#[serde(deny_unknown_fields)]
895pub struct ViewerTargetCapacity {
896 pub id: String,
897 pub label: String,
900 pub target_ids: Vec<String>,
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub cpu_percent: Option<u8>,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub memory_used_bytes: Option<u64>,
905 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub memory_total_bytes: Option<u64>,
907 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub logical_cores: Option<u64>,
909 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub disk_total_bytes: Option<u64>,
911 #[serde(default, skip_serializing_if = "Option::is_none")]
913 pub virtual_machines: Option<u64>,
914 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub sampled_at_epoch_seconds: Option<u64>,
916 pub refreshing: bool,
917 pub stale: bool,
918 pub has_error: bool,
921}
922
923fn unknown_availability() -> crate::server::api::LaunchAvailability {
924 crate::server::api::LaunchAvailability::Unknown
925}
926
927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
928#[serde(deny_unknown_fields)]
929pub struct ViewerTarget {
930 pub id: String,
931 pub kind: String,
932 pub requires_project_directory: bool,
933 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
938 pub runtime_missing: bool,
939 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
944 pub default_candidate: bool,
945 #[serde(default = "unknown_availability")]
948 pub availability: crate::server::api::LaunchAvailability,
949 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub unavailable_reason: Option<String>,
952 #[serde(default)]
956 pub recent_project_directories: Vec<String>,
957}
958
959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960#[serde(deny_unknown_fields)]
961pub struct ViewerBundle {
962 pub id: String,
963 pub primary_repository: String,
964 pub repositories: Vec<ViewerRepository>,
965}
966
967#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
968#[serde(deny_unknown_fields)]
969pub struct ViewerRepository {
970 pub id: String,
971 pub github: Option<String>,
972 pub destination: String,
973}
974
975#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
983#[serde(deny_unknown_fields)]
984pub struct ViewerSessionCapabilities {
985 #[serde(default)]
986 pub clear_context: bool,
987 pub open: bool,
988 pub prompt: bool,
989 pub run_shell: bool,
990 pub interrupt_turn: bool,
992 pub cancel_operation: bool,
994 pub suspend: bool,
995 pub destroy: bool,
996 pub rename: bool,
997 pub resume: bool,
998 #[serde(default)]
1001 pub move_session: bool,
1002 pub set_config: bool,
1003 pub set_plan_mode: bool,
1004}
1005
1006#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1012#[serde(rename_all = "kebab-case")]
1013pub enum ViewerLifecycleCategory {
1014 Live,
1015 Starting,
1016 Suspending,
1017 Suspended,
1018 Failed,
1019}
1020
1021pub const LAUNCHING_STATE: &str = "launching";
1025
1026fn public_title(session: &mj_core::state::SessionRecord) -> String {
1031 session.listed_title().to_owned()
1032}
1033
1034impl ViewerLifecycleCategory {
1035 pub(super) const fn of(state: SessionState) -> Self {
1036 match state {
1037 SessionState::Provisioning => Self::Starting,
1038 SessionState::Running
1040 | SessionState::Disconnected
1041 | SessionState::Checkpointing
1042 | SessionState::Parked => Self::Live,
1043 SessionState::Closing | SessionState::Destroying | SessionState::StartupCleanup => {
1044 Self::Suspending
1045 }
1046 SessionState::Stopped => Self::Suspended,
1047 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1048 Self::Failed
1049 }
1050 }
1051 }
1052
1053 pub const fn is_dashboard_visible(self) -> bool {
1057 matches!(self, Self::Live | Self::Starting | Self::Suspending)
1058 }
1059}
1060
1061#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(rename_all = "kebab-case")]
1063pub enum ViewerOperationKind {
1064 Create,
1065 Resume,
1066 Move,
1067 Suspend,
1068 Destroy,
1069 Stop,
1071 Cleanup,
1072 Checkpoint,
1073}
1074
1075impl ViewerOperationKind {
1076 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1077 match self {
1078 Self::Create => Some(SessionTransitionKind::Starting),
1079 Self::Resume => Some(SessionTransitionKind::Resuming),
1080 Self::Move => Some(SessionTransitionKind::Moving),
1081 Self::Suspend => Some(SessionTransitionKind::Suspending),
1082 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1083 Self::Stop => Some(SessionTransitionKind::Stopping),
1084 Self::Checkpoint => None,
1087 }
1088 }
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093#[serde(deny_unknown_fields)]
1094pub struct ViewerOperationStage {
1095 pub label: String,
1096 pub started_at_epoch_seconds: u64,
1097}
1098
1099#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1105#[serde(deny_unknown_fields)]
1106pub struct ViewerOperation {
1107 pub id: String,
1108 pub session_id: String,
1109 pub kind: ViewerOperationKind,
1110 pub started_at_epoch_seconds: u64,
1111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1112 pub stages: Vec<ViewerOperationStage>,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub notice: Option<String>,
1117 pub cancellable: bool,
1118}
1119
1120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1122#[serde(rename_all = "kebab-case")]
1123pub enum ViewerChatPhase {
1124 #[default]
1125 Idle,
1126 Running,
1127 Closing,
1128 Closed,
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1132#[serde(deny_unknown_fields)]
1133pub struct ViewerConfigChoice {
1134 pub value: String,
1135 pub name: String,
1136 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub description: Option<String>,
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146#[serde(deny_unknown_fields)]
1147pub struct ViewerConfigOption {
1148 pub key: String,
1149 pub label: String,
1150 #[serde(default, skip_serializing_if = "Option::is_none")]
1151 pub current: Option<String>,
1152 pub choices: Vec<ViewerConfigChoice>,
1153}