merge: panel role session registry

This commit is contained in:
Keisuke Hirata 2026-06-07 11:34:21 +09:00
commit 7e8e03d4a1
No known key found for this signature in database
4 changed files with 937 additions and 29 deletions

View File

@ -8,6 +8,7 @@ mod markdown;
mod multi_pod;
mod picker;
mod pod_list;
mod role_session_registry;
mod scroll;
mod single_pod;
mod spawn;

View File

@ -1,11 +1,11 @@
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use client::ticket_role::{
TicketIntakeHandoff, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError,
TicketIntakeHandoff, TicketRef, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError,
TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod,
launch_ticket_role_pod_with_options,
launch_ticket_role_pod_with_options, plan_ticket_role_launch,
};
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
@ -33,12 +33,16 @@ use crate::pod_list::{
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos,
read_stored_pod_infos,
};
use crate::role_session_registry::{
PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult,
};
use crate::workspace_panel::{
ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
PanelRowKey, TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic,
build_current_ticket_row, build_workspace_panel, decide_orchestrator_lifecycle,
orchestrator_pod_presence, ticket_config_availability, workspace_orchestrator_pod_name,
PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel,
bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel,
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
ticket_config_availability, workspace_orchestrator_pod_name,
};
const MAX_ENTRIES: usize = 50;
@ -258,6 +262,18 @@ pub(crate) struct IntakeLaunchRequest {
context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand,
peer_registration: IntakePeerRegistrationRequest,
registry_update: IntakeRegistryUpdate,
}
#[derive(Debug, Clone)]
pub(crate) enum IntakeRegistryUpdate {
RecordSession {
registry_root: PathBuf,
pod_name: String,
origin: RoleSessionOrigin,
related_tickets: Vec<RelatedTicketRef>,
},
ClaimedTicket,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -270,6 +286,7 @@ pub(crate) enum IntakePeerRegistrationRequest {
pub(crate) struct IntakeLaunchOutcome {
launch: TicketRoleLaunchResult,
peer_registration: IntakePeerRegistrationStatus,
registry_warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -311,6 +328,28 @@ pub(crate) async fn launch_intake_with_handoff(request: IntakeLaunchRequest) ->
options,
)
.await?;
let registry_warning = match request.registry_update {
IntakeRegistryUpdate::RecordSession {
registry_root,
pod_name,
origin,
related_tickets,
} => PanelRegistryStore::from_root(registry_root)
.record_session(
pod_name,
TicketRole::Intake.as_str().to_string(),
origin,
None,
related_tickets,
)
.err()
.map(|error| {
bounded_panel_diagnostic(format!(
"local role session registry could not be updated after Intake launch: {error}"
))
}),
IntakeRegistryUpdate::ClaimedTicket => None,
};
let peer_registration = match (orchestrator_pod, skip_warning) {
(_, Some(warning)) => warning,
(Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => {
@ -331,6 +370,7 @@ pub(crate) async fn launch_intake_with_handoff(request: IntakeLaunchRequest) ->
Ok(IntakeLaunchOutcome {
launch,
peer_registration,
registry_warning,
})
}
@ -710,8 +750,37 @@ impl MultiPodApp {
}
let mut context =
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
let pod_name = unique_preticket_intake_pod_name();
context.pod_name = Some(pod_name.clone());
context.user_instruction = Some(body);
let peer_registration = match self.panel.header.orchestrator.as_ref() {
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store,
Err(error) => {
self.notice = Some(format!("Ticket Intake registry unavailable: {error}"));
return None;
}
};
let peer_registration = self.prepare_intake_peer_registration(&mut context);
self.sending = true;
self.notice = Some("Launching Ticket Intake…".to_string());
Some(IntakeLaunchRequest {
context,
runtime_command: self.runtime_command.clone(),
peer_registration,
registry_update: IntakeRegistryUpdate::RecordSession {
registry_root: store.root().to_path_buf(),
pod_name,
origin: RoleSessionOrigin::PreTicketIntake,
related_tickets: Vec::new(),
},
})
}
fn prepare_intake_peer_registration(
&self,
context: &mut TicketRoleLaunchContext,
) -> IntakePeerRegistrationRequest {
match self.panel.header.orchestrator.as_ref() {
Some(orchestrator) => {
context.intake_handoff = Some(TicketIntakeHandoff::new(
orchestrator.pod_name.clone(),
@ -734,13 +803,104 @@ impl MultiPodApp {
None => IntakePeerRegistrationRequest::Skip {
reason: "workspace Orchestrator is not configured for this panel".to_string(),
},
}
}
pub(crate) fn prepare_existing_ticket_intake_launch(&mut self) -> Option<IntakeLaunchRequest> {
let row = match self.selected_panel_row() {
Some(row) if row.is_ticket_action() => row,
Some(row) if row.ticket.is_some() => {
self.notice = Some("Selected Ticket row has no Intake action.".to_string());
return None;
}
_ => {
self.notice = Some("No Ticket Intake action is selected.".to_string());
return None;
}
};
let Some(action) = row.next_action else {
self.notice = Some("Selected Ticket row has no Intake action.".to_string());
return None;
};
if action != NextUserAction::Clarify {
self.notice = Some(format!(
"{} is not handled by Ticket Intake launch.",
action.label()
));
return None;
}
let Some(ticket) = row.ticket.as_ref() else {
self.notice = Some("No Ticket Intake action is selected.".to_string());
return None;
};
let ticket_id = ticket.id.clone();
let ticket_slug = ticket.slug.clone();
let mut context =
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef {
id: Some(ticket_id.clone()),
slug: Some(ticket_slug.clone()),
});
context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id} ({ticket_slug}). Do not create a duplicate Ticket unless the user explicitly requests one."
));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store,
Err(error) => {
self.notice = Some(format!("Ticket Intake registry unavailable: {error}"));
return None;
}
};
match store.claim_for_ticket(&ticket_id) {
Ok(Some(claim)) => {
let status = local_claim_status_for_pod(&claim.pod_name, &self.list);
self.notice = Some(existing_ticket_claim_notice(
&ticket_id,
&claim.pod_name,
status,
));
return None;
}
Ok(None) => {}
Err(error) => {
self.notice = Some(format!("Ticket claim diagnostic required: {error}"));
return None;
}
}
let planned = match plan_ticket_role_launch(context.clone()) {
Ok(plan) => plan,
Err(error) => {
self.notice = Some(format!(
"Ticket Intake launch plan failed; no claim written: {}",
bounded_panel_diagnostic(error.to_string())
));
return None;
}
};
context.pod_name = Some(planned.pod_name.clone());
match store.claim_ticket(
&ticket_id,
Some(&ticket_slug),
&planned.pod_name,
TicketRole::Intake.as_str(),
) {
Ok(TicketClaimResult::Claimed) | Ok(TicketClaimResult::AlreadyOwned(_)) => {}
Err(error) => {
self.notice = Some(format!("Ticket claim diagnostic required: {error}"));
return None;
}
}
let peer_registration = self.prepare_intake_peer_registration(&mut context);
self.sending = true;
self.notice = Some("Launching Ticket Intake…".to_string());
self.notice = Some(format!(
"Launching Ticket Intake for {} as {}…",
ticket_slug, planned.pod_name
));
Some(IntakeLaunchRequest {
context,
runtime_command: self.runtime_command.clone(),
peer_registration,
registry_update: IntakeRegistryUpdate::ClaimedTicket,
})
}
@ -758,8 +918,12 @@ impl MultiPodApp {
format!(" Handoff warning: {message}")
}
};
let registry_notice = result
.registry_warning
.map(|warning| format!(" Registry warning: {warning}"))
.unwrap_or_default();
self.notice = Some(bounded_panel_diagnostic(format!(
"Launched Ticket Intake Pod {pod_name}.{peer_notice}"
"Launched Ticket Intake Pod {pod_name}.{peer_notice}{registry_notice}"
)));
}
Err(error) => {
@ -804,6 +968,14 @@ impl MultiPodApp {
self.input.insert_newline();
MultiPodAction::None
}
KeyCode::Enter
if self.composer_is_blank()
&& self.selected_ticket_action() == Some(NextUserAction::Clarify) =>
{
self.prepare_existing_ticket_intake_launch()
.map(MultiPodAction::LaunchIntake)
.unwrap_or(MultiPodAction::None)
}
KeyCode::Enter
if self.composer_is_blank() && self.selected_ticket_action().is_some() =>
{
@ -1093,6 +1265,30 @@ fn orchestrator_status_is_peer_reachable(status: OrchestratorPanelStatus) -> boo
)
}
fn unique_preticket_intake_pod_name() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
format!("ticket-intake-{nanos:x}")
}
fn existing_ticket_claim_notice(
ticket_id: &str,
pod_name: &str,
status: TicketLocalClaimStatus,
) -> String {
match status {
TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => format!(
"Ticket {ticket_id} is already claimed by local Intake Pod {pod_name} ({}); open that Pod instead of starting a second Intake.",
status.label()
),
TicketLocalClaimStatus::Stale => format!(
"Ticket {ticket_id} has a stale local Intake claim for {pod_name}; explicit reclaim/diagnostic is required before starting a replacement."
),
}
}
async fn load_exact_pod_presence(pod_name: &str) -> Result<OrchestratorPodPresence, MultiPodError> {
let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?;
Ok(orchestrator_pod_presence(pod_name, &list))
@ -3238,6 +3434,7 @@ mod tests {
peer_registration: IntakePeerRegistrationStatus::Registered {
orchestrator_pod: "test-orchestrator".to_string(),
},
registry_warning: None,
}));
assert!(!app.sending);
@ -3357,6 +3554,7 @@ mod tests {
latest_event_excerpt: Some("latest event stays out of the primary row".to_string()),
blocked_reason: None,
related_pods: Vec::new(),
local_claim: None,
};
PanelRow {
key: PanelRowKey::Ticket(ticket.id.clone()),

View File

@ -0,0 +1,556 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const REGISTRY_VERSION: u32 = 1;
const REGISTRY_FILE: &str = "role-sessions.json";
const REGISTRY_LOCK_FILE: &str = "role-sessions.lock";
const CLAIMS_DIR: &str = "ticket-claims";
#[derive(Debug, Clone)]
pub(crate) struct PanelRegistryStore {
root: PathBuf,
workspace_root: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRegistry {
pub version: u32,
pub workspace_root: String,
pub sessions: BTreeMap<String, RoleSessionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRecord {
pub role: String,
pub pod_name: String,
pub origin: RoleSessionOrigin,
pub created_at: String,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default)]
pub related_tickets: Vec<RelatedTicketRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RoleSessionOrigin {
PreTicketIntake,
TicketClaim,
RoleLaunch,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct RelatedTicketRef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct TicketClaim {
pub ticket_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_slug: Option<String>,
pub pod_name: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PanelRegistrySnapshot {
pub sessions: Vec<RoleSessionRecord>,
pub claims: Vec<TicketClaim>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TicketClaimResult {
Claimed,
AlreadyOwned(TicketClaim),
}
#[derive(Debug)]
pub(crate) enum PanelRegistryError {
Io(io::Error),
Json(serde_json::Error),
TicketAlreadyClaimed(TicketClaim),
}
impl std::fmt::Display for PanelRegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "local role session registry I/O error: {error}"),
Self::Json(error) => write!(f, "local role session registry JSON error: {error}"),
Self::TicketAlreadyClaimed(claim) => write!(
f,
"Ticket {} is already claimed locally by {} ({})",
claim.ticket_id, claim.pod_name, claim.role
),
}
}
}
impl std::error::Error for PanelRegistryError {}
impl From<io::Error> for PanelRegistryError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for PanelRegistryError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl PanelRegistryStore {
pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result<Self, PanelRegistryError> {
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
PanelRegistryError::Io(io::Error::other("failed to resolve yoi data directory"))
})?;
Ok(Self::for_data_dir(data_dir, workspace_root))
}
pub(crate) fn for_data_dir(data_dir: impl AsRef<Path>, workspace_root: &Path) -> Self {
let workspace_root = normalized_workspace_key(workspace_root);
let leaf = workspace_leaf(&workspace_root);
let digest = fnv1a64_hex(workspace_root.as_bytes());
Self {
root: data_dir
.as_ref()
.join("panel")
.join("workspaces")
.join(format!("{leaf}-{digest}")),
workspace_root: Some(workspace_root),
}
}
pub(crate) fn from_root(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
workspace_root: None,
}
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn snapshot(&self) -> Result<PanelRegistrySnapshot, PanelRegistryError> {
let registry = self.load_registry()?;
let claims = self.load_claims()?;
Ok(PanelRegistrySnapshot {
sessions: registry.sessions.into_values().collect(),
claims,
})
}
pub(crate) fn load_registry(&self) -> Result<RoleSessionRegistry, PanelRegistryError> {
match fs::read(self.registry_path()) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(RoleSessionRegistry {
version: REGISTRY_VERSION,
workspace_root: self.workspace_root.clone().unwrap_or_default(),
sessions: BTreeMap::new(),
}),
Err(error) => Err(error.into()),
}
}
pub(crate) fn record_session(
&self,
pod_name: impl Into<String>,
role: impl Into<String>,
origin: RoleSessionOrigin,
session_id: Option<String>,
related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
) -> Result<(), PanelRegistryError> {
let pod_name = pod_name.into();
let role = role.into();
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
self.update_registry(|registry| {
let now = now_timestamp_string();
let mut tickets: BTreeSet<RelatedTicketRef> = registry
.sessions
.get(&pod_name)
.map(|record| record.related_tickets.iter().cloned().collect())
.unwrap_or_default();
tickets.extend(related_tickets);
let created_at = registry
.sessions
.get(&pod_name)
.map(|record| record.created_at.clone())
.unwrap_or_else(|| now.clone());
registry.sessions.insert(
pod_name.clone(),
RoleSessionRecord {
role,
pod_name,
origin,
created_at,
updated_at: now,
session_id,
related_tickets: tickets.into_iter().collect(),
},
);
Ok(())
})
}
pub(crate) fn claim_ticket(
&self,
ticket_id: &str,
ticket_slug: Option<&str>,
pod_name: &str,
role: &str,
) -> Result<TicketClaimResult, PanelRegistryError> {
fs::create_dir_all(self.claims_dir())?;
let claim_path = self.claim_path(ticket_id);
let claim = TicketClaim {
ticket_id: ticket_id.to_string(),
ticket_slug: ticket_slug.map(ToOwned::to_owned),
pod_name: pod_name.to_string(),
role: role.to_string(),
};
match self.create_claim_file(&claim_path, &claim) {
Ok(()) => {
if let Err(error) = self.record_session(
pod_name.to_string(),
role.to_string(),
RoleSessionOrigin::TicketClaim,
None,
[RelatedTicketRef {
id: ticket_id.to_string(),
slug: ticket_slug.map(ToOwned::to_owned),
}],
) {
let _ = fs::remove_file(&claim_path);
return Err(error);
}
Ok(TicketClaimResult::Claimed)
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let existing = self.load_claim(ticket_id)?;
if existing.pod_name == pod_name && existing.role == role {
Ok(TicketClaimResult::AlreadyOwned(existing))
} else {
Err(PanelRegistryError::TicketAlreadyClaimed(existing))
}
}
Err(error) => Err(error.into()),
}
}
pub(crate) fn load_claim(&self, ticket_id: &str) -> Result<TicketClaim, PanelRegistryError> {
let bytes = fs::read(self.claim_path(ticket_id))?;
Ok(serde_json::from_slice(&bytes)?)
}
pub(crate) fn claim_for_ticket(
&self,
ticket_id: &str,
) -> Result<Option<TicketClaim>, PanelRegistryError> {
match self.load_claim(ticket_id) {
Ok(claim) => Ok(Some(claim)),
Err(PanelRegistryError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
Ok(None)
}
Err(error) => Err(error),
}
}
fn update_registry(
&self,
update: impl FnOnce(&mut RoleSessionRegistry) -> Result<(), PanelRegistryError>,
) -> Result<(), PanelRegistryError> {
fs::create_dir_all(&self.root)?;
let _lock = self.acquire_registry_lock()?;
let mut registry = self.load_registry()?;
registry.version = REGISTRY_VERSION;
if let Some(workspace_root) = self.workspace_root.as_ref() {
registry.workspace_root = workspace_root.clone();
}
update(&mut registry)?;
self.save_registry(&registry)
}
fn acquire_registry_lock(&self) -> Result<RegistryLockGuard, PanelRegistryError> {
let lock_path = self.root.join(REGISTRY_LOCK_FILE);
for _ in 0..50 {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => return Ok(RegistryLockGuard { path: lock_path }),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
}
Err(PanelRegistryError::Io(io::Error::new(
io::ErrorKind::WouldBlock,
"timed out acquiring panel role session registry lock",
)))
}
fn save_registry(&self, registry: &RoleSessionRegistry) -> Result<(), PanelRegistryError> {
let path = self.registry_path();
let temp_path = path.with_extension(format!("json.{}.tmp", now_timestamp_string()));
let bytes = serde_json::to_vec_pretty(registry)?;
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
fs::rename(temp_path, path)?;
Ok(())
}
fn create_claim_file(&self, claim_path: &Path, claim: &TicketClaim) -> io::Result<()> {
let temp_path = self
.claims_dir()
.join(format!(".{}.tmp", now_timestamp_string()));
let bytes = serde_json::to_vec_pretty(claim).map_err(io::Error::other)?;
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
let link_result = fs::hard_link(&temp_path, claim_path);
let remove_result = fs::remove_file(&temp_path);
match (link_result, remove_result) {
(Ok(()), Ok(())) | (Ok(()), Err(_)) => Ok(()),
(Err(error), _) => Err(error),
}
}
fn load_claims(&self) -> Result<Vec<TicketClaim>, PanelRegistryError> {
let mut claims: Vec<TicketClaim> = Vec::new();
match fs::read_dir(self.claims_dir()) {
Ok(entries) => {
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_file()
&& entry
.path()
.extension()
.is_some_and(|extension| extension == "json")
{
let bytes = fs::read(entry.path())?;
claims.push(serde_json::from_slice(&bytes)?);
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
claims.sort_by(|left, right| left.ticket_id.cmp(&right.ticket_id));
Ok(claims)
}
fn registry_path(&self) -> PathBuf {
self.root.join(REGISTRY_FILE)
}
fn claims_dir(&self) -> PathBuf {
self.root.join(CLAIMS_DIR)
}
fn claim_path(&self, ticket_id: &str) -> PathBuf {
self.claims_dir()
.join(format!("{}.json", encode_path_component(ticket_id)))
}
}
struct RegistryLockGuard {
path: PathBuf,
}
impl Drop for RegistryLockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl PanelRegistrySnapshot {
pub(crate) fn empty() -> Self {
Self {
sessions: Vec::new(),
claims: Vec::new(),
}
}
pub(crate) fn claim_for_ticket(&self, ticket_id: &str) -> Option<&TicketClaim> {
self.claims
.iter()
.find(|claim| claim.ticket_id == ticket_id)
}
}
fn normalized_workspace_key(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn workspace_leaf(workspace_root: &str) -> String {
let leaf = workspace_root
.rsplit('/')
.find(|part| !part.is_empty())
.unwrap_or("workspace");
let sanitized = leaf
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
"workspace".to_string()
} else {
sanitized
}
}
fn fnv1a64_hex(bytes: &[u8]) -> String {
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn encode_path_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => encoded.push(byte as char),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn now_timestamp_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos().to_string())
.unwrap_or_else(|_| "0".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn registry_path_is_workspace_scoped_under_data_dir() {
let data_dir = TempDir::new().unwrap();
let store = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
let other = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
assert!(store.root().starts_with(data_dir.path()));
let root = store.root().to_string_lossy();
assert!(root.contains("panel/workspaces/yoi-"));
assert_ne!(store.root(), other.root());
store
.record_session(
"ticket-intake-preticket",
"intake",
RoleSessionOrigin::PreTicketIntake,
None,
[],
)
.unwrap();
assert_eq!(store.load_registry().unwrap().workspace_root, "/repo/yoi");
}
#[test]
fn claim_ticket_rejects_second_active_local_pod() {
let temp = TempDir::new().unwrap();
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
assert!(matches!(
store.claim_ticket("T-1", Some("ticket-one"), "ticket-one-intake", "intake"),
Ok(TicketClaimResult::Claimed)
));
let error = store
.claim_ticket("T-1", Some("ticket-one"), "ticket-two-intake", "intake")
.unwrap_err();
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
assert_eq!(claim.pod_name, "ticket-one-intake");
assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one"));
}
#[test]
fn intake_session_relation_is_not_one_to_one_with_tickets() {
let temp = TempDir::new().unwrap();
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
store
.record_session(
"ticket-intake-preticket",
"intake",
RoleSessionOrigin::PreTicketIntake,
None,
[],
)
.unwrap();
store
.record_session(
"ticket-intake-shared",
"intake",
RoleSessionOrigin::RoleLaunch,
None,
[
RelatedTicketRef {
id: "T-1".to_string(),
slug: Some("one".to_string()),
},
RelatedTicketRef {
id: "T-2".to_string(),
slug: Some("two".to_string()),
},
],
)
.unwrap();
let snapshot = store.snapshot().unwrap();
let preticket = snapshot
.sessions
.iter()
.find(|session| session.pod_name == "ticket-intake-preticket")
.unwrap();
let shared = snapshot
.sessions
.iter()
.find(|session| session.pod_name == "ticket-intake-shared")
.unwrap();
assert!(preticket.related_tickets.is_empty());
assert_eq!(shared.role, "intake");
assert_eq!(shared.origin, RoleSessionOrigin::RoleLaunch);
assert!(!shared.created_at.is_empty());
assert!(!shared.updated_at.is_empty());
assert_eq!(
shared.related_tickets,
vec![
RelatedTicketRef {
id: "T-1".to_string(),
slug: Some("one".to_string()),
},
RelatedTicketRef {
id: "T-2".to_string(),
slug: Some("two".to_string()),
},
]
);
}
}

View File

@ -8,6 +8,7 @@ use ticket::{
};
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspacePanelViewModel {
@ -200,6 +201,31 @@ pub(crate) struct TicketPanelEntry {
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)]
@ -371,7 +397,44 @@ pub(crate) fn build_workspace_panel(
workspace_root: &Path,
pods: &PodList,
) -> WorkspacePanelViewModel {
let mut model = WorkspacePanelViewModel::empty(workspace_root);
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, &registry)
}
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 => {
@ -381,7 +444,7 @@ pub(crate) fn build_workspace_panel(
Ok(config) => {
model.header.ticket_root = config.backend_root().to_path_buf();
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf());
match build_ticket_rows(&backend, pods) {
match build_ticket_rows(&backend, pods, registry) {
Ok(rows) => model.rows.extend(rows),
Err(error) => {
model
@ -434,7 +497,8 @@ pub(crate) fn build_current_ticket_row(
)));
}
let summary = ticket_summary_from_meta(&ticket.meta);
Ok(ticket_row(summary, &ticket.events, pods))
let registry = PanelRegistrySnapshot::empty();
Ok(ticket_row(summary, &ticket.events, pods, &registry))
}
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
@ -461,6 +525,7 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
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())? {
@ -468,13 +533,19 @@ fn build_ticket_rows(
continue;
}
let ticket = backend.show(TicketIdOrSlug::Query(summary.slug.clone()))?;
rows.push(ticket_row(summary, &ticket.events, pods));
rows.push(ticket_row(summary, &ticket.events, pods, registry));
}
Ok(rows)
}
fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow {
let related_pods = related_pods_for_ticket(&summary, pods);
fn ticket_row(
summary: TicketSummary,
events: &[TicketEvent],
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);
let latest_event = events.last();
let entry = TicketPanelEntry {
@ -494,6 +565,7 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) ->
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 {
@ -608,22 +680,60 @@ fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
}
}
fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec<String> {
fn related_pods_for_ticket(
summary: &TicketSummary,
pods: &PodList,
registry: &PanelRegistrySnapshot,
) -> Vec<String> {
let slug = lowercase(&summary.slug);
let id = lowercase(&summary.id);
pods.entries
.iter()
.filter_map(|pod| {
let name = lowercase(&pod.name);
if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id))
{
Some(pod.name.clone())
} else {
None
}
})
.take(5)
.collect()
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 (!slug.is_empty() && name.contains(&slug)) || (!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> {
@ -635,6 +745,13 @@ fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
if let Some(reason) = entry.attention_required.as_deref() {
parts.push(format!("attention: {reason}"));
}
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(", ")));
}
@ -942,6 +1059,42 @@ mod tests {
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 Intake", "claimed-intake", |_| {});
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"]),
&registry,
);
let row = model
.rows
.iter()
.find(|row| row.title == "Claimed Intake")
.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_orchestrator_pod_name_is_stable_and_safe() {
assert_eq!(