1410 lines
45 KiB
Rust
1410 lines
45 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use protocol::PodStatus;
|
|
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
|
use ticket::{
|
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
|
TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
|
};
|
|
|
|
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
|
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct WorkspacePanelViewModel {
|
|
pub(crate) header: WorkspacePanelHeader,
|
|
pub(crate) rows: Vec<PanelRow>,
|
|
pub(crate) composer: WorkspacePanelComposer,
|
|
}
|
|
|
|
impl WorkspacePanelViewModel {
|
|
pub(crate) fn empty(workspace_root: &Path) -> Self {
|
|
Self {
|
|
header: WorkspacePanelHeader {
|
|
workspace_label: workspace_root
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("workspace")
|
|
.to_string(),
|
|
ticket_root: workspace_root
|
|
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
|
ticket_configured: false,
|
|
companion: None,
|
|
orchestrator: None,
|
|
diagnostics: Vec::new(),
|
|
},
|
|
rows: Vec::new(),
|
|
composer: WorkspacePanelComposer::companion_only(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn row(&self, key: &PanelRowKey) -> Option<&PanelRow> {
|
|
self.rows.iter().find(|row| &row.key == key)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct WorkspacePanelHeader {
|
|
pub(crate) workspace_label: String,
|
|
pub(crate) ticket_root: PathBuf,
|
|
pub(crate) ticket_configured: bool,
|
|
pub(crate) companion: Option<CompanionPanelState>,
|
|
pub(crate) orchestrator: Option<OrchestratorPanelState>,
|
|
pub(crate) diagnostics: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ComposerTarget {
|
|
Companion,
|
|
TicketIntake,
|
|
}
|
|
|
|
impl ComposerTarget {
|
|
pub(crate) fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Companion => "Companion",
|
|
Self::TicketIntake => "Ticket Planning",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct WorkspacePanelComposer {
|
|
pub(crate) available_targets: Vec<ComposerTarget>,
|
|
}
|
|
|
|
impl WorkspacePanelComposer {
|
|
pub(crate) fn companion_only() -> Self {
|
|
Self {
|
|
available_targets: vec![ComposerTarget::Companion],
|
|
}
|
|
}
|
|
|
|
pub(crate) fn ticket_enabled() -> Self {
|
|
Self {
|
|
available_targets: vec![ComposerTarget::Companion, ComposerTarget::TicketIntake],
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_available(&self, target: ComposerTarget) -> bool {
|
|
self.available_targets.contains(&target)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct CompanionPanelState {
|
|
pub(crate) pod_name: String,
|
|
pub(crate) status: CompanionPanelStatus,
|
|
pub(crate) detail: Option<String>,
|
|
}
|
|
|
|
impl CompanionPanelState {
|
|
pub(crate) fn new(
|
|
pod_name: impl Into<String>,
|
|
status: CompanionPanelStatus,
|
|
detail: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
pod_name: pod_name.into(),
|
|
status,
|
|
detail,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum CompanionPanelStatus {
|
|
Live,
|
|
Restored,
|
|
Spawned,
|
|
Stopped,
|
|
Missing,
|
|
Unavailable,
|
|
}
|
|
|
|
impl CompanionPanelStatus {
|
|
pub(crate) fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Live => "live",
|
|
Self::Restored => "restored",
|
|
Self::Spawned => "spawned",
|
|
Self::Stopped => "stopped/restorable",
|
|
Self::Missing => "missing",
|
|
Self::Unavailable => "unavailable",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct OrchestratorPanelState {
|
|
pub(crate) pod_name: String,
|
|
pub(crate) status: OrchestratorPanelStatus,
|
|
pub(crate) detail: Option<String>,
|
|
}
|
|
|
|
impl OrchestratorPanelState {
|
|
pub(crate) fn new(
|
|
pod_name: impl Into<String>,
|
|
status: OrchestratorPanelStatus,
|
|
detail: Option<String>,
|
|
) -> Self {
|
|
Self {
|
|
pod_name: pod_name.into(),
|
|
status,
|
|
detail,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum OrchestratorPanelStatus {
|
|
Live,
|
|
Restored,
|
|
Spawned,
|
|
Stopped,
|
|
Missing,
|
|
Unavailable,
|
|
}
|
|
|
|
impl OrchestratorPanelStatus {
|
|
pub(crate) fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Live => "live",
|
|
Self::Restored => "restored",
|
|
Self::Spawned => "spawned",
|
|
Self::Stopped => "stopped/restorable",
|
|
Self::Missing => "missing",
|
|
Self::Unavailable => "unavailable",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub(crate) enum PanelRowKey {
|
|
Ticket(String),
|
|
Pod(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum PanelRowKind {
|
|
Planning,
|
|
Ticket,
|
|
Review,
|
|
Blocked,
|
|
ActiveWork,
|
|
Pod,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub(crate) enum ActionPriority {
|
|
UserReply,
|
|
ReadyForQueue,
|
|
ActiveWork,
|
|
Background,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum NextUserAction {
|
|
Clarify,
|
|
Queue,
|
|
Close,
|
|
Edit,
|
|
Wait,
|
|
OpenPod,
|
|
}
|
|
|
|
impl NextUserAction {
|
|
pub(crate) fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Clarify => "Clarify",
|
|
Self::Queue => "Queue",
|
|
Self::Close => "Close",
|
|
Self::Edit => "Edit",
|
|
Self::Wait => "Wait",
|
|
Self::OpenPod => "Open",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct TicketPanelEntry {
|
|
pub(crate) id: String,
|
|
pub(crate) title: String,
|
|
pub(crate) priority: String,
|
|
pub(crate) workflow_state: TicketWorkflowState,
|
|
pub(crate) workflow_state_explicit: bool,
|
|
pub(crate) next_action: Option<NextUserAction>,
|
|
pub(crate) updated_at: Option<String>,
|
|
pub(crate) latest_event_kind: Option<String>,
|
|
pub(crate) latest_event_excerpt: Option<String>,
|
|
pub(crate) blocked_reason: Option<String>,
|
|
pub(crate) related_pods: Vec<String>,
|
|
pub(crate) local_claim: Option<TicketLocalClaimEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct TicketLocalClaimEntry {
|
|
pub(crate) pod_name: String,
|
|
pub(crate) role: String,
|
|
pub(crate) status: TicketLocalClaimStatus,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum TicketLocalClaimStatus {
|
|
Live,
|
|
Restorable,
|
|
Stale,
|
|
}
|
|
|
|
impl TicketLocalClaimStatus {
|
|
pub(crate) fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Live => "live",
|
|
Self::Restorable => "restorable",
|
|
Self::Stale => "stale",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct PanelRow {
|
|
pub(crate) key: PanelRowKey,
|
|
pub(crate) kind: PanelRowKind,
|
|
pub(crate) title: String,
|
|
pub(crate) subtitle: Option<String>,
|
|
pub(crate) status: String,
|
|
pub(crate) priority: ActionPriority,
|
|
pub(crate) next_action: Option<NextUserAction>,
|
|
pub(crate) ticket: Option<TicketPanelEntry>,
|
|
pub(crate) related_pods: Vec<String>,
|
|
pub(crate) disabled_reason: Option<String>,
|
|
pub(crate) key_hint: Option<String>,
|
|
}
|
|
|
|
impl PanelRow {
|
|
pub(crate) fn is_ticket_action(&self) -> bool {
|
|
!matches!(self.kind, PanelRowKind::Pod)
|
|
}
|
|
}
|
|
|
|
const MAX_POD_NAME_CHARS: usize = 80;
|
|
const ORCHESTRATOR_SUFFIX: &str = "-orchestrator";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum TicketConfigAvailability {
|
|
Absent,
|
|
Usable,
|
|
Unusable(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum CompanionPodPresence {
|
|
Live,
|
|
Restorable,
|
|
Missing,
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum CompanionLifecyclePlan {
|
|
ReportLive,
|
|
Restore,
|
|
Spawn,
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum OrchestratorPodPresence {
|
|
Live,
|
|
Restorable,
|
|
Missing,
|
|
Unavailable(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum OrchestratorLifecyclePlan {
|
|
SkipNoTicketConfig,
|
|
ReportLive,
|
|
Restore,
|
|
Spawn,
|
|
Unavailable(String),
|
|
}
|
|
|
|
pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String {
|
|
let seed = workspace_root
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("workspace");
|
|
sanitise_pod_name_component(seed, MAX_POD_NAME_CHARS)
|
|
.filter(|component| !component.is_empty())
|
|
.unwrap_or_else(|| "workspace".to_string())
|
|
}
|
|
|
|
pub(crate) fn workspace_orchestrator_pod_name(workspace_root: &Path) -> String {
|
|
let seed = workspace_root
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("workspace");
|
|
let max_component_chars = MAX_POD_NAME_CHARS.saturating_sub(ORCHESTRATOR_SUFFIX.len());
|
|
let component = sanitise_pod_name_component(seed, max_component_chars)
|
|
.filter(|component| !component.is_empty())
|
|
.unwrap_or_else(|| "workspace".to_string());
|
|
format!("{component}{ORCHESTRATOR_SUFFIX}")
|
|
}
|
|
|
|
fn sanitise_pod_name_component(value: &str, max_chars: usize) -> Option<String> {
|
|
let mut out = String::new();
|
|
let mut last_was_dash = false;
|
|
for ch in value.trim().chars() {
|
|
let mapped = if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.') {
|
|
ch.to_ascii_lowercase()
|
|
} else {
|
|
'-'
|
|
};
|
|
if mapped == '-' {
|
|
if !last_was_dash && !out.is_empty() {
|
|
out.push(mapped);
|
|
}
|
|
last_was_dash = true;
|
|
} else {
|
|
out.push(mapped);
|
|
last_was_dash = false;
|
|
}
|
|
}
|
|
let trimmed = out.trim_matches(|ch| matches!(ch, '-' | '_' | '.'));
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.chars().take(max_chars).collect())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn companion_pod_presence(pod_name: &str, pods: &PodList) -> CompanionPodPresence {
|
|
let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else {
|
|
return CompanionPodPresence::Missing;
|
|
};
|
|
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
|
return CompanionPodPresence::Live;
|
|
}
|
|
if entry.actions.can_restore {
|
|
return CompanionPodPresence::Restorable;
|
|
}
|
|
let reason = entry
|
|
.actions
|
|
.disabled_reason
|
|
.clone()
|
|
.or_else(|| {
|
|
entry
|
|
.diagnostics
|
|
.first()
|
|
.map(|diagnostic| diagnostic.message.clone())
|
|
})
|
|
.unwrap_or_else(|| "pod state is not live, restorable, or spawn-safe".to_string());
|
|
CompanionPodPresence::Unavailable(reason)
|
|
}
|
|
|
|
pub(crate) fn decide_companion_lifecycle(
|
|
presence: &CompanionPodPresence,
|
|
) -> CompanionLifecyclePlan {
|
|
match presence {
|
|
CompanionPodPresence::Live => CompanionLifecyclePlan::ReportLive,
|
|
CompanionPodPresence::Restorable => CompanionLifecyclePlan::Restore,
|
|
CompanionPodPresence::Missing => CompanionLifecyclePlan::Spawn,
|
|
CompanionPodPresence::Unavailable(message) => CompanionLifecyclePlan::Unavailable(format!(
|
|
"Workspace Companion Pod state is unusable: {message}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn orchestrator_pod_presence(pod_name: &str, pods: &PodList) -> OrchestratorPodPresence {
|
|
let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else {
|
|
return OrchestratorPodPresence::Missing;
|
|
};
|
|
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
|
return OrchestratorPodPresence::Live;
|
|
}
|
|
if entry.actions.can_restore {
|
|
return OrchestratorPodPresence::Restorable;
|
|
}
|
|
let reason = entry
|
|
.actions
|
|
.disabled_reason
|
|
.clone()
|
|
.or_else(|| {
|
|
entry
|
|
.diagnostics
|
|
.first()
|
|
.map(|diagnostic| diagnostic.message.clone())
|
|
})
|
|
.unwrap_or_else(|| "pod state is not live, restorable, or spawn-safe".to_string());
|
|
OrchestratorPodPresence::Unavailable(reason)
|
|
}
|
|
|
|
pub(crate) fn decide_orchestrator_lifecycle(
|
|
config: &TicketConfigAvailability,
|
|
presence: &OrchestratorPodPresence,
|
|
) -> OrchestratorLifecyclePlan {
|
|
match config {
|
|
TicketConfigAvailability::Absent => OrchestratorLifecyclePlan::SkipNoTicketConfig,
|
|
TicketConfigAvailability::Unusable(message) => OrchestratorLifecyclePlan::Unavailable(
|
|
format!("Ticket config is unusable; workspace Orchestrator not started: {message}"),
|
|
),
|
|
TicketConfigAvailability::Usable => match presence {
|
|
OrchestratorPodPresence::Live => OrchestratorLifecyclePlan::ReportLive,
|
|
OrchestratorPodPresence::Restorable => OrchestratorLifecyclePlan::Restore,
|
|
OrchestratorPodPresence::Missing => OrchestratorLifecyclePlan::Spawn,
|
|
OrchestratorPodPresence::Unavailable(message) => {
|
|
OrchestratorLifecyclePlan::Unavailable(format!(
|
|
"Workspace Orchestrator Pod state is unusable: {message}"
|
|
))
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability {
|
|
let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH);
|
|
match config_path.symlink_metadata() {
|
|
Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!(
|
|
"{} exists but is not a regular file",
|
|
TICKET_CONFIG_RELATIVE_PATH
|
|
)),
|
|
Ok(_) => match TicketConfig::load_workspace(workspace_root) {
|
|
Ok(_) => TicketConfigAvailability::Usable,
|
|
Err(error) => TicketConfigAvailability::Unusable(error.to_string()),
|
|
},
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
TicketConfigAvailability::Absent
|
|
}
|
|
Err(error) => TicketConfigAvailability::Unusable(format!(
|
|
"could not inspect {}: {error}",
|
|
TICKET_CONFIG_RELATIVE_PATH
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn bounded_panel_diagnostic(message: impl AsRef<str>) -> String {
|
|
let collapsed = message
|
|
.as_ref()
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string())
|
|
}
|
|
|
|
pub(crate) fn build_workspace_panel(
|
|
workspace_root: &Path,
|
|
pods: &PodList,
|
|
) -> WorkspacePanelViewModel {
|
|
let registry = match PanelRegistryStore::default_for_workspace(workspace_root)
|
|
.and_then(|store| store.snapshot())
|
|
{
|
|
Ok(snapshot) => snapshot,
|
|
Err(error) => {
|
|
let mut model = WorkspacePanelViewModel::empty(workspace_root);
|
|
model
|
|
.header
|
|
.diagnostics
|
|
.push(bounded_panel_diagnostic(format!(
|
|
"Panel local role registry unavailable: {error}"
|
|
)));
|
|
return build_workspace_panel_with_registry_model(
|
|
model,
|
|
workspace_root,
|
|
pods,
|
|
&PanelRegistrySnapshot::empty(),
|
|
);
|
|
}
|
|
};
|
|
build_workspace_panel_with_registry(workspace_root, pods, ®istry)
|
|
}
|
|
|
|
fn build_workspace_panel_with_registry(
|
|
workspace_root: &Path,
|
|
pods: &PodList,
|
|
registry: &PanelRegistrySnapshot,
|
|
) -> WorkspacePanelViewModel {
|
|
let model = WorkspacePanelViewModel::empty(workspace_root);
|
|
build_workspace_panel_with_registry_model(model, workspace_root, pods, registry)
|
|
}
|
|
|
|
fn build_workspace_panel_with_registry_model(
|
|
mut model: WorkspacePanelViewModel,
|
|
workspace_root: &Path,
|
|
pods: &PodList,
|
|
registry: &PanelRegistrySnapshot,
|
|
) -> WorkspacePanelViewModel {
|
|
match ticket_config_availability(workspace_root) {
|
|
TicketConfigAvailability::Absent => {}
|
|
TicketConfigAvailability::Usable => {
|
|
model.header.ticket_configured = true;
|
|
model.composer = WorkspacePanelComposer::ticket_enabled();
|
|
match TicketConfig::load_workspace(workspace_root) {
|
|
Ok(config) => {
|
|
model.header.ticket_root = config.backend_root().to_path_buf();
|
|
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
|
.with_record_language(config.ticket_record_language());
|
|
match build_ticket_rows(&backend, pods, registry) {
|
|
Ok(rows) => model.rows.extend(rows),
|
|
Err(error) => {
|
|
model
|
|
.header
|
|
.diagnostics
|
|
.push(bounded_panel_diagnostic(format!(
|
|
"Ticket rows unavailable: {error}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
Err(error) => model
|
|
.header
|
|
.diagnostics
|
|
.push(bounded_panel_diagnostic(format!(
|
|
"Ticket config is unusable: {error}"
|
|
))),
|
|
}
|
|
}
|
|
TicketConfigAvailability::Unusable(message) => {
|
|
model.header.ticket_configured = true;
|
|
model
|
|
.header
|
|
.diagnostics
|
|
.push(bounded_panel_diagnostic(format!(
|
|
"Ticket config is unusable: {message}"
|
|
)));
|
|
}
|
|
}
|
|
|
|
model.rows.extend(pod_rows(pods));
|
|
model.rows.sort_by(|a, b| {
|
|
a.priority
|
|
.cmp(&b.priority)
|
|
.then_with(|| row_updated_at(b).cmp(row_updated_at(a)))
|
|
.then_with(|| a.title.cmp(&b.title))
|
|
});
|
|
model
|
|
}
|
|
|
|
pub(crate) fn build_current_ticket_row(
|
|
backend: &LocalTicketBackend,
|
|
ticket_id: &str,
|
|
pods: &PodList,
|
|
) -> ticket::Result<PanelRow> {
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.to_owned()))?;
|
|
if ticket.meta.workflow_state == TicketWorkflowState::Closed {
|
|
return Err(TicketError::Conflict(format!(
|
|
"Ticket {ticket_id} is already closed"
|
|
)));
|
|
}
|
|
let summary = ticket_summary_from_meta(&ticket.meta);
|
|
let registry = PanelRegistrySnapshot::empty();
|
|
Ok(ticket_row(
|
|
summary,
|
|
&ticket.events,
|
|
&ticket.relations.blockers,
|
|
pods,
|
|
®istry,
|
|
))
|
|
}
|
|
|
|
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
|
TicketSummary {
|
|
id: meta.id.clone(),
|
|
slug: meta.slug.clone(),
|
|
title: meta.title.clone(),
|
|
status: meta.status.clone(),
|
|
kind: meta.kind.clone(),
|
|
priority: meta.priority.clone(),
|
|
labels: meta.labels.clone(),
|
|
readiness: meta.readiness.clone(),
|
|
workflow_state: meta.workflow_state,
|
|
workflow_state_explicit: meta.workflow_state_explicit,
|
|
queued_by: meta.queued_by.clone(),
|
|
queued_at: meta.queued_at.clone(),
|
|
updated_at: meta.updated_at.clone(),
|
|
}
|
|
}
|
|
|
|
fn build_ticket_rows(
|
|
backend: &LocalTicketBackend,
|
|
pods: &PodList,
|
|
registry: &PanelRegistrySnapshot,
|
|
) -> ticket::Result<Vec<PanelRow>> {
|
|
let mut rows = Vec::new();
|
|
for summary in backend.list(TicketFilter::all())? {
|
|
if summary.workflow_state == TicketWorkflowState::Closed {
|
|
continue;
|
|
}
|
|
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
|
|
rows.push(ticket_row(
|
|
summary,
|
|
&ticket.events,
|
|
&ticket.relations.blockers,
|
|
pods,
|
|
registry,
|
|
));
|
|
}
|
|
Ok(rows)
|
|
}
|
|
|
|
fn ticket_row(
|
|
summary: TicketSummary,
|
|
events: &[TicketEvent],
|
|
relation_blockers: &[TicketRelationBlocker],
|
|
pods: &PodList,
|
|
registry: &PanelRegistrySnapshot,
|
|
) -> PanelRow {
|
|
let local_claim = local_claim_for_ticket(&summary, pods, registry);
|
|
let related_pods = related_pods_for_ticket(&summary, pods, registry);
|
|
let derived = derive_ticket_state(&summary, relation_blockers);
|
|
let latest_event = events.last();
|
|
let entry = TicketPanelEntry {
|
|
id: summary.id.clone(),
|
|
title: summary.title.clone(),
|
|
priority: summary.priority.clone(),
|
|
workflow_state: summary.workflow_state,
|
|
workflow_state_explicit: summary.workflow_state_explicit,
|
|
next_action: derived.action,
|
|
updated_at: summary.updated_at.clone(),
|
|
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
|
|
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
|
blocked_reason: derived.blocked_reason.clone(),
|
|
related_pods: related_pods.clone(),
|
|
local_claim,
|
|
};
|
|
let subtitle = ticket_subtitle(&entry);
|
|
PanelRow {
|
|
key: PanelRowKey::Ticket(summary.id),
|
|
kind: derived.kind,
|
|
title: summary.title,
|
|
subtitle,
|
|
status: summary.workflow_state.as_str().to_string(),
|
|
priority: derived.priority,
|
|
next_action: derived.action,
|
|
ticket: Some(entry),
|
|
related_pods,
|
|
disabled_reason: derived.disabled_reason,
|
|
key_hint: derived.key_hint,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct DerivedTicketState {
|
|
kind: PanelRowKind,
|
|
priority: ActionPriority,
|
|
action: Option<NextUserAction>,
|
|
disabled_reason: Option<String>,
|
|
key_hint: Option<String>,
|
|
blocked_reason: Option<String>,
|
|
}
|
|
|
|
fn derive_ticket_state(
|
|
summary: &TicketSummary,
|
|
relation_blockers: &[TicketRelationBlocker],
|
|
) -> DerivedTicketState {
|
|
if !relation_blockers.is_empty() {
|
|
let blockers = relation_blockers
|
|
.iter()
|
|
.take(3)
|
|
.map(|blocker| {
|
|
format!(
|
|
"{} via {} (state: {})",
|
|
blocker.blocking_ticket,
|
|
blocker.reason_kind,
|
|
blocker.blocking_state.as_str()
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return DerivedTicketState {
|
|
kind: PanelRowKind::Blocked,
|
|
priority: ActionPriority::UserReply,
|
|
action: Some(NextUserAction::Edit),
|
|
disabled_reason: Some(
|
|
"Unresolved Ticket relation blocks queueing; resolve dependency/blocker before ready -> queued."
|
|
.to_string(),
|
|
),
|
|
key_hint: Some("Open the Ticket relation diagnostics before queueing".to_string()),
|
|
blocked_reason: Some(blockers),
|
|
};
|
|
}
|
|
|
|
match summary.workflow_state {
|
|
TicketWorkflowState::Ready => DerivedTicketState {
|
|
kind: PanelRowKind::Ticket,
|
|
priority: ActionPriority::ReadyForQueue,
|
|
action: Some(NextUserAction::Queue),
|
|
disabled_reason: None,
|
|
key_hint: Some(
|
|
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
|
),
|
|
blocked_reason: None,
|
|
},
|
|
TicketWorkflowState::Queued => DerivedTicketState {
|
|
kind: PanelRowKind::ActiveWork,
|
|
priority: ActionPriority::ActiveWork,
|
|
action: Some(NextUserAction::Wait),
|
|
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
|
key_hint: None,
|
|
blocked_reason: None,
|
|
},
|
|
TicketWorkflowState::InProgress => DerivedTicketState {
|
|
kind: PanelRowKind::ActiveWork,
|
|
priority: ActionPriority::ActiveWork,
|
|
action: Some(NextUserAction::Wait),
|
|
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
|
key_hint: None,
|
|
blocked_reason: None,
|
|
},
|
|
TicketWorkflowState::Done => DerivedTicketState {
|
|
kind: PanelRowKind::Review,
|
|
priority: ActionPriority::Background,
|
|
action: Some(NextUserAction::Close),
|
|
disabled_reason: Some(
|
|
"state is done; close if a resolution is still missing.".to_string(),
|
|
),
|
|
key_hint: None,
|
|
blocked_reason: None,
|
|
},
|
|
TicketWorkflowState::Planning => DerivedTicketState {
|
|
kind: PanelRowKind::Planning,
|
|
priority: ActionPriority::Background,
|
|
action: Some(NextUserAction::Clarify),
|
|
disabled_reason: Some(
|
|
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
|
),
|
|
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
|
blocked_reason: None,
|
|
},
|
|
TicketWorkflowState::Closed => DerivedTicketState {
|
|
kind: PanelRowKind::Review,
|
|
priority: ActionPriority::Background,
|
|
action: Some(NextUserAction::Wait),
|
|
disabled_reason: Some("Ticket is closed.".to_string()),
|
|
key_hint: None,
|
|
blocked_reason: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn related_pods_for_ticket(
|
|
summary: &TicketSummary,
|
|
pods: &PodList,
|
|
registry: &PanelRegistrySnapshot,
|
|
) -> Vec<String> {
|
|
let id = lowercase(&summary.id);
|
|
let mut names = Vec::new();
|
|
if let Some(claim) = registry.claim_for_ticket(&summary.id) {
|
|
names.push(claim.pod_name.clone());
|
|
}
|
|
for pod in pods.entries.iter().filter_map(|pod| {
|
|
let name = lowercase(&pod.name);
|
|
if !id.is_empty() && name.contains(&id) {
|
|
Some(pod.name.clone())
|
|
} else {
|
|
None
|
|
}
|
|
}) {
|
|
if !names.iter().any(|existing| existing == &pod) {
|
|
names.push(pod);
|
|
}
|
|
if names.len() >= 5 {
|
|
break;
|
|
}
|
|
}
|
|
names
|
|
}
|
|
|
|
fn local_claim_for_ticket(
|
|
summary: &TicketSummary,
|
|
pods: &PodList,
|
|
registry: &PanelRegistrySnapshot,
|
|
) -> Option<TicketLocalClaimEntry> {
|
|
let claim = registry.claim_for_ticket(&summary.id)?;
|
|
let status = local_claim_status_for_pod(&claim.pod_name, pods);
|
|
Some(TicketLocalClaimEntry {
|
|
pod_name: claim.pod_name.clone(),
|
|
role: claim.role.clone(),
|
|
status,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> TicketLocalClaimStatus {
|
|
let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else {
|
|
return TicketLocalClaimStatus::Stale;
|
|
};
|
|
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
|
return TicketLocalClaimStatus::Live;
|
|
}
|
|
if entry.actions.can_restore {
|
|
return TicketLocalClaimStatus::Restorable;
|
|
}
|
|
TicketLocalClaimStatus::Stale
|
|
}
|
|
|
|
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
|
let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())];
|
|
if let Some(claim) = entry.local_claim.as_ref() {
|
|
parts.push(format!(
|
|
"claim: {} ({})",
|
|
claim.pod_name,
|
|
claim.status.label()
|
|
));
|
|
}
|
|
if !entry.related_pods.is_empty() {
|
|
parts.push(format!("pods: {}", entry.related_pods.join(", ")));
|
|
}
|
|
if let Some(excerpt) = entry.latest_event_excerpt.as_ref() {
|
|
parts.push(format!("latest: {excerpt}"));
|
|
}
|
|
Some(parts.join(" "))
|
|
}
|
|
|
|
fn pod_rows(pods: &PodList) -> Vec<PanelRow> {
|
|
pods.entries.iter().map(pod_row).collect()
|
|
}
|
|
|
|
fn pod_row(entry: &PodListEntry) -> PanelRow {
|
|
let status = pod_status_label(entry).to_string();
|
|
let next_action = if entry.actions.can_open {
|
|
Some(NextUserAction::OpenPod)
|
|
} else {
|
|
None
|
|
};
|
|
let mut subtitle = entry.summary.preview.clone();
|
|
if subtitle.is_none()
|
|
&& entry
|
|
.stored
|
|
.as_ref()
|
|
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
|
|
{
|
|
subtitle = Some("metadata corrupt".to_string());
|
|
}
|
|
|
|
PanelRow {
|
|
key: PanelRowKey::Pod(entry.name.clone()),
|
|
kind: PanelRowKind::Pod,
|
|
title: entry.name.clone(),
|
|
subtitle,
|
|
status,
|
|
priority: ActionPriority::Background,
|
|
next_action,
|
|
ticket: None,
|
|
related_pods: Vec::new(),
|
|
disabled_reason: entry.actions.disabled_reason.clone(),
|
|
key_hint: Some("Enter opens/attaches for inspection".to_string()),
|
|
}
|
|
}
|
|
|
|
fn pod_status_label(entry: &PodListEntry) -> &'static str {
|
|
if let Some(live) = entry.live.as_ref() {
|
|
if !live.reachable {
|
|
return "unreachable";
|
|
}
|
|
return match live.status {
|
|
Some(PodStatus::Idle) => "live idle",
|
|
Some(PodStatus::Running) => "live running",
|
|
Some(PodStatus::Paused) => "live paused",
|
|
None => "live",
|
|
};
|
|
}
|
|
if entry
|
|
.stored
|
|
.as_ref()
|
|
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
|
|
{
|
|
"corrupt"
|
|
} else {
|
|
"stopped/restorable"
|
|
}
|
|
}
|
|
|
|
fn row_updated_at(row: &PanelRow) -> &str {
|
|
row.ticket
|
|
.as_ref()
|
|
.and_then(|ticket| ticket.updated_at.as_deref())
|
|
.unwrap_or("")
|
|
}
|
|
|
|
fn excerpt(markdown: &str, max_chars: usize) -> Option<String> {
|
|
let collapsed = markdown
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
if collapsed.is_empty() {
|
|
None
|
|
} else if collapsed.chars().count() <= max_chars {
|
|
Some(collapsed)
|
|
} else {
|
|
let mut value = collapsed
|
|
.chars()
|
|
.take(max_chars.saturating_sub(1))
|
|
.collect::<String>();
|
|
value.push('…');
|
|
Some(value)
|
|
}
|
|
}
|
|
|
|
fn lowercase(value: &str) -> String {
|
|
value.to_ascii_lowercase()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::pod_list::{LivePodInfo, PodEntrySummary};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use tempfile::TempDir;
|
|
use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState};
|
|
|
|
fn empty_pods() -> PodList {
|
|
PodList::from_sources(
|
|
crate::pod_list::PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![],
|
|
None,
|
|
10,
|
|
)
|
|
}
|
|
|
|
fn create_ticket(
|
|
backend: &LocalTicketBackend,
|
|
title: &str,
|
|
configure: impl FnOnce(&mut NewTicket),
|
|
) {
|
|
let mut input = NewTicket::new(title);
|
|
configure(&mut input);
|
|
backend.create(input).unwrap();
|
|
}
|
|
|
|
fn write_ticket_config(workspace_root: &Path) {
|
|
let config_dir = workspace_root.join(".yoi");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
fs::write(
|
|
config_dir.join("ticket.config.toml"),
|
|
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn live_pods(names: &[&str]) -> PodList {
|
|
PodList::from_sources(
|
|
crate::pod_list::PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
names
|
|
.iter()
|
|
.map(|name| LivePodInfo {
|
|
pod_name: (*name).to_string(),
|
|
socket_path: PathBuf::from(format!("/tmp/{name}.sock")),
|
|
status: Some(PodStatus::Idle),
|
|
reachable: true,
|
|
segment_id: None,
|
|
summary: PodEntrySummary::default(),
|
|
})
|
|
.collect(),
|
|
None,
|
|
10,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_without_ticket_config_is_pod_only() {
|
|
let temp = TempDir::new().unwrap();
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
create_ticket(&backend, "Hidden Without Config", |_| {});
|
|
|
|
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
|
|
|
assert!(model.header.diagnostics.is_empty());
|
|
assert_eq!(
|
|
model.composer.available_targets,
|
|
vec![ComposerTarget::Companion]
|
|
);
|
|
assert_eq!(model.rows.len(), 1);
|
|
assert_eq!(model.rows[0].key, PanelRowKey::Pod("idle".to_string()));
|
|
assert!(model.rows[0].ticket.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_uses_explicit_workflow_state_for_queue_priority() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_ticket_config(temp.path());
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
create_ticket(&backend, "Ready Ticket", |input| {
|
|
input.workflow_state = Some(TicketWorkflowState::Ready);
|
|
});
|
|
create_ticket(&backend, "Planning Ticket", |_| {});
|
|
|
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
|
assert_eq!(
|
|
model.composer.available_targets,
|
|
vec![ComposerTarget::Companion, ComposerTarget::TicketIntake]
|
|
);
|
|
let row = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Ready Ticket")
|
|
.unwrap();
|
|
|
|
assert_eq!(row.status, "ready");
|
|
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
|
|
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_ticket_config(temp.path());
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
create_ticket(&backend, "Readiness Heuristic", |input| {
|
|
input.readiness = Some("implementation-ready".to_string());
|
|
});
|
|
create_ticket(&backend, "Queued Words Are Not State", |_| {});
|
|
create_ticket(&backend, "Queued Explicit", |input| {
|
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
|
});
|
|
|
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
|
let readiness = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Readiness Heuristic")
|
|
.unwrap();
|
|
let title = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Queued Words Are Not State")
|
|
.unwrap();
|
|
let queued = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Queued Explicit")
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
readiness.ticket.as_ref().unwrap().workflow_state,
|
|
TicketWorkflowState::Planning
|
|
);
|
|
assert_eq!(readiness.next_action, Some(NextUserAction::Clarify));
|
|
assert_eq!(
|
|
title.ticket.as_ref().unwrap().workflow_state,
|
|
TicketWorkflowState::Planning
|
|
);
|
|
assert_eq!(title.next_action, Some(NextUserAction::Clarify));
|
|
assert_eq!(
|
|
queued.ticket.as_ref().unwrap().workflow_state,
|
|
TicketWorkflowState::Queued
|
|
);
|
|
assert_eq!(queued.next_action, Some(NextUserAction::Wait));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_blocked() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_ticket_config(temp.path());
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
let mut ready_input = NewTicket::new("Ready Blocked By Relation");
|
|
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
|
let ready = backend.create(ready_input).unwrap();
|
|
let dependency = backend
|
|
.create(NewTicket::new("Relation Dependency"))
|
|
.unwrap();
|
|
backend
|
|
.add_ticket_relation(
|
|
TicketIdOrSlug::Id(ready.id.clone()),
|
|
NewTicketRelation {
|
|
kind: TicketRelationKind::DependsOn,
|
|
target: dependency.id.clone(),
|
|
note: None,
|
|
author: Some("test".to_string()),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
|
let row = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Ready Blocked By Relation")
|
|
.unwrap();
|
|
|
|
assert_eq!(row.kind, PanelRowKind::Blocked);
|
|
assert_eq!(row.next_action, Some(NextUserAction::Edit));
|
|
assert_eq!(row.priority, ActionPriority::UserReply);
|
|
assert!(
|
|
row.disabled_reason
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Unresolved Ticket relation")
|
|
);
|
|
assert!(
|
|
row.ticket
|
|
.as_ref()
|
|
.unwrap()
|
|
.blocked_reason
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains(&dependency.id)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_defaults_missing_open_state_to_planning_and_displays_done_state() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_ticket_config(temp.path());
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
create_ticket(&backend, "Plain Backlog", |_| {});
|
|
create_ticket(&backend, "Done Explicit", |input| {
|
|
input.workflow_state = Some(TicketWorkflowState::Done);
|
|
});
|
|
|
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
|
let backlog = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Plain Backlog")
|
|
.unwrap();
|
|
let done = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Done Explicit")
|
|
.unwrap();
|
|
|
|
assert_eq!(backlog.status, "planning");
|
|
assert_eq!(backlog.next_action, Some(NextUserAction::Clarify));
|
|
assert!(backlog.is_ticket_action());
|
|
assert_eq!(done.status, "done");
|
|
assert_eq!(done.next_action, Some(NextUserAction::Close));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_displays_local_ticket_claim_status() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_ticket_config(temp.path());
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
create_ticket(&backend, "Claimed Planning", |_| {});
|
|
let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
|
|
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
|
store
|
|
.claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake")
|
|
.unwrap();
|
|
let registry = store.snapshot().unwrap();
|
|
|
|
let model = build_workspace_panel_with_registry(
|
|
temp.path(),
|
|
&live_pods(&["ticket-claimed-intake"]),
|
|
®istry,
|
|
);
|
|
let row = model
|
|
.rows
|
|
.iter()
|
|
.find(|row| row.title == "Claimed Planning")
|
|
.unwrap();
|
|
let claim = row.ticket.as_ref().unwrap().local_claim.as_ref().unwrap();
|
|
|
|
assert_eq!(claim.pod_name, "ticket-claimed-intake");
|
|
assert_eq!(claim.status, TicketLocalClaimStatus::Live);
|
|
assert_eq!(row.related_pods, vec!["ticket-claimed-intake"]);
|
|
assert!(
|
|
row.subtitle
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("claim: ticket-claimed-intake (live)")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_companion_pod_name_is_workspace_basename_without_suffix() {
|
|
assert_eq!(
|
|
workspace_companion_pod_name(Path::new("/home/hare/Projects/yoi")),
|
|
"yoi"
|
|
);
|
|
assert_eq!(
|
|
workspace_companion_pod_name(Path::new("/tmp/Yoi Workspace")),
|
|
"yoi-workspace"
|
|
);
|
|
assert_eq!(
|
|
workspace_companion_pod_name(Path::new("/tmp/.strange_日本語!!")),
|
|
"strange"
|
|
);
|
|
assert_eq!(
|
|
workspace_companion_pod_name(Path::new("/tmp/___")),
|
|
"workspace"
|
|
);
|
|
let long = "a".repeat(120);
|
|
let name = workspace_companion_pod_name(&PathBuf::from(format!("/tmp/{long}")));
|
|
assert_eq!(name.chars().count(), 80);
|
|
assert!(!name.ends_with("-companion"));
|
|
}
|
|
|
|
#[test]
|
|
fn companion_lifecycle_decisions_follow_pod_state_without_ticket_gate() {
|
|
assert_eq!(
|
|
decide_companion_lifecycle(&CompanionPodPresence::Live),
|
|
CompanionLifecyclePlan::ReportLive
|
|
);
|
|
assert_eq!(
|
|
decide_companion_lifecycle(&CompanionPodPresence::Restorable),
|
|
CompanionLifecyclePlan::Restore
|
|
);
|
|
assert_eq!(
|
|
decide_companion_lifecycle(&CompanionPodPresence::Missing),
|
|
CompanionLifecyclePlan::Spawn
|
|
);
|
|
assert!(matches!(
|
|
decide_companion_lifecycle(&CompanionPodPresence::Unavailable(
|
|
"corrupt metadata".to_string()
|
|
)),
|
|
CompanionLifecyclePlan::Unavailable(message) if message.contains("corrupt metadata")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_orchestrator_pod_name_is_stable_and_safe() {
|
|
assert_eq!(
|
|
workspace_orchestrator_pod_name(Path::new("/tmp/Yoi Workspace")),
|
|
"yoi-workspace-orchestrator"
|
|
);
|
|
assert_eq!(
|
|
workspace_orchestrator_pod_name(Path::new("/tmp/.strange_日本語!!")),
|
|
"strange-orchestrator"
|
|
);
|
|
assert_eq!(
|
|
workspace_orchestrator_pod_name(Path::new("/tmp/___")),
|
|
"workspace-orchestrator"
|
|
);
|
|
let long = "a".repeat(120);
|
|
let name = workspace_orchestrator_pod_name(&PathBuf::from(format!("/tmp/{long}")));
|
|
assert_eq!(name.chars().count(), 80);
|
|
assert!(name.ends_with("-orchestrator"));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestrator_lifecycle_decisions_follow_ticket_gate_and_pod_state() {
|
|
assert_eq!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Absent,
|
|
&OrchestratorPodPresence::Missing,
|
|
),
|
|
OrchestratorLifecyclePlan::SkipNoTicketConfig
|
|
);
|
|
assert!(matches!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Unusable("bad config".to_string()),
|
|
&OrchestratorPodPresence::Missing,
|
|
),
|
|
OrchestratorLifecyclePlan::Unavailable(message) if message.contains("bad config")
|
|
));
|
|
assert_eq!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Usable,
|
|
&OrchestratorPodPresence::Live,
|
|
),
|
|
OrchestratorLifecyclePlan::ReportLive
|
|
);
|
|
assert_eq!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Usable,
|
|
&OrchestratorPodPresence::Restorable,
|
|
),
|
|
OrchestratorLifecyclePlan::Restore
|
|
);
|
|
assert_eq!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Usable,
|
|
&OrchestratorPodPresence::Missing,
|
|
),
|
|
OrchestratorLifecyclePlan::Spawn
|
|
);
|
|
assert!(matches!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Usable,
|
|
&OrchestratorPodPresence::Unavailable("corrupt metadata".to_string()),
|
|
),
|
|
OrchestratorLifecyclePlan::Unavailable(message) if message.contains("corrupt metadata")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn existing_non_file_ticket_config_is_unusable_not_absent() {
|
|
let temp = TempDir::new().unwrap();
|
|
let config_parent = temp.path().join(".yoi");
|
|
fs::create_dir_all(&config_parent).unwrap();
|
|
fs::create_dir(config_parent.join("ticket.config.toml")).unwrap();
|
|
|
|
assert!(matches!(
|
|
ticket_config_availability(temp.path()),
|
|
TicketConfigAvailability::Unusable(message) if message.contains("not a regular file")
|
|
));
|
|
let model = build_workspace_panel(temp.path(), &empty_pods());
|
|
|
|
assert!(model.header.ticket_configured);
|
|
assert_eq!(
|
|
model.composer.available_targets,
|
|
vec![ComposerTarget::Companion]
|
|
);
|
|
assert!(model.header.diagnostics.iter().any(|diagnostic| {
|
|
diagnostic.contains("Ticket config is unusable")
|
|
&& diagnostic.contains("not a regular file")
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestrator_presence_can_be_decided_from_untruncated_authority() {
|
|
let live = (0..60)
|
|
.map(|index| LivePodInfo {
|
|
pod_name: format!("pod-{index:02}"),
|
|
socket_path: PathBuf::from(format!("/tmp/pod-{index:02}.sock")),
|
|
status: Some(PodStatus::Idle),
|
|
reachable: true,
|
|
segment_id: None,
|
|
summary: PodEntrySummary::default(),
|
|
})
|
|
.chain(std::iter::once(LivePodInfo {
|
|
pod_name: "zz-workspace-orchestrator".to_string(),
|
|
socket_path: PathBuf::from("/tmp/zz-workspace-orchestrator.sock"),
|
|
status: Some(PodStatus::Idle),
|
|
reachable: true,
|
|
segment_id: None,
|
|
summary: PodEntrySummary::default(),
|
|
}))
|
|
.collect::<Vec<_>>();
|
|
let visible = PodList::from_sources(
|
|
crate::pod_list::PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
live.clone(),
|
|
None,
|
|
50,
|
|
);
|
|
let authority = PodList::from_sources(
|
|
crate::pod_list::PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
live,
|
|
None,
|
|
usize::MAX,
|
|
);
|
|
|
|
assert_eq!(
|
|
orchestrator_pod_presence("zz-workspace-orchestrator", &visible),
|
|
OrchestratorPodPresence::Missing
|
|
);
|
|
assert_eq!(
|
|
orchestrator_pod_presence("zz-workspace-orchestrator", &authority),
|
|
OrchestratorPodPresence::Live
|
|
);
|
|
assert_eq!(
|
|
decide_orchestrator_lifecycle(
|
|
&TicketConfigAvailability::Usable,
|
|
&orchestrator_pod_presence("zz-workspace-orchestrator", &authority),
|
|
),
|
|
OrchestratorLifecyclePlan::ReportLive
|
|
);
|
|
}
|
|
}
|