use super::*;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerSnapshot {
pub revision: u64,
pub generated_at: String,
#[serde(default)]
pub server_time_ms: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<ViewerWorkspace>,
pub sessions: Vec<ViewerSession>,
pub profiles: Vec<ViewerProfile>,
pub targets: Vec<ViewerTarget>,
pub bundles: Vec<ViewerBundle>,
#[serde(default)]
pub review_config: ViewerReviewConfig,
#[serde(default)]
pub subagents_enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capacity: Vec<ViewerTargetCapacity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub launch_failures: Vec<ViewerLaunchFailure>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewerLaunchFailure {
pub id: String,
pub workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl ViewerSnapshot {
pub fn from_config_state(config: &Config, state: &AppState, revision: u64) -> Self {
let sessions = state
.sessions
.values()
.map(|session| {
let incompatible = config
.targets
.keys()
.filter(|target_id| {
crate::controller::resume_compatibility(session, config, target_id).is_err()
})
.cloned()
.collect::<Vec<_>>();
let lifecycle = ViewerLifecycleCategory::of(session.state);
let source = session.project_source(config);
let subagent = state.subagents.get(&session.id);
let subagent_session_ids = state
.subagents
.values()
.filter(|child| child.parent_session_id == session.id)
.map(|child| child.child_session_id.clone())
.collect();
ViewerSession {
capacity_retry: None,
id: session.id.clone(),
workspace_id: session.workspace_id.clone(),
title: session.display_title().to_owned(),
subagent_parent_id: subagent.map(|child| child.parent_session_id.clone()),
subagent_task_name: subagent.map(|child| child.task_name.clone()),
subagent_session_ids,
harness_kind: session.harness_kind.id().into(),
profile_id: session.last_profile.clone(),
bundle_id: session.bundle_id.clone(),
target_id: session.target_template_id.clone(),
state: session.state.as_str().into(),
created_at: session.created_at.clone(),
updated_at: session.updated_at.clone(),
has_error: session.last_error.is_some()
|| session.configuration_issue(config).is_some(),
configuration_issue: session.configuration_issue(config),
launch_error: (session.state == SessionState::Error)
.then(|| session.last_error.clone())
.flatten(),
preview: Vec::new(),
queued_prompts: Vec::new(),
active_user_shells: Vec::new(),
background_tasks: Vec::new(),
pending_elicitations: Vec::new(),
conversation_available: false,
prompt_images_supported: false,
incompatible_resume_targets: incompatible.clone(),
compatible_resume_targets: config
.targets
.keys()
.filter(|target_id| !incompatible.contains(*target_id))
.cloned()
.collect(),
project_label: source.short,
project_key: project_key(&source.key),
display_location: session.project_target(config, &session.target_template_id),
lifecycle,
transitioning: session.state.transition_kind().is_some(),
latest_event_ordinal: 0,
last_activity_at_ms: None,
activity_details: None,
activity: String::new(),
operation: None,
move_recovery: None,
chat_phase: ViewerChatPhase::default(),
is_idle: false,
config_options: Vec::new(),
plan_mode_active: None,
turn_review: None,
available_commands: Vec::new(),
capabilities: ViewerSessionCapabilities {
open: false,
prompt: false,
run_shell: false,
cancel_turn: false,
cancel_operation: false,
stop: lifecycle.is_dashboard_visible(),
rename: true,
resume: !lifecycle.is_dashboard_visible(),
move_session: false,
set_config: false,
set_plan_mode: false,
},
}
})
.collect();
let profiles = config
.enabled_profiles()
.map(|(id, profile)| ViewerProfile {
id: id.to_owned(),
harness_kind: profile.kind.id().into(),
quota: None,
})
.collect();
let targets = config
.targets
.iter()
.map(|(id, target)| ViewerTarget {
id: id.clone(),
kind: target.kind_name().into(),
requires_project_directory: matches!(
target,
TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
),
recent_project_directories: project_history_host(target)
.map(|host| {
state
.project_directories(host)
.iter()
.map(|directory| directory.to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default(),
})
.collect();
let bundles = config
.bundles
.iter()
.map(|(id, bundle)| ViewerBundle {
id: id.clone(),
primary_repository: bundle.primary_repo.clone(),
repositories: bundle
.repositories
.iter()
.map(|repository| ViewerRepository {
id: repository.id.clone(),
github: repository.github.clone(),
destination: repository.destination.to_string_lossy().into_owned(),
})
.collect(),
})
.collect();
Self {
revision,
generated_at: now_unix().to_string(),
server_time_ms: mj_core::clock::epoch_millis(),
workspaces: Vec::new(),
sessions,
profiles,
targets,
bundles,
review_config: ViewerReviewConfig {
enabled: config.review.enabled,
tier: config.review.tier.label().to_owned(),
profile: config.review.profile.clone(),
},
subagents_enabled: config.subagents.enabled,
capacity: Vec::new(),
launch_failures: Vec::new(),
}
}
}
pub(super) fn project_key(identity: &str) -> String {
use sha2::Digest as _;
let digest = Sha256::digest(identity.as_bytes());
mj_core::hex::lower_hex(&digest[..8])
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerSession {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capacity_retry: Option<mj_core::relay::CapacityRetry>,
pub id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub workspace_id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_task_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subagent_session_ids: Vec<String>,
pub harness_kind: String,
pub profile_id: String,
pub bundle_id: String,
pub target_id: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
pub has_error: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub configuration_issue: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch_error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preview: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<ViewerQueuedPrompt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub active_user_shells: Vec<ViewerUserShell>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub background_tasks: Vec<ViewerBackgroundTask>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_elicitations: Vec<ElicitationRequest>,
pub conversation_available: bool,
#[serde(default)]
pub prompt_images_supported: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub incompatible_resume_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compatible_resume_targets: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project_label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project_key: String,
#[serde(default)]
pub display_location: String,
pub lifecycle: ViewerLifecycleCategory,
#[serde(default)]
pub transitioning: bool,
#[serde(default)]
pub latest_event_ordinal: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity_at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_details: Option<ViewerActivityDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<ViewerOperation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub move_recovery: Option<ViewerMoveRecovery>,
#[serde(default)]
pub chat_phase: ViewerChatPhase,
#[serde(default)]
pub is_idle: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub activity: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config_options: Vec<ViewerConfigOption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_mode_active: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_review: Option<ViewerTurnReview>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub available_commands: Vec<ViewerMjCommand>,
pub capabilities: ViewerSessionCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerMoveRecovery {
pub operation_id: String,
pub source_profile_id: String,
pub source_target_template_id: String,
pub destination_profile_id: String,
pub destination_target_template_id: String,
pub phase: String,
pub queue: String,
pub clear_resource_allocation: bool,
#[serde(default)]
pub source_additional_mounts: Vec<AdditionalMount>,
#[serde(default)]
pub source_resource_allocation: Option<SessionResourceAllocation>,
#[serde(default)]
pub destination_additional_mounts: Vec<AdditionalMount>,
#[serde(default)]
pub destination_resource_allocation: Option<SessionResourceAllocation>,
pub checkpoint_retained: bool,
pub destination_ready: bool,
pub queue_admission_started: bool,
pub queue_admission_finished: bool,
}
impl ViewerMoveRecovery {
#[must_use]
pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
if matches!(operation.phase, MovePhase::Completed) {
return None;
}
Some(Self {
operation_id: operation.operation_id.clone(),
source_profile_id: operation.source_profile_id.clone(),
source_target_template_id: operation.source_target_template_id.clone(),
destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
destination_target_template_id: operation
.selection
.target_template_id
.clone()
.unwrap_or_default(),
phase: match operation.phase {
MovePhase::Preparing => "preparing",
MovePhase::ClosingSource => "closing_source",
MovePhase::ResumingDestination => "resuming_destination",
MovePhase::StartingQueue => "starting_queue",
MovePhase::Completed => "completed",
MovePhase::Failed => "failed",
MovePhase::Cancelled => "cancelled",
}
.into(),
queue: match operation.queue {
ResumeQueueDisposition::Start => "start",
ResumeQueueDisposition::Discard => "discard",
}
.into(),
clear_resource_allocation: operation.selection.clear_resource_allocation,
source_additional_mounts: operation.source_additional_mounts.clone(),
source_resource_allocation: operation.source_resource_allocation.clone(),
destination_additional_mounts: operation
.selection
.additional_mounts
.clone()
.unwrap_or_default(),
destination_resource_allocation: operation.selection.resource_allocation.clone(),
checkpoint_retained: operation.checkpoint.is_some(),
destination_ready: operation.destination_target.is_some()
&& operation.destination_native_session_id.is_some(),
queue_admission_started: operation.queue_admission_started,
queue_admission_finished: operation.queue_admission_finished,
})
}
}
impl ViewerSession {
pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
self.project_label = source.short.clone();
self.project_key = project_key(&source.key);
}
}
pub use crate::database::{
ApiActivityDetails as ViewerActivityDetails, ApiActivityKind as ViewerActivityKind,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerMjCommand {
pub name: String,
pub description: String,
pub source: ViewerCommandSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewerCommandSource {
Mj,
Agent,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerReviewConfig {
pub enabled: bool,
pub tier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTurnReview {
pub tier: String,
pub status: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<ViewerReviewRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<ViewerReviewVerdict>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerReviewRole {
pub label: String,
pub state: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerReviewVerdict {
pub kind: String,
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed: Vec<String>,
}
impl ViewerTurnReview {
#[must_use]
pub fn from_runtime(review: &crate::review_host::RuntimeReviewView) -> Self {
Self {
tier: review.tier.label().to_owned(),
status: review.status.clone(),
roles: review
.roles
.iter()
.map(|role| ViewerReviewRole {
label: role.label.clone(),
state: role.state.label().to_owned(),
})
.collect(),
verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
kind: match verdict.kind {
crate::review_host::VerdictKind::Clean => "clean",
crate::review_host::VerdictKind::Findings => "findings",
crate::review_host::VerdictKind::Failed => "failed",
}
.to_owned(),
text: verdict.text.clone(),
allowed: verdict
.allowed
.iter()
.filter_map(resolution_name)
.map(str::to_owned)
.collect(),
}),
}
}
}
#[must_use]
pub fn resolution_name(resolution: &mj_core::review::driver::Resolution) -> Option<&'static str> {
match resolution {
mj_core::review::driver::Resolution::Forwarded => Some("forward"),
mj_core::review::driver::Resolution::Dismissed => Some("dismiss"),
mj_core::review::driver::Resolution::Cancelled => Some("cancel"),
mj_core::review::driver::Resolution::NothingToReview
| mj_core::review::driver::Resolution::CoverageStarted => None,
}
}
#[must_use]
pub fn resolution_from_name(name: &str) -> Option<mj_core::review::driver::Resolution> {
match name {
"forward" => Some(mj_core::review::driver::Resolution::Forwarded),
"dismiss" => Some(mj_core::review::driver::Resolution::Dismissed),
"cancel" => Some(mj_core::review::driver::Resolution::Cancelled),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerWorkspace {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerQueuedPrompt {
pub id: String,
pub text: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerUserShell {
pub id: String,
pub command: String,
pub started_at_ms: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerBackgroundTask {
pub id: String,
pub command: String,
pub started_at_ms: i64,
pub can_stop: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerProfile {
pub id: String,
pub harness_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quota: Option<ViewerQuota>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerQuotaWindow {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percent_used: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
pub projects_exhaustion_before_reset: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerQuota {
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub windows: Vec<ViewerQuotaWindow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
pub stale: bool,
#[serde(default)]
pub refreshed_at_epoch_seconds: u64,
pub has_error: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTargetCapacity {
pub id: String,
pub label: String,
pub target_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_percent: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_used_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_total_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logical_cores: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_total_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub virtual_machines: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampled_at_epoch_seconds: Option<u64>,
pub refreshing: bool,
pub stale: bool,
pub has_error: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTarget {
pub id: String,
pub kind: String,
pub requires_project_directory: bool,
#[serde(default)]
pub recent_project_directories: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerBundle {
pub id: String,
pub primary_repository: String,
pub repositories: Vec<ViewerRepository>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerRepository {
pub id: String,
pub github: Option<String>,
pub destination: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerSessionCapabilities {
pub open: bool,
pub prompt: bool,
pub run_shell: bool,
pub cancel_turn: bool,
pub cancel_operation: bool,
pub stop: bool,
pub rename: bool,
pub resume: bool,
#[serde(default)]
pub move_session: bool,
pub set_config: bool,
pub set_plan_mode: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViewerLifecycleCategory {
Live,
Starting,
Stopping,
Stopped,
Failed,
}
impl ViewerLifecycleCategory {
pub(super) const fn of(state: SessionState) -> Self {
match state {
SessionState::Provisioning => Self::Starting,
SessionState::Running | SessionState::Disconnected | SessionState::Checkpointing => {
Self::Live
}
SessionState::Closing | SessionState::Destroying => Self::Stopping,
SessionState::Stopped => Self::Stopped,
SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
Self::Failed
}
}
}
pub const fn is_dashboard_visible(self) -> bool {
matches!(self, Self::Live | Self::Starting | Self::Stopping)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViewerOperationKind {
Create,
Resume,
Move,
Stop,
Destroy,
Cleanup,
Checkpoint,
}
impl ViewerOperationKind {
pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
match self {
Self::Create => Some(SessionTransitionKind::Starting),
Self::Resume => Some(SessionTransitionKind::Resuming),
Self::Move => Some(SessionTransitionKind::Moving),
Self::Stop => Some(SessionTransitionKind::Stopping),
Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
Self::Checkpoint => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerOperationStage {
pub label: String,
pub started_at_epoch_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerOperation {
pub id: String,
pub session_id: String,
pub kind: ViewerOperationKind,
pub started_at_epoch_seconds: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stages: Vec<ViewerOperationStage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notice: Option<String>,
pub cancellable: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViewerChatPhase {
#[default]
Idle,
Running,
Closing,
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerConfigChoice {
pub value: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerConfigOption {
pub key: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current: Option<String>,
pub choices: Vec<ViewerConfigChoice>,
}