From 16c0fc704dc45b27e68d8c5373ec2063388a2ed6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 00:28:58 +0900 Subject: [PATCH 01/18] feat: unify builtin Profile resolution authority --- Cargo.lock | 1 + crates/manifest/Cargo.toml | 1 + crates/manifest/src/builtin_profile.rs | 318 ++++++++++++ crates/manifest/src/config.rs | 19 +- crates/manifest/src/lib.rs | 15 +- crates/manifest/src/profile.rs | 510 ++++++++++---------- crates/worker-runtime/src/worker_backend.rs | 2 +- crates/worker/src/entrypoint.rs | 9 +- crates/worker/src/spawn/tool.rs | 11 +- crates/workspace-server/src/hosts.rs | 147 +++--- resources/profiles/default.dcdl | 34 ++ 11 files changed, 698 insertions(+), 369 deletions(-) create mode 100644 crates/manifest/src/builtin_profile.rs create mode 100644 resources/profiles/default.dcdl diff --git a/Cargo.lock b/Cargo.lock index e4f7f2bb..7a313ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2629,6 +2629,7 @@ version = "0.1.0" dependencies = [ "agen", "arc-swap", + "decodal", "protocol", "secrets", "serde", diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index c8150391..f41f4b20 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] arc-swap = "1" agen = { workspace = true } +decodal.workspace = true protocol = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/manifest/src/builtin_profile.rs b/crates/manifest/src/builtin_profile.rs new file mode 100644 index 00000000..a1edfa25 --- /dev/null +++ b/crates/manifest/src/builtin_profile.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; + +use decodal::{Data, Engine, ImportLoader, LoadedImport}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest, Sha256}; + +use crate::profile::ProfileError; + +pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2"; +pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProfileImport { + pub specifier: &'static str, + pub resolved_path: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProfileResource { + pub selector: Option<&'static str>, + pub path: &'static str, + pub source: &'static str, + pub description: &'static str, + pub imports: &'static [BuiltinProfileImport], +} + +const BASE_PATH: &str = "profiles/base.dcdl"; +const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { + specifier: "./base.dcdl", + resolved_path: BASE_PATH, +}]; +const NO_IMPORTS: &[BuiltinProfileImport] = &[]; + +pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ + BuiltinProfileResource { + selector: None, + path: BASE_PATH, + source: include_str!("../../../resources/profiles/base.dcdl"), + description: "Shared built-in Profile defaults.", + imports: NO_IMPORTS, + }, + BuiltinProfileResource { + selector: Some(BUILTIN_DEFAULT_PROFILE), + path: "profiles/default.dcdl", + source: include_str!("../../../resources/profiles/default.dcdl"), + description: "Standalone Yoi coding profile.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:coder"), + path: "profiles/coder.dcdl", + source: include_str!("../../../resources/profiles/coder.dcdl"), + description: "Ticket implementation with direct Reviewer SubWorkers.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:companion"), + path: "profiles/companion.dcdl", + source: include_str!("../../../resources/profiles/companion.dcdl"), + description: "General assistance with Workspace tools.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:intake"), + path: "profiles/intake.dcdl", + source: include_str!("../../../resources/profiles/intake.dcdl"), + description: "Read-only intake and planning.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:reviewer"), + path: "profiles/reviewer.dcdl", + source: include_str!("../../../resources/profiles/reviewer.dcdl"), + description: "Independent review of a published Merge Request source.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:orchestrator"), + path: "profiles/orchestrator.dcdl", + source: include_str!("../../../resources/profiles/orchestrator.dcdl"), + description: "Workspace orchestration and Worker control.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:memory-consolidation"), + path: "profiles/memory-consolidation.dcdl", + source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"), + description: "Internal Memory consolidation service.", + imports: BASE_IMPORT, + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltinProfileCatalogSnapshot { + pub id: &'static str, + pub sources: BTreeMap, + pub entrypoints: BTreeMap, + pub imports: BTreeMap, +} + +impl BuiltinProfileCatalogSnapshot { + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.id.as_bytes()); + for (path, source) in &self.sources { + hasher.update((path.len() as u64).to_le_bytes()); + hasher.update(path.as_bytes()); + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source.as_bytes()); + } + for (selector, path) in &self.entrypoints { + hasher.update((selector.len() as u64).to_le_bytes()); + hasher.update(selector.as_bytes()); + hasher.update((path.len() as u64).to_le_bytes()); + hasher.update(path.as_bytes()); + } + for (request, resolved_path) in &self.imports { + hasher.update((request.len() as u64).to_le_bytes()); + hasher.update(request.as_bytes()); + hasher.update((resolved_path.len() as u64).to_le_bytes()); + hasher.update(resolved_path.as_bytes()); + } + format!("sha256:{:x}", hasher.finalize()) + } +} + +pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot { + let mut sources = BTreeMap::new(); + let mut entrypoints = BTreeMap::new(); + let mut imports = BTreeMap::new(); + + for resource in BUILTIN_PROFILE_RESOURCES { + sources.insert(resource.path.to_owned(), resource.source.to_owned()); + for import in resource.imports { + imports.insert( + format!("{}\0{}", resource.path, import.specifier), + import.resolved_path.to_owned(), + ); + } + if let Some(selector) = resource.selector { + entrypoints.insert(selector.to_owned(), resource.path.to_owned()); + } + } + + BuiltinProfileCatalogSnapshot { + id: BUILTIN_PROFILE_CATALOG_ID, + sources, + entrypoints, + imports, + } +} + +pub fn builtin_profile_entrypoints() -> impl Iterator { + BUILTIN_PROFILE_RESOURCES + .iter() + .filter(|resource| resource.selector.is_some()) +} + +pub(crate) fn resolve_builtin_profile_artifact( + selector: &str, +) -> Result, ProfileError> { + let catalog = builtin_profile_catalog_snapshot(); + let Some(entrypoint) = catalog.entrypoints.get(selector) else { + return Ok(None); + }; + let source = catalog + .sources + .get(entrypoint) + .expect("built-in Profile entrypoint must name a source") + .clone(); + let mut engine = Engine::new(BuiltinProfileImportLoader { + sources: catalog.sources, + }); + let module = engine + .add_root_source(entrypoint, entrypoint, &source) + .map_err(|error| ProfileError::BuiltinProfileEvaluation { + selector: selector.to_owned(), + message: format!("{error:?}"), + })?; + let value = + engine + .eval_module(module) + .map_err(|error| ProfileError::BuiltinProfileEvaluation { + selector: selector.to_owned(), + message: format!("{error:?}"), + })?; + let data = + engine + .materialize(&value) + .map_err(|error| ProfileError::BuiltinProfileEvaluation { + selector: selector.to_owned(), + message: format!("{error:?}"), + })?; + Ok(Some(data_to_json(&data))) +} + +#[derive(Debug)] +struct BuiltinProfileImportLoader { + sources: BTreeMap, +} + +impl ImportLoader for BuiltinProfileImportLoader { + fn load( + &mut self, + current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + let current_key = current_key.ok_or_else(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("built-in Profile import `{specifier}` has no source context"), + ) + })?; + let resolved = resolve_import_path(current_key, specifier).ok_or_else(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("built-in Profile import `{specifier}` from `{current_key}` is invalid"), + ) + })?; + let source = self.sources.get(&resolved).ok_or_else(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("built-in Profile import `{specifier}` from `{current_key}` was not found"), + ) + })?; + Ok(LoadedImport::source( + resolved.clone(), + resolved, + source.clone(), + )) + } +} + +fn resolve_import_path(current_key: &str, specifier: &str) -> Option { + let current_parent = current_key + .rsplit_once('/') + .map_or("", |(parent, _)| parent); + let joined = if let Some(relative) = specifier.strip_prefix("./") { + format!("{current_parent}/{relative}") + } else { + return None; + }; + if joined + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + { + return None; + } + Some(joined) +} + +fn data_to_json(data: &Data) -> Value { + match data { + Data::Bool(value) => Value::Bool(*value), + Data::Int(value) => Value::Number(Number::from(*value)), + Data::Float(value) => Number::from_f64(*value) + .map(Value::Number) + .unwrap_or(Value::Null), + Data::String(value) => Value::String(value.clone()), + Data::Array(values) => Value::Array(values.iter().map(data_to_json).collect()), + Data::Object(fields) => Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), data_to_json(&field.value))) + .collect::>(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_has_one_explicit_entrypoint_for_each_builtin_profile() { + let catalog = builtin_profile_catalog_snapshot(); + assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len()); + assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len()); + assert_eq!( + catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE), + Some(&"profiles/default.dcdl".to_owned()) + ); + assert!(catalog.digest().starts_with("sha256:")); + } + + #[test] + fn default_profile_evaluates_from_the_shared_resource_graph() { + let value = resolve_builtin_profile_artifact(BUILTIN_DEFAULT_PROFILE) + .expect("evaluate built-in default") + .expect("default exists"); + assert_eq!(value["slug"], "default"); + assert_eq!(value["feature"]["task"]["enabled"], true); + assert_eq!(value["feature"]["sub_worker"]["enabled"], true); + assert_eq!(value["feature"]["memory"]["enabled"], false); + assert_eq!(value["feature"]["ticket"]["enabled"], false); + assert_eq!(value["feature"]["worker"]["enabled"], false); + assert_eq!(value["feature"]["manage_workdir"]["enabled"], false); + } + + #[test] + fn imports_cannot_escape_the_builtin_resource_catalog() { + assert_eq!( + resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(), + Some("profiles/base.dcdl") + ); + assert_eq!( + resolve_import_path("profiles/default.dcdl", "../outside.dcdl"), + None + ); + assert_eq!( + resolve_import_path("profiles/default.dcdl", "/outside.dcdl"), + None + ); + } +} diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 9671675d..f263dd14 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -566,15 +566,16 @@ impl WorkerManifestConfig { }) } - /// Base config populated with the in-code defaults listed in - /// [`crate::defaults`]. Profile and one-file Manifest resolvers start - /// from this layer so every per-field default lives at exactly one - /// call site (the `defaults` module). + /// Base config populated with the in-code per-field defaults listed in + /// [`crate::defaults`]. This is not a selectable Profile and does not + /// enable a launch capability surface. Profile and one-file Manifest + /// resolvers start from this layer so every per-field default lives at + /// exactly one call site (the `defaults` module). /// /// `TryFrom` also reads the same constants as a /// belt-and-suspenders fallback, so a manually-constructed config /// that skips this layer still resolves to the same values. - pub fn builtin_defaults() -> Self { + pub fn resolution_defaults() -> Self { Self { engine: EngineManifestConfig { tool_output: ToolOutputLimitsPartial { @@ -1973,7 +1974,7 @@ enabled = false "#, ) .unwrap(); - let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() + let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults() .merge(cfg) .merge(WorkerManifestConfig { worker: WorkerMetaConfig { @@ -2074,7 +2075,7 @@ enabled = true "#, ) .unwrap(); - let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() + let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults() .merge(base) .merge(upper) .merge(WorkerManifestConfig { @@ -2137,7 +2138,7 @@ permission = "write" #[test] fn builtin_defaults_populates_worker_limit_defaults() { - let cfg = WorkerManifestConfig::builtin_defaults(); + let cfg = WorkerManifestConfig::resolution_defaults(); assert_eq!( cfg.engine.tool_output.default_max_bytes, Some(defaults::TOOL_OUTPUT_MAX_BYTES) @@ -2172,7 +2173,7 @@ permission = "write" }, ..Default::default() }; - let merged = WorkerManifestConfig::builtin_defaults().merge(overlay); + let merged = WorkerManifestConfig::resolution_defaults().merge(overlay); let manifest: WorkerManifest = merged.try_into().unwrap(); assert_eq!( manifest.engine.tool_output.default_max_bytes, diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 3d057c05..7920c050 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -1,3 +1,4 @@ +mod builtin_profile; mod config; pub mod defaults; mod model; @@ -7,6 +8,11 @@ pub mod plugin; mod profile; mod scope; +pub use builtin_profile::{ + BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES, + BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource, + builtin_profile_catalog_snapshot, builtin_profile_entrypoints, +}; pub use config::{ CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial, @@ -17,10 +23,11 @@ pub use model::{ }; pub use paths::user_profiles_path; pub use profile::{ - ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, - ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, - ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, - resolve_profile_artifact_value, + ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, + ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, + ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile, + WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value, + validate_profile_execution_target, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index d84c195d..64ae4050 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -6,9 +6,14 @@ //! from launch context. use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::path::{Path, PathBuf}; +use crate::builtin_profile::{ + BUILTIN_DEFAULT_PROFILE, builtin_profile_catalog_snapshot, builtin_profile_entrypoints, + resolve_builtin_profile_artifact, +}; use crate::config::{ CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, }; @@ -23,45 +28,6 @@ use crate::{ const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); -struct BuiltinProfile { - name: &'static str, - label: &'static str, - description: &'static str, -} - -const BUILTIN_PROFILES: &[BuiltinProfile] = &[ - BuiltinProfile { - name: "companion", - label: "builtin:companion", - description: "Bundled Companion role profile", - }, - BuiltinProfile { - name: "intake", - label: "builtin:intake", - description: "Bundled Intake role profile", - }, - BuiltinProfile { - name: "orchestrator", - label: "builtin:orchestrator", - description: "Bundled Orchestrator role profile", - }, - BuiltinProfile { - name: "coder", - label: "builtin:coder", - description: "Bundled Coder role profile", - }, - BuiltinProfile { - name: "reviewer", - label: "builtin:reviewer", - description: "Bundled Reviewer role profile", - }, - BuiltinProfile { - name: "memory-consolidation", - label: "builtin:memory-consolidation", - description: "Bundled Memory staging consolidation profile", - }, -]; - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProfileRegistrySource { @@ -159,6 +125,108 @@ impl ProfileSelector { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileExecutionTarget { + Workspace, + Standalone, +} + +impl fmt::Display for ProfileExecutionTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Workspace => formatter.write_str("workspace"), + Self::Standalone => formatter.write_str("standalone"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum WorkspaceAuthorityRequirement { + Flow, + ManageWorkdir, + Memory, + MergeRequest, + Objective, + Orchestration, + Plugins, + Ticket, + Worker, +} + +impl fmt::Display for WorkspaceAuthorityRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Flow => formatter.write_str("feature.flow"), + Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"), + Self::Memory => formatter.write_str("feature.memory"), + Self::MergeRequest => formatter.write_str("feature.merge_request"), + Self::Objective => formatter.write_str("feature.objective"), + Self::Orchestration => formatter.write_str("feature.orchestration"), + Self::Plugins => formatter.write_str("feature.plugins or plugin packages"), + Self::Ticket => formatter.write_str("feature.ticket"), + Self::Worker => formatter.write_str("feature.worker"), + } + } +} + +pub fn validate_profile_execution_target( + manifest: &WorkerManifest, + target: ProfileExecutionTarget, +) -> Result<(), ProfileError> { + if target == ProfileExecutionTarget::Workspace { + return Ok(()); + } + + let feature = &manifest.feature; + let mut requirements = BTreeSet::new(); + if feature.flow.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Flow); + } + if feature.manage_workdir.enabled { + requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); + } + if feature.memory.enabled || feature.memory.staging { + requirements.insert(WorkspaceAuthorityRequirement::Memory); + } + if feature.merge_request.show + || feature.merge_request.open + || feature.merge_request.review + || feature.merge_request.readiness_check + || feature.merge_request.complete + { + requirements.insert(WorkspaceAuthorityRequirement::MergeRequest); + } + if feature.objective.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Objective); + } + if feature.orchestration.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Orchestration); + } + if feature.plugins.enabled || !manifest.plugins.is_empty() { + requirements.insert(WorkspaceAuthorityRequirement::Plugins); + } + if feature.ticket.enabled + || feature.ticket.authoring + || feature.ticket.thread + || feature.ticket.intake + || feature.ticket.workflow + { + requirements.insert(WorkspaceAuthorityRequirement::Ticket); + } + if feature.worker.enabled { + requirements.insert(WorkspaceAuthorityRequirement::Worker); + } + + if requirements.is_empty() { + Ok(()) + } else { + Err(ProfileError::UnsupportedExecutionTarget { + target, + requirements: requirements.into_iter().collect(), + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ProfileSource { @@ -217,13 +285,14 @@ impl ProfileRegistryEntry { source: ProfileRegistrySource, name: &'static str, label: &'static str, + provenance: String, description: Option, ) -> Self { Self { source, name: name.to_string(), path: None, - provenance: label.to_string(), + provenance, description, is_default: false, artifact: ProfileRegistryArtifact::Builtin { label }, @@ -321,12 +390,16 @@ pub struct ProfileDiscovery { } impl ProfileDiscovery { - pub fn for_cwd(_cwd: &Path) -> Self { + pub fn user_settings() -> Self { Self { user_config: paths::user_profiles_path(), project_config: None, } } + + pub fn for_cwd(_cwd: &Path) -> Self { + Self::user_settings() + } pub fn with_sources(user_config: Option, project_config: Option) -> Self { Self { user_config, @@ -412,15 +485,22 @@ impl ProfileResolver { options, ), ProfileSelector::Named { .. } | ProfileSelector::Default => { - let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { - path: PathBuf::from("."), - source, - })?; - let registry = ProfileDiscovery::for_cwd(&cwd).discover()?; + let registry = ProfileDiscovery::user_settings().discover()?; self.resolve_from_registry(selector, ®istry, options) } } } + + pub fn resolve_for_target( + &self, + selector: &ProfileSelector, + options: ProfileResolveOptions, + target: ProfileExecutionTarget, + ) -> Result { + let resolved = self.resolve(selector, options)?; + validate_profile_execution_target(&resolved.manifest, target)?; + Ok(resolved) + } /// Resolve a registry/default selector against an already-discovered /// registry. Callers such as SubWorkerSpawn use this to bind discovery to the /// Worker's cwd instead of the process current directory. @@ -503,7 +583,7 @@ impl ProfileResolver { .as_deref() .unwrap_or_else(|| Path::new(".")), )?; - let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| { + let raw_artifact = resolve_builtin_profile_artifact(label)?.ok_or_else(|| { ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`")) })?; resolve_profile_value( @@ -565,7 +645,8 @@ fn resolve_profile_value( memory: profile.memory.map(Into::into), skills: profile.skills, }; - let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); + let config = + WorkerManifestConfig::resolution_defaults().merge(config.resolve_paths(profile_dir)); let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; manifest.profile = Some(ProfileManifestSnapshot { source: source.clone(), @@ -759,14 +840,30 @@ fn load_profile_registry_file( } fn add_builtin_profiles(registry: &mut ProfileRegistry) { - for profile in BUILTIN_PROFILES { + let catalog = builtin_profile_catalog_snapshot(); + let digest = catalog.digest(); + for profile in builtin_profile_entrypoints() { + let label = profile + .selector + .expect("built-in Profile entrypoint must have a selector"); + let name = label + .strip_prefix("builtin:") + .expect("built-in Profile selector must be source-qualified"); registry.push_entry(ProfileRegistryEntry::embedded( ProfileRegistrySource::Builtin, - profile.name, - profile.label, + name, + label, + format!("{}#{digest}", profile.path), Some(profile.description.into()), )); } + registry.set_default(ProfileDefault { + source: Some(ProfileRegistrySource::Builtin), + name: BUILTIN_DEFAULT_PROFILE + .strip_prefix("builtin:") + .expect("built-in default selector must be source-qualified") + .to_owned(), + }); } fn parse_profile_ref(raw: &str) -> (Option, String) { @@ -804,201 +901,6 @@ fn read_profile_artifact_file(path: &Path) -> Result Option { - let mut value = builtin_base_profile_artifact(); - match label { - "builtin:companion" | "companion" => { - apply_role_profile( - &mut value, - "companion", - "Workspace companion profile.", - "workspace_write", - true, - true, - true, - true, - ); - Some(value) - } - "builtin:intake" | "intake" => { - apply_role_profile( - &mut value, - "intake", - "Ticket intake profile.", - "workspace_write", - true, - true, - true, - false, - ); - Some(value) - } - "builtin:orchestrator" | "orchestrator" => { - apply_role_profile( - &mut value, - "orchestrator", - "Ticket orchestrator profile.", - "workspace_write", - true, - true, - true, - false, - ); - Some(value) - } - "builtin:coder" | "coder" => { - apply_role_profile( - &mut value, - "coder", - "Ticket implementation coder profile.", - "workspace_write", - true, - true, - true, - true, - ); - Some(value) - } - "builtin:reviewer" | "reviewer" => { - apply_role_profile( - &mut value, - "reviewer", - "Ticket review profile.", - "workspace_read", - true, - true, - true, - false, - ); - Some(value) - } - "builtin:memory-consolidation" | "memory-consolidation" => { - value["slug"] = serde_json::Value::String("memory-consolidation".to_string()); - value["description"] = - serde_json::Value::String("Memory staging consolidation profile.".to_string()); - value["feature"]["task"] = serde_json::json!({ "enabled": false }); - value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true }); - value["feature"]["web"] = serde_json::json!({ "enabled": false }); - value["feature"]["sub_worker"] = serde_json::json!({ "enabled": false }); - value["feature"]["objective"] = serde_json::json!({ "enabled": false }); - value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false }); - Some(value) - } - _ => None, - } -} - -fn builtin_base_profile_artifact() -> serde_json::Value { - serde_json::json!({ - "slug": "default", - "description": "Default Yoi coding profile.", - "model": { "ref": "codex-oauth/gpt-5.5" }, - "session": { "record_event_trace": true }, - "engine": { "reasoning": "high" }, - "compaction": { - "kind": "tokens", - "threshold": 240000, - "request_threshold": 270000, - "worker_context_max_tokens": 100000 - }, - "feature": { - "task": { "enabled": true }, - "memory": { "enabled": true }, - "web": { "enabled": true }, - "image": { "enabled": true }, - "sub_worker": { "enabled": true }, - "worker": { "enabled": false }, - "objective": { "enabled": true }, - "ticket": { "enabled": true, "authoring": true, "thread": true } - }, - "memory": { - "extract_threshold": 50000, - "consolidation_threshold_files": 5, - "consolidation_threshold_bytes": 50000 - }, - "web": { - "enabled": true, - "search": { - "provider": "brave", - "api_key_secret": "web/brave/default" - } - } - }) -} - -#[allow(clippy::too_many_arguments)] -fn apply_role_profile( - value: &mut serde_json::Value, - slug: &str, - description: &str, - _scope: &str, - task: bool, - memory: bool, - web: bool, - sub_worker: bool, -) { - value["slug"] = serde_json::Value::String(slug.to_string()); - value["description"] = serde_json::Value::String(description.to_string()); - value["feature"]["task"] = serde_json::json!({ "enabled": task }); - value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); - value["feature"]["web"] = serde_json::json!({ "enabled": web }); - value["feature"]["image"] = serde_json::json!({ "enabled": true }); - value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker }); - value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" }); - value["feature"]["worker"] = serde_json::json!({ - "enabled": slug == "orchestrator", - "direct_spawn": slug != "orchestrator" - }); - value["feature"]["manage_workdir"] = serde_json::json!({ - "enabled": matches!(slug, "companion" | "orchestrator") - }); - value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" }); - let ticket = match slug { - "companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), - "intake" => { - serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true }) - } - "orchestrator" => { - serde_json::json!({ "enabled": true, "thread": true, "workflow": true }) - } - "coder" => serde_json::json!({ "enabled": true, "thread": true }), - "reviewer" => serde_json::json!({ "enabled": true, "thread": true }), - _ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }), - }; - value["feature"]["ticket"] = ticket; - let merge_request = match slug { - "coder" => serde_json::json!({ - "show": true, - "open": true, - "review": false, - "readiness_check": false, - "complete": false - }), - "reviewer" => serde_json::json!({ - "show": true, - "open": false, - "review": true, - "readiness_check": false, - "complete": false - }), - "orchestrator" => serde_json::json!({ - "show": true, - "open": false, - "review": false, - "readiness_check": true, - "complete": true - }), - _ => serde_json::json!({ - "show": false, - "open": false, - "review": false, - "readiness_check": false, - "complete": false - }), - }; - value["feature"]["merge_request"] = merge_request; -} - fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { let Some(map) = value.as_object() else { return Err(ProfileError::InvalidProfile( @@ -1288,6 +1190,13 @@ pub enum ProfileError { #[source] source: toml::de::Error, }, + #[error("failed to evaluate built-in Profile `{selector}`: {message}")] + BuiltinProfileEvaluation { selector: String, message: String }, + #[error("Profile requires unsupported {target} launch authorities: {requirements:?}")] + UnsupportedExecutionTarget { + target: ProfileExecutionTarget, + requirements: Vec, + }, #[error("no default profile is configured")] NoDefaultProfile, #[error("profile resolution requires an explicit runtime Worker name")] @@ -1341,18 +1250,21 @@ mod tests { ); } #[test] - fn builtin_profiles_do_not_define_an_implicit_default() { + fn builtin_default_is_explicit_registry_authority() { let registry = ProfileDiscovery::with_sources(None, None) .discover() .unwrap(); - assert!(matches!( - registry.default_entry(), - Err(ProfileError::NoDefaultProfile) - )); - assert!(matches!( - registry.select(&ProfileSelector::Default), - Err(ProfileError::NoDefaultProfile) - )); + let default = registry.default_entry().unwrap(); + assert_eq!(default.source, ProfileRegistrySource::Builtin); + assert_eq!(default.name, "default"); + assert_eq!(default.qualified_name(), BUILTIN_DEFAULT_PROFILE); + assert!(default.is_default); + assert!( + default + .provenance + .starts_with("profiles/default.dcdl#sha256:") + ); + assert_eq!(registry.select(&ProfileSelector::Default).unwrap(), default); } #[test] fn builtin_role_profiles_are_registered_and_resolve() { @@ -1407,6 +1319,92 @@ mod tests { } } + #[test] + fn builtin_default_resolves_as_a_standalone_local_capability_profile() { + let tmp = TempDir::new().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve_for_target( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), + ProfileResolveOptions::with_worker_name("standalone-worker"), + ProfileExecutionTarget::Standalone, + ) + .unwrap(); + + assert!(matches!( + &resolved.source, + ProfileSource::Registry { + source: ProfileRegistrySource::Builtin, + name, + path: None, + provenance: Some(provenance), + .. + } if name == "default" && provenance.starts_with("profiles/default.dcdl#sha256:") + )); + assert!(resolved.manifest.feature.task.enabled); + assert!(resolved.manifest.feature.web.enabled); + assert!(resolved.manifest.feature.image.enabled); + assert!(resolved.manifest.feature.sub_worker.enabled); + assert!(resolved.manifest.scope.allow.iter().any(|rule| { + rule.permission == protocol::Permission::Write && rule.target == tmp.path() + })); + assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| { + rule.permission == protocol::Permission::Write && rule.target == tmp.path() + })); + assert!(!resolved.manifest.feature.memory.enabled); + assert!(!resolved.manifest.feature.ticket.enabled); + assert!(!resolved.manifest.feature.objective.enabled); + assert!(!resolved.manifest.feature.flow.enabled); + assert!(!resolved.manifest.feature.worker.enabled); + assert!(!resolved.manifest.feature.manage_workdir.enabled); + assert!(!resolved.manifest.feature.plugins.enabled); + assert!(resolved.manifest.plugins.is_empty()); + } + + #[test] + fn standalone_rejects_profiles_that_require_workspace_authority() { + let tmp = TempDir::new().unwrap(); + let error = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve_for_target( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"), + ProfileResolveOptions::with_worker_name("standalone-worker"), + ProfileExecutionTarget::Standalone, + ) + .unwrap_err(); + let diagnostic = error.to_string(); + + let ProfileError::UnsupportedExecutionTarget { + target, + requirements, + } = error + else { + panic!("unexpected error: {error}"); + }; + assert_eq!(target, ProfileExecutionTarget::Standalone); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory)); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest)); + assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket)); + assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref())); + } + + #[test] + fn repository_markers_do_not_change_builtin_profile_authority() { + let tmp = TempDir::new().unwrap(); + let nested = tmp.path().join("repository/nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(tmp.path().join("repository/.yoi")).unwrap(); + std::fs::write( + tmp.path().join("repository/.yoi/profiles.toml"), + "default = { source = 'project', name = 'shadow' }\n", + ) + .unwrap(); + + let discovery = ProfileDiscovery::for_cwd(&nested); + assert_eq!(discovery.user_config, paths::user_profiles_path()); + assert!(discovery.project_config.is_none()); + } + #[test] fn builtin_coder_uses_sub_worker_control_without_worker_control() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d112d7df..0bf468fa 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -422,7 +422,7 @@ impl ProfileRuntimeWorkerFactory { fn restore_fallback_manifest( worker_name: &str, ) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> { - let mut config = manifest::WorkerManifestConfig::builtin_defaults(); + let mut config = manifest::WorkerManifestConfig::resolution_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = manifest::WorkerManifest::try_from(config) .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index ad345d03..957cedd3 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -184,15 +184,16 @@ fn load_spawn_config_json( ) -> Result<(WorkerManifest, PromptCatalogSource), String> { let config = serde_json::from_str::(config_json) .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; - let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config)) - .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; + let manifest = + WorkerManifest::try_from(WorkerManifestConfig::resolution_defaults().merge(config)) + .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; Ok((manifest, PromptCatalogSource::builtins_only())) } fn load_builtin_default_manifest( worker_name: &str, ) -> Result<(WorkerManifest, PromptCatalogSource), String> { - let mut config = WorkerManifestConfig::builtin_defaults(); + let mut config = WorkerManifestConfig::resolution_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = WorkerManifest::try_from(config) .map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?; @@ -259,7 +260,7 @@ fn load_single_manifest( absolute_path.display() ) })?; - let mut config = WorkerManifestConfig::builtin_defaults().merge( + let mut config = WorkerManifestConfig::resolution_defaults().merge( WorkerManifestConfig::from_toml(&toml) .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? .resolve_paths(base_dir), diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 21a9371a..49b1fe61 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -403,11 +403,10 @@ impl Tool for SubWorkerSpawnTool { allow: scope_allow.clone(), deny: Vec::new(), }; - let mut child_manifest = - WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(child_config)) - .map_err(|error| { - ToolError::ExecutionFailed(format!("resolve child manifest: {error}")) - })?; + let mut child_manifest = WorkerManifest::try_from( + WorkerManifestConfig::resolution_defaults().merge(child_config), + ) + .map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?; // Delegated children stay bound to their scoped session and cannot use // Workspace attachment tools to replace it with parent-level authority. child_manifest.feature.manage_workdir.enabled = false; @@ -1631,7 +1630,7 @@ max_tokens = 3333 Some(true) ); - let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() + let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults() .merge(parsed) .try_into() .unwrap(); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 8fb9cd5d..9e63f151 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -9,7 +9,6 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ - collections::BTreeMap, future::Future, path::PathBuf, pin::Pin, @@ -3722,94 +3721,6 @@ fn builtin_profile_config_bundle( .with_computed_digest()) } -fn builtin_profile_source_archive( - profile: &ProfileSelector, -) -> Result { - let selected = embedded_profile_label(profile) - .ok_or_else(|| "profile selector must identify a concrete profile".to_string())?; - let selected_path = embedded_profile_path(profile)?; - let mut entrypoints = BTreeMap::new(); - for slug in [ - "companion", - "intake", - "orchestrator", - "coder", - "reviewer", - "memory-consolidation", - ] { - entrypoints.insert(format!("builtin:{slug}"), format!("profiles/{slug}.dcdl")); - } - entrypoints.insert(selected, selected_path); - - let mut sources = BTreeMap::new(); - sources.insert( - "profiles/base.dcdl".to_string(), - include_str!("../../../resources/profiles/base.dcdl").to_string(), - ); - sources.insert( - "profiles/companion.dcdl".to_string(), - include_str!("../../../resources/profiles/companion.dcdl").to_string(), - ); - sources.insert( - "profiles/intake.dcdl".to_string(), - include_str!("../../../resources/profiles/intake.dcdl").to_string(), - ); - sources.insert( - "profiles/orchestrator.dcdl".to_string(), - include_str!("../../../resources/profiles/orchestrator.dcdl").to_string(), - ); - sources.insert( - "profiles/coder.dcdl".to_string(), - include_str!("../../../resources/profiles/coder.dcdl").to_string(), - ); - sources.insert( - "profiles/reviewer.dcdl".to_string(), - include_str!("../../../resources/profiles/reviewer.dcdl").to_string(), - ); - sources.insert( - "profiles/memory-consolidation.dcdl".to_string(), - include_str!("../../../resources/profiles/memory-consolidation.dcdl").to_string(), - ); - - let mut imports = BTreeMap::new(); - for slug in [ - "companion", - "intake", - "orchestrator", - "coder", - "reviewer", - "memory-consolidation", - ] { - imports.insert( - format!("profiles/{slug}.dcdl\0./base.dcdl"), - "profiles/base.dcdl".to_string(), - ); - } - - ProfileSourceArchive::build(ProfileSourceArchiveInput { - id: "builtin-decodal-profiles-v1".to_string(), - entrypoints, - imports, - sources, - }) - .map_err(|err| err.to_string()) -} - -fn embedded_profile_path(profile: &ProfileSelector) -> Result { - match profile { - ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) { - "companion" => Ok("profiles/companion.dcdl".to_string()), - "intake" => Ok("profiles/intake.dcdl".to_string()), - "orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()), - "coder" => Ok("profiles/coder.dcdl".to_string()), - "reviewer" => Ok("profiles/reviewer.dcdl".to_string()), - "memory-consolidation" => Ok("profiles/memory-consolidation.dcdl".to_string()), - other => Err(format!("unknown builtin profile selector: builtin:{other}")), - }, - ProfileSelector::Named(name) => Err(format!("unknown named profile selector: {name}")), - } -} - fn embedded_profile_label(profile: &ProfileSelector) -> Option { Some(match profile { ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => { @@ -3825,6 +3736,33 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option { }) } +fn builtin_profile_source_archive( + profile: &ProfileSelector, +) -> Result { + let selected_profile = match profile { + ProfileSelector::Builtin(name) => name.clone(), + ProfileSelector::Named(name) => { + return Err(format!( + "embedded runtime does not provide named Profile `{name}`" + )); + } + }; + let catalog = manifest::builtin_profile_catalog_snapshot(); + if !catalog.entrypoints.contains_key(&selected_profile) { + return Err(format!( + "embedded runtime does not provide Profile `{selected_profile}`" + )); + } + + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: catalog.id.to_owned(), + sources: catalog.sources, + entrypoints: catalog.entrypoints, + imports: catalog.imports, + }) + .map_err(|error| format!("failed to build built-in Profile source archive: {error}")) +} + const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation"; const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation"; const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator"; @@ -4451,6 +4389,37 @@ mod tests { assert!(companion.feature.manage_workdir.enabled); assert!(companion.feature.sub_worker.enabled); assert!(!companion.feature.worker.enabled); + let default_from_archive = archive + .resolve_profile("builtin:default", root.path(), "embedded-test-default") + .unwrap(); + let default_from_native = manifest::ProfileResolver::new() + .with_workspace_base(root.path()) + .resolve( + &manifest::ProfileSelector::source_named( + manifest::ProfileRegistrySource::Builtin, + "default", + ), + manifest::ProfileResolveOptions::with_worker_name("embedded-test-default"), + ) + .unwrap() + .manifest; + let mut archive_value = serde_json::to_value(&default_from_archive).unwrap(); + let mut native_value = serde_json::to_value(&default_from_native).unwrap(); + let archive_profile = archive_value + .as_object_mut() + .and_then(|value| value.remove("profile")) + .expect("archive resolution records Profile provenance"); + let native_profile = native_value + .as_object_mut() + .and_then(|value| value.remove("profile")) + .expect("native resolution records Profile provenance"); + assert_eq!(archive_value, native_value); + assert_eq!(archive_profile["source"]["kind"], "archive"); + assert_eq!(native_profile["source"]["kind"], "registry"); + assert!(default_from_archive.feature.sub_worker.enabled); + assert!(!default_from_archive.feature.ticket.enabled); + assert!(!default_from_archive.feature.objective.enabled); + let coder = archive .resolve_profile("builtin:coder", root.path(), "embedded-test-coder") .unwrap(); diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl new file mode 100644 index 00000000..3d3721c2 --- /dev/null +++ b/resources/profiles/default.dcdl @@ -0,0 +1,34 @@ +import "./base.dcdl" // { + slug = "default"; + description = "Standalone Yoi coding profile."; + scope = "workspace_write"; + delegation_scope = "workspace_write"; + + feature = { + task = { enabled = true; }; + memory = { enabled = false; staging = false; }; + web = { enabled = true; }; + image = { enabled = true; }; + sub_worker = { enabled = true; }; + flow = { enabled = false; }; + worker = { enabled = false; direct_spawn = true; }; + objective = { enabled = false; }; + manage_workdir = { enabled = false; }; + ticket = { + enabled = false; + authoring = false; + thread = false; + intake = false; + workflow = false; + }; + merge_request = { + show = false; + open = false; + review = false; + readiness_check = false; + complete = false; + }; + orchestration = { enabled = false; }; + plugins = { enabled = false; }; + }; +} From 22867faa9c4e319a497fa8867414acd1abd71c9c Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 00:37:52 +0900 Subject: [PATCH 02/18] test: align Profile consumers with explicit default --- crates/worker/src/spawn/tool.rs | 26 +++++++++++++++----------- crates/workspace-server/src/hosts.rs | 8 +++++++- crates/workspace-server/src/server.rs | 7 ++++++- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 49b1fe61..af8c69be 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -1850,7 +1850,7 @@ max_tokens = 3333 } #[test] - fn invalid_ambiguous_and_no_default_diagnostics_include_available_selectors() { + fn invalid_and_ambiguous_diagnostics_include_available_selectors() { let tmp = TempDir::new().unwrap(); let project = tmp.path().join("project"); std::fs::create_dir_all(&project).unwrap(); @@ -1868,17 +1868,21 @@ max_tokens = 3333 assert!(invalid.contains("Use `default`, `inherit`")); assert!(invalid.contains("`project:coder`")); - let default_error = build_spawn_config_json_for_profile( - &parent, - &available, - &project, - "child", - None, - &scope, - SpawnProfileSelector::Default, + let default_config: serde_json::Value = serde_json::from_str( + &build_spawn_config_json_for_profile( + &parent, + &available, + &project, + "child", + None, + &scope, + SpawnProfileSelector::Default, + ) + .unwrap(), ) - .unwrap_err(); - assert!(default_error.contains("no default profile is configured")); + .unwrap(); + assert_eq!(default_config["feature"]["sub_worker"]["enabled"], true); + assert_eq!(default_config["feature"]["ticket"]["enabled"], false); let user_config = tmp.path().join("user-profiles.toml"); std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap(); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 9e63f151..05ac7f46 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -3740,7 +3740,13 @@ fn builtin_profile_source_archive( profile: &ProfileSelector, ) -> Result { let selected_profile = match profile { - ProfileSelector::Builtin(name) => name.clone(), + ProfileSelector::Builtin(name) => { + if name.starts_with("builtin:") { + name.clone() + } else { + format!("builtin:{name}") + } + } ProfileSelector::Named(name) => { return Err(format!( "embedded runtime does not provide named Profile `{name}`" diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bb0263d7..69626ada 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -18155,7 +18155,12 @@ mod tests { }, ) .unwrap(); - assert_eq!(existing.state, WorkerOperationState::Accepted); + assert_eq!( + existing.state, + WorkerOperationState::Accepted, + "diagnostics: {:?}", + existing.diagnostics + ); let worker_id = existing.worker.unwrap().worker.worker_id; let worker = api .runtime From 4d9b211d69cdab67ae9c95ffef15f9bd28f8f11a Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 11:29:59 +0900 Subject: [PATCH 03/18] feat: add in-process standalone worker host --- Cargo.lock | 17 +++ Cargo.toml | 3 + crates/standalone/Cargo.toml | 22 +++ crates/standalone/src/host.rs | 154 +++++++++++++++++++ crates/standalone/src/launch.rs | 86 +++++++++++ crates/standalone/src/lib.rs | 13 ++ crates/standalone/tests/host.rs | 212 +++++++++++++++++++++++++++ crates/worker/src/bootstrap.rs | 157 ++++++++++++++++++++ crates/worker/src/controller.rs | 14 ++ crates/worker/src/entrypoint.rs | 17 ++- crates/worker/src/lib.rs | 5 + crates/worker/src/worker.rs | 49 ++++++- docs/README.md | 1 + docs/design/standalone-agent-host.md | 38 +++++ 14 files changed, 782 insertions(+), 6 deletions(-) create mode 100644 crates/standalone/Cargo.toml create mode 100644 crates/standalone/src/host.rs create mode 100644 crates/standalone/src/launch.rs create mode 100644 crates/standalone/src/lib.rs create mode 100644 crates/standalone/tests/host.rs create mode 100644 crates/worker/src/bootstrap.rs create mode 100644 docs/design/standalone-agent-host.md diff --git a/Cargo.lock b/Cargo.lock index 7a313ade..7b6397bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4616,6 +4616,23 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "standalone" +version = "0.1.0" +dependencies = [ + "agen", + "async-trait", + "futures", + "manifest", + "protocol", + "session-store", + "tempfile", + "thiserror 2.0.18", + "tokio", + "uuid", + "worker", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index f6e03a24..36789a2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/agen", "crates/agen-macros", "crates/session-store", + "crates/standalone", "crates/secrets", "crates/manifest", "crates/mcp", @@ -36,6 +37,7 @@ default-members = [ "crates/agen", "crates/agen-macros", "crates/session-store", + "crates/standalone", "crates/secrets", "crates/manifest", "crates/mcp", @@ -87,6 +89,7 @@ protocol = { path = "crates/protocol" } session-metrics = { path = "crates/session-metrics" } session-analytics = { path = "crates/session-analytics" } session-store = { path = "crates/session-store" } +standalone = { path = "crates/standalone" } secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } config-source = { path = "crates/config-source" } diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml new file mode 100644 index 00000000..d23af099 --- /dev/null +++ b/crates/standalone/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "standalone" +description = "In-process standalone Worker host" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +agen.workspace = true +manifest.workspace = true +protocol.workspace = true +session-store.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt", "sync", "time"] } +worker.workspace = true + +[dev-dependencies] +async-trait.workspace = true +futures.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +uuid = { workspace = true, features = ["v7"] } diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs new file mode 100644 index 00000000..d0bbdd35 --- /dev/null +++ b/crates/standalone/src/host.rs @@ -0,0 +1,154 @@ +use std::time::Duration; + +use agen::llm_client::client::LlmClient; +use protocol::{Event, Method}; +use session_store::{CombinedStore, FsStore, FsWorkerStore}; +use thiserror::Error; +use tokio::sync::broadcast; +use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; +use worker::controller::WorkerControllerTransport; +use worker::{WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; + +use crate::launch::ResolvedStandaloneLaunch; + +const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); + +/// One top-level Worker plus its process-owned local Workdir session. +/// +/// The host deliberately exposes the existing typed Worker protocol rather +/// than owning an HTTP/WebSocket server or a second execution model. +pub struct StandaloneHost { + handle: worker::WorkerHandle, + shutdown: worker::controller::ShutdownReceiver, + shutdown_timeout: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneStartupError { + #[error("the standalone state store could not be opened")] + StateStore, + #[error("the resolved Worker configuration is invalid")] + WorkerConfiguration, + #[error("the configured model provider is unavailable")] + ModelProvider, + #[error("the fixed standalone feature composition could not be installed")] + FeatureComposition, + #[error("the in-process Worker controller could not start")] + Controller, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneRequestError { + #[error("the standalone Worker is no longer accepting requests")] + WorkerUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneShutdownError { + #[error("the standalone Worker did not stop before the shutdown deadline")] + DeadlineExceeded, + #[error("the standalone Worker shutdown confirmation was lost")] + ConfirmationLost, +} + +impl StandaloneHost { + pub async fn start(launch: ResolvedStandaloneLaunch) -> Result { + Self::start_with_optional_model_client(launch, None).await + } + + pub async fn start_with_model_client( + launch: ResolvedStandaloneLaunch, + model_client: C, + ) -> Result + where + C: LlmClient + 'static, + { + Self::start_with_optional_model_client(launch, Some(Box::new(model_client))).await + } + + async fn start_with_optional_model_client( + launch: ResolvedStandaloneLaunch, + model_client: Option>, + ) -> Result { + std::fs::create_dir_all(&launch.state_dir) + .map_err(|_| StandaloneStartupError::StateStore)?; + let session_store = FsStore::new(launch.state_dir.join("sessions")) + .map_err(|_| StandaloneStartupError::StateStore)?; + let worker_store = FsWorkerStore::new(launch.state_dir.join("workers")) + .map_err(|_| StandaloneStartupError::StateStore)?; + let store = CombinedStore::new(session_store, worker_store); + let filesystem_authority = + WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); + let runtime_base = launch.state_dir.join("runtime"); + + let mut bootstrap = WorkerBootstrap::new( + launch.profile.manifest, + store, + launch.prompt_catalog, + workspace_context, + filesystem_authority, + WorkerBootstrapLayout::Direct { runtime_base }, + WorkerControllerTransport::InProcess, + ); + if let Some(model_client) = model_client { + bootstrap = bootstrap.with_model_client(model_client); + } + let started = bootstrap.start().await.map_err(classify_startup_error)?; + + Ok(Self { + handle: started.handle, + shutdown: started.shutdown, + shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT, + }) + } + + pub async fn send(&self, method: Method) -> Result<(), StandaloneRequestError> { + self.handle + .send(method) + .await + .map_err(|_| StandaloneRequestError::WorkerUnavailable) + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.handle.subscribe() + } + + pub fn snapshot(&self) -> Event { + self.handle.snapshot_event() + } + + pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self { + self.shutdown_timeout = shutdown_timeout; + self + } + + pub async fn shutdown(self) -> Result<(), StandaloneShutdownError> { + let StandaloneHost { + handle, + shutdown, + shutdown_timeout, + } = self; + let _ = handle.send(Method::Shutdown).await; + match tokio::time::timeout(shutdown_timeout, shutdown).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(StandaloneShutdownError::ConfirmationLost), + Err(_) => Err(StandaloneShutdownError::DeadlineExceeded), + } + } +} + +fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError { + match error { + WorkerBootstrapError::Worker(WorkerError::Provider(_)) => { + StandaloneStartupError::ModelProvider + } + WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration, + WorkerBootstrapError::Controller { source, .. } + if source.kind() == std::io::ErrorKind::Other => + { + StandaloneStartupError::FeatureComposition + } + WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller, + } +} diff --git a/crates/standalone/src/launch.rs b/crates/standalone/src/launch.rs new file mode 100644 index 00000000..621d3f80 --- /dev/null +++ b/crates/standalone/src/launch.rs @@ -0,0 +1,86 @@ +use std::path::{Path, PathBuf}; + +use manifest::{ + ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector, + ResolvedProfile, +}; +use thiserror::Error; +use worker::PromptCatalogSource; + +/// Process launch input resolved before any Worker/session side effect occurs. +#[derive(Debug, Clone)] +pub struct StandaloneLaunchConfig { + pub cwd: PathBuf, + pub state_dir: PathBuf, + pub profile: ProfileSelector, + pub worker_name: String, +} + +pub struct ResolvedStandaloneLaunch { + pub cwd: PathBuf, + pub state_dir: PathBuf, + pub profile: ResolvedProfile, + pub prompt_catalog: PromptCatalogSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StandaloneLaunchError { + #[error("the standalone working directory is unavailable")] + WorkingDirectoryUnavailable, + #[error("path-based profiles are not standalone launch authority")] + PathProfileUnsupported, + #[error("the standalone profile could not be resolved")] + ProfileResolutionFailed, +} + +impl StandaloneLaunchConfig { + pub fn new( + cwd: impl Into, + state_dir: impl Into, + profile: ProfileSelector, + worker_name: impl Into, + ) -> Self { + Self { + cwd: cwd.into(), + state_dir: state_dir.into(), + profile, + worker_name: worker_name.into(), + } + } + + /// Resolve only built-in/XDG profile authority and bind standalone scope + /// to the canonical process cwd. Repository-local profile discovery is + /// deliberately not part of this path. + pub fn resolve(self) -> Result { + if matches!(self.profile, ProfileSelector::Path { .. }) { + return Err(StandaloneLaunchError::PathProfileUnsupported); + } + let cwd = canonical_directory(&self.cwd)?; + let profile = ProfileResolver::new() + .with_workspace_base(&cwd) + .resolve_for_target( + &self.profile, + ProfileResolveOptions { + worker_name: Some(self.worker_name), + }, + ProfileExecutionTarget::Standalone, + ) + .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; + + Ok(ResolvedStandaloneLaunch { + cwd, + state_dir: self.state_dir, + profile, + prompt_catalog: PromptCatalogSource::builtins_only(), + }) + } +} + +fn canonical_directory(path: &Path) -> Result { + let path = std::fs::canonicalize(path) + .map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?; + if !path.is_dir() { + return Err(StandaloneLaunchError::WorkingDirectoryUnavailable); + } + Ok(path) +} diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs new file mode 100644 index 00000000..b0c65331 --- /dev/null +++ b/crates/standalone/src/lib.rs @@ -0,0 +1,13 @@ +//! In-process standalone host for one top-level Yoi Worker. +//! +//! The crate composes existing `worker`, `manifest`, `session-store`, and +//! `workdir` contracts. It intentionally owns no TUI, Runtime, Workspace +//! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path. + +pub mod host; +pub mod launch; + +pub use host::{ + StandaloneHost, StandaloneRequestError, StandaloneShutdownError, StandaloneStartupError, +}; +pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError}; diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs new file mode 100644 index 00000000..157f15e6 --- /dev/null +++ b/crates/standalone/tests/host.rs @@ -0,0 +1,212 @@ +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agen::llm_client::client::LlmClient; +use agen::llm_client::error::ClientError; +use agen::llm_client::event::{Event as LlmEvent, StopReason}; +use agen::llm_client::types::Request; +use async_trait::async_trait; +use futures::{Stream, stream}; +use protocol::{Event, Method}; +use standalone::{StandaloneHost, StandaloneLaunchConfig}; +use uuid::Uuid; + +#[derive(Clone)] +struct ScriptedClient { + responses: Arc>>>, + requests: Arc>>, +} + +impl ScriptedClient { + fn new(responses: Vec>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses.into())), + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("requests lock").clone() + } +} + +#[async_trait] +impl LlmClient for ScriptedClient { + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + self.requests.lock().expect("requests lock").push(request); + let response = self + .responses + .lock() + .expect("responses lock") + .pop_front() + .expect("scripted response"); + Ok(Box::pin(stream::iter(response.into_iter().map(Ok)))) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } +} + +#[tokio::test] +async fn in_process_host_runs_text_and_read_tool_then_shuts_down() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write(temp.path().join("probe.txt"), "standalone tool evidence\n") + .expect("write probe"); + let worker_name = format!("standalone-{}", Uuid::now_v7()); + let launch = StandaloneLaunchConfig::new( + temp.path(), + temp.path().join("state"), + manifest::ProfileSelector::Default, + &worker_name, + ) + .resolve() + .expect("resolve standalone profile"); + + let client = ScriptedClient::new(vec![ + vec![ + LlmEvent::tool_use_start(0, "read-1", "Read"), + LlmEvent::tool_input_delta(0, r#"{"file_path":"probe.txt"}"#), + LlmEvent::tool_use_stop(0), + ], + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "standalone response"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ], + ]); + let inspection = client.clone(); + let host = StandaloneHost::start_with_model_client(launch, client) + .await + .expect("start in-process host"); + let mut events = host.subscribe(); + + host.send(Method::run_text("read the probe")) + .await + .expect("submit input"); + + tokio::time::timeout(Duration::from_secs(30), async { + let mut saw_text = false; + let mut saw_tool_result = false; + loop { + match events.recv().await.expect("worker event") { + Event::TextDelta { text } if text.contains("standalone response") => { + saw_text = true; + } + Event::ToolResult { .. } => { + saw_tool_result = true; + } + Event::RunEnd { .. } => { + assert!(saw_text, "stream must expose the model text delta"); + assert!(saw_tool_result, "stream must expose the tool result"); + break; + } + _ => {} + } + } + }) + .await + .expect("run completed"); + + let requests = inspection.requests(); + assert_eq!(requests.len(), 2); + let tool_names = requests[0] + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert!(tool_names.contains(&"Read")); + assert!(tool_names.contains(&"TaskCreate")); + assert!(tool_names.contains(&"SubWorkerSpawn")); + assert!(format!("{:?}", requests[1].items).contains("standalone tool evidence")); + assert!( + !temp + .path() + .join("state/runtime") + .join(&worker_name) + .join("worker.sock") + .exists() + ); + + host.shutdown().await.expect("graceful shutdown"); +} + +#[tokio::test] +async fn state_store_failure_is_redacted_and_starts_no_controller() { + let temp = tempfile::tempdir().expect("tempdir"); + let state_path = temp.path().join("state-file-with-secret-name"); + std::fs::write(&state_path, "not a directory").expect("write blocking file"); + let launch = StandaloneLaunchConfig::new( + temp.path(), + &state_path, + manifest::ProfileSelector::Default, + format!("standalone-failure-{}", Uuid::now_v7()), + ) + .resolve() + .expect("resolve launch"); + let client = ScriptedClient::new(Vec::new()); + + let error = StandaloneHost::start_with_model_client(launch, client) + .await + .err() + .expect("state store startup rejected"); + assert_eq!(error, standalone::StandaloneStartupError::StateStore); + assert_eq!( + error.to_string(), + "the standalone state store could not be opened" + ); + assert!(!error.to_string().contains("secret-name")); + assert!( + !temp + .path() + .join("state-file-with-secret-name/runtime") + .exists() + ); +} + +#[test] +fn standalone_crate_has_no_tui_runtime_or_workspace_server_dependency() { + let manifest = include_str!("../Cargo.toml"); + let dependencies = manifest + .split("[dependencies]") + .nth(1) + .expect("dependencies section") + .split("[dev-dependencies]") + .next() + .expect("dependency body"); + for forbidden in ["tui", "worker-runtime", "yoi-workspace-server"] { + assert!( + !dependencies.lines().any(|line| { + line.split_once('=') + .is_some_and(|(name, _)| name.trim() == forbidden) + }), + "standalone must not depend on {forbidden}" + ); + } +} + +#[test] +fn launch_rejects_path_profile_before_worker_startup() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = StandaloneLaunchConfig::new( + temp.path(), + temp.path().join("state"), + manifest::ProfileSelector::Path { + path: temp.path().join("profile.dcdl"), + }, + "standalone-path-profile", + ) + .resolve() + .err() + .expect("path profile rejected"); + assert_eq!( + error, + standalone::StandaloneLaunchError::PathProfileUnsupported + ); +} diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs new file mode 100644 index 00000000..fe474b1b --- /dev/null +++ b/crates/worker/src/bootstrap.rs @@ -0,0 +1,157 @@ +use std::path::PathBuf; + +use agen::llm_client::client::LlmClient; +use session_store::{Store, WorkerMetadataStore}; +use thiserror::Error; +use workdir::WorkdirSessionHandle; + +use crate::PromptCatalogSource; +use crate::controller::{ + ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle, +}; +use crate::worker::{Worker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; +use manifest::WorkerManifest; + +/// Filesystem layout used by a Worker controller started through the reusable +/// bootstrap boundary. +#[derive(Debug, Clone)] +pub enum WorkerBootstrapLayout { + /// A direct Worker rooted below the supplied runtime base directory. + Direct { runtime_base: PathBuf }, + /// A runtime-managed Worker with an exact persisted run directory. + RuntimeManagedRun { run_dir: PathBuf }, +} + +/// Construction and controller inputs that are stable for one Worker launch. +pub struct WorkerBootstrap { + manifest: WorkerManifest, + store: St, + prompt_catalog: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, + model_client: Option>, + workdir_session: Option, +} + +/// Live controller returned only after Worker construction and feature +/// installation have completed successfully. +pub struct BootstrappedWorker { + pub handle: WorkerHandle, + pub shutdown: ShutdownReceiver, +} + +#[derive(Debug, Error)] +pub enum WorkerBootstrapError { + #[error("worker construction failed")] + Worker(#[source] WorkerError), + #[error("worker controller startup failed")] + Controller { + #[source] + source: std::io::Error, + cleanup_failed: bool, + }, +} + +impl WorkerBootstrap +where + St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + #[allow(clippy::too_many_arguments)] + pub fn new( + manifest: WorkerManifest, + store: St, + prompt_catalog: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, + ) -> Self { + Self { + manifest, + store, + prompt_catalog, + workspace_context, + filesystem_authority, + layout, + transport, + model_client: None, + workdir_session: None, + } + } + + /// Inject a process-owned model client. This is primarily useful for + /// embedded hosts and deterministic tests that must not start an external + /// model server. + pub fn with_model_client(mut self, model_client: C) -> Self + where + C: LlmClient + 'static, + { + self.model_client = Some(Box::new(model_client)); + self + } + + /// Bind an already materialized Workdir session instead of asking the + /// Worker to derive one from filesystem authority. + pub fn with_workdir_session(mut self, workdir_session: WorkdirSessionHandle) -> Self { + self.workdir_session = Some(workdir_session); + self + } + + pub async fn start(self) -> Result { + let mut worker = Worker::from_manifest_with_context_and_model_client( + self.manifest, + self.store, + self.prompt_catalog, + self.workspace_context, + self.filesystem_authority, + self.model_client, + ) + .await + .map_err(WorkerBootstrapError::Worker)?; + + if let Some(workdir_session) = self.workdir_session { + worker.bind_workdir_session(Some(workdir_session)); + } + start_worker_controller(worker, self.layout, self.transport).await + } +} + +/// Start the shared direct/runtime-managed controller lifecycle for an already +/// constructed Worker. Restore paths use this after replaying durable state; +/// fresh hosts normally use [`WorkerBootstrap::start`]. +pub async fn start_worker_controller( + worker: Worker, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, +) -> Result +where + C: LlmClient + Clone + 'static, + St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + let cleanup_session = worker.workdir_session().cloned(); + let controller = match layout { + WorkerBootstrapLayout::Direct { runtime_base } => { + WorkerController::spawn_with_transport(worker, &runtime_base, transport).await + } + WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => { + WorkerController::spawn_runtime_managed_run_with_transport(worker, &run_dir, transport) + .await + } + }; + + match controller { + Ok((handle, shutdown)) => Ok(BootstrappedWorker { handle, shutdown }), + Err(source) => { + let cleanup_failed = match cleanup_session { + Some(session) => session.close().await.is_err(), + None => false, + }; + Err(WorkerBootstrapError::Controller { + source, + cleanup_failed, + }) + } + } +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index ff943c60..516486d6 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -240,6 +240,20 @@ impl WorkerController { .await } + /// Spawn a direct Worker while letting an in-process host select the + /// controller transport explicitly. + pub async fn spawn_with_transport( + worker: Worker, + runtime_base: &Path, + transport: WorkerControllerTransport, + ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> + where + C: LlmClient + Clone + 'static, + St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, + { + Self::spawn_inner(worker, runtime_base, false, None, transport).await + } + /// Spawn a Worker owned by `worker-runtime`. /// /// The controller still uses an ephemeral directory for Unix sockets and diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 957cedd3..2b258df6 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -3,8 +3,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use crate::{ - PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority, - WorkerWorkspaceContext, + PromptCatalogSource, Worker, WorkerBootstrapLayout, WorkerControllerTransport, + WorkerFilesystemAuthority, WorkerWorkspaceContext, start_worker_controller, }; use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths}; @@ -634,13 +634,22 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { return ExitCode::FAILURE; } }; - let (handle, shutdown_rx) = match WorkerController::spawn(worker, &runtime_base).await { - Ok(pair) => pair, + let started = match start_worker_controller( + worker, + WorkerBootstrapLayout::Direct { + runtime_base: runtime_base.clone(), + }, + WorkerControllerTransport::UnixSocket, + ) + .await + { + Ok(started) => started, Err(e) => { eprintln!("error: failed to start worker controller: {e}"); return ExitCode::FAILURE; } }; + let (handle, shutdown_rx) = (started.handle, started.shutdown); let socket_path = handle.runtime_dir.socket_path(); // Machine-readable ready line for parents that spawned this Worker diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 8de12fbd..a631b47d 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bootstrap; pub mod compact; pub mod controller; pub mod discovery; @@ -23,6 +24,10 @@ mod interrupt_prep; mod permission; mod worker; +pub use bootstrap::{ + BootstrappedWorker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, + start_worker_controller, +}; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; pub use hook::{Hook, HookEventKind, HookRegistryBuilder}; diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 28f69623..6aa1d871 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -5107,15 +5107,35 @@ where loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, + ) -> Result { + Self::from_manifest_with_context_and_model_client( + manifest, + store, + loader, + workspace_context, + filesystem_authority, + None, + ) + .await + } + + pub(crate) async fn from_manifest_with_context_and_model_client( + manifest: WorkerManifest, + store: St, + loader: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + model_client: Option>, ) -> Result { validate_workspace_memory_snapshot(&manifest.worker.name, &manifest, &workspace_context)?; - let common = prepare_worker_common_with_context( + let common = prepare_worker_common_with_context_and_model_client( &manifest, &loader, /* parse_template */ true, workspace_context, filesystem_authority, manifest.scope.clone(), + model_client, )?; // Segment creation is deferred to the first run (see @@ -6592,6 +6612,26 @@ fn prepare_worker_common_with_context( workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope_config: ScopeConfig, +) -> Result { + prepare_worker_common_with_context_and_model_client( + manifest, + loader, + parse_template, + workspace_context, + filesystem_authority, + scope_config, + None, + ) +} + +fn prepare_worker_common_with_context_and_model_client( + manifest: &WorkerManifest, + loader: &PromptCatalogSource, + parse_template: bool, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + scope_config: ScopeConfig, + model_client: Option>, ) -> Result { let filesystem_authority = match filesystem_authority { WorkerFilesystemAuthority::None => WorkerFilesystemAuthority::None, @@ -6627,6 +6667,7 @@ fn prepare_worker_common_with_context( workspace_context, filesystem_authority, scope, + model_client, ) } @@ -6637,6 +6678,7 @@ fn prepare_worker_common_from_scope( workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, scope: Scope, + model_client: Option>, ) -> Result { if let Some(local) = filesystem_authority.as_local() { if !scope.is_readable(&local.root) { @@ -6653,7 +6695,10 @@ fn prepare_worker_common_from_scope( let delegation_scope = DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?; - let client = crate::model_client::build_client(&manifest.model)?; + let client = match model_client { + Some(client) => client, + None => crate::model_client::build_client(&manifest.model)?, + }; let prompts = Arc::new(ArcSwap::from(PromptCatalog::load(loader)?)); let system_prompt_template = if parse_template { Some( diff --git a/docs/README.md b/docs/README.md index 4cd18397..d02c1065 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 16. [`development/validation.md`](development/validation.md) — how to check changes. 17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. +18. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 ## What belongs here diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md new file mode 100644 index 00000000..d5ff72da --- /dev/null +++ b/docs/design/standalone-agent-host.md @@ -0,0 +1,38 @@ +# Standalone Agent Host + +`crates/standalone` は、既存の Yoi Worker を同一プロセス内で起動する最小の host 境界である。 +新しい Agent 実行系ではなく、`manifest`、`worker`、`session-store`、`workdir` の既存契約を固定構成で組み立てる。 + +## 依存方向 + +```text +standalone + ├─ manifest + ├─ worker + ├─ session-store + └─ protocol + +worker + └─ WorkerBootstrap / start_worker_controller +``` + +`standalone` は `tui`、`worker-runtime`、`yoi-workspace-server` に依存しない。 +`worker` の direct entrypoint と `standalone` は同じ `start_worker_controller` lifecycle を使い、後者は `WorkerBootstrap` で fresh Worker construction も共有する。 + +## authority と lifecycle + +- launch は `ProfileExecutionTarget::Standalone` で built-in/XDG Profile を resolve する。repository-local Profile と path selector は authority にしない。 +- canonical cwd を `WorkerFilesystemAuthority::local` の root/cwd とし、host ごとに top-level Worker と process-owned `WorkdirSession` を一つ作る。 +- Controller transport は `InProcess` に固定する。通常起動で Worker subprocess、HTTP/WS server、Unix socket を作らない。 +- model provider は通常の resolved Manifest から構築する。埋め込み host と deterministic test は `start_with_model_client` で同じ bootstrap に process-owned client を注入できる。 +- feature plan/install は既存 `WorkerController` が行う。Task や optional direct SubWorker を standalone 側で再実装しない。 +- `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。 +- startup error は category のみを公開し、credential、prompt 本文、session metadata、内部 path を error text に含めない。 + +## 非目標 + +- TUI/CLI routing や画面実装 +- Runtime / Workspace Server / Orchestrator / Ticket authority の内包 +- standalone 独自の HTTP/WS API +- Worker/Tool/Task/SubWorker protocol の fork +- subprocess Worker launcher や runtime-owned Worker catalog From 862eeb7add15ad4169352de8258f7491b226f6e4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 11:54:20 +0900 Subject: [PATCH 04/18] fix: route runtime workers through shared bootstrap --- crates/worker-runtime/src/worker_backend.rs | 130 ++++++++++++++++---- crates/worker/src/bootstrap.rs | 53 +++++++- crates/worker/src/lib.rs | 4 +- docs/design/standalone-agent-host.md | 2 +- 4 files changed, 159 insertions(+), 30 deletions(-) diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 0bf468fa..524fd8b5 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -46,6 +46,8 @@ use tokio::runtime::Runtime; use tokio::sync::broadcast; use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle}; +#[cfg(test)] +use worker::WorkerController; use worker::feature::builtin::{ CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider, WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture, @@ -54,9 +56,10 @@ use worker::feature::builtin::{ #[cfg(feature = "ws-server")] use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session}; use worker::{ - PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, - WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, - WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, + PreparedWorker, PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, + Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, + WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, + WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, }; const DEFAULT_BACKEND_ID: &str = "worker-crate"; @@ -881,15 +884,31 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { )?; let store = CombinedStore::new(session_store, worker_metadata_store); - let mut worker = Worker::from_manifest_with_context( + let run_dir = worker_aggregate_dir + .join("runs") + .join(request.run_generation.to_string()); + let mut prepared = WorkerBootstrap::new( manifest, store, loader, workspace_context, filesystem_authority, + WorkerBootstrapLayout::RuntimeManagedRun { + run_dir: run_dir.clone(), + }, + self.controller_transport, ) + .prepare() .await - .map_err(|err| format!("failed to create Worker from profile: {err}"))?; + .map_err(|error| match error { + WorkerBootstrapError::Worker(source) => { + format!("failed to create Worker from profile: {source}") + } + WorkerBootstrapError::Controller { source, .. } => { + format!("failed to prepare Worker controller: {source}") + } + })?; + let worker = prepared.worker_mut(); validate_worker_memory_settings(worker.manifest(), &request.request)?; if let Some(binding) = request.working_directory.as_ref() { worker.bind_workdir_session(Some(runtime_local_workdir_session( @@ -935,21 +954,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { } let workspace_client = worker.workspace_client_handle(); - let run_dir = worker_aggregate_dir - .join("runs") - .join(request.run_generation.to_string()); - let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport( - worker, - &run_dir, - self.controller_transport, - ) - .await - .map_err(|err| { - format!( - "failed to spawn Worker controller in {}: {err}", + let started = prepared.start().await.map_err(|error| match error { + WorkerBootstrapError::Worker(source) => { + format!("failed to prepare Worker before controller start: {source}") + } + WorkerBootstrapError::Controller { source, .. } => format!( + "failed to spawn Worker controller in {}: {source}", run_dir.display() - ) + ), })?; + let (handle, shutdown_rx) = (started.handle, started.shutdown); if flow_transition_enabled { handle.shared_state.enable_flow_transition(); } @@ -1118,18 +1132,25 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let run_dir = worker_aggregate_dir .join("runs") .join(request.run_generation.to_string()); - let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport( + let started = PreparedWorker::new( worker, - &run_dir, + WorkerBootstrapLayout::RuntimeManagedRun { + run_dir: run_dir.clone(), + }, self.controller_transport, ) + .start() .await - .map_err(|err| { - format!( - "failed to spawn restored Worker controller in {}: {err}", + .map_err(|error| match error { + WorkerBootstrapError::Worker(source) => { + format!("failed to prepare restored Worker: {source}") + } + WorkerBootstrapError::Controller { source, .. } => format!( + "failed to spawn restored Worker controller in {}: {source}", run_dir.display() - ) + ), })?; + let (handle, shutdown_rx) = (started.handle, started.shutdown); if flow_transition_enabled { handle.shared_state.enable_flow_transition(); } @@ -3091,9 +3112,68 @@ mod tests { assert!(!socket_path.exists()); } + #[test] + fn profile_runtime_factory_uses_shared_worker_bootstrap_seams() { + let source = include_str!("worker_backend.rs"); + let production = source + .split_once("#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("worker backend test module marker"); + let factory = production + .split_once("impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory") + .map(|(_, factory)| factory) + .expect("profile runtime factory implementation"); + let (fresh, restore) = factory + .split_once("async fn restore_controller") + .expect("fresh and restore factory paths"); + let assert_in_order = |path: &str, markers: &[&str]| { + let mut offset = 0; + for marker in markers { + let relative = path[offset..] + .find(marker) + .unwrap_or_else(|| panic!("missing ordered factory marker {marker}")); + offset += relative + marker.len(); + } + }; + assert_in_order( + fresh, + &[ + "WorkerBootstrap::new(", + ".prepare()", + "worker.bind_workdir_session(", + "worker.bind_worker_observation_provider(", + "install_runtime_flow_transition_feature()", + "prepared.start()", + ], + ); + assert_in_order( + restore, + &[ + "Worker::restore_from_worker_metadata_with_context(", + "worker.bind_workdir_session(", + "worker.bind_worker_observation_provider(", + "install_runtime_flow_transition_feature()", + "PreparedWorker::new(", + ".start()", + ], + ); + assert!( + production.contains("WorkerBootstrap::new("), + "fresh runtime Workers must use the shared construction bootstrap" + ); + assert!( + production.contains("PreparedWorker::new("), + "restored runtime Workers must use the shared pre-exposure lifecycle" + ); + assert!( + !production.contains("WorkerController::spawn_runtime_managed_run_with_transport"), + "runtime factory paths must not bypass the shared controller lifecycle" + ); + } + #[test] #[serial_test::serial(worker_allocation)] - fn in_process_runtime_reopens_persisted_worker_without_overlong_unix_socket() { + fn shared_bootstrap_preserves_in_process_transport_for_fresh_and_restored_runtime_workers() { let root = tempfile::tempdir().unwrap(); let long_component = "embedded-workspace-store-segment".repeat(4); let runtime_store_dir = root.path().join(long_component); diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index fe474b1b..7be14174 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -35,6 +35,14 @@ pub struct WorkerBootstrap { workdir_session: Option, } +/// A constructed Worker whose host-owned live bindings can still be installed +/// before Feature installation and controller exposure. +pub struct PreparedWorker { + worker: Worker, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, +} + /// Live controller returned only after Worker construction and feature /// installation have completed successfully. pub struct BootstrappedWorker { @@ -99,7 +107,12 @@ where self } - pub async fn start(self) -> Result { + /// Construct the Worker without exposing a controller handle. Runtime hosts + /// use this seam to bind Workdir, observation, Flow, and other live services + /// before [`PreparedWorker::start`] performs Feature installation. + pub async fn prepare( + self, + ) -> Result, St>, WorkerBootstrapError> { let mut worker = Worker::from_manifest_with_context_and_model_client( self.manifest, self.store, @@ -114,7 +127,43 @@ where if let Some(workdir_session) = self.workdir_session { worker.bind_workdir_session(Some(workdir_session)); } - start_worker_controller(worker, self.layout, self.transport).await + Ok(PreparedWorker::new(worker, self.layout, self.transport)) + } + + pub async fn start(self) -> Result { + self.prepare().await?.start().await + } +} + +impl PreparedWorker +where + C: LlmClient + Clone + 'static, + St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + /// Wrap a restored Worker in the same pre-exposure lifecycle used by fresh + /// bootstraps. + pub fn new( + worker: Worker, + layout: WorkerBootstrapLayout, + transport: WorkerControllerTransport, + ) -> Self { + Self { + worker, + layout, + transport, + } + } + + pub fn worker(&self) -> &Worker { + &self.worker + } + + pub fn worker_mut(&mut self) -> &mut Worker { + &mut self.worker + } + + pub async fn start(self) -> Result { + start_worker_controller(self.worker, self.layout, self.transport).await } } diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index a631b47d..2ebb30d0 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -25,8 +25,8 @@ mod permission; mod worker; pub use bootstrap::{ - BootstrappedWorker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, - start_worker_controller, + BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError, + WorkerBootstrapLayout, start_worker_controller, }; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md index d5ff72da..1e70eb44 100644 --- a/docs/design/standalone-agent-host.md +++ b/docs/design/standalone-agent-host.md @@ -17,7 +17,7 @@ worker ``` `standalone` は `tui`、`worker-runtime`、`yoi-workspace-server` に依存しない。 -`worker` の direct entrypoint と `standalone` は同じ `start_worker_controller` lifecycle を使い、後者は `WorkerBootstrap` で fresh Worker construction も共有する。 +`worker` の direct entrypoint、`worker-runtime` の fresh/restore factory、`standalone` は同じ controller lifecycle を使う。fresh runtime/standalone construction は `WorkerBootstrap` を共有し、Runtime は `prepare()` 後かつ Feature install/controller exposure 前に Workdir、observation、Flow の live binding を追加する。restore は replay 済み Worker を `PreparedWorker` へ渡して同じ pre-exposure lifecycle を通す。 ## authority と lifecycle From 4b3b4fda61ed1c5d1eca7c0227ef002c59db9594 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 12:40:22 +0900 Subject: [PATCH 05/18] feat: connect standalone host to local TUI --- Cargo.lock | 1 + crates/client/src/lib.rs | 6 +- crates/client/src/target.rs | 113 +++++++++++++++++++++++-- crates/tui/Cargo.toml | 1 + crates/tui/src/console/mod.rs | 141 +++++++++++++++++++++++++------ crates/tui/src/lib.rs | 72 +++++++++++++--- crates/yoi/src/cli_connection.rs | 27 +++++- crates/yoi/src/main.rs | 101 +++++++++++++++++++++- crates/yoi/src/objective_cli.rs | 3 + crates/yoi/src/ticket_cli.rs | 3 + 10 files changed, 413 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b6397bf..e4ceb03e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5331,6 +5331,7 @@ dependencies = [ "serde", "serde_json", "session-store", + "standalone", "tempfile", "ticket", "tokio", diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 4ea7a11a..9969238f 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -37,9 +37,9 @@ pub use backend_workspace::{ }; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ - BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind, - WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, - WorkerResume, WorkerSpawn, + BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError, + TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, + WorkerListRequest, WorkerResume, WorkerSpawn, }; pub use spawn::{ diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 1b98819f..73eed1b3 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -1,16 +1,20 @@ -use std::fmt; +use std::{fmt, path::PathBuf}; use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TargetKind { + /// Legacy local Runtime process/socket authority. Local, + /// One-process Standalone authority with no Runtime or Workspace backend. + Standalone, Backend, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedTarget { Local, + Standalone, Backend { base_url: String, workspace_id: String, @@ -21,6 +25,7 @@ impl ResolvedTarget { pub fn kind(&self) -> TargetKind { match self { Self::Local => TargetKind::Local, + Self::Standalone => TargetKind::Standalone, Self::Backend { .. } => TargetKind::Backend, } } @@ -30,6 +35,7 @@ impl fmt::Display for TargetKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Local => f.write_str("local"), + Self::Standalone => f.write_str("Standalone"), Self::Backend => f.write_str("Backend"), } } @@ -107,8 +113,13 @@ impl WorkerConnectionSelector { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkerSpawn { - pub runtime_command: WorkerRuntimeCommand, +pub enum WorkerSpawn { + LegacyLocal { + runtime_command: WorkerRuntimeCommand, + }, + Standalone { + state_dir: PathBuf, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -213,7 +224,7 @@ impl Target for LocalTarget { } fn spawn_worker(&self) -> Result { - Ok(WorkerSpawn { + Ok(WorkerSpawn::LegacyLocal { runtime_command: self.runtime_command()?, }) } @@ -261,6 +272,65 @@ impl Target for LocalTarget { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandaloneTarget { + state_dir: PathBuf, +} + +impl StandaloneTarget { + #[must_use] + pub fn new(state_dir: impl Into) -> Self { + Self { + state_dir: state_dir.into(), + } + } +} + +impl Target for StandaloneTarget { + fn kind(&self) -> TargetKind { + TargetKind::Standalone + } + + fn resolve(&self) -> Result { + Ok(ResolvedTarget::Standalone) + } + + fn spawn_worker(&self) -> Result { + Ok(WorkerSpawn::Standalone { + state_dir: self.state_dir.clone(), + }) + } + + fn worker_by_name(&self) -> Result { + Err(TargetError::unsupported( + "Worker name attachment", + self.kind(), + )) + } + + fn resume_worker(&self) -> Result { + Err(TargetError::unsupported("Worker restore", self.kind())) + } + + fn dashboard(&self) -> Result { + Err(TargetError::unsupported("Worker dashboard", self.kind())) + } + + fn list_workers(&self, _request: WorkerListRequest) -> Result { + Err(TargetError::unsupported("Worker listing", self.kind())) + } + + fn connect_worker( + &self, + _selector: WorkerConnectionSelector, + ) -> Result { + Err(TargetError::unsupported( + "Backend runtime worker connection", + self.kind(), + )) + } +} + impl Target for BackendTarget { fn kind(&self) -> TargetKind { TargetKind::Backend @@ -303,7 +373,9 @@ impl Target for BackendTarget { base_url, workspace_id, }), - ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"), + ResolvedTarget::Local | ResolvedTarget::Standalone => { + unreachable!("BackendTarget cannot resolve as a local target") + } } } @@ -375,6 +447,37 @@ mod tests { assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local); } + #[test] + fn standalone_target_carries_in_process_state_without_resolving_runtime_command() { + let target = StandaloneTarget::new("/tmp/yoi-standalone-state"); + + assert_eq!(target.kind(), TargetKind::Standalone); + assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone); + assert_eq!( + target.spawn_worker().unwrap(), + WorkerSpawn::Standalone { + state_dir: PathBuf::from("/tmp/yoi-standalone-state"), + } + ); + } + + #[test] + fn standalone_target_never_falls_back_to_legacy_local_operations() { + let target = StandaloneTarget::new("/tmp/yoi-standalone-state"); + + assert_eq!( + target.worker_by_name().unwrap_err().to_string(), + "Worker name attachment is not supported by Standalone target" + ); + assert_eq!( + target + .list_workers(WorkerListRequest::new(None)) + .unwrap_err() + .to_string(), + "Worker listing is not supported by Standalone target" + ); + } + #[test] fn backend_target_builds_workspace_scoped_dashboard() { let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 0c087539..f9f2a267 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -10,6 +10,7 @@ e2e-test = [] [dependencies] client = { workspace = true } +standalone = { workspace = true } protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } base64 = "0.22.1" diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 0a6262ee..2618fc4f 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -22,7 +22,8 @@ use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use session_store::SegmentId; -use tokio::sync::mpsc; +use standalone::{StandaloneHost, StandaloneLaunchConfig}; +use tokio::sync::{broadcast, mpsc}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand}; @@ -174,13 +175,33 @@ pub(crate) async fn run_worker_name( enum ConsoleConnection { LegacySocket(WorkerClient), BackendRuntime(BackendRuntimeClient), + Standalone { + host: Option, + events: broadcast::Receiver, + initial_snapshot: Option, + }, } impl ConsoleConnection { + fn standalone(host: StandaloneHost) -> Self { + let events = host.subscribe(); + let initial_snapshot = Some(host.snapshot()); + Self::Standalone { + host: Some(host), + events, + initial_snapshot, + } + } + fn try_next_event(&mut self) -> Option { match self { Self::LegacySocket(client) => client.try_next_event(), Self::BackendRuntime(client) => client.try_next_event(), + Self::Standalone { + events, + initial_snapshot, + .. + } => initial_snapshot.take().or_else(|| events.try_recv().ok()), } } @@ -188,6 +209,18 @@ impl ConsoleConnection { match self { Self::LegacySocket(client) => client.next_event().await, Self::BackendRuntime(client) => client.next_event().await, + Self::Standalone { host, events, .. } => loop { + match events.recv().await { + Ok(event) => break Some(event), + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(host) = host.as_ref() else { + break None; + }; + break Some(host.snapshot()); + } + Err(broadcast::error::RecvError::Closed) => break None, + } + }, } } @@ -195,8 +228,78 @@ impl ConsoleConnection { match self { Self::LegacySocket(client) => Ok(client.send(method).await?), Self::BackendRuntime(client) => Ok(client.send(method).await?), + Self::Standalone { host, .. } => { + let host = host.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "Standalone Worker has already shut down", + ) + })?; + Ok(host.send(method.clone()).await?) + } } } + + async fn shutdown(&mut self) -> Result<(), Box> { + if let Self::Standalone { host, .. } = self + && let Some(host) = host.take() + { + host.shutdown().await?; + } + Ok(()) + } +} + +pub(crate) async fn run_standalone( + workspace_root: PathBuf, + state_dir: PathBuf, + worker_name: Option, + profile: Option, +) -> Result<(), Box> { + let worker_name = worker_name.unwrap_or_else(|| "local".to_string()); + let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| { + manifest::ProfileSelector::parse_cli(&profile) + }); + let history_root = workspace_root.clone(); + let launch = StandaloneLaunchConfig { + state_dir, + cwd: workspace_root, + profile, + worker_name: worker_name.clone(), + } + .resolve() + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Standalone launch configuration failed: {error}"), + ) + })?; + let host = StandaloneHost::start(launch) + .await + .map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?; + let mut connection = ConsoleConnection::standalone(host); + + let mut terminal = match enter_fullscreen() { + Ok(terminal) => terminal, + Err(error) => { + let _ = connection.shutdown().await; + return Err(error); + } + }; + let mut app = App::new_with_persistent_input_history(worker_name, &history_root); + let run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await; + let shutdown_result = connection + .shutdown() + .await + .map_err(|error| io::Error::other(format!("Standalone Worker shutdown failed: {error}"))); + let leave_result = leave_fullscreen(&mut terminal); + + if let Err(error) = run_result { + return Err(error); + } + shutdown_result?; + leave_result?; + Ok(()) } pub(crate) async fn run_backend_runtime( @@ -208,13 +311,8 @@ pub(crate) async fn run_backend_runtime( let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); app.connected = true; - let result = run_loop( - &mut terminal, - &mut app, - ConsoleConnection::BackendRuntime(client), - None, - ) - .await; + let mut connection = ConsoleConnection::BackendRuntime(client); + let result = run_loop(&mut terminal, &mut app, &mut connection, None).await; let _ = leave_fullscreen(&mut terminal); result } @@ -228,13 +326,8 @@ async fn run_connected_pod( let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root); app.connected = true; - run_loop( - terminal, - &mut app, - ConsoleConnection::LegacySocket(client), - Some(runtime_command), - ) - .await + let mut connection = ConsoleConnection::LegacySocket(client); + run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await } pub(crate) async fn open_from_dashboard( @@ -460,13 +553,8 @@ async fn run( app.connected = true; // The Worker sends `Event::Snapshot` automatically on connect; // no explicit method call is required to fetch history. - run_loop( - terminal, - &mut app, - ConsoleConnection::LegacySocket(client), - Some(runtime_command), - ) - .await?; + let mut connection = ConsoleConnection::LegacySocket(client); + run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await?; } Err(e) => { app.push_error(format!( @@ -791,7 +879,7 @@ async fn drain_worker_events( async fn run_loop( terminal: &mut Terminal>, app: &mut App, - mut client: ConsoleConnection, + client: &mut ConsoleConnection, runtime_command: Option, ) -> Result<(), Box> { let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; @@ -804,11 +892,11 @@ async fn run_loop( } let handled_term_event = - drain_terminal_events(app, &mut client, &mut term_rx, runtime_command.as_ref()).await?; + drain_terminal_events(app, client, &mut term_rx, runtime_command.as_ref()).await?; if app.quit { break; } - let handled_worker_event = drain_worker_events(app, &mut client).await?; + let handled_worker_event = drain_worker_events(app, client).await?; if handled_term_event || handled_worker_event { terminal.draw(|f| ui::draw(f, app))?; continue; @@ -816,8 +904,7 @@ async fn run_loop( match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { LoopInput::Terminal(term_event) => { - handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref()) - .await?; + handle_terminal_event(app, client, term_event?, runtime_command.as_ref()).await?; } LoopInput::Worker(event) => match event { Some(ev) => { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index e7b7ec5b..f30da214 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -36,7 +36,7 @@ use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use session_store::SegmentId; -use client::{Target, WorkerConnectionSelector, WorkerListRequest}; +use client::{Target, WorkerConnectionSelector, WorkerListRequest, WorkerSpawn}; #[derive(Debug)] pub struct LaunchOptions { @@ -85,6 +85,49 @@ pub enum LaunchMode { Panel { include_stopped: bool }, } +struct TerminalModeGuard { + active: bool, +} + +impl TerminalModeGuard { + fn new() -> Self { + Self { active: true } + } + + fn restore(&mut self) -> io::Result<()> { + if !self.active { + return Ok(()); + } + self.active = false; + let mut stdout = io::stdout(); + execute!( + stdout, + DisableMouseCapture, + LeaveAlternateScreen, + DisableBracketedPaste, + crossterm::cursor::Show + )?; + disable_raw_mode() + } +} + +impl Drop for TerminalModeGuard { + fn drop(&mut self) { + if self.active { + let mut stdout = io::stdout(); + let _ = execute!( + stdout, + DisableMouseCapture, + LeaveAlternateScreen, + DisableBracketedPaste, + crossterm::cursor::Show + ); + let _ = disable_raw_mode(); + self.active = false; + } + } +} + pub async fn launch(options: LaunchOptions) -> ExitCode { let LaunchOptions { target, @@ -109,14 +152,19 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { eprintln!("yoi: {e}"); return ExitCode::FAILURE; } + let mut terminal_mode = TerminalModeGuard::new(); let result = match mode { LaunchMode::Spawn { worker_name, profile, } => match target.spawn_worker() { - Ok(spawn) => { - console::run_spawn(None, worker_name, profile, spawn.runtime_command).await + Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { + console::run_spawn(None, worker_name, profile, runtime_command).await + } + Ok(WorkerSpawn::Standalone { state_dir }) => { + console::run_standalone(workspace_root.clone(), state_dir, worker_name, profile) + .await } Err(e) => Err(Box::new(e) as Box), }, @@ -176,9 +224,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(e) => Err(Box::new(e) as Box), }, LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() { - Ok(spawn) => { - console::run_spawn(Some(id), worker_name, None, spawn.runtime_command).await + Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { + console::run_spawn(Some(id), worker_name, None, runtime_command).await } + Ok(WorkerSpawn::Standalone { .. }) => Err(Box::new(io::Error::new( + io::ErrorKind::Unsupported, + "Standalone session restore is not implemented", + )) as Box), Err(e) => Err(Box::new(e) as Box), }, LaunchMode::Panel { include_stopped } => match target.dashboard() { @@ -198,15 +250,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { // alternate-screen buffer. #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); - let mut stdout = io::stdout(); - let _ = execute!( - stdout, - DisableMouseCapture, - LeaveAlternateScreen, - DisableBracketedPaste - ); - let _ = disable_raw_mode(); - let _ = execute!(stdout, crossterm::cursor::Show); + let _ = terminal_mode.restore(); #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 4d5bd379..7d438eeb 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -1,4 +1,4 @@ -use client::{BackendTarget, LocalTarget, Target, TargetKind}; +use client::{BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind}; use serde::Deserialize; use super::{ParseError, read_client_default_connection, resolve_backend_url}; @@ -107,6 +107,18 @@ pub(crate) trait CliConnectionResolver { #[derive(Debug, Default, Clone, Copy)] pub(crate) struct ClientConfigCliConnectionResolver; +fn standalone_target() -> Result, ParseError> { + let state_dir = manifest::paths::data_dir() + .ok_or_else(|| { + ParseError( + "Standalone state directory is unavailable; set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME" + .to_string(), + ) + })? + .join("standalone"); + Ok(Box::new(StandaloneTarget::new(state_dir))) +} + impl CliConnectionResolver for ClientConfigCliConnectionResolver { fn resolve_connection( &self, @@ -147,6 +159,11 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver { resolve_backend_url(explicit_backend_url, workspace_id)?, workspace_id.map(str::to_string), ))), + (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) + if command == CliCommand::DefaultTui => + { + standalone_target() + } (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => { Ok(Box::new(LocalTarget::new())) } @@ -171,6 +188,10 @@ pub(crate) fn resolve_local_cli_connection( let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?; match target.kind() { TargetKind::Local => Ok(target), + TargetKind::Standalone => Err(ParseError(format!( + "{} resolved Standalone where a legacy local target was required", + command.display_name() + ))), TargetKind::Backend => Err(ParseError(format!( "{} resolved a Backend target where a local target was required", command.display_name() @@ -193,8 +214,8 @@ pub(crate) fn resolve_backend_cli_connection( )?; match target.kind() { TargetKind::Backend => Ok(target), - TargetKind::Local => Err(ParseError(format!( - "{} resolved a local target where a Backend target was required", + TargetKind::Local | TargetKind::Standalone => Err(ParseError(format!( + "{} resolved a non-Backend target where a Backend target was required", command.display_name() ))), } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index c2f10655..0db52b6b 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -687,6 +687,21 @@ fn parse_console_options( &workspace_root, )?; + if target.kind() == TargetKind::Standalone { + if session.is_some() { + return Err(ParseError( + "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + .to_string(), + )); + } + if socket_override.is_some() { + return Err(ParseError( + "--local uses an in-process Standalone connection and does not accept --socket" + .to_string(), + )); + } + } + if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { return Ok(Mode::Tui { target, @@ -715,6 +730,11 @@ fn parse_console_options( worker_name, profile: Some(profile), } + } else if target.kind() == TargetKind::Standalone { + LaunchMode::Spawn { + worker_name, + profile: None, + } } else if let Some(session) = session { LaunchMode::ResumeWithSession { id: parse_session_id(&session.to_string_lossy())?, @@ -1650,7 +1670,7 @@ Usage: Target selection: Target options are top-level options and must appear before the command. - --local Use the local Worker runtime explicitly + --local Start a one-process Standalone Worker (no Server or Runtime) --backend Use a Workspace Backend explicitly --workspace-id Scope Backend routes to a Workspace id @@ -1725,7 +1745,9 @@ fn print_memory_lint_help() { mod tests { use super::*; use crate::cli_connection::CliConnectionInput; - use client::{BackendTarget, LocalTarget, TargetKind, WorkerListRequest}; + use client::{ + BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest, + }; struct FixedCliConnectionResolver { backend_url: &'static str, @@ -1761,7 +1783,11 @@ mod tests { let workspace_id = match input { CliConnectionInput::DefaultTarget { workspace_id } | CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id, - CliConnectionInput::LocalTarget => return Ok(Box::new(LocalTarget::new())), + CliConnectionInput::LocalTarget => { + return Ok(Box::new(StandaloneTarget::new( + "/tmp/yoi-test-standalone-state", + ))); + } }; Ok(Box::new(BackendTarget::new( self.backend_url, @@ -2026,6 +2052,75 @@ backend = "shared" } } + #[test] + fn parser_explicit_local_overrides_default_backend_with_standalone_target() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + let args = ["--local", "--worker", "my-local-worker"] + .into_iter() + .map(String::from) + .collect::>(); + let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(); + let Mode::Tui { target, mode, .. } = mode else { + panic!("expected TUI mode") + }; + + assert_eq!(target.kind(), TargetKind::Standalone); + assert!(matches!( + mode, + LaunchMode::Spawn { + worker_name: Some(ref name), + profile: None, + } if name == "my-local-worker" + )); + assert!(matches!( + target.spawn_worker().unwrap(), + client::WorkerSpawn::Standalone { .. } + )); + } + + #[test] + fn parser_rejects_standalone_and_backend_target_together() { + let err = parse_args_from(["--local", "--backend", "http://backend.example"]).unwrap_err(); + assert_eq!( + err.to_string(), + "--local and --backend are mutually exclusive" + ); + } + + #[test] + fn parser_standalone_rejects_legacy_session_and_socket_inputs() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + let session_args = ["--local", "--session", "session-1"] + .into_iter() + .map(String::from) + .collect::>(); + let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err(); + assert_eq!( + err.0, + "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + ); + + let socket_args = [ + "--local", + "--worker", + "worker-a", + "--socket", + "/tmp/worker.sock", + ] + .into_iter() + .map(String::from) + .collect::>(); + let err = parse_args_slice_with_connection_resolver(&socket_args, &resolver).unwrap_err(); + assert_eq!( + err.0, + "--local uses an in-process Standalone connection and does not accept --socket" + ); + } + #[test] fn parse_backend_runtime_target_rejects_legacy_socket_mix() { let err = parse_args_from([ diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs index 844f5305..3ce9c7eb 100644 --- a/crates/yoi/src/objective_cli.rs +++ b/crates/yoi/src/objective_cli.rs @@ -179,6 +179,9 @@ pub fn run( })?; run_in_workspace(cli, &workspace) } + ResolvedTarget::Standalone => Err(ObjectiveCliError::new( + "Standalone is a one-shot Worker host, not Objective storage authority", + )), ResolvedTarget::Backend { base_url, workspace_id, diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index b84909f1..fd965e62 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -214,6 +214,9 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result Err(TicketCliError::new( + "Standalone is a one-shot Worker host, not Ticket storage authority", + )), ResolvedTarget::Backend { base_url, workspace_id, From c5fd9c01e5068033f9c97aae8005326079694efc Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 13:46:16 +0900 Subject: [PATCH 06/18] feat: persist standalone sessions for restore --- Cargo.lock | 4 + crates/standalone/Cargo.toml | 5 +- crates/standalone/src/host.rs | 319 +++++++++++++-- crates/standalone/src/lib.rs | 6 + crates/standalone/src/store.rs | 674 ++++++++++++++++++++++++++++++++ crates/standalone/tests/host.rs | 265 ++++++++++++- crates/worker/src/bootstrap.rs | 23 ++ crates/worker/src/worker.rs | 53 ++- 8 files changed, 1309 insertions(+), 40 deletions(-) create mode 100644 crates/standalone/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index e4ceb03e..45f97992 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4622,9 +4622,12 @@ version = "0.1.0" dependencies = [ "agen", "async-trait", + "fs4", "futures", "manifest", "protocol", + "serde", + "serde_json", "session-store", "tempfile", "thiserror 2.0.18", @@ -5333,6 +5336,7 @@ dependencies = [ "session-store", "standalone", "tempfile", + "thiserror 2.0.18", "ticket", "tokio", "toml", diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml index d23af099..a3bb1813 100644 --- a/crates/standalone/Cargo.toml +++ b/crates/standalone/Cargo.toml @@ -7,11 +7,15 @@ license.workspace = true [dependencies] agen.workspace = true +fs4.workspace = true manifest.workspace = true protocol.workspace = true +serde.workspace = true +serde_json.workspace = true session-store.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt", "sync", "time"] } +uuid = { workspace = true, features = ["v7"] } worker.workspace = true [dev-dependencies] @@ -19,4 +23,3 @@ async-trait.workspace = true futures.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } -uuid = { workspace = true, features = ["v7"] } diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index d0bbdd35..d9839f76 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -1,33 +1,49 @@ +use std::path::PathBuf; use std::time::Duration; use agen::llm_client::client::LlmClient; use protocol::{Event, Method}; -use session_store::{CombinedStore, FsStore, FsWorkerStore}; +use session_store::{ + CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore, +}; use thiserror::Error; use tokio::sync::broadcast; use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; use worker::controller::WorkerControllerTransport; -use worker::{WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; +use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext}; use crate::launch::ResolvedStandaloneLaunch; +use crate::store::{ + StaleLeasePolicy, StandaloneSessionId, StandaloneSessionLease, StandaloneSessionRecord, + StandaloneSessionStore, StandaloneShutdownReason, StandaloneStoreError, +}; const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +type StandaloneBackingStore = CombinedStore; -/// One top-level Worker plus its process-owned local Workdir session. +/// One client-owned top-level Worker and its standalone session authority. /// -/// The host deliberately exposes the existing typed Worker protocol rather -/// than owning an HTTP/WebSocket server or a second execution model. +/// The host deliberately exposes the existing typed Worker protocol rather than owning an +/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records. pub struct StandaloneHost { handle: worker::WorkerHandle, - shutdown: worker::controller::ShutdownReceiver, + shutdown: Option, shutdown_timeout: Duration, + store: StandaloneSessionStore, + worker_store: FsWorkerStore, + record: StandaloneSessionRecord, + lease: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] pub enum StandaloneStartupError { - #[error("the standalone state store could not be opened")] + #[error("the standalone state store could not be opened or validated")] StateStore, - #[error("the resolved Worker configuration is invalid")] + #[error("the standalone session is already active")] + SessionActive, + #[error("the standalone session working directory is unavailable or changed")] + WorkingDirectoryUnavailable, + #[error("the resolved Worker configuration or persisted history is invalid")] WorkerConfiguration, #[error("the configured model provider is unavailable")] ModelProvider, @@ -49,6 +65,8 @@ pub enum StandaloneShutdownError { DeadlineExceeded, #[error("the standalone Worker shutdown confirmation was lost")] ConfirmationLost, + #[error("the standalone session final state could not be committed")] + StateStore, } impl StandaloneHost { @@ -67,24 +85,36 @@ impl StandaloneHost { } async fn start_with_optional_model_client( - launch: ResolvedStandaloneLaunch, + mut launch: ResolvedStandaloneLaunch, model_client: Option>, ) -> Result { - std::fs::create_dir_all(&launch.state_dir) - .map_err(|_| StandaloneStartupError::StateStore)?; - let session_store = FsStore::new(launch.state_dir.join("sessions")) - .map_err(|_| StandaloneStartupError::StateStore)?; - let worker_store = FsWorkerStore::new(launch.state_dir.join("workers")) - .map_err(|_| StandaloneStartupError::StateStore)?; - let store = CombinedStore::new(session_store, worker_store); + let store = StandaloneSessionStore::open(&launch.state_dir) + .map_err(classify_store_startup_error)?; + let allocation = store + .allocate(&launch.cwd, StaleLeasePolicy::Reject) + .map_err(classify_store_startup_error)?; + let id = allocation.id(); + + // The standalone session ID is the local identity. A unique internal Worker name avoids + // process-global allocation collisions without creating a Runtime/Workspace Worker ID. + launch.profile.manifest.worker.name = format!("standalone-{id}"); + let manifest = launch.profile.manifest.clone(); + let worker_name = manifest.worker.name.clone(); + let (backing_store, worker_store) = match backing_store(&store, id) { + Ok(stores) => stores, + Err(error) => { + let _ = store.abandon_allocation(allocation); + return Err(error); + } + }; let filesystem_authority = WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); - let runtime_base = launch.state_dir.join("runtime"); + let runtime_base = store.runtime_dir(id); let mut bootstrap = WorkerBootstrap::new( - launch.profile.manifest, - store, + manifest.clone(), + backing_store, launch.prompt_catalog, workspace_context, filesystem_authority, @@ -94,13 +124,155 @@ impl StandaloneHost { if let Some(model_client) = model_client { bootstrap = bootstrap.with_model_client(model_client); } - let started = bootstrap.start().await.map_err(classify_startup_error)?; + let started = match bootstrap.start().await { + Ok(started) => started, + Err(error) => { + let _ = store.abandon_allocation(allocation); + return Err(classify_startup_error(error)); + } + }; + let active = match active_pointer(&worker_store, &worker_name) { + Ok(active) => active, + Err(error) => { + stop_started_worker(started).await; + let _ = store.abandon_allocation(allocation); + return Err(error); + } + }; + let record = + match store.commit_created(&allocation, manifest, active.session_id, active.segment_id) + { + Ok(record) => record, + Err(_) => { + stop_started_worker(started).await; + let _ = store.abandon_allocation(allocation); + return Err(StandaloneStartupError::StateStore); + } + }; + Ok(Self::from_started( + started, + store, + worker_store, + record, + allocation.into_lease(), + )) + } - Ok(Self { + pub async fn restore( + state_dir: PathBuf, + session_id: StandaloneSessionId, + ) -> Result { + Self::restore_with_optional_model_client(state_dir, session_id, None).await + } + + pub async fn restore_with_model_client( + state_dir: PathBuf, + session_id: StandaloneSessionId, + model_client: C, + ) -> Result + where + C: LlmClient + 'static, + { + Self::restore_with_optional_model_client( + state_dir, + session_id, + Some(Box::new(model_client)), + ) + .await + } + + async fn restore_with_optional_model_client( + state_dir: PathBuf, + session_id: StandaloneSessionId, + model_client: Option>, + ) -> Result { + let store = + StandaloneSessionStore::open(state_dir).map_err(classify_store_startup_error)?; + let record = store + .load(session_id) + .map_err(classify_store_startup_error)?; + record.cwd.verify().map_err(classify_store_startup_error)?; + let lease = store + .acquire_lease(session_id, StaleLeasePolicy::Recover) + .map_err(classify_store_startup_error)?; + let (backing_store, worker_store) = backing_store(&store, session_id)?; + let worker_name = record.worker_name.clone(); + let manifest = record.manifest.clone(); + let filesystem_authority = WorkerFilesystemAuthority::local( + record.cwd.canonical_path.clone(), + record.cwd.canonical_path.clone(), + ); + let workspace_context = WorkerWorkspaceContext::local_filesystem(None); + let runtime_base = store.runtime_dir(session_id); + + let mut bootstrap = WorkerBootstrap::new( + manifest, + backing_store, + worker::PromptCatalogSource::builtins_only(), + workspace_context, + filesystem_authority, + WorkerBootstrapLayout::Direct { runtime_base }, + WorkerControllerTransport::InProcess, + ); + if let Some(model_client) = model_client { + bootstrap = bootstrap.with_model_client(model_client); + } + let prepared = bootstrap + .prepare_restored(&worker_name) + .await + .map_err(classify_startup_error)?; + let started = prepared.start().await.map_err(classify_startup_error)?; + let active = match active_pointer(&worker_store, &worker_name) { + Ok(active) => active, + Err(error) => { + stop_started_worker(started).await; + return Err(error); + } + }; + let record = + match store.update_active_pointer(&record, active.session_id, active.segment_id) { + Ok(record) => record, + Err(_) => { + stop_started_worker(started).await; + lease.retain(); + return Err(StandaloneStartupError::StateStore); + } + }; + Ok(Self::from_started( + started, + store, + worker_store, + record, + lease, + )) + } + + fn from_started( + started: BootstrappedWorker, + store: StandaloneSessionStore, + worker_store: FsWorkerStore, + record: StandaloneSessionRecord, + lease: StandaloneSessionLease, + ) -> Self { + Self { handle: started.handle, - shutdown: started.shutdown, + shutdown: Some(started.shutdown), shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT, - }) + store, + worker_store, + record, + lease: Some(lease), + } + } + + #[must_use] + pub fn session_id(&self) -> StandaloneSessionId { + self.record.session_id + } + + #[must_use] + pub fn record(&self) -> &StandaloneSessionRecord { + &self.record } pub async fn send(&self, method: Method) -> Result<(), StandaloneRequestError> { @@ -123,18 +295,97 @@ impl StandaloneHost { self } - pub async fn shutdown(self) -> Result<(), StandaloneShutdownError> { - let StandaloneHost { - handle, - shutdown, - shutdown_timeout, - } = self; - let _ = handle.send(Method::Shutdown).await; - match tokio::time::timeout(shutdown_timeout, shutdown).await { - Ok(Ok(())) => Ok(()), - Ok(Err(_)) => Err(StandaloneShutdownError::ConfirmationLost), - Err(_) => Err(StandaloneShutdownError::DeadlineExceeded), + pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> { + let _ = self.handle.send(Method::Shutdown).await; + let Some(shutdown) = self.shutdown.take() else { + self.retain_lease(); + return Err(StandaloneShutdownError::ConfirmationLost); + }; + match tokio::time::timeout(self.shutdown_timeout, shutdown).await { + Ok(Ok(())) => {} + Ok(Err(_)) => { + self.retain_lease(); + return Err(StandaloneShutdownError::ConfirmationLost); + } + Err(_) => { + self.retain_lease(); + return Err(StandaloneShutdownError::DeadlineExceeded); + } } + let active = match active_pointer(&self.worker_store, &self.record.worker_name) { + Ok(active) => active, + Err(_) => { + self.retain_lease(); + return Err(StandaloneShutdownError::StateStore); + } + }; + if self + .store + .mark_stopped( + &self.record, + active.session_id, + active.segment_id, + StandaloneShutdownReason::UserExit, + ) + .is_err() + { + self.retain_lease(); + return Err(StandaloneShutdownError::StateStore); + } + if let Some(lease) = self.lease.take() { + lease + .release() + .map_err(|_| StandaloneShutdownError::StateStore)?; + } + Ok(()) + } + + fn retain_lease(&mut self) { + if let Some(lease) = self.lease.take() { + lease.retain(); + } + } +} + +fn backing_store( + store: &StandaloneSessionStore, + id: StandaloneSessionId, +) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> { + let session_store = + FsStore::new(store.session_log_dir(id)).map_err(|_| StandaloneStartupError::StateStore)?; + let worker_store = FsWorkerStore::new(store.worker_metadata_dir(id)) + .map_err(|_| StandaloneStartupError::StateStore)?; + Ok(( + CombinedStore::new(session_store, worker_store.clone()), + worker_store, + )) +} + +fn active_pointer( + worker_store: &FsWorkerStore, + worker_name: &str, +) -> Result { + worker_store + .read_by_name(worker_name) + .map_err(|_| StandaloneStartupError::StateStore)? + .and_then(|metadata| metadata.active) + .ok_or(StandaloneStartupError::StateStore) +} + +async fn stop_started_worker(started: BootstrappedWorker) { + let _ = started.handle.send(Method::Shutdown).await; + let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await; +} + +fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError { + match error { + StandaloneStoreError::SessionLeased(_) => StandaloneStartupError::SessionActive, + StandaloneStoreError::CwdUnavailable(_) + | StandaloneStoreError::CwdNotDirectory + | StandaloneStoreError::CwdIdentityMismatch => { + StandaloneStartupError::WorkingDirectoryUnavailable + } + _ => StandaloneStartupError::StateStore, } } diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index b0c65331..19123cb4 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -6,8 +6,14 @@ pub mod host; pub mod launch; +pub mod store; pub use host::{ StandaloneHost, StandaloneRequestError, StandaloneShutdownError, StandaloneStartupError, }; pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError}; +pub use store::{ + StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneSessionId, + StandaloneSessionRecord, StandaloneSessionStatus, StandaloneSessionStore, + StandaloneShutdownReason, StandaloneStoreError, +}; diff --git a/crates/standalone/src/store.rs b/crates/standalone/src/store.rs new file mode 100644 index 00000000..6736ce10 --- /dev/null +++ b/crates/standalone/src/store.rs @@ -0,0 +1,674 @@ +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs4::fs_std::FileExt; +use manifest::WorkerManifest; +use serde::{Deserialize, Serialize}; +use session_store::{SegmentId, SessionId}; +use thiserror::Error; +use uuid::Uuid; + +const RECORD_FILE: &str = "record.json"; +const COMMIT_MARKER: &str = "commit.pending"; +const LEASE_FILE: &str = "lease.json"; +const LEASE_LOCK_FILE: &str = "lease.lock"; +const SESSION_DIR: &str = "session"; +const WORKER_DIR: &str = "worker"; +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StandaloneSessionId(Uuid); + +impl StandaloneSessionId { + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + + #[must_use] + pub fn short(self) -> String { + let simple = self.0.simple().to_string(); + simple[simple.len() - 12..].to_string() + } +} + +impl Default for StandaloneSessionId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for StandaloneSessionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for StandaloneSessionId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + Uuid::parse_str(value).map(Self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StandaloneCwdIdentity { + pub canonical_path: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inode: Option, +} + +impl StandaloneCwdIdentity { + pub fn capture(path: impl AsRef) -> Result { + let canonical_path = + fs::canonicalize(path).map_err(StandaloneStoreError::CwdUnavailable)?; + let metadata = + fs::metadata(&canonical_path).map_err(StandaloneStoreError::CwdUnavailable)?; + if !metadata.is_dir() { + return Err(StandaloneStoreError::CwdNotDirectory); + } + #[cfg(unix)] + let (device, inode) = { + use std::os::unix::fs::MetadataExt; + (Some(metadata.dev()), Some(metadata.ino())) + }; + #[cfg(not(unix))] + let (device, inode) = (None, None); + Ok(Self { + canonical_path, + device, + inode, + }) + } + + pub fn verify(&self) -> Result { + let current = Self::capture(&self.canonical_path)?; + if current != *self { + return Err(StandaloneStoreError::CwdIdentityMismatch); + } + Ok(current.canonical_path) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StandaloneSessionStatus { + Active, + Stopped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StandaloneShutdownReason { + UserExit, + StartupFailed, + ControllerError, + ProcessInterrupted, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StandaloneSessionRecord { + pub schema_version: u32, + pub revision: u64, + pub session_id: StandaloneSessionId, + pub worker_name: String, + pub cwd: StandaloneCwdIdentity, + pub manifest: WorkerManifest, + pub active_session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_segment_id: Option, + pub status: StandaloneSessionStatus, + pub created_at_unix_ms: u64, + pub updated_at_unix_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shutdown_reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StandaloneListScope { + CurrentCwd, + All, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StaleLeasePolicy { + Reject, + Recover, +} + +#[derive(Debug, Clone)] +pub struct StandaloneSessionStore { + root: PathBuf, +} + +impl StandaloneSessionStore { + pub fn open(root: impl Into) -> Result { + let root = root.into(); + fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?; + if !fs::metadata(&root) + .map_err(StandaloneStoreError::Io)? + .is_dir() + { + return Err(StandaloneStoreError::NotDirectory); + } + Ok(Self { root }) + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + pub fn allocate( + &self, + cwd: impl AsRef, + policy: StaleLeasePolicy, + ) -> Result { + let id = StandaloneSessionId::new(); + let cwd = StandaloneCwdIdentity::capture(cwd)?; + let dir = self.session_dir(id); + fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?; + fs::create_dir(dir.join(SESSION_DIR)).map_err(StandaloneStoreError::Io)?; + fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?; + let lease = self.acquire_lease(id, policy)?; + Ok(StandaloneSessionAllocation { id, cwd, lease }) + } + + pub fn commit_created( + &self, + allocation: &StandaloneSessionAllocation, + manifest: WorkerManifest, + active_session_id: SessionId, + active_segment_id: Option, + ) -> Result { + let now = now_unix_ms()?; + let record = StandaloneSessionRecord { + schema_version: SCHEMA_VERSION, + revision: 1, + session_id: allocation.id, + worker_name: manifest.worker.name.clone(), + cwd: allocation.cwd.clone(), + manifest, + active_session_id, + active_segment_id, + status: StandaloneSessionStatus::Active, + created_at_unix_ms: now, + updated_at_unix_ms: now, + shutdown_reason: None, + }; + self.commit_record(None, &record)?; + Ok(record) + } + + pub fn load( + &self, + id: StandaloneSessionId, + ) -> Result { + let dir = self.session_dir(id); + if dir.join(COMMIT_MARKER).exists() { + return Err(StandaloneStoreError::IncompleteCommit(id)); + } + let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + StandaloneStoreError::SessionNotFound(id) + } else { + StandaloneStoreError::Io(error) + } + })?; + let record: StandaloneSessionRecord = serde_json::from_slice(&bytes) + .map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?; + if record.schema_version > SCHEMA_VERSION { + return Err(StandaloneStoreError::NewerSchema { + id, + found: record.schema_version, + supported: SCHEMA_VERSION, + }); + } + if record.schema_version != SCHEMA_VERSION || record.session_id != id { + return Err(StandaloneStoreError::InvalidRecord(id)); + } + Ok(record) + } + + pub fn list( + &self, + cwd: impl AsRef, + scope: StandaloneListScope, + limit: usize, + ) -> Result, StandaloneStoreError> { + let current_cwd = (scope == StandaloneListScope::CurrentCwd) + .then(|| StandaloneCwdIdentity::capture(cwd)) + .transpose()?; + let mut records = Vec::new(); + for entry in fs::read_dir(&self.root).map_err(StandaloneStoreError::Io)? { + let entry = entry.map_err(StandaloneStoreError::Io)?; + if !entry + .file_type() + .map_err(StandaloneStoreError::Io)? + .is_dir() + { + continue; + } + let Ok(id) = entry.file_name().to_string_lossy().parse() else { + continue; + }; + let record = self.load(id)?; + if current_cwd.as_ref().is_none_or(|cwd| &record.cwd == cwd) { + records.push(record); + } + } + records.sort_by(|left, right| { + right + .updated_at_unix_ms + .cmp(&left.updated_at_unix_ms) + .then_with(|| { + right + .session_id + .to_string() + .cmp(&left.session_id.to_string()) + }) + }); + records.truncate(limit); + Ok(records) + } + + pub fn acquire_lease( + &self, + id: StandaloneSessionId, + policy: StaleLeasePolicy, + ) -> Result { + let dir = self.session_dir(id); + let path = dir.join(LEASE_FILE); + let _guard = LeaseMutationGuard::acquire(&dir)?; + let lease = LeaseRecord::current()?; + loop { + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + serde_json::to_writer(&mut file, &lease).map_err(StandaloneStoreError::Json)?; + file.write_all(b"\n").map_err(StandaloneStoreError::Io)?; + file.sync_all().map_err(StandaloneStoreError::Io)?; + sync_directory(&dir)?; + return Ok(StandaloneSessionLease { + path, + lease_id: lease.lease_id, + released: false, + }); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let existing = read_lease(&path, id)?; + if existing.is_live() { + return Err(StandaloneStoreError::SessionLeased(id)); + } + if policy == StaleLeasePolicy::Reject { + return Err(StandaloneStoreError::StaleLease(id)); + } + fs::remove_file(&path).map_err(StandaloneStoreError::Io)?; + sync_directory(&dir)?; + } + Err(error) => return Err(StandaloneStoreError::Io(error)), + } + } + } + + pub fn update_active_pointer( + &self, + record: &StandaloneSessionRecord, + active_session_id: SessionId, + active_segment_id: Option, + ) -> Result { + let mut next = record.clone(); + next.revision = next.revision.saturating_add(1); + next.updated_at_unix_ms = now_unix_ms()?; + next.active_session_id = active_session_id; + next.active_segment_id = active_segment_id; + next.status = StandaloneSessionStatus::Active; + next.shutdown_reason = None; + self.commit_record(Some(record.revision), &next)?; + Ok(next) + } + + pub fn mark_stopped( + &self, + record: &StandaloneSessionRecord, + active_session_id: SessionId, + active_segment_id: Option, + reason: StandaloneShutdownReason, + ) -> Result { + let mut next = record.clone(); + next.revision = next.revision.saturating_add(1); + next.updated_at_unix_ms = now_unix_ms()?; + next.active_session_id = active_session_id; + next.active_segment_id = active_segment_id; + next.status = StandaloneSessionStatus::Stopped; + next.shutdown_reason = Some(reason); + self.commit_record(Some(record.revision), &next)?; + Ok(next) + } + + pub fn delete(&self, id: StandaloneSessionId) -> Result<(), StandaloneStoreError> { + let record = self.load(id)?; + if record.status != StandaloneSessionStatus::Stopped { + return Err(StandaloneStoreError::DeleteActive(id)); + } + let session_dir = self.session_dir(id); + let _guard = LeaseMutationGuard::acquire(&session_dir)?; + let lease_path = session_dir.join(LEASE_FILE); + if lease_path.exists() { + let lease = read_lease(&lease_path, id)?; + return Err(if lease.is_live() { + StandaloneStoreError::SessionLeased(id) + } else { + StandaloneStoreError::StaleLease(id) + }); + } + fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?; + sync_directory(&self.root) + } + + #[must_use] + pub fn session_log_dir(&self, id: StandaloneSessionId) -> PathBuf { + self.session_dir(id).join(SESSION_DIR) + } + + #[must_use] + pub fn worker_metadata_dir(&self, id: StandaloneSessionId) -> PathBuf { + self.session_dir(id).join(WORKER_DIR) + } + + #[must_use] + pub(crate) fn runtime_dir(&self, id: StandaloneSessionId) -> PathBuf { + self.session_dir(id).join("runtime") + } + + pub(crate) fn abandon_allocation( + &self, + allocation: StandaloneSessionAllocation, + ) -> Result<(), StandaloneStoreError> { + let id = allocation.id; + allocation.lease.release()?; + fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?; + sync_directory(&self.root) + } + + fn commit_record( + &self, + expected_revision: Option, + next: &StandaloneSessionRecord, + ) -> Result<(), StandaloneStoreError> { + let dir = self.session_dir(next.session_id); + let marker = dir.join(COMMIT_MARKER); + let mut marker_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&marker) + .map_err(|error| { + if error.kind() == io::ErrorKind::AlreadyExists { + StandaloneStoreError::IncompleteCommit(next.session_id) + } else { + StandaloneStoreError::Io(error) + } + })?; + writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?; + marker_file.sync_all().map_err(StandaloneStoreError::Io)?; + sync_directory(&dir)?; + + if let Some(expected) = expected_revision { + let current = self.load_record_while_committing(next.session_id)?; + if current.revision != expected { + let _ = fs::remove_file(&marker); + return Err(StandaloneStoreError::RevisionConflict { + id: next.session_id, + expected, + found: current.revision, + }); + } + } + + let temporary = dir.join(format!("record.{}.tmp", Uuid::now_v7())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(StandaloneStoreError::Io)?; + serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?; + file.write_all(b"\n").map_err(StandaloneStoreError::Io)?; + file.sync_all().map_err(StandaloneStoreError::Io)?; + fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; + sync_directory(&dir)?; + fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?; + sync_directory(&dir) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } + + fn load_record_while_committing( + &self, + id: StandaloneSessionId, + ) -> Result { + let bytes = + fs::read(self.session_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; + serde_json::from_slice(&bytes) + .map_err(|source| StandaloneStoreError::CorruptRecord { id, source }) + } + + fn session_dir(&self, id: StandaloneSessionId) -> PathBuf { + self.root.join(id.to_string()) + } +} + +#[derive(Debug)] +pub struct StandaloneSessionAllocation { + id: StandaloneSessionId, + cwd: StandaloneCwdIdentity, + lease: StandaloneSessionLease, +} + +impl StandaloneSessionAllocation { + #[must_use] + pub fn id(&self) -> StandaloneSessionId { + self.id + } + + #[must_use] + pub fn cwd(&self) -> &StandaloneCwdIdentity { + &self.cwd + } + + pub fn into_lease(self) -> StandaloneSessionLease { + self.lease + } +} + +#[derive(Debug)] +pub struct StandaloneSessionLease { + path: PathBuf, + lease_id: Uuid, + released: bool, +} + +impl StandaloneSessionLease { + pub fn release(mut self) -> Result<(), StandaloneStoreError> { + self.release_inner() + } + + pub(crate) fn retain(mut self) { + self.released = true; + } + + fn release_inner(&mut self) -> Result<(), StandaloneStoreError> { + if self.released { + return Ok(()); + } + if self.path.exists() { + let parent = self.path.parent().expect("lease parent"); + let _guard = LeaseMutationGuard::acquire(parent)?; + let bytes = fs::read(&self.path).map_err(StandaloneStoreError::Io)?; + let current: LeaseRecord = + serde_json::from_slice(&bytes).map_err(StandaloneStoreError::Json)?; + if current.lease_id != self.lease_id { + return Err(StandaloneStoreError::LeaseOwnershipLost); + } + fs::remove_file(&self.path).map_err(StandaloneStoreError::Io)?; + sync_directory(self.path.parent().expect("lease parent"))?; + } + self.released = true; + Ok(()) + } +} + +impl Drop for StandaloneSessionLease { + fn drop(&mut self) { + let _ = self.release_inner(); + } +} + +struct LeaseMutationGuard { + file: File, +} + +impl LeaseMutationGuard { + fn acquire(dir: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(dir.join(LEASE_LOCK_FILE)) + .map_err(StandaloneStoreError::Io)?; + file.lock_exclusive().map_err(StandaloneStoreError::Io)?; + Ok(Self { file }) + } +} + +impl Drop for LeaseMutationGuard { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct LeaseRecord { + lease_id: Uuid, + pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + process_start_marker: Option, + acquired_at_unix_ms: u64, +} + +impl LeaseRecord { + fn current() -> Result { + Ok(Self { + lease_id: Uuid::now_v7(), + pid: std::process::id(), + process_start_marker: process_start_marker(std::process::id()), + acquired_at_unix_ms: now_unix_ms()?, + }) + } + + fn is_live(&self) -> bool { + process_start_marker(self.pid) + .zip(self.process_start_marker) + .is_some_and(|(current, recorded)| current == recorded) + } +} + +fn read_lease(path: &Path, id: StandaloneSessionId) -> Result { + let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?; + serde_json::from_slice(&bytes) + .map_err(|source| StandaloneStoreError::CorruptLease { id, source }) +} + +#[cfg(target_os = "linux")] +fn process_start_marker(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let tail = stat.rsplit_once(") ")?.1; + tail.split_whitespace().nth(19)?.parse().ok() +} + +#[cfg(not(target_os = "linux"))] +fn process_start_marker(pid: u32) -> Option { + (pid == std::process::id()).then_some(0) +} + +fn now_unix_ms() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| StandaloneStoreError::Clock)?; + u64::try_from(duration.as_millis()).map_err(|_| StandaloneStoreError::Clock) +} + +fn sync_directory(path: &Path) -> Result<(), StandaloneStoreError> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(StandaloneStoreError::Io) +} + +#[derive(Debug, Error)] +pub enum StandaloneStoreError { + #[error("standalone state path is not a directory")] + NotDirectory, + #[error("standalone cwd is unavailable")] + CwdUnavailable(#[source] io::Error), + #[error("standalone cwd is not a directory")] + CwdNotDirectory, + #[error("standalone cwd identity no longer matches the persisted session")] + CwdIdentityMismatch, + #[error("standalone session {0} was not found")] + SessionNotFound(StandaloneSessionId), + #[error("standalone session {0} has an incomplete metadata commit")] + IncompleteCommit(StandaloneSessionId), + #[error("standalone session {0} has invalid metadata")] + InvalidRecord(StandaloneSessionId), + #[error("standalone session {id} metadata is corrupt")] + CorruptRecord { + id: StandaloneSessionId, + #[source] + source: serde_json::Error, + }, + #[error("standalone session {id} lease is corrupt")] + CorruptLease { + id: StandaloneSessionId, + #[source] + source: serde_json::Error, + }, + #[error("standalone session {id} uses schema {found}, newer than supported schema {supported}")] + NewerSchema { + id: StandaloneSessionId, + found: u32, + supported: u32, + }, + #[error("standalone session {0} is already active")] + SessionLeased(StandaloneSessionId), + #[error("standalone session {0} has a stale lease; explicit recovery is required")] + StaleLease(StandaloneSessionId), + #[error("standalone session lease ownership changed")] + LeaseOwnershipLost, + #[error("standalone session {0} must be stopped before deletion")] + DeleteActive(StandaloneSessionId), + #[error( + "standalone session {id} metadata revision changed (expected {expected}, found {found})" + )] + RevisionConflict { + id: StandaloneSessionId, + expected: u64, + found: u64, + }, + #[error("system clock is before the Unix epoch or out of range")] + Clock, + #[error("standalone metadata serialization failed")] + Json(#[source] serde_json::Error), + #[error("standalone state I/O failed")] + Io(#[source] io::Error), +} diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index 157f15e6..0e69739c 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -10,7 +10,10 @@ use agen::llm_client::types::Request; use async_trait::async_trait; use futures::{Stream, stream}; use protocol::{Event, Method}; -use standalone::{StandaloneHost, StandaloneLaunchConfig}; +use standalone::{ + StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope, + StandaloneSessionStatus, StandaloneSessionStore, StandaloneStartupError, StandaloneStoreError, +}; use uuid::Uuid; #[derive(Clone)] @@ -159,7 +162,7 @@ async fn state_store_failure_is_redacted_and_starts_no_controller() { assert_eq!(error, standalone::StandaloneStartupError::StateStore); assert_eq!( error.to_string(), - "the standalone state store could not be opened" + "the standalone state store could not be opened or validated" ); assert!(!error.to_string().contains("secret-name")); assert!( @@ -210,3 +213,261 @@ fn launch_rejects_path_profile_before_worker_startup() { standalone::StandaloneLaunchError::PathProfileUnsupported ); } + +type TestResult = Result<(), Box>; + +#[tokio::test] +async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult { + let temp = tempfile::tempdir()?; + let cwd = temp.path().join("project"); + let state_dir = temp.path().join("client").join("standalone-sessions"); + std::fs::create_dir_all(&cwd)?; + let launch = StandaloneLaunchConfig::new( + &cwd, + &state_dir, + manifest::ProfileSelector::Default, + "display-name-is-not-session-identity", + ) + .resolve()?; + let first_client = ScriptedClient::new(vec![ + vec![ + LlmEvent::tool_use_start(0, "task-1", "TaskCreate"), + LlmEvent::tool_input_delta( + 0, + r#"{"subject":"persisted task","description":"survives restore"}"#, + ), + LlmEvent::tool_use_stop(0), + ], + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "first answer"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ], + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "notification acknowledged"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ], + ]); + let host = StandaloneHost::start_with_model_client(launch, first_client).await?; + let session_id = host.session_id(); + let mut events = host.subscribe(); + host.send(Method::run_text("first request")).await?; + wait_for_run_end(&mut events).await?; + host.send(Method::Notify { + message: "persisted notification".to_string(), + auto_run: true, + }) + .await?; + wait_for_run_end(&mut events).await?; + host.shutdown().await?; + + let store = StandaloneSessionStore::open(&state_dir)?; + let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?; + assert_eq!(current.len(), 1); + assert_eq!(current[0].session_id, session_id); + assert_eq!(current[0].status, StandaloneSessionStatus::Stopped); + let other_cwd = temp.path().join("other"); + std::fs::create_dir(&other_cwd)?; + assert!( + store + .list(&other_cwd, StandaloneListScope::CurrentCwd, 100)? + .is_empty() + ); + assert_eq!( + store.list(&other_cwd, StandaloneListScope::All, 100)?.len(), + 1 + ); + + let second_client = ScriptedClient::new(vec![vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "second answer"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ]]); + let second_inspection = second_client.clone(); + let host = + StandaloneHost::restore_with_model_client(state_dir.clone(), session_id, second_client) + .await?; + let snapshot = format!("{:?}", host.snapshot()); + assert!(snapshot.contains("first request"), "{snapshot}"); + assert!(snapshot.contains("first answer"), "{snapshot}"); + assert!(snapshot.contains("persisted task"), "{snapshot}"); + assert!(snapshot.contains("persisted notification"), "{snapshot}"); + + let mut events = host.subscribe(); + host.send(Method::run_text("continue after restore")) + .await?; + wait_for_run_end(&mut events).await?; + let request = second_inspection + .requests() + .into_iter() + .next() + .expect("restored run request"); + let projected = format!("{:?}", request.items); + assert!(projected.contains("first answer"), "{projected}"); + assert!(projected.contains("persisted notification"), "{projected}"); + assert!(projected.contains("persisted task"), "{projected}"); + host.shutdown().await?; + + store.delete(session_id)?; + assert!(cwd.exists(), "deleting session state must not mutate cwd"); + assert!(matches!( + store.load(session_id), + Err(StandaloneStoreError::SessionNotFound(_)) + )); + Ok(()) +} + +#[tokio::test] +async fn standalone_restore_rejects_concurrent_lease_and_missing_cwd() -> TestResult { + let temp = tempfile::tempdir()?; + let cwd = temp.path().join("project"); + let moved = temp.path().join("moved-project"); + let state_dir = temp.path().join("state"); + std::fs::create_dir(&cwd)?; + let launch = StandaloneLaunchConfig::new( + &cwd, + &state_dir, + manifest::ProfileSelector::Default, + "standalone-lease-test", + ) + .resolve()?; + let host = + StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; + let session_id = host.session_id(); + let store = StandaloneSessionStore::open(&state_dir)?; + assert!(matches!( + store.acquire_lease(session_id, StaleLeasePolicy::Recover), + Err(StandaloneStoreError::SessionLeased(id)) if id == session_id + )); + let restore = StandaloneHost::restore_with_model_client( + state_dir.clone(), + session_id, + ScriptedClient::new(Vec::new()), + ) + .await; + assert!(matches!( + restore, + Err(StandaloneStartupError::SessionActive) + )); + host.shutdown().await?; + + std::fs::rename(&cwd, &moved)?; + let restore = StandaloneHost::restore_with_model_client( + state_dir, + session_id, + ScriptedClient::new(Vec::new()), + ) + .await; + assert!(matches!( + restore, + Err(StandaloneStartupError::WorkingDirectoryUnavailable) + )); + Ok(()) +} + +#[tokio::test] +async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult { + let temp = tempfile::tempdir()?; + let state_dir = temp.path().join("state"); + let mut launch = StandaloneLaunchConfig::new( + temp.path(), + &state_dir, + manifest::ProfileSelector::Default, + "standalone-stale-lease-test", + ) + .resolve()?; + launch.profile.manifest.profile = Some(manifest::ProfileManifestSnapshot { + source: manifest::ProfileSource::Registry { + source: manifest::ProfileRegistrySource::User, + name: "user-standalone".to_string(), + path: None, + provenance: Some("user-config-revision-7".to_string()), + }, + profile: Some(manifest::ProfileMetadata { + name: Some("User standalone".to_string()), + description: None, + format: None, + }), + }); + let host = + StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; + let session_id = host.session_id(); + host.shutdown().await?; + let store = StandaloneSessionStore::open(&state_dir)?; + assert!(matches!( + store.load(session_id)?.manifest.profile, + Some(manifest::ProfileManifestSnapshot { + source: manifest::ProfileSource::Registry { + source: manifest::ProfileRegistrySource::User, + .. + }, + .. + }) + )); + let session_dir = state_dir.join(session_id.to_string()); + std::fs::write( + session_dir.join("lease.json"), + serde_json::to_vec(&serde_json::json!({ + "lease_id": uuid::Uuid::now_v7(), + "pid": u32::MAX, + "process_start_marker": 1, + "acquired_at_unix_ms": 1 + }))?, + )?; + + let host = StandaloneHost::restore_with_model_client( + state_dir, + session_id, + ScriptedClient::new(Vec::new()), + ) + .await?; + host.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> TestResult { + let temp = tempfile::tempdir()?; + let state_dir = temp.path().join("state"); + let launch = StandaloneLaunchConfig::new( + temp.path(), + &state_dir, + manifest::ProfileSelector::Default, + "standalone-schema-test", + ) + .resolve()?; + let host = + StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; + let session_id = host.session_id(); + host.shutdown().await?; + let store = StandaloneSessionStore::open(&state_dir)?; + let session_dir = state_dir.join(session_id.to_string()); + std::fs::write(session_dir.join("commit.pending"), b"interrupted\n")?; + assert!(matches!( + store.load(session_id), + Err(StandaloneStoreError::IncompleteCommit(id)) if id == session_id + )); + std::fs::remove_file(session_dir.join("commit.pending"))?; + let record_path = session_dir.join("record.json"); + let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?; + record["schema_version"] = serde_json::json!(u32::MAX); + std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?; + assert!(matches!( + store.load(session_id), + Err(StandaloneStoreError::NewerSchema { id, .. }) if id == session_id + )); + Ok(()) +} + +async fn wait_for_run_end(events: &mut tokio::sync::broadcast::Receiver) -> TestResult { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if matches!(events.recv().await, Ok(Event::RunEnd { .. })) { + break; + } + } + }) + .await?; + Ok(()) +} diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index 7be14174..c5ec07c5 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -130,6 +130,29 @@ where Ok(PreparedWorker::new(worker, self.layout, self.transport)) } + pub async fn prepare_restored( + self, + worker_name: &str, + ) -> Result, St>, WorkerBootstrapError> { + let mut worker = + Worker::restore_pending_from_worker_metadata_with_context_and_model_client( + worker_name, + self.manifest, + self.store, + self.prompt_catalog, + self.workspace_context, + self.filesystem_authority, + self.model_client, + ) + .await + .map_err(WorkerBootstrapError::Worker)?; + + if let Some(workdir_session) = self.workdir_session { + worker.bind_workdir_session(Some(workdir_session)); + } + Ok(PreparedWorker::new(worker, self.layout, self.transport)) + } + pub async fn start(self) -> Result { self.prepare().await?.start().await } diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 6aa1d871..c1f15ca0 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -5515,6 +5515,27 @@ where loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, + ) -> Result { + Self::restore_pending_from_worker_metadata_with_context_and_model_client( + worker_name, + fallback, + store, + loader, + workspace_context, + filesystem_authority, + None, + ) + .await + } + + pub(crate) async fn restore_pending_from_worker_metadata_with_context_and_model_client( + worker_name: &str, + fallback: WorkerManifest, + store: St, + loader: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + model_client: Option>, ) -> Result { let metadata = store @@ -5535,7 +5556,7 @@ where worker_name: worker_name.to_string(), })?; if let Some(segment_id) = active.segment_id { - return Self::restore_from_manifest_with_context( + return Self::restore_from_manifest_with_context_and_model_client( active.session_id, segment_id, restore_manifest_from_worker_metadata_snapshot( @@ -5547,6 +5568,7 @@ where loader, workspace_context, filesystem_authority, + model_client, ) .await; } @@ -5557,12 +5579,13 @@ where })?; let manifest = restore_manifest_from_worker_metadata_snapshot(worker_name, Some(snapshot), fallback)?; - Self::from_manifest_with_context( + Self::from_manifest_with_context_and_model_client( manifest, store, loader, workspace_context, filesystem_authority, + model_client, ) .await } @@ -5614,6 +5637,29 @@ where loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, + ) -> Result { + Self::restore_from_manifest_with_context_and_model_client( + session_id, + segment_id, + manifest, + store, + loader, + workspace_context, + filesystem_authority, + None, + ) + .await + } + + pub(crate) async fn restore_from_manifest_with_context_and_model_client( + session_id: SessionId, + segment_id: SegmentId, + manifest: WorkerManifest, + store: St, + loader: PromptCatalogSource, + workspace_context: WorkerWorkspaceContext, + filesystem_authority: WorkerFilesystemAuthority, + model_client: Option>, ) -> Result { // Read raw entries once so we can both reconstruct state and // seed the broadcast sink's mirror with the same prefix that @@ -5629,13 +5675,14 @@ where let mirror_entries: Vec = raw_entries.clone(); let scope_config = effective_restore_scope_config(&store, &manifest)?; - let common = prepare_worker_common_with_context( + let common = prepare_worker_common_with_context_and_model_client( &manifest, &loader, /* parse_template */ false, workspace_context, filesystem_authority, scope_config, + model_client, )?; // Atomic: register_worker inside install_top_level rejects when From 133feb8c7668b1e140f2b3663b2716ef5fc00257 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 13:46:24 +0900 Subject: [PATCH 07/18] feat: add standalone resume picker --- crates/client/src/lib.rs | 7 +- crates/client/src/target.rs | 79 +++++++++++++ crates/tui/Cargo.toml | 1 + crates/tui/src/console/mod.rs | 32 +++++- crates/tui/src/lib.rs | 11 ++ crates/tui/src/standalone_picker.rs | 166 ++++++++++++++++++++++++++++ crates/yoi/src/cli_connection.rs | 3 +- crates/yoi/src/main.rs | 85 +++++++++++++- 8 files changed, 373 insertions(+), 11 deletions(-) create mode 100644 crates/tui/src/standalone_picker.rs diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 9969238f..bec7cdde 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -37,9 +37,10 @@ pub use backend_workspace::{ }; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ - BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError, - TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, - WorkerListRequest, WorkerResume, WorkerSpawn, + BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent, + StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName, + WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume, + WorkerSpawn, }; pub use spawn::{ diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 73eed1b3..eb164da0 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -132,6 +132,19 @@ pub struct WorkerResume { pub runtime_command: WorkerRuntimeCommand, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandaloneSessionListIntent { + pub state_dir: PathBuf, + pub cwd: PathBuf, + pub include_all: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandaloneSessionResumeIntent { + pub state_dir: PathBuf, + pub session_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Dashboard { Local { @@ -204,6 +217,26 @@ pub trait Target: fmt::Debug + Send + Sync { fn resume_worker(&self) -> Result; + fn standalone_session_list( + &self, + _include_all: bool, + ) -> Result { + Err(TargetError::unsupported( + "standalone session listing", + self.kind(), + )) + } + + fn standalone_session_resume( + &self, + _session_id: String, + ) -> Result { + Err(TargetError::unsupported( + "standalone session restore", + self.kind(), + )) + } + fn dashboard(&self) -> Result; fn list_workers(&self, request: WorkerListRequest) -> Result; @@ -312,6 +345,29 @@ impl Target for StandaloneTarget { Err(TargetError::unsupported("Worker restore", self.kind())) } + fn standalone_session_list( + &self, + include_all: bool, + ) -> Result { + let cwd = std::env::current_dir() + .map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?; + Ok(StandaloneSessionListIntent { + state_dir: self.state_dir.clone(), + cwd, + include_all, + }) + } + + fn standalone_session_resume( + &self, + session_id: String, + ) -> Result { + Ok(StandaloneSessionResumeIntent { + state_dir: self.state_dir.clone(), + session_id, + }) + } + fn dashboard(&self) -> Result { Err(TargetError::unsupported("Worker dashboard", self.kind())) } @@ -575,6 +631,29 @@ mod tests { ); } + #[test] + fn standalone_target_builds_explicit_session_intents() { + let target = StandaloneTarget::new("/tmp/yoi-client-sessions"); + let list = target.standalone_session_list(true).unwrap(); + assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-sessions")); + assert!(list.include_all); + assert!(list.cwd.is_absolute()); + + let resume = target + .standalone_session_resume("019d1234-0000-7000-8000-000000000000".to_string()) + .unwrap(); + assert_eq!(resume.state_dir, list.state_dir); + assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000"); + + assert!( + LocalTarget::new() + .standalone_session_list(false) + .unwrap_err() + .to_string() + .contains("not supported") + ); + } + #[test] fn local_target_builds_local_worker_list() { let target = LocalTarget::new(); diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f9f2a267..8a99c4a5 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -11,6 +11,7 @@ e2e-test = [] [dependencies] client = { workspace = true } standalone = { workspace = true } +thiserror.workspace = true protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } base64 = "0.22.1" diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 2618fc4f..6da12bbb 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -26,7 +26,10 @@ use standalone::{StandaloneHost, StandaloneLaunchConfig}; use tokio::sync::{broadcast, mpsc}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand}; +use client::{ + BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent, WorkerClient, + WorkerRuntimeCommand, +}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::composer_keys::{ComposerEditAction, composer_edit_action}; @@ -277,6 +280,31 @@ pub(crate) async fn run_standalone( let host = StandaloneHost::start(launch) .await .map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?; + run_standalone_host(host, worker_name, history_root).await +} + +pub(crate) async fn run_standalone_restore( + intent: StandaloneSessionResumeIntent, +) -> Result<(), Box> { + let session_id = intent.session_id.parse().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Invalid standalone session ID: {error}"), + ) + })?; + let host = StandaloneHost::restore(intent.state_dir, session_id) + .await + .map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?; + let worker_label = format!("standalone-{}", session_id.short()); + let history_root = host.record().cwd.canonical_path.clone(); + run_standalone_host(host, worker_label, history_root).await +} + +async fn run_standalone_host( + host: StandaloneHost, + worker_label: String, + history_root: PathBuf, +) -> Result<(), Box> { let mut connection = ConsoleConnection::standalone(host); let mut terminal = match enter_fullscreen() { @@ -286,7 +314,7 @@ pub(crate) async fn run_standalone( return Err(error); } }; - let mut app = App::new_with_persistent_input_history(worker_name, &history_root); + let mut app = App::new_with_persistent_input_history(worker_label, &history_root); let run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await; let shutdown_result = connection .shutdown() diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index f30da214..f5f895b5 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -19,6 +19,7 @@ mod role_session_registry; mod scroll; pub mod setup_model; mod spawn; +mod standalone_picker; mod task; mod text_selection; mod tool; @@ -51,6 +52,9 @@ pub enum LaunchMode { worker_name: Option, profile: Option, }, + /// Restore one client-owned standalone session. The current cwd is the default scope; + /// `include_all` opts into all standalone sessions under the same client data root. + StandaloneResume { include_all: bool }, /// `yoi --worker `: attach to a live Worker by name if possible; /// otherwise launch the Worker runtime command with `--worker ` so it /// resumes from name-keyed state or creates a fresh same-name Worker. @@ -168,6 +172,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, + LaunchMode::StandaloneResume { include_all } => { + match standalone_picker::pick(target.as_ref(), include_all) { + Ok(Some(intent)) => console::run_standalone_restore(intent).await, + Ok(None) => Ok(()), + Err(error) => Err(Box::new(error) as Box), + } + } LaunchMode::WorkerName { worker_name, socket_override, diff --git a/crates/tui/src/standalone_picker.rs b/crates/tui/src/standalone_picker.rs new file mode 100644 index 00000000..44581429 --- /dev/null +++ b/crates/tui/src/standalone_picker.rs @@ -0,0 +1,166 @@ +use std::io; +use std::time::Duration; + +use client::{StandaloneSessionListIntent, StandaloneSessionResumeIntent, Target}; +use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout}; +use ratatui::prelude::{Color, Line, Modifier, Span, Style}; +use ratatui::widgets::Paragraph; +use ratatui::{TerminalOptions, Viewport}; +use standalone::{StandaloneListScope, StandaloneSessionRecord, StandaloneSessionStore}; +use thiserror::Error; + +const LIMIT: usize = 100; + +pub(crate) fn pick( + target: &dyn Target, + include_all: bool, +) -> Result, StandalonePickerError> { + let intent = target + .standalone_session_list(include_all) + .map_err(StandalonePickerError::Target)?; + let records = load_records(&intent)?; + if records.is_empty() { + return Err(StandalonePickerError::NoSessions { include_all }); + } + let selected = run_picker(records)?; + selected + .map(|record| { + target + .standalone_session_resume(record.session_id.to_string()) + .map_err(StandalonePickerError::Target) + }) + .transpose() +} + +fn load_records( + intent: &StandaloneSessionListIntent, +) -> Result, StandalonePickerError> { + let store = StandaloneSessionStore::open(&intent.state_dir) + .map_err(StandalonePickerError::StateStore)?; + store + .list( + &intent.cwd, + if intent.include_all { + StandaloneListScope::All + } else { + StandaloneListScope::CurrentCwd + }, + LIMIT, + ) + .map_err(StandalonePickerError::StateStore) +} + +fn run_picker( + records: Vec, +) -> Result, StandalonePickerError> { + let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20); + let mut terminal = Terminal::with_options( + CrosstermBackend::new(io::stdout()), + TerminalOptions { + viewport: Viewport::Inline(height), + }, + ) + .map_err(StandalonePickerError::Io)?; + let mut selected = 0usize; + loop { + terminal + .draw(|frame| draw(frame, &records, selected)) + .map_err(StandalonePickerError::Io)?; + if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? { + continue; + } + let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else { + continue; + }; + if key.kind == KeyEventKind::Release { + continue; + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Up | KeyCode::Char('k') if !ctrl => { + selected = selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') if !ctrl => { + selected = (selected + 1).min(records.len() - 1); + } + KeyCode::Enter => return Ok(Some(records[selected].clone())), + KeyCode::Esc => return Ok(None), + KeyCode::Char('c') if ctrl => return Ok(None), + _ => {} + } + } +} + +fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], selected: usize) { + let mut constraints = vec![Constraint::Length(1)]; + constraints.extend(records.iter().map(|_| Constraint::Length(1))); + constraints.push(Constraint::Length(1)); + let rows = Layout::vertical(constraints).split(frame.area()); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "resume standalone session", + Style::default().add_modifier(Modifier::BOLD), + ))), + rows[0], + ); + for (index, record) in records.iter().enumerate() { + let active = index == selected; + let marker = if active { "▶ " } else { " " }; + let style = if active { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let cwd = record.cwd.canonical_path.display(); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(marker), + Span::styled(record.session_id.short(), style), + Span::raw(format!( + " [{:?}] updated:{} {}", + record.status, record.updated_at_unix_ms, cwd + )), + ])), + rows[index + 1], + ); + } + frame.render_widget( + Paragraph::new(" [↑/↓] select [enter] restore [esc] cancel"), + rows[records.len() + 1], + ); +} + +#[derive(Debug, Error)] +pub(crate) enum StandalonePickerError { + #[error("standalone target error: {0}")] + Target(#[source] client::TargetError), + #[error("standalone session state is unavailable: {0}")] + StateStore(#[source] standalone::StandaloneStoreError), + #[error( + "no standalone sessions found for this cwd; use `yoi --local --resume --all` to include all cwd identities" + )] + NoSessions { include_all: bool }, + #[error("standalone session picker I/O failed: {0}")] + Io(#[source] io::Error), +} + +#[cfg(test)] +mod tests { + use client::StandaloneTarget; + + use super::*; + + #[test] + fn empty_picker_keeps_current_cwd_as_default_scope() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = StandaloneTarget::new(temp.path()); + let error = pick(&target, false).expect_err("empty picker should fail explicitly"); + assert!(error.to_string().contains("this cwd")); + assert!(error.to_string().contains("--all")); + } +} diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 7d438eeb..159c6108 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -115,7 +115,8 @@ fn standalone_target() -> Result, ParseError> { .to_string(), ) })? - .join("standalone"); + .join("client") + .join("standalone-sessions"); Ok(Box::new(StandaloneTarget::new(state_dir))) } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 0db52b6b..11c14c12 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -502,10 +502,20 @@ fn parse_console_options( let mut socket_override = None; let mut runtime_id = None; let mut worker_id = None; + let mut standalone_resume = false; + let mut standalone_all = false; let mut i = 0; while i < args.len() { let arg = &args[i]; match arg.as_str() { + "--resume" => { + standalone_resume = true; + i += 1; + } + "--all" => { + standalone_all = true; + i += 1; + } "--worker" => { let value = args .get(i + 1) @@ -687,10 +697,33 @@ fn parse_console_options( &workspace_root, )?; + if standalone_all && !standalone_resume { + return Err(ParseError("--all requires --resume".to_string())); + } + if standalone_resume { + if target.kind() != TargetKind::Standalone { + return Err(ParseError( + "--resume is a Standalone option and requires --local".to_string(), + )); + } + if worker_name.is_some() + || profile.is_some() + || session.is_some() + || socket_override.is_some() + || runtime_id.is_some() + || worker_id.is_some() + { + return Err(ParseError( + "--local --resume cannot be combined with Worker, profile, session, socket, or Runtime selectors" + .to_string(), + )); + } + } + if target.kind() == TargetKind::Standalone { if session.is_some() { return Err(ParseError( - "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + "--local does not accept legacy --session; use --local --resume for Standalone session restore" .to_string(), )); } @@ -725,7 +758,11 @@ fn parse_console_options( }); } - let mode = if let Some(profile) = profile { + let mode = if standalone_resume { + LaunchMode::StandaloneResume { + include_all: standalone_all, + } + } else if let Some(profile) = profile { LaunchMode::Spawn { worker_name, profile: Some(profile), @@ -1661,6 +1698,7 @@ const TOP_LEVEL_HELP: &str = r#"yoi Usage: yoi [TARGET] [CONSOLE_OPTIONS] + yoi --local --resume [--all] yoi [TARGET] workers [-r|--stopped] [--workspace ] [--runtime-id ] yoi [TARGET] resume [--workspace |--all] [--runtime-id ] yoi [TARGET] panel [-r|--stopped] [--workspace ] @@ -1670,7 +1708,9 @@ Usage: Target selection: Target options are top-level options and must appear before the command. - --local Start a one-process Standalone Worker (no Server or Runtime) + --local Start or restore a client-owned one-process Standalone Worker + --resume With --local, open the Standalone session picker for the current cwd + --all With --local --resume, include sessions from every cwd identity --backend Use a Workspace Backend explicitly --workspace-id Scope Backend routes to a Workspace id @@ -2080,6 +2120,42 @@ backend = "shared" )); } + #[test] + fn parser_local_resume_uses_standalone_picker_scope() { + let mode = parse_args_from(["--local", "--resume"]).unwrap(); + let Mode::Tui { target, mode, .. } = mode else { + panic!("expected TUI mode") + }; + assert_eq!(target.kind(), TargetKind::Standalone); + assert!(matches!( + mode, + LaunchMode::StandaloneResume { include_all: false } + )); + let intent = target.standalone_session_list(false).unwrap(); + assert!(intent.state_dir.ends_with("client/standalone-sessions")); + assert!(!intent.include_all); + + let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap(); + let Mode::Tui { mode, .. } = mode else { + panic!("expected TUI mode") + }; + assert!(matches!( + mode, + LaunchMode::StandaloneResume { include_all: true } + )); + + assert_eq!( + parse_args_from(["--resume"]).unwrap_err().to_string(), + "--resume is a Standalone option and requires --local" + ); + assert_eq!( + parse_args_from(["--local", "--all"]) + .unwrap_err() + .to_string(), + "--all requires --resume" + ); + } + #[test] fn parser_rejects_standalone_and_backend_target_together() { let err = parse_args_from(["--local", "--backend", "http://backend.example"]).unwrap_err(); @@ -2101,7 +2177,7 @@ backend = "shared" let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err(); assert_eq!( err.0, - "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + "--local does not accept legacy --session; use --local --resume for Standalone session restore" ); let socket_args = [ @@ -2567,7 +2643,6 @@ backend = "shared" fn parse_rejects_legacy_resume_flags() { let cases = [ (vec!["-r".to_string()], "unknown argument: -r"), - (vec!["--resume".to_string()], "unknown argument: --resume"), ( vec![ "--worker".to_string(), From 31e18205f0b44fe7d8e1276f0d94d9abfc2b4af7 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 19:44:15 +0900 Subject: [PATCH 08/18] fix: fail closed on unknown standalone leases --- crates/standalone/src/host.rs | 5 ++ crates/standalone/src/store.rs | 136 ++++++++++++++++++++++++++++---- crates/standalone/tests/host.rs | 43 ++++++++++ 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index d9839f76..031a554f 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -41,6 +41,8 @@ pub enum StandaloneStartupError { StateStore, #[error("the standalone session is already active")] SessionActive, + #[error("the standalone session lease cannot be observed safely; recovery is rejected")] + LeaseLivenessUnknown, #[error("the standalone session working directory is unavailable or changed")] WorkingDirectoryUnavailable, #[error("the resolved Worker configuration or persisted history is invalid")] @@ -380,6 +382,9 @@ async fn stop_started_worker(started: BootstrappedWorker) { fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError { match error { StandaloneStoreError::SessionLeased(_) => StandaloneStartupError::SessionActive, + StandaloneStoreError::LeaseLivenessUnknown(_) => { + StandaloneStartupError::LeaseLivenessUnknown + } StandaloneStoreError::CwdUnavailable(_) | StandaloneStoreError::CwdNotDirectory | StandaloneStoreError::CwdIdentityMismatch => { diff --git a/crates/standalone/src/store.rs b/crates/standalone/src/store.rs index 6736ce10..e1dfea0c 100644 --- a/crates/standalone/src/store.rs +++ b/crates/standalone/src/store.rs @@ -304,8 +304,14 @@ impl StandaloneSessionStore { } Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { let existing = read_lease(&path, id)?; - if existing.is_live() { - return Err(StandaloneStoreError::SessionLeased(id)); + match existing.liveness() { + LeaseLiveness::Live => { + return Err(StandaloneStoreError::SessionLeased(id)); + } + LeaseLiveness::Unknown => { + return Err(StandaloneStoreError::LeaseLivenessUnknown(id)); + } + LeaseLiveness::Stale => {} } if policy == StaleLeasePolicy::Reject { return Err(StandaloneStoreError::StaleLease(id)); @@ -363,10 +369,10 @@ impl StandaloneSessionStore { let lease_path = session_dir.join(LEASE_FILE); if lease_path.exists() { let lease = read_lease(&lease_path, id)?; - return Err(if lease.is_live() { - StandaloneStoreError::SessionLeased(id) - } else { - StandaloneStoreError::StaleLease(id) + return Err(match lease.liveness() { + LeaseLiveness::Live => StandaloneStoreError::SessionLeased(id), + LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id), + LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id), }); } fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?; @@ -572,15 +578,49 @@ impl LeaseRecord { Ok(Self { lease_id: Uuid::now_v7(), pid: std::process::id(), - process_start_marker: process_start_marker(std::process::id()), + process_start_marker: match observe_process(std::process::id()) { + ProcessObservation::Running { start_marker } => Some(start_marker), + ProcessObservation::Missing | ProcessObservation::Unobservable => None, + }, acquired_at_unix_ms: now_unix_ms()?, }) } - fn is_live(&self) -> bool { - process_start_marker(self.pid) - .zip(self.process_start_marker) - .is_some_and(|(current, recorded)| current == recorded) + fn liveness(&self) -> LeaseLiveness { + classify_lease_liveness(self.process_start_marker, observe_process(self.pid)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LeaseLiveness { + Live, + Stale, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProcessObservation { + Running { start_marker: u64 }, + Missing, + Unobservable, +} + +fn classify_lease_liveness( + recorded_start_marker: Option, + observation: ProcessObservation, +) -> LeaseLiveness { + match (recorded_start_marker, observation) { + (Some(recorded), ProcessObservation::Running { start_marker }) + if recorded == start_marker => + { + LeaseLiveness::Live + } + (Some(_), ProcessObservation::Running { .. }) | (_, ProcessObservation::Missing) => { + LeaseLiveness::Stale + } + (None, ProcessObservation::Running { .. }) | (_, ProcessObservation::Unobservable) => { + LeaseLiveness::Unknown + } } } @@ -591,15 +631,44 @@ fn read_lease(path: &Path, id: StandaloneSessionId) -> Result Option { - let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - let tail = stat.rsplit_once(") ")?.1; +fn observe_process(pid: u32) -> ProcessObservation { + let stat = match fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => stat, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return if pid != std::process::id() && linux_proc_is_observable() { + ProcessObservation::Missing + } else { + ProcessObservation::Unobservable + }; + } + Err(_) => return ProcessObservation::Unobservable, + }; + parse_linux_process_start_marker(&stat) + .map(|start_marker| ProcessObservation::Running { start_marker }) + .unwrap_or(ProcessObservation::Unobservable) +} + +#[cfg(target_os = "linux")] +fn linux_proc_is_observable() -> bool { + fs::read_to_string("/proc/self/stat") + .ok() + .and_then(|stat| parse_linux_process_start_marker(&stat)) + .is_some() +} + +#[cfg(target_os = "linux")] +fn parse_linux_process_start_marker(stat: &str) -> Option { + let (_, tail) = stat.rsplit_once(") ")?; tail.split_whitespace().nth(19)?.parse().ok() } #[cfg(not(target_os = "linux"))] -fn process_start_marker(pid: u32) -> Option { - (pid == std::process::id()).then_some(0) +fn observe_process(pid: u32) -> ProcessObservation { + if pid == std::process::id() { + ProcessObservation::Running { start_marker: 0 } + } else { + ProcessObservation::Unobservable + } } fn now_unix_ms() -> Result { @@ -651,6 +720,8 @@ pub enum StandaloneStoreError { }, #[error("standalone session {0} is already active")] SessionLeased(StandaloneSessionId), + #[error("standalone session {0} lease liveness cannot be proven; recovery is rejected")] + LeaseLivenessUnknown(StandaloneSessionId), #[error("standalone session {0} has a stale lease; explicit recovery is required")] StaleLease(StandaloneSessionId), #[error("standalone session lease ownership changed")] @@ -672,3 +743,36 @@ pub enum StandaloneStoreError { #[error("standalone state I/O failed")] Io(#[source] io::Error), } + +#[cfg(test)] +mod tests { + use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness}; + + #[test] + fn lease_liveness_requires_positive_live_or_stale_evidence() { + assert_eq!( + classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 41 }), + LeaseLiveness::Live + ); + assert_eq!( + classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 42 }), + LeaseLiveness::Stale + ); + assert_eq!( + classify_lease_liveness(Some(41), ProcessObservation::Missing), + LeaseLiveness::Stale + ); + assert_eq!( + classify_lease_liveness(None, ProcessObservation::Running { start_marker: 41 }), + LeaseLiveness::Unknown + ); + assert_eq!( + classify_lease_liveness(Some(41), ProcessObservation::Unobservable), + LeaseLiveness::Unknown + ); + assert_eq!( + classify_lease_liveness(None, ProcessObservation::Unobservable), + LeaseLiveness::Unknown + ); + } +} diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index 0e69739c..b01bcbfb 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -426,6 +426,49 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult { Ok(()) } +#[tokio::test] +async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestResult { + let temp = tempfile::tempdir()?; + let state_dir = temp.path().join("state"); + let launch = StandaloneLaunchConfig::new( + temp.path(), + &state_dir, + manifest::ProfileSelector::Default, + "standalone-unknown-lease-test", + ) + .resolve()?; + let host = + StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?; + let session_id = host.session_id(); + host.shutdown().await?; + let session_dir = state_dir.join(session_id.to_string()); + std::fs::write( + session_dir.join("lease.json"), + serde_json::to_vec(&serde_json::json!({ + "lease_id": uuid::Uuid::now_v7(), + "pid": std::process::id(), + "acquired_at_unix_ms": 1 + }))?, + )?; + + let store = StandaloneSessionStore::open(&state_dir)?; + assert!(matches!( + store.acquire_lease(session_id, StaleLeasePolicy::Recover), + Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == session_id + )); + let restore = StandaloneHost::restore_with_model_client( + state_dir, + session_id, + ScriptedClient::new(Vec::new()), + ) + .await; + assert!(matches!( + restore, + Err(StandaloneStartupError::LeaseLivenessUnknown) + )); + Ok(()) +} + #[tokio::test] async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> TestResult { let temp = tempfile::tempdir()?; From 6945986dd171c9877c3da87ddb599abb0f935cac Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 19:53:31 +0900 Subject: [PATCH 09/18] fix: use canonical standalone session path --- crates/yoi/src/cli_connection.rs | 3 ++- crates/yoi/src/main.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 159c6108..a2748b35 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -116,7 +116,8 @@ fn standalone_target() -> Result, ParseError> { ) })? .join("client") - .join("standalone-sessions"); + .join("standalone") + .join("sessions"); Ok(Box::new(StandaloneTarget::new(state_dir))) } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 11c14c12..50bf8669 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -2132,7 +2132,7 @@ backend = "shared" LaunchMode::StandaloneResume { include_all: false } )); let intent = target.standalone_session_list(false).unwrap(); - assert!(intent.state_dir.ends_with("client/standalone-sessions")); + assert!(intent.state_dir.ends_with("client/standalone/sessions")); assert!(!intent.include_all); let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap(); From 406559b13dea4aa6edca15f49b9aec4b9fcc922c Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 20:35:26 +0900 Subject: [PATCH 10/18] refactor: remove legacy local client authority --- Cargo.lock | 3 - crates/client/Cargo.toml | 3 +- crates/client/src/lib.rs | 33 +- crates/client/src/spawn.rs | 435 -- crates/client/src/target.rs | 306 +- crates/client/src/ticket_role.rs | 1242 ---- crates/tui/Cargo.toml | 4 +- crates/tui/src/console/mod.rs | 312 +- crates/tui/src/dashboard/mod.rs | 5377 ----------------- crates/tui/src/dashboard/render.rs | 784 --- crates/tui/src/dashboard/tests.rs | 3562 ----------- crates/tui/src/lib.rs | 117 +- crates/tui/src/picker.rs | 525 -- crates/tui/src/role_session_registry.rs | 556 -- crates/tui/src/spawn.rs | 756 --- crates/tui/src/worker_list.rs | 1310 ---- crates/tui/src/workspace_panel.rs | 2750 --------- crates/worker/src/discovery.rs | 2 +- crates/worker/src/lib.rs | 1 + .../{client => worker}/src/runtime_command.rs | 0 crates/yoi/src/cli_connection.rs | 52 +- crates/yoi/src/main.rs | 437 +- crates/yoi/src/objective_cli.rs | 534 +- crates/yoi/src/ticket_cli.rs | 626 +- docs/design/overview.md | 5 +- docs/design/standalone-agent-host.md | 7 + 26 files changed, 322 insertions(+), 19417 deletions(-) delete mode 100644 crates/client/src/spawn.rs delete mode 100644 crates/client/src/ticket_role.rs delete mode 100644 crates/tui/src/dashboard/mod.rs delete mode 100644 crates/tui/src/dashboard/render.rs delete mode 100644 crates/tui/src/dashboard/tests.rs delete mode 100644 crates/tui/src/picker.rs delete mode 100644 crates/tui/src/role_session_registry.rs delete mode 100644 crates/tui/src/spawn.rs delete mode 100644 crates/tui/src/worker_list.rs delete mode 100644 crates/tui/src/workspace_panel.rs rename crates/{client => worker}/src/runtime_command.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 45f97992..879484e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,7 +638,6 @@ name = "client" version = "0.1.0" dependencies = [ "futures", - "manifest", "protocol", "reqwest", "serde", @@ -5325,7 +5324,6 @@ dependencies = [ "base64 0.22.1", "client", "crossterm 0.28.1", - "fs4", "manifest", "protocol", "pulldown-cmark", @@ -5342,7 +5340,6 @@ dependencies = [ "toml", "unicode-width", "uuid", - "worker", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index bb5f92a3..223d4b7e 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -6,14 +6,13 @@ license.workspace = true [dependencies] protocol = { workspace = true } -manifest = { workspace = true } ticket = { workspace = true } futures = { workspace = true } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } +tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] } tokio-tungstenite = { workspace = true } uuid = { workspace = true } workspace-api.workspace = true diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index bec7cdde..431b992b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1,20 +1,12 @@ -//! Worker プロトコルを喋るクライアント。 +//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。 //! -//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、 -//! `Event` を受け取る低レベル接続。 -//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY` -//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が -//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。 -//! -//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 +//! Standalone execution is owned by the `standalone` crate and does not spawn +//! a Worker subprocess through this crate. pub mod backend_auth; pub mod backend_runtime; pub mod backend_workspace; -pub mod runtime_command; -pub mod spawn; pub mod target; -pub mod ticket_role; mod worker_client; mod workspace_product; @@ -35,23 +27,10 @@ pub use backend_workspace::{ CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, }; -pub use runtime_command::WorkerRuntimeCommand; pub use target::{ - BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent, - StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName, - WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume, - WorkerSpawn, -}; - -pub use spawn::{ - SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions, - spawn_worker, spawn_worker_with_options, -}; -pub use ticket_role::{ - TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions, - TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, - launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch, - plan_ticket_role_launch_with_config, + BackendTarget, Dashboard, ResolvedTarget, StandaloneSessionListIntent, + StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, + WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, }; pub use worker_client::WorkerClient; pub use workspace_api::{ObjectiveDetail, ObjectiveSummary}; diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs deleted file mode 100644 index d8ccb647..00000000 --- a/crates/client/src/spawn.rs +++ /dev/null @@ -1,435 +0,0 @@ -//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ -//! ハンドシェイク。 -//! -//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を -//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に -//! `YOI-READY\t\t` を吐く。 -//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。 -//! UI の進捗表示や E2E のログ収集はここで賄う。 -//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス -//! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle -//! 管理は runtime ディレクトリ / socket を介して行う。 - -use std::io; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::Duration; - -use crate::WorkerRuntimeCommand; -use tokio::process::Command; -use uuid::Uuid; - -const READY_PREFIX: &str = "YOI-READY\t"; -const READY_TIMEOUT: Duration = Duration::from_secs(20); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkerProcessLaunchConfig { - pub runtime_command: WorkerRuntimeCommand, - /// `worker.name` として使う識別子。runtime ディレクトリ - /// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る - /// 名前との突き合わせに使う。 - pub worker_name: String, - /// Optional reusable Profile selector. Worker identity is always supplied - /// separately with `--worker`; profile selection must not imply a name. - pub profile: Option, - /// Explicit runtime workspace root. The child receives it via - /// `--workspace` so startup does not infer workspace identity from the - /// parent process cwd. - pub workspace_root: PathBuf, - /// Optional child process cwd. This is not runtime workspace identity and - /// is not passed as a CLI argument; the child observes it as its ordinary - /// process current directory. - pub cwd: Option, - /// `Some(id)` のとき `--session ` を付与し、当該セッションから - /// resume させる。 - pub resume_from: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct WorkerProcessLaunchOptions { - /// Extra child CLI arguments supplied by an upper resolver layer. The - /// low-level launch config intentionally does not model Ticket IDs, - /// Ticket roles, orchestration roles, executable authority, or raw - /// browser-provided profile/cwd/workspace inputs. - pub extra_args: Vec, -} - -impl WorkerProcessLaunchOptions { - pub fn with_hidden_arg(mut self, name: impl Into, value: impl Into) -> Self { - self.extra_args.extend([name.into(), value.into()]); - self - } - - pub fn is_empty(&self) -> bool { - self.extra_args.is_empty() - } -} - -pub type SpawnConfig = WorkerProcessLaunchConfig; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SpawnReady { - pub worker_name: String, - pub socket_path: PathBuf, -} - -#[derive(Debug)] -pub enum SpawnError { - Io(io::Error), - /// runtime ディレクトリが解決できなかった (環境変数未設定等)。 - RuntimeDirUnavailable, - WorkerLaunchFailed { - command: WorkerRuntimeCommand, - source: io::Error, - }, - WorkerExitedEarly { - stderr_tail: String, - }, - Timeout, -} - -impl std::fmt::Display for SpawnError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(e) => write!(f, "io error: {e}"), - Self::RuntimeDirUnavailable => write!( - f, - "could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)" - ), - Self::WorkerLaunchFailed { command, source } => write!( - f, - "failed to launch worker runtime command `{command}`: {source}" - ), - Self::WorkerExitedEarly { stderr_tail } => { - if stderr_tail.is_empty() { - write!(f, "worker exited before becoming ready") - } else { - write!(f, "worker exited before becoming ready: {stderr_tail}") - } - } - Self::Timeout => write!( - f, - "worker did not become ready within {}s", - READY_TIMEOUT.as_secs() - ), - } - } -} - -impl std::error::Error for SpawnError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(error) | Self::WorkerLaunchFailed { source: error, .. } => Some(error), - Self::RuntimeDirUnavailable | Self::WorkerExitedEarly { .. } | Self::Timeout => None, - } - } -} - -impl From for SpawnError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } -} - -fn runtime_args( - config: &WorkerProcessLaunchConfig, - options: &WorkerProcessLaunchOptions, -) -> Vec { - let mut args = vec![ - "--workspace".to_string(), - config.workspace_root.display().to_string(), - ]; - if let Some(id) = config.resume_from { - args.extend([ - "--session".to_string(), - id.to_string(), - "--worker".to_string(), - config.worker_name.clone(), - ]); - } else { - args.extend(["--worker".to_string(), config.worker_name.clone()]); - if let Some(profile) = &config.profile { - args.extend(["--profile".to_string(), profile.clone()]); - } - } - args.extend(options.extra_args.clone()); - args -} - -/// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。 -/// -/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる -/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。 -pub async fn spawn_worker( - config: WorkerProcessLaunchConfig, - progress: F, -) -> Result -where - F: FnMut(&str), -{ - spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await -} - -pub async fn spawn_worker_with_options( - config: WorkerProcessLaunchConfig, - options: WorkerProcessLaunchOptions, - mut progress: F, -) -> Result -where - F: FnMut(&str), -{ - let worker_runtime_dir = manifest::paths::worker_runtime_dir(&config.worker_name) - .ok_or(SpawnError::RuntimeDirUnavailable)?; - std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?; - let stderr_path = worker_runtime_dir.join("stderr.log"); - let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?; - - let mut command = Command::new(config.runtime_command.program()); - command - .args(config.runtime_command.prefix_args()) - .current_dir(config.cwd.as_ref().unwrap_or(&config.workspace_root)) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::from(stderr_file)) - .process_group(0); - for arg in runtime_args(&config, &options) { - command.arg(arg); - } - let mut child = command - .spawn() - .map_err(|source| SpawnError::WorkerLaunchFailed { - command: config.runtime_command.clone(), - source, - })?; - - // Default `kill_on_drop = false` plus `process_group(0)` makes this - // a detached Worker once startup succeeds: dropping the handle does not - // terminate it, and terminal-generated signals for the parent's - // process group do not hit the Worker. Runtime state/socket files are - // the source of truth after that point. - let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await { - Ok(ready) => ready, - Err(e) => { - let _ = child.start_kill(); - let _ = child.wait().await; - return Err(e); - } - }; - tokio::spawn(async move { - let _ = child.wait().await; - }); - Ok(ready) -} - -async fn wait_for_ready_file( - progress: &mut F, - stderr_path: &Path, - child: &mut tokio::process::Child, -) -> Result -where - F: FnMut(&str), -{ - let mut tail = StderrTail::new(); - let deadline = tokio::time::Instant::now() + READY_TIMEOUT; - let mut offset = 0usize; - - loop { - let content = match tokio::fs::read_to_string(stderr_path).await { - Ok(content) => content, - Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(SpawnError::Io(e)), - }; - if content.len() > offset { - for line in content[offset..].lines() { - if let Some(rest) = line.strip_prefix(READY_PREFIX) { - let mut parts = rest.splitn(2, '\t'); - let worker_name = parts.next().unwrap_or("").to_string(); - let socket_str = parts.next().unwrap_or("").to_string(); - if worker_name.is_empty() || socket_str.is_empty() { - return Err(SpawnError::WorkerExitedEarly { - stderr_tail: format!("malformed ready line: {line}"), - }); - } - let socket_path = PathBuf::from(socket_str); - wait_for_socket( - &socket_path, - deadline, - child, - stderr_path, - &mut tail, - &mut offset, - ) - .await?; - return Ok(SpawnReady { - worker_name, - socket_path, - }); - } - tail.push(line); - progress(line); - } - offset = content.len(); - } - - if tokio::time::Instant::now() >= deadline { - return Err(SpawnError::Timeout); - } - tokio::select! { - status = child.wait() => { - let _ = status; - // Worker は exit 直前に最終 stderr 行を flush することがある。 - // child.wait() が解決した後に再読みして、原因行を取りこ - // ぼさず WorkerExitedEarly に載せる。 - drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await; - return Err(SpawnError::WorkerExitedEarly { - stderr_tail: tail.into_string(), - }); - } - _ = tokio::time::sleep(Duration::from_millis(100)) => {} - } - } -} - -async fn wait_for_socket( - socket_path: &Path, - deadline: tokio::time::Instant, - child: &mut tokio::process::Child, - stderr_path: &Path, - tail: &mut StderrTail, - offset: &mut usize, -) -> Result<(), SpawnError> { - loop { - match tokio::net::UnixStream::connect(socket_path).await { - Ok(_) => return Ok(()), - Err(e) - if e.kind() == io::ErrorKind::NotFound - || e.kind() == io::ErrorKind::ConnectionRefused => {} - Err(e) => return Err(SpawnError::Io(e)), - } - if tokio::time::Instant::now() >= deadline { - return Err(SpawnError::Timeout); - } - tokio::select! { - status = child.wait() => { - let _ = status; - drain_stderr_into_tail(stderr_path, tail, offset).await; - return Err(SpawnError::WorkerExitedEarly { - stderr_tail: tail.as_string(), - }); - } - _ = tokio::time::sleep(Duration::from_millis(50)) => {} - } - } -} - -async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offset: &mut usize) { - let Ok(content) = tokio::fs::read_to_string(stderr_path).await else { - return; - }; - if content.len() <= *offset { - return; - } - for line in content[*offset..].lines() { - if !line.starts_with(READY_PREFIX) { - tail.push(line); - } - } - *offset = content.len(); -} - -struct StderrTail { - lines: std::collections::VecDeque, -} - -impl StderrTail { - fn new() -> Self { - Self { - lines: std::collections::VecDeque::with_capacity(8), - } - } - fn push(&mut self, line: &str) { - if self.lines.len() == 8 { - self.lines.pop_front(); - } - self.lines.push_back(line.to_string()); - } - fn as_string(&self) -> String { - self.lines.iter().cloned().collect::>().join(" | ") - } - fn into_string(self) -> String { - self.lines.into_iter().collect::>().join(" | ") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsString; - - fn base_config() -> WorkerProcessLaunchConfig { - WorkerProcessLaunchConfig { - runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]), - worker_name: "explicit-worker".to_string(), - profile: Some("project:companion".to_string()), - workspace_root: PathBuf::from("/work/other-project"), - cwd: None, - resume_from: None, - } - } - - #[test] - fn runtime_args_keep_workspace_worker_and_profile_separate() { - assert_eq!( - runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()), - vec![ - "--workspace", - "/work/other-project", - "--worker", - "explicit-worker", - "--profile", - "project:companion", - ] - ); - } - - #[test] - fn runtime_args_use_session_mode_without_profile_identity_alias() { - let mut config = base_config(); - config.resume_from = Some(Uuid::nil()); - assert_eq!( - runtime_args(&config, &WorkerProcessLaunchOptions::default()), - vec![ - "--workspace", - "/work/other-project", - "--session", - "00000000-0000-0000-0000-000000000000", - "--worker", - "explicit-worker", - ] - ); - } - - #[test] - fn runtime_args_include_upper_resolver_extra_args_without_child_cwd() { - let mut config = base_config(); - config.cwd = Some(PathBuf::from("/work/main/.worktree/orchestration/yoi")); - - assert_eq!( - runtime_args( - &config, - &WorkerProcessLaunchOptions::default() - .with_hidden_arg("--ticket-role", "orchestrator"), - ), - vec![ - "--workspace", - "/work/other-project", - "--worker", - "explicit-worker", - "--profile", - "project:companion", - "--ticket-role", - "orchestrator", - ] - ); - } -} diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index eb164da0..9ca68fe7 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -1,11 +1,9 @@ use std::{fmt, path::PathBuf}; -use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; +use crate::{BackendRuntimeListTarget, BackendRuntimeTarget}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TargetKind { - /// Legacy local Runtime process/socket authority. - Local, /// One-process Standalone authority with no Runtime or Workspace backend. Standalone, Backend, @@ -13,7 +11,6 @@ pub enum TargetKind { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedTarget { - Local, Standalone, Backend { base_url: String, @@ -24,7 +21,6 @@ pub enum ResolvedTarget { impl ResolvedTarget { pub fn kind(&self) -> TargetKind { match self { - Self::Local => TargetKind::Local, Self::Standalone => TargetKind::Standalone, Self::Backend { .. } => TargetKind::Backend, } @@ -34,32 +30,12 @@ impl ResolvedTarget { impl fmt::Display for TargetKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Local => f.write_str("local"), Self::Standalone => f.write_str("Standalone"), Self::Backend => f.write_str("Backend"), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct LocalTarget; - -impl LocalTarget { - pub fn new() -> Self { - Self - } - - fn runtime_command(&self) -> Result { - WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command) - } -} - -impl Default for LocalTarget { - fn default() -> Self { - Self::new() - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendTarget { pub base_url: String, @@ -113,23 +89,8 @@ impl WorkerConnectionSelector { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum WorkerSpawn { - LegacyLocal { - runtime_command: WorkerRuntimeCommand, - }, - Standalone { - state_dir: PathBuf, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkerByName { - pub runtime_command: WorkerRuntimeCommand, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkerResume { - pub runtime_command: WorkerRuntimeCommand, +pub struct WorkerSpawn { + pub state_dir: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -146,20 +107,14 @@ pub struct StandaloneSessionResumeIntent { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Dashboard { - Local { - runtime_command: WorkerRuntimeCommand, - }, - Backend { - base_url: String, - workspace_id: String, - }, +pub struct Dashboard { + pub base_url: String, + pub workspace_id: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerList { - pub local_runtime_command: Option, - pub backend_target: Option, + pub backend_target: BackendRuntimeListTarget, pub include_stopped: bool, } @@ -185,12 +140,6 @@ impl TargetError { message: format!("invalid {target} target: {}", message.into()), } } - - fn local_runtime_command(error: std::io::Error) -> Self { - Self { - message: format!("failed to resolve local Worker runtime command: {error}"), - } - } } impl fmt::Display for TargetError { @@ -207,15 +156,13 @@ pub trait Target: fmt::Debug + Send + Sync { /// Resolve the target once for Workspace product-state operations. /// /// Backend targets must carry an explicit Workspace identity. Callers use - /// this value instead of rediscovering Backend/local authority from cwd or - /// process configuration after command dispatch. + /// this value instead of rediscovering authority from cwd or process + /// configuration after command dispatch. fn resolve(&self) -> Result; - fn spawn_worker(&self) -> Result; - - fn worker_by_name(&self) -> Result; - - fn resume_worker(&self) -> Result; + fn spawn_worker(&self) -> Result { + Err(TargetError::unsupported("Worker spawn", self.kind())) + } fn standalone_session_list( &self, @@ -237,61 +184,12 @@ pub trait Target: fmt::Debug + Send + Sync { )) } - fn dashboard(&self) -> Result; - - fn list_workers(&self, request: WorkerListRequest) -> Result; - - fn connect_worker( - &self, - selector: WorkerConnectionSelector, - ) -> Result; -} - -impl Target for LocalTarget { - fn kind(&self) -> TargetKind { - TargetKind::Local - } - - fn resolve(&self) -> Result { - Ok(ResolvedTarget::Local) - } - - fn spawn_worker(&self) -> Result { - Ok(WorkerSpawn::LegacyLocal { - runtime_command: self.runtime_command()?, - }) - } - - fn worker_by_name(&self) -> Result { - Ok(WorkerByName { - runtime_command: self.runtime_command()?, - }) - } - - fn resume_worker(&self) -> Result { - Ok(WorkerResume { - runtime_command: self.runtime_command()?, - }) - } - fn dashboard(&self) -> Result { - Ok(Dashboard::Local { - runtime_command: self.runtime_command()?, - }) + Err(TargetError::unsupported("Worker dashboard", self.kind())) } - fn list_workers(&self, request: WorkerListRequest) -> Result { - if request.runtime_id.is_some() { - return Err(TargetError::unsupported( - "Explicit runtime id for local worker listing", - self.kind(), - )); - } - Ok(WorkerList { - local_runtime_command: Some(self.runtime_command()?), - backend_target: None, - include_stopped: request.include_stopped, - }) + fn list_workers(&self, _request: WorkerListRequest) -> Result { + Err(TargetError::unsupported("Worker listing", self.kind())) } fn connect_worker( @@ -329,22 +227,11 @@ impl Target for StandaloneTarget { } fn spawn_worker(&self) -> Result { - Ok(WorkerSpawn::Standalone { + Ok(WorkerSpawn { state_dir: self.state_dir.clone(), }) } - fn worker_by_name(&self) -> Result { - Err(TargetError::unsupported( - "Worker name attachment", - self.kind(), - )) - } - - fn resume_worker(&self) -> Result { - Err(TargetError::unsupported("Worker restore", self.kind())) - } - fn standalone_session_list( &self, include_all: bool, @@ -367,24 +254,6 @@ impl Target for StandaloneTarget { session_id, }) } - - fn dashboard(&self) -> Result { - Err(TargetError::unsupported("Worker dashboard", self.kind())) - } - - fn list_workers(&self, _request: WorkerListRequest) -> Result { - Err(TargetError::unsupported("Worker listing", self.kind())) - } - - fn connect_worker( - &self, - _selector: WorkerConnectionSelector, - ) -> Result { - Err(TargetError::unsupported( - "Backend runtime worker connection", - self.kind(), - )) - } } impl Target for BackendTarget { @@ -405,44 +274,27 @@ impl Target for BackendTarget { }) } - fn spawn_worker(&self) -> Result { - Err(TargetError::unsupported("Worker spawn", self.kind())) - } - - fn worker_by_name(&self) -> Result { - Err(TargetError::unsupported( - "Worker name attachment", - self.kind(), - )) - } - - fn resume_worker(&self) -> Result { - Err(TargetError::unsupported("Worker resume", self.kind())) - } - fn dashboard(&self) -> Result { - match self.resolve()? { - ResolvedTarget::Backend { - base_url, - workspace_id, - } => Ok(Dashboard::Backend { - base_url, - workspace_id, - }), - ResolvedTarget::Local | ResolvedTarget::Standalone => { - unreachable!("BackendTarget cannot resolve as a local target") - } - } + let ResolvedTarget::Backend { + base_url, + workspace_id, + } = self.resolve()? + else { + unreachable!("BackendTarget resolves only Backend authority") + }; + Ok(Dashboard { + base_url, + workspace_id, + }) } fn list_workers(&self, request: WorkerListRequest) -> Result { Ok(WorkerList { - local_runtime_command: None, - backend_target: Some(BackendRuntimeListTarget::new( + backend_target: BackendRuntimeListTarget::new( self.base_url.clone(), self.workspace_id.clone(), request.runtime_id, - )), + ), include_stopped: request.include_stopped, }) } @@ -499,32 +351,23 @@ mod tests { } #[test] - fn local_target_resolves_local_product_state_authority() { - assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local); - } - - #[test] - fn standalone_target_carries_in_process_state_without_resolving_runtime_command() { + fn standalone_target_carries_in_process_state_without_runtime_command() { let target = StandaloneTarget::new("/tmp/yoi-standalone-state"); assert_eq!(target.kind(), TargetKind::Standalone); assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone); assert_eq!( target.spawn_worker().unwrap(), - WorkerSpawn::Standalone { + WorkerSpawn { state_dir: PathBuf::from("/tmp/yoi-standalone-state"), } ); } #[test] - fn standalone_target_never_falls_back_to_legacy_local_operations() { + fn standalone_target_never_exposes_workspace_worker_operations() { let target = StandaloneTarget::new("/tmp/yoi-standalone-state"); - assert_eq!( - target.worker_by_name().unwrap_err().to_string(), - "Worker name attachment is not supported by Standalone target" - ); assert_eq!( target .list_workers(WorkerListRequest::new(None)) @@ -532,6 +375,10 @@ mod tests { .to_string(), "Worker listing is not supported by Standalone target" ); + assert_eq!( + target.dashboard().unwrap_err().to_string(), + "Worker dashboard is not supported by Standalone target" + ); } #[test] @@ -540,26 +387,13 @@ mod tests { assert_eq!( target.dashboard().unwrap(), - Dashboard::Backend { + Dashboard { base_url: "http://127.0.0.1:8787".to_string(), workspace_id: "workspace-a".to_string(), } ); } - #[test] - fn backend_target_rejects_dashboard_without_workspace_selection() { - let target = BackendTarget::new("http://127.0.0.1:8787", None::); - - assert!( - target - .dashboard() - .unwrap_err() - .to_string() - .contains("workspace selection is required") - ); - } - #[test] fn backend_target_builds_worker_list() { let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); @@ -567,26 +401,13 @@ mod tests { .list_workers(WorkerListRequest::new(Some("runtime-a".to_string()))) .unwrap(); + assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787"); assert_eq!( - workers.backend_target.as_ref().unwrap().base_url, - "http://127.0.0.1:8787" - ); - assert_eq!( - workers - .backend_target - .as_ref() - .unwrap() - .workspace_id - .as_deref(), + workers.backend_target.workspace_id.as_deref(), Some("workspace-a") ); assert_eq!( - workers - .backend_target - .as_ref() - .unwrap() - .runtime_id - .as_deref(), + workers.backend_target.runtime_id.as_deref(), Some("runtime-a") ); } @@ -604,33 +425,6 @@ mod tests { assert_eq!(connection.target.worker_id, "worker-b"); } - #[test] - fn backend_target_rejects_worker_connection_before_workspace_selection() { - let target = BackendTarget::new("http://127.0.0.1:8787", None::); - let error = - match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) { - Ok(_) => panic!("unscoped connection must fail"), - Err(error) => error, - }; - - assert!( - error - .to_string() - .contains("workspace selection is required") - ); - } - - #[test] - fn backend_target_rejects_local_worker_operations() { - let target = BackendTarget::new("http://127.0.0.1:8787", None::); - let err = target.spawn_worker().unwrap_err(); - - assert_eq!( - err.to_string(), - "Worker spawn is not supported by Backend target" - ); - } - #[test] fn standalone_target_builds_explicit_session_intents() { let target = StandaloneTarget::new("/tmp/yoi-client-sessions"); @@ -644,25 +438,5 @@ mod tests { .unwrap(); assert_eq!(resume.state_dir, list.state_dir); assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000"); - - assert!( - LocalTarget::new() - .standalone_session_list(false) - .unwrap_err() - .to_string() - .contains("not supported") - ); - } - - #[test] - fn local_target_builds_local_worker_list() { - let target = LocalTarget::new(); - let workers = target - .list_workers(WorkerListRequest::with_stopped(None)) - .unwrap(); - - assert!(workers.local_runtime_command.is_some()); - assert!(workers.backend_target.is_none()); - assert!(workers.include_stopped); } } diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs deleted file mode 100644 index 687800cc..00000000 --- a/crates/client/src/ticket_role.rs +++ /dev/null @@ -1,1242 +0,0 @@ -//! Ticket-role Worker launch planning and execution. -//! -//! This module keeps Ticket role configuration, generated first-run input, and -//! host-side Worker spawning behind the `client` crate so UI callers do not need to -//! depend on `worker` internals. - -use std::io; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use manifest::{ProfileDiscovery, ProfileResolveOptions, ProfileResolver, ProfileSelector}; -use protocol::{ErrorCode, Event, InvokeKind, Method, Segment}; -use thiserror::Error; -pub use ticket::config::TicketRole; -use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError}; - -use crate::{ - SpawnError, SpawnReady, WorkerClient, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions, - WorkerRuntimeCommand, spawn_worker_with_options, -}; - -const MAX_FIELD_CHARS: usize = 8_000; -const MAX_POD_NAME_CHARS: usize = 80; -const RUN_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(10); -const PRE_RUN_ACTION_TIMEOUT: Duration = Duration::from_secs(5); - -/// Ticket identifier carried by a role launch request. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct TicketRef { - pub id: Option, -} - -impl TicketRef { - pub fn id(id: impl Into) -> Self { - Self { - id: Some(id.into()), - } - } - - fn worker_name_seed(&self) -> Option<&str> { - non_empty(self.id.as_deref()) - } - - fn append_submit_lines(&self, out: &mut String) { - match non_empty(self.id.as_deref()) { - None => out.push_str("Target Ticket: not specified\n"), - Some(id) => { - out.push_str("Target Ticket:\n"); - push_bounded_bullet(out, "id", id); - } - } - } -} - -/// Auditable panel handoff target included in a Ticket Intake launch. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketIntakeHandoff { - pub workspace_orchestrator_worker: String, - pub workspace_label: String, -} - -impl TicketIntakeHandoff { - pub fn new( - workspace_orchestrator_worker: impl Into, - workspace_label: impl Into, - ) -> Self { - Self { - workspace_orchestrator_worker: workspace_orchestrator_worker.into(), - workspace_label: workspace_label.into(), - } - } - - fn append_submit_lines(&self, out: &mut String) { - out.push_str("\nPanel handoff:\n"); - push_bounded_bullet(out, "workspace", &self.workspace_label); - push_bounded_bullet( - out, - "workspace_workspace_orchestrator_worker", - &self.workspace_orchestrator_worker, - ); - } -} - -/// Typed input for constructing a Ticket role launch. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRoleLaunchContext { - pub workspace_root: PathBuf, - pub cwd: Option, - pub original_workspace_root: Option, - pub target_workspace_root: Option, - pub role: TicketRole, - pub worker_name: Option, - pub ticket: Option, - pub user_instruction: Option, - pub intake_handoff: Option, - pub ticket_record_language: Option, - pub intent_packet: Option, - pub worktree_path: Option, - pub branch: Option, - pub validation: Vec, - pub report_expectations: Vec, -} - -impl TicketRoleLaunchContext { - pub fn new(workspace_root: impl Into, role: TicketRole) -> Self { - Self { - workspace_root: workspace_root.into(), - cwd: None, - original_workspace_root: None, - target_workspace_root: None, - role, - worker_name: None, - ticket: None, - user_instruction: None, - intake_handoff: None, - ticket_record_language: None, - intent_packet: None, - worktree_path: None, - branch: None, - validation: Vec::new(), - report_expectations: Vec::new(), - } - } - - pub fn with_cwd(mut self, root: impl Into) -> Self { - self.cwd = Some(root.into()); - self - } - - pub fn with_original_workspace_root(mut self, root: impl Into) -> Self { - self.original_workspace_root = Some(root.into()); - self - } - - pub fn with_target_workspace_root(mut self, root: impl Into) -> Self { - self.target_workspace_root = Some(root.into()); - self - } - - fn original_workspace_root(&self) -> &Path { - self.original_workspace_root - .as_deref() - .unwrap_or(&self.workspace_root) - } - - fn target_workspace_root(&self) -> &Path { - self.target_workspace_root - .as_deref() - .unwrap_or_else(|| self.original_workspace_root()) - } - - fn implementation_worktree_root(&self) -> PathBuf { - self.original_workspace_root().join(".worktree") - } -} - -/// Pure launch plan usable by TUI/CLI surfaces before executing the launch. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRoleLaunchPlan { - pub workspace_root: PathBuf, - pub cwd: Option, - pub original_workspace_root: PathBuf, - pub target_workspace_root: PathBuf, - pub implementation_worktree_root: PathBuf, - pub role: TicketRole, - pub worker_name: String, - pub profile: String, - pub launch_prompt_ref: Option, - pub run_segments: Vec, -} - -impl TicketRoleLaunchPlan { - pub fn run_method(&self) -> Method { - Method::Run { - input: self.run_segments.clone(), - } - } - - pub fn spawn_config( - &self, - runtime_command: WorkerRuntimeCommand, - ) -> Result { - if self.profile == "inherit" { - return Err(TicketRoleLaunchError::UnsupportedInheritProfile); - } - Ok(WorkerProcessLaunchConfig { - runtime_command, - worker_name: self.worker_name.clone(), - profile: Some(self.profile.clone()), - workspace_root: self.workspace_root.clone(), - cwd: self.cwd.clone(), - resume_from: None, - }) - } - - pub fn spawn_options(&self) -> WorkerProcessLaunchOptions { - WorkerProcessLaunchOptions::default() - .with_hidden_arg("--ticket-role", self.role.as_str().to_string()) - } -} - -/// Result of executing a Ticket role launch. -#[derive(Debug, Clone)] -pub struct TicketRoleLaunchResult { - pub plan: TicketRoleLaunchPlan, - pub ready: SpawnReady, - /// Evidence that the spawned worker accepted the initial Run request. - /// This is intentionally distinct from process readiness: a socket - /// snapshot only proves that the runtime is reachable, not that the - /// worker operation was durably queued/started. - pub acceptance_evidence: TicketRoleLaunchAcceptanceEvidence, - pub pre_run_warnings: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRoleLaunchAcceptanceEvidence { - pub worker_name: String, - pub accepted_run_segments: usize, - pub event: TicketRoleLaunchAcceptanceEvent, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TicketRoleLaunchAcceptanceEvent { - UserMessage, - UserSendInvokeStart, - TurnStart, -} - -/// Non-fatal diagnostic produced by bounded pre-run launch actions. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TicketRolePreRunWarning { - pub message: String, -} - -/// Optional bounded actions executed after spawn readiness and before the first Run. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct TicketRoleLaunchOptions { - pub pre_run_peer_registrations: Vec, -} - -impl TicketRoleLaunchOptions { - pub fn with_pre_run_peer_registration(mut self, worker_name: impl Into) -> Self { - self.pre_run_peer_registrations.push(worker_name.into()); - self - } -} - -#[derive(Debug, Error)] -pub enum TicketRoleLaunchError { - #[error(transparent)] - Config(#[from] TicketConfigError), - #[error(transparent)] - LaunchConfig(#[from] TicketRoleLaunchConfigError), - #[error( - "Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[ticket.roles.{role}].profile` with an executable concrete profile selector such as `builtin:companion` or a project/user profile" - )] - ProfileResolution { - role: TicketRole, - selector: String, - message: String, - }, - #[error("Ticket role Worker name must not be empty")] - EmptyWorkerName, - #[error( - "Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector" - )] - UnsupportedInheritProfile, - #[error(transparent)] - Spawn(#[from] SpawnError), - #[error("failed to connect to spawned Ticket role Worker at {}: {source}", .socket_path.display())] - Connect { - socket_path: PathBuf, - #[source] - source: io::Error, - }, - #[error("failed to send first run input to spawned Ticket role Worker: {source}")] - SendRun { - #[source] - source: io::Error, - }, - #[error("Ticket role Worker rejected first run input with {code:?}: {message}")] - RunRejected { code: ErrorCode, message: String }, - #[error("Ticket role Worker closed before confirming first run acceptance")] - RunAcceptanceClosed, - #[error("timed out waiting for Ticket role Worker to confirm first run acceptance")] - RunAcceptanceTimeout, -} - -/// Load Ticket policy from `.yoi/workspace.toml` and construct a launch plan. -pub fn plan_ticket_role_launch( - context: TicketRoleLaunchContext, -) -> Result { - let config = TicketConfig::load_workspace(&context.workspace_root)?; - plan_ticket_role_launch_with_config(context, &config) -} - -/// Construct a launch plan from an already-loaded Ticket config. -pub fn plan_ticket_role_launch_with_config( - mut context: TicketRoleLaunchContext, - config: &TicketConfig, -) -> Result { - if context.ticket_record_language.is_none() { - context.ticket_record_language = config.ticket_record_language().map(str::to_string); - } - let role_config = config.role_launch_config(context.role)?; - let profile = role_config.profile.as_str().to_string(); - let launch_prompt_ref = role_config - .launch_prompt - .as_ref() - .map(|prompt| prompt.as_str().to_string()); - let worker_name = match context.worker_name.as_deref().map(str::trim) { - Some("") => return Err(TicketRoleLaunchError::EmptyWorkerName), - Some(name) => name.to_string(), - None => default_worker_name(context.role, context.ticket.as_ref()), - }; - validate_ticket_role_profile( - context.role, - &profile, - &context.workspace_root, - &worker_name, - )?; - let prompt = build_launch_prompt(&context); - - let original_workspace_root = context.original_workspace_root().to_path_buf(); - let target_workspace_root = context.target_workspace_root().to_path_buf(); - let implementation_worktree_root = context.implementation_worktree_root(); - - Ok(TicketRoleLaunchPlan { - workspace_root: context.workspace_root, - cwd: context.cwd, - original_workspace_root, - target_workspace_root, - implementation_worktree_root, - role: context.role, - worker_name, - profile, - launch_prompt_ref, - run_segments: vec![Segment::Text { - content: format!("\n\n{prompt}"), - }], - }) -} - -fn validate_ticket_role_profile( - role: TicketRole, - profile: &str, - workspace_root: &std::path::Path, - worker_name: &str, -) -> Result<(), TicketRoleLaunchError> { - let selector = ProfileSelector::parse_cli(profile); - let registry = ProfileDiscovery::for_cwd(workspace_root) - .discover() - .map_err(|source| TicketRoleLaunchError::ProfileResolution { - role, - selector: profile.to_string(), - message: source.to_string(), - })?; - ProfileResolver::new() - .with_workspace_base(workspace_root) - .resolve_from_registry( - &selector, - ®istry, - ProfileResolveOptions::with_worker_name(worker_name), - ) - .map(|_| ()) - .map_err(|source| TicketRoleLaunchError::ProfileResolution { - role, - selector: profile.to_string(), - message: source.to_string(), - }) -} - -/// Spawn the Worker, connect to its socket, send the first `Method::Run` input, -/// and wait for bounded acceptance evidence from the Worker event stream. -pub async fn launch_ticket_role_worker( - context: TicketRoleLaunchContext, - runtime_command: WorkerRuntimeCommand, - progress: F, -) -> Result -where - F: FnMut(&str), -{ - launch_ticket_role_worker_with_options( - context, - runtime_command, - progress, - TicketRoleLaunchOptions::default(), - ) - .await -} - -/// Spawn the Worker, run bounded pre-run launch options while it is still idle, -/// then send the first `Method::Run` input and wait for acceptance evidence. -pub async fn launch_ticket_role_worker_with_options( - context: TicketRoleLaunchContext, - runtime_command: WorkerRuntimeCommand, - progress: F, - options: TicketRoleLaunchOptions, -) -> Result -where - F: FnMut(&str), -{ - let plan = plan_ticket_role_launch(context)?; - let spawn_config = plan.spawn_config(runtime_command)?; - let spawn_options = plan.spawn_options(); - let ready = spawn_worker_with_options(spawn_config, spawn_options, progress).await?; - let mut client = WorkerClient::connect(&ready.socket_path) - .await - .map_err(|source| TicketRoleLaunchError::Connect { - socket_path: ready.socket_path.clone(), - source, - })?; - let pre_run_warnings = run_pre_run_options_then_send_run(&mut client, &plan, &options).await?; - let acceptance_event = - wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?; - let acceptance_evidence = TicketRoleLaunchAcceptanceEvidence { - worker_name: ready.worker_name.clone(), - accepted_run_segments: plan.run_segments.len(), - event: acceptance_event, - }; - Ok(TicketRoleLaunchResult { - plan, - ready, - acceptance_evidence, - pre_run_warnings, - }) -} - -async fn run_pre_run_options_then_send_run( - client: &mut WorkerClient, - plan: &TicketRoleLaunchPlan, - options: &TicketRoleLaunchOptions, -) -> Result, TicketRoleLaunchError> { - let pre_run_warnings = perform_pre_run_peer_registrations( - client, - &options.pre_run_peer_registrations, - PRE_RUN_ACTION_TIMEOUT, - ) - .await; - client - .send(&plan.run_method()) - .await - .map_err(|source| TicketRoleLaunchError::SendRun { source })?; - Ok(pre_run_warnings) -} - -async fn perform_pre_run_peer_registrations( - client: &mut WorkerClient, - peer_names: &[String], - timeout: Duration, -) -> Vec { - let mut warnings = Vec::new(); - for peer_name in peer_names { - if peer_name.trim().is_empty() { - warnings.push(TicketRolePreRunWarning { - message: "pre-run peer registration skipped: peer Worker name is empty".to_string(), - }); - continue; - } - if let Err(message) = pre_run_register_peer(client, peer_name, timeout).await { - warnings.push(TicketRolePreRunWarning { message }); - } - } - warnings -} - -async fn pre_run_register_peer( - client: &mut WorkerClient, - peer_name: &str, - timeout: Duration, -) -> Result<(), String> { - if let Err(source) = client - .send(&Method::RegisterPeer { - name: peer_name.to_string(), - }) - .await - { - return Err(format!( - "pre-run peer registration for {peer_name} failed while sending request: {source}" - )); - } - - let wait = async { - loop { - let Some(event) = client.next_event().await else { - return Err(format!( - "pre-run peer registration for {peer_name} failed: connection closed before response" - )); - }; - match event { - Event::PeerRegistered { .. } => return Ok(()), - Event::Error { code, message } => { - return Err(format!( - "pre-run peer registration for {peer_name} failed with {code:?}: {message}" - )); - } - _ => {} - } - } - }; - - tokio::time::timeout(timeout, wait) - .await - .unwrap_or_else(|_| { - Err(format!( - "pre-run peer registration for {peer_name} timed out before first Run" - )) - }) -} - -async fn wait_for_run_acceptance( - client: &mut WorkerClient, - expected_segments: &[Segment], - timeout: Duration, -) -> Result { - let wait = async { - loop { - let Some(event) = client.next_event().await else { - return Err(TicketRoleLaunchError::RunAcceptanceClosed); - }; - match event { - Event::UserMessage { segments } if segments == expected_segments => { - return Ok(TicketRoleLaunchAcceptanceEvent::UserMessage); - } - Event::InvokeStart { - kind: InvokeKind::UserSend, - } => return Ok(TicketRoleLaunchAcceptanceEvent::UserSendInvokeStart), - Event::TurnStart { .. } => return Ok(TicketRoleLaunchAcceptanceEvent::TurnStart), - Event::Error { code, message } => { - return Err(TicketRoleLaunchError::RunRejected { code, message }); - } - _ => {} - } - } - }; - - tokio::time::timeout(timeout, wait) - .await - .map_err(|_| TicketRoleLaunchError::RunAcceptanceTimeout)? -} - -fn build_launch_prompt(context: &TicketRoleLaunchContext) -> String { - let mut out = String::new(); - - if let Some(ticket) = &context.ticket { - ticket.append_submit_lines(&mut out); - } else { - out.push_str("Target Ticket: not specified\n"); - } - - if let Some(instruction) = non_empty(context.user_instruction.as_deref()) { - push_bounded_section(&mut out, "Action instruction", instruction); - } - - if let Some(handoff) = &context.intake_handoff { - handoff.append_submit_lines(&mut out); - } - - if let Some(intent_packet) = non_empty(context.intent_packet.as_deref()) { - push_bounded_section(&mut out, "Intent packet", intent_packet); - } - - append_operation_targets(&mut out, context); - - if context.worktree_path.is_some() || non_empty(context.branch.as_deref()).is_some() { - out.push_str("\nWorktree target:\n"); - if let Some(path) = &context.worktree_path { - push_bounded_bullet(&mut out, "path", &path.display().to_string()); - } - if let Some(branch) = non_empty(context.branch.as_deref()) { - push_bounded_bullet(&mut out, "branch", branch); - } - } - - if !context.validation.is_empty() { - push_bounded_list(&mut out, "Validation expectations", &context.validation); - } - if !context.report_expectations.is_empty() { - push_bounded_list( - &mut out, - "Report expectations", - &context.report_expectations, - ); - } - - out -} - -fn append_operation_targets(out: &mut String, context: &TicketRoleLaunchContext) { - if context.role != TicketRole::Orchestrator { - return; - } - - out.push_str("\nOrchestrator operation targets:\n"); - push_bounded_bullet( - out, - "implementation_worktree_root", - &context.implementation_worktree_root().display().to_string(), - ); -} - -fn default_worker_name(role: TicketRole, ticket: Option<&TicketRef>) -> String { - let mut name = format!("ticket-{}", role.as_str()); - if let Some(seed) = ticket.and_then(TicketRef::worker_name_seed) { - let suffix = sanitise_worker_name_component(seed); - if !suffix.is_empty() { - name.push('-'); - name.push_str(&suffix); - } - } - name.chars().take(MAX_POD_NAME_CHARS).collect() -} - -fn sanitise_worker_name_component(value: &str) -> 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, '_' | '.') { - Some(ch.to_ascii_lowercase()) - } else if ch == '-' || ch.is_whitespace() { - Some('-') - } else { - Some('-') - }; - if let Some(ch) = mapped { - if ch == '-' { - if !last_was_dash && !out.is_empty() { - out.push(ch); - } - last_was_dash = true; - } else { - out.push(ch); - last_was_dash = false; - } - } - } - out.trim_matches(|ch| matches!(ch, '-' | '_' | '.')) - .chars() - .take(MAX_POD_NAME_CHARS) - .collect() -} - -fn push_bounded_bullet(out: &mut String, label: &str, value: &str) { - out.push_str("- "); - out.push_str(label); - out.push_str(": "); - out.push_str(&bounded(value)); - out.push('\n'); -} - -fn push_bounded_section(out: &mut String, label: &str, value: &str) { - out.push('\n'); - out.push_str(label); - out.push_str(":\n"); - out.push_str(&bounded(value)); - out.push('\n'); -} - -fn push_bounded_list(out: &mut String, label: &str, values: &[String]) { - out.push('\n'); - out.push_str(label); - out.push_str(":\n"); - for value in values - .iter() - .filter_map(|value| non_empty(Some(value.as_str()))) - { - out.push_str("- "); - out.push_str(&bounded(value)); - out.push('\n'); - } -} - -fn bounded(value: &str) -> String { - let trimmed = value.trim(); - let mut out: String = trimmed.chars().take(MAX_FIELD_CHARS).collect(); - if trimmed.chars().count() > MAX_FIELD_CHARS { - out.push_str("\n[truncated]"); - } - out -} - -fn non_empty(value: Option<&str>) -> Option<&str> { - value.map(str::trim).filter(|value| !value.is_empty()) -} - -#[cfg(test)] -mod tests { - use super::*; - use protocol::{Greeting, WorkerStatus}; - use tempfile::TempDir; - use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; - use tokio::net::UnixListener; - - fn write_config(workspace: &std::path::Path, content: &str) { - let dir = workspace.join(".yoi"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("workspace.toml"), content).unwrap(); - } - - fn write_builtin_role_config(workspace: &std::path::Path, roles: &[TicketRole]) { - let mut config = String::from("[ticket]\n"); - for role in roles { - config.push_str(&format!( - "\n[ticket.roles.{role}]\nprofile = \"builtin:companion\"\n" - )); - } - write_config(workspace, &config); - } - - fn text_segment(plan: &TicketRoleLaunchPlan) -> &str { - plan.run_segments - .iter() - .find_map(|segment| match segment { - Segment::Text { content } => Some(content.as_str()), - _ => None, - }) - .expect("expected text segment") - } - - async fn write_test_event(writer: &mut W, event: Event) - where - W: AsyncWrite + Unpin, - { - writer - .write_all(serde_json::to_string(&event).unwrap().as_bytes()) - .await - .unwrap(); - writer.write_all(b"\n").await.unwrap(); - } - - fn test_snapshot() -> Event { - Event::Snapshot { - entries: vec![], - greeting: Greeting { - worker_name: "ticket-intake".to_string(), - cwd: "/tmp".to_string(), - provider: "test".to_string(), - model: "test".to_string(), - scope_summary: "test".to_string(), - tools: vec![], - context_window: 0, - context_tokens: 0, - }, - status: WorkerStatus::Idle, - in_flight: protocol::InFlightSnapshot::default(), - internal_workers: vec![], - } - } - - fn test_launch_plan(workspace: &std::path::Path) -> TicketRoleLaunchPlan { - TicketRoleLaunchPlan { - workspace_root: workspace.to_path_buf(), - cwd: None, - original_workspace_root: workspace.to_path_buf(), - target_workspace_root: workspace.to_path_buf(), - implementation_worktree_root: workspace.join(".worktree"), - role: TicketRole::Intake, - worker_name: "ticket-intake".to_string(), - profile: "project:intake".to_string(), - launch_prompt_ref: None, - run_segments: vec![Segment::Text { - content: "intake request".to_string(), - }], - } - } - - #[tokio::test] - async fn pre_run_peer_registration_is_sent_before_first_run_submission() { - let temp = TempDir::new().unwrap(); - let socket_path = temp.path().join("worker.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, mut writer) = stream.into_split(); - write_test_event(&mut writer, test_snapshot()).await; - let mut reader = BufReader::new(reader); - - let mut first = String::new(); - reader.read_line(&mut first).await.unwrap(); - match serde_json::from_str::(&first).unwrap() { - Method::RegisterPeer { name } => assert_eq!(name, "workspace-orchestrator"), - method => panic!("expected RegisterPeer before Run, got {method:?}"), - } - write_test_event( - &mut writer, - Event::PeerRegistered { - result: serde_json::json!({"peer": "workspace-orchestrator"}), - }, - ) - .await; - - let mut second = String::new(); - reader.read_line(&mut second).await.unwrap(); - match serde_json::from_str::(&second).unwrap() { - Method::Run { input } => { - assert_eq!( - input, - test_launch_plan(std::path::Path::new("/tmp")).run_segments - ) - } - method => panic!("expected Run after pre-run RegisterPeer, got {method:?}"), - } - }); - - let mut client = WorkerClient::connect(&socket_path).await.unwrap(); - let options = TicketRoleLaunchOptions::default() - .with_pre_run_peer_registration("workspace-orchestrator"); - let warnings = run_pre_run_options_then_send_run( - &mut client, - &test_launch_plan(std::path::Path::new("/tmp")), - &options, - ) - .await - .unwrap(); - - server.await.unwrap(); - assert!(warnings.is_empty()); - } - - #[tokio::test] - async fn pre_run_peer_registration_failure_warns_but_still_sends_run() { - let temp = TempDir::new().unwrap(); - let socket_path = temp.path().join("worker.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, mut writer) = stream.into_split(); - write_test_event(&mut writer, test_snapshot()).await; - let mut reader = BufReader::new(reader); - - let mut first = String::new(); - reader.read_line(&mut first).await.unwrap(); - assert!(matches!( - serde_json::from_str::(&first).unwrap(), - Method::RegisterPeer { .. } - )); - write_test_event( - &mut writer, - Event::Error { - code: protocol::ErrorCode::InvalidRequest, - message: "peer metadata unavailable".to_string(), - }, - ) - .await; - - let mut second = String::new(); - reader.read_line(&mut second).await.unwrap(); - assert!(matches!( - serde_json::from_str::(&second).unwrap(), - Method::Run { .. } - )); - }); - - let mut client = WorkerClient::connect(&socket_path).await.unwrap(); - let options = TicketRoleLaunchOptions::default() - .with_pre_run_peer_registration("workspace-orchestrator"); - let warnings = run_pre_run_options_then_send_run( - &mut client, - &test_launch_plan(std::path::Path::new("/tmp")), - &options, - ) - .await - .unwrap(); - - server.await.unwrap(); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].message.contains("InvalidRequest")); - assert!(warnings[0].message.contains("workspace-orchestrator")); - } - - #[test] - fn default_config_role_launch_plan_requires_explicit_role_config() { - let temp = TempDir::new().unwrap(); - let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); - context.ticket = Some(TicketRef::id("Ticket Role Worker Launcher")); - - let err = plan_ticket_role_launch(context).unwrap_err(); - - assert!( - err.to_string() - .contains("Ticket role `coder` is not launch-configured") - ); - assert!(err.to_string().contains("[ticket.roles.coder]")); - } - - #[test] - fn backend_only_config_is_not_sufficient_for_role_launch_plan() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[backend] -provider = "builtin:yoi_local" -root = ".yoi/tickets" -"#, - ); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - - let err = plan_ticket_role_launch(context).unwrap_err(); - - assert!( - err.to_string() - .contains("Ticket role `intake` is not launch-configured") - ); - } - - #[test] - fn explicit_inherit_profile_fails_before_launch_planning() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[ticket.roles.intake] -profile = "inherit" -"#, - ); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - - let err = plan_ticket_role_launch(context).unwrap_err(); - - assert!(err.to_string().contains("profile = \"inherit\"")); - assert!(err.to_string().contains("top-level Ticket role launch")); - } - - #[test] - fn unresolvable_profile_selector_fails_before_spawn() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[ticket.roles.intake] -profile = "project:no-such-ticket-role-profile" -"#, - ); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - - let err = plan_ticket_role_launch(context).unwrap_err(); - - assert!( - err.to_string().contains( - "profile selector `project:no-such-ticket-role-profile` is not resolvable" - ) - ); - assert!(err.to_string().contains("[ticket.roles.intake].profile")); - } - - #[test] - fn full_concrete_role_config_allows_launch_planning() { - let temp = TempDir::new().unwrap(); - write_builtin_role_config(temp.path(), &[TicketRole::Intake]); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - - let plan = plan_ticket_role_launch(context).unwrap(); - - assert_eq!(plan.role, TicketRole::Intake); - assert_eq!(plan.profile, "builtin:companion"); - } - - #[test] - fn ticket_record_language_stays_out_of_first_run_text() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[ticket] -language = "Japanese" - -[ticket.roles.intake] -profile = "builtin:companion" -"#, - ); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - - let plan = plan_ticket_role_launch(context).unwrap(); - let text = text_segment(&plan); - - assert!(!text.contains("Ticket record language")); - assert!(!text.contains("Japanese")); - assert!(!text.contains("write durable Ticket item/thread/resolution text")); - } - - #[test] - fn scaffold_config_allows_intake_and_orchestrator_launch_planning() { - let temp = TempDir::new().unwrap(); - write_config(temp.path(), &ticket::config::ticket_config_scaffold()); - - let intake = plan_ticket_role_launch(TicketRoleLaunchContext::new( - temp.path(), - TicketRole::Intake, - )) - .unwrap(); - assert_eq!(intake.role, TicketRole::Intake); - assert_eq!(intake.profile, TicketRole::Intake.default_profile()); - - let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new( - temp.path(), - TicketRole::Orchestrator, - )) - .unwrap(); - assert_eq!(orchestrator.role, TicketRole::Orchestrator); - assert_eq!( - orchestrator.profile, - TicketRole::Orchestrator.default_profile() - ); - } - - #[test] - fn spawn_config_still_rejects_inherit_profile_defensively() { - let temp = TempDir::new().unwrap(); - let mut plan = test_launch_plan(temp.path()); - plan.profile = "inherit".to_string(); - - let err = plan - .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi")) - .unwrap_err(); - - assert!(matches!( - err, - TicketRoleLaunchError::UnsupportedInheritProfile - )); - assert!(err.to_string().contains("'inherit' cannot be used")); - } - - #[test] - fn configured_role_refs_are_plan_metadata_not_submit_text() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[ticket.roles.reviewer] -profile = "builtin:companion" -launch_prompt = "ticket.reviewer.launch" -"#, - ); - let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer); - context.worker_name = Some("reviewer-fixed".to_string()); - context.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher")); - context.user_instruction = Some("Review the submitted implementation.".to_string()); - - let plan = plan_ticket_role_launch(context).unwrap(); - let text = text_segment(&plan); - - assert_eq!(plan.worker_name, "reviewer-fixed"); - assert_eq!(plan.profile, "builtin:companion"); - assert_eq!( - plan.launch_prompt_ref.as_deref(), - Some("ticket.reviewer.launch") - ); - assert!(matches!(&plan.run_segments[0], Segment::Text { .. })); - assert!(!text.contains("Configured launch_prompt")); - assert!(!text.contains("ticket.reviewer.launch")); - assert!(!text.contains("Profile selector: builtin:companion")); - assert!(!text.contains("Role: reviewer")); - assert!(!text.contains("system_instruction")); - assert!(text.contains("Target Ticket:")); - assert!(text.contains("id: 20260605-190330-ticket-role-worker-launcher")); - assert!(text.contains("Action instruction:")); - assert!(text.contains("Review the submitted implementation.")); - let spawn = plan - .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi")) - .unwrap(); - assert_eq!(spawn.worker_name, "reviewer-fixed"); - assert_eq!(spawn.profile.as_deref(), Some("builtin:companion")); - assert_eq!(spawn.workspace_root, temp.path()); - assert!(spawn.cwd.is_none()); - assert_eq!( - plan.spawn_options().extra_args, - vec!["--ticket-role".to_string(), "reviewer".to_string()] - ); - } - - #[test] - fn submit_text_contains_only_ticket_action_and_per_launch_context() { - let temp = TempDir::new().unwrap(); - write_builtin_role_config( - temp.path(), - &[ - TicketRole::Intake, - TicketRole::Orchestrator, - TicketRole::Coder, - TicketRole::Reviewer, - ], - ); - - let mut intake = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - intake.user_instruction = Some("Clarify and materialize this request as a Ticket.".into()); - let intake_plan = plan_ticket_role_launch(intake).unwrap(); - let intake_text = text_segment(&intake_plan); - assert!(intake_text.contains("Action instruction:")); - assert!(intake_text.contains("Clarify and materialize")); - assert!(!intake_text.contains("Workflow:")); - assert!(!intake_text.contains("Profile selector:")); - assert!(!intake_text.contains("Role:")); - - let mut handoff_intake = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - handoff_intake.intake_handoff = Some(TicketIntakeHandoff::new( - "panel-orchestrator-demo", - "Demo workspace", - )); - let handoff_plan = plan_ticket_role_launch(handoff_intake).unwrap(); - let handoff_text = text_segment(&handoff_plan); - assert!(handoff_text.contains("Panel handoff:")); - assert!( - handoff_text - .contains("workspace_workspace_orchestrator_worker: panel-orchestrator-demo") - ); - assert!(handoff_text.contains("workspace: Demo workspace")); - assert!(!handoff_text.contains("created_or_updated_ticket_id")); - assert!(!handoff_text.contains("Ticket tool surface")); - assert!(!handoff_text.contains("ready -> queued")); - - let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator); - orchestrator.ticket = Some(TicketRef::id("launcher")); - orchestrator.intent_packet = Some("Route to implementation after planning sync.".into()); - orchestrator.validation = vec!["cargo check --workspace --all-targets".into()]; - let orchestrator_plan = plan_ticket_role_launch(orchestrator).unwrap(); - let orchestrator_text = text_segment(&orchestrator_plan); - assert!(orchestrator_text.contains("id: launcher")); - assert!(orchestrator_text.contains("Route to implementation after planning sync.")); - assert!(orchestrator_text.contains("cargo check --workspace --all-targets")); - assert!(!orchestrator_text.contains("state = inprogress")); - assert!(!orchestrator_text.contains("root/original workspace reads")); - assert!(!orchestrator_text.contains("role_workspace_root")); - assert!(!orchestrator_text.contains("role_cwd")); - - let mut coder = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); - coder.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher")); - coder.worktree_path = Some(PathBuf::from("/tmp/yoi-code")); - coder.branch = Some("work/ticket-role-worker-launcher".into()); - coder.validation = vec!["cargo test -p client ticket_role".into()]; - coder.report_expectations = vec!["implementation report with validation".into()]; - let coder_plan = plan_ticket_role_launch(coder).unwrap(); - let coder_text = text_segment(&coder_plan); - assert!(coder_text.contains("path: /tmp/yoi-code")); - assert!(coder_text.contains("branch: work/ticket-role-worker-launcher")); - assert!(coder_text.contains("cargo test -p client ticket_role")); - assert!(coder_text.contains("implementation report with validation")); - assert!(!coder_text.contains("provided child worktree/branch")); - assert!(!coder_text.contains("choose local tactics")); - assert!(!coder_text.contains("Do not merge, push")); - - let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer); - reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher")); - reviewer.worktree_path = Some(PathBuf::from("/tmp/yoi-review")); - reviewer.branch = Some("work/ticket-role-worker-launcher".into()); - reviewer.report_expectations = vec!["approve or request changes".into()]; - let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap(); - let reviewer_text = text_segment(&reviewer_plan); - assert!(reviewer_text.contains("path: /tmp/yoi-review")); - assert!(reviewer_text.contains("branch: work/ticket-role-worker-launcher")); - assert!(reviewer_text.contains("approve or request changes")); - assert!(!reviewer_text.contains("read-only by default")); - assert!(!reviewer_text.contains("Orchestrator-side integration")); - assert!(!reviewer_text.contains("Do not merge, close")); - } - - #[test] - fn orchestrator_submit_exposes_operation_targets_without_runtime_workspace_context() { - let temp = TempDir::new().unwrap(); - write_builtin_role_config(temp.path(), &[TicketRole::Orchestrator]); - let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator); - orchestrator.ticket = Some(TicketRef::id("orchestrator-integration")); - orchestrator.intent_packet = Some("Complete an already-reviewed Ticket.".into()); - orchestrator.validation = vec!["cargo test -p client ticket_role --lib".into()]; - orchestrator = orchestrator - .with_original_workspace_root(temp.path().join("original")) - .with_target_workspace_root(temp.path().join("target")); - - let plan = plan_ticket_role_launch(orchestrator).unwrap(); - let text = text_segment(&plan); - assert_eq!(plan.workspace_root, temp.path()); - assert_eq!(plan.original_workspace_root, temp.path().join("original")); - assert_eq!( - plan.implementation_worktree_root, - temp.path().join("original").join(".worktree") - ); - assert_eq!(plan.target_workspace_root, temp.path().join("target")); - let spawn_config = plan - .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi")) - .unwrap(); - assert_eq!(spawn_config.workspace_root, temp.path()); - assert_eq!(spawn_config.cwd, None); - - assert!(text.contains("Orchestrator operation targets:")); - assert!(text.contains("implementation_worktree_root")); - assert!(!text.contains("merge_target_workspace_root")); - assert!(!text.contains("Workspace routing context:")); - assert!(!text.contains("role_workspace_root")); - assert!(!text.contains("role_cwd")); - assert!(!text.contains("original_workspace_root")); - assert!(!text.contains("Orchestrator workspace on the orchestration branch")); - assert!(!text.contains("Root/original workspace reads, writes, validation, cleanup, and git operations are prohibited")); - assert!(!text.contains("Orchestrator implementation integration guidance")); - } - #[test] - fn caller_provided_worker_name_is_used_exactly() { - let temp = TempDir::new().unwrap(); - write_builtin_role_config(temp.path(), &[TicketRole::Intake]); - let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); - context.worker_name = Some("custom-intake-worker".into()); - - let plan = plan_ticket_role_launch(context).unwrap(); - - assert_eq!(plan.worker_name, "custom-intake-worker"); - } - - #[test] - fn malformed_ticket_config_surfaces_error() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[ticket.roles.coder] -profile = "./coder.toml" -"#, - ); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); - - let err = plan_ticket_role_launch(context).unwrap_err(); - - assert!(err.to_string().contains("path selectors are not supported")); - } - - #[test] - fn system_instruction_is_not_a_launch_config_field() { - let temp = TempDir::new().unwrap(); - write_config( - temp.path(), - r#" -[ticket.roles.coder] -profile = "inherit" -system_instruction = "unsupported" -"#, - ); - let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); - - let err = plan_ticket_role_launch(context).unwrap_err(); - - assert!(err.to_string().contains("unknown field")); - assert!(err.to_string().contains("system_instruction")); - } -} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 8a99c4a5..67199b7e 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -16,7 +16,7 @@ protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } base64 = "0.22.1" crossterm = "0.28" -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } serde_json = { workspace = true } unicode-width = "0.2.2" uuid = { workspace = true } @@ -24,10 +24,8 @@ toml = { workspace = true } manifest = { workspace = true } secrets = { workspace = true } session-store = { workspace = true } -fs4 = { workspace = true } ticket = { workspace = true } serde = { workspace = true, features = ["derive"] } -worker = { path = "../worker" } pulldown-cmark = { version = "0.13.3", default-features = false } agen.workspace = true diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 6da12bbb..87c90043 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -1,4 +1,3 @@ -use std::error::Error; use std::fmt; use std::future::Future; use std::io; @@ -21,30 +20,18 @@ use protocol::{Event, Method, WorkerStatus}; use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use session_store::SegmentId; use standalone::{StandaloneHost, StandaloneLaunchConfig}; use tokio::sync::{broadcast, mpsc}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use client::{ - BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent, WorkerClient, - WorkerRuntimeCommand, -}; +use client::{BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::composer_keys::{ComposerEditAction, composer_edit_action}; -use crate::picker::PickerOutcome; -use crate::spawn::{SpawnOutcome, SpawnReady}; -use crate::{picker, spawn, ui}; +use crate::ui; pub(crate) type ConsoleTerminal = Terminal>; -/// Narrow request bridge used when the workspace Dashboard opens a Worker Console. -pub(crate) struct DashboardConsoleOpenRequest { - pub(crate) worker_name: String, - pub(crate) socket_override: Option, -} - /// Enable SGR coordinates plus normal mouse tracking. This captures clicks, /// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`) /// so terminal-native drag selection remains available during startup. @@ -132,51 +119,7 @@ fn copy_selection_to_terminal(app: &mut App) -> bool { copy_selection_to_writer(app, &mut stdout) } -fn resolve_socket(worker_name: &str, override_path: Option) -> PathBuf { - if let Some(p) = override_path { - return p; - } - manifest::paths::worker_socket_path(worker_name).unwrap_or_else(|| { - PathBuf::from("/tmp") - .join("yoi") - .join(worker_name) - .join("sock") - }) -} - -pub(crate) async fn run_worker_name( - worker_name: String, - socket_override: Option, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), Box> { - #[cfg(feature = "e2e-test")] - if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { - let mut terminal = enter_fullscreen()?; - terminal.clear()?; - let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await; - let _ = leave_fullscreen(&mut terminal); - return result; - } - - if let Some(client) = try_connect_live_pod(&worker_name, socket_override.clone()).await { - let mut terminal = enter_fullscreen()?; - run_connected_pod(&mut terminal, worker_name, client, runtime_command.clone()).await?; - return Ok(()); - } - - let ready = match spawn::run_worker_name(worker_name, runtime_command.clone()).await? { - SpawnOutcome::Ready(r) => r, - SpawnOutcome::Cancelled => return Ok(()), - }; - let mut terminal = enter_fullscreen()?; - terminal.clear()?; - let result = run_ready_pod(&mut terminal, ready, runtime_command).await; - let _ = leave_fullscreen(&mut terminal); - result -} - enum ConsoleConnection { - LegacySocket(WorkerClient), BackendRuntime(BackendRuntimeClient), Standalone { host: Option, @@ -198,7 +141,6 @@ impl ConsoleConnection { fn try_next_event(&mut self) -> Option { match self { - Self::LegacySocket(client) => client.try_next_event(), Self::BackendRuntime(client) => client.try_next_event(), Self::Standalone { events, @@ -210,7 +152,6 @@ impl ConsoleConnection { async fn next_event(&mut self) -> Option { match self { - Self::LegacySocket(client) => client.next_event().await, Self::BackendRuntime(client) => client.next_event().await, Self::Standalone { host, events, .. } => loop { match events.recv().await { @@ -229,7 +170,6 @@ impl ConsoleConnection { async fn send(&mut self, method: &Method) -> Result<(), Box> { match self { - Self::LegacySocket(client) => Ok(client.send(method).await?), Self::BackendRuntime(client) => Ok(client.send(method).await?), Self::Standalone { host, .. } => { let host = host.as_ref().ok_or_else(|| { @@ -315,7 +255,7 @@ async fn run_standalone_host( } }; let mut app = App::new_with_persistent_input_history(worker_label, &history_root); - let run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await; + let run_result = run_loop(&mut terminal, &mut app, &mut connection).await; let shutdown_result = connection .shutdown() .await @@ -340,190 +280,11 @@ pub(crate) async fn run_backend_runtime( let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); app.connected = true; let mut connection = ConsoleConnection::BackendRuntime(client); - let result = run_loop(&mut terminal, &mut app, &mut connection, None).await; + let result = run_loop(&mut terminal, &mut app, &mut connection).await; let _ = leave_fullscreen(&mut terminal); result } -async fn run_connected_pod( - terminal: &mut ConsoleTerminal, - worker_name: String, - client: WorkerClient, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), Box> { - let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root); - app.connected = true; - let mut connection = ConsoleConnection::LegacySocket(client); - run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await -} - -pub(crate) async fn open_from_dashboard( - terminal: &mut ConsoleTerminal, - request: DashboardConsoleOpenRequest, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), Box> { - let DashboardConsoleOpenRequest { - worker_name, - socket_override, - } = request; - - if let Some(client) = try_connect_live_pod(&worker_name, socket_override).await { - return run_connected_pod(terminal, worker_name, client, runtime_command.clone()).await; - } - - let ready = - spawn_worker_name_from_fullscreen(terminal, &worker_name, runtime_command.clone()).await?; - run_ready_pod(terminal, ready, runtime_command).await -} - -async fn spawn_worker_name_from_fullscreen( - terminal: &mut ConsoleTerminal, - worker_name: &str, - runtime_command: WorkerRuntimeCommand, -) -> Result> { - leave_fullscreen(terminal)?; - let outcome = spawn::run_worker_name(worker_name.to_string(), runtime_command).await; - enter_fullscreen_existing(terminal)?; - terminal.clear()?; - - match outcome? { - SpawnOutcome::Ready(ready) => Ok(ready), - SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)), - } -} - -async fn try_connect_live_pod( - worker_name: &str, - socket_override: Option, -) -> Option { - let preferred_socket = resolve_socket(worker_name, socket_override.clone()); - connect_live_pod(worker_name, preferred_socket, socket_override.is_none()) - .await - .map(|(_, client)| client) -} - -#[derive(Debug)] -struct NestedOpenCancelled; - -impl std::fmt::Display for NestedOpenCancelled { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Worker open was cancelled") - } -} - -impl std::error::Error for NestedOpenCancelled {} - -async fn run_ready_pod( - terminal: &mut ConsoleTerminal, - ready: SpawnReady, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), Box> { - let SpawnReady { - worker_name, - socket_path, - } = ready; - run(terminal, worker_name, &socket_path, runtime_command).await -} - -async fn connect_live_pod( - worker_name: &str, - preferred_socket: PathBuf, - allow_registry_fallback: bool, -) -> Option<(PathBuf, WorkerClient)> { - if let Ok(client) = WorkerClient::connect(&preferred_socket).await { - return Some((preferred_socket, client)); - } - - if !allow_registry_fallback { - return None; - } - let registry_socket = picker::live_socket_for_worker(worker_name)?; - if registry_socket == preferred_socket { - return None; - } - WorkerClient::connect(®istry_socket) - .await - .ok() - .map(|client| (registry_socket, client)) -} - -pub(crate) async fn run_resume( - runtime_command: WorkerRuntimeCommand, - workspace_root: PathBuf, - all: bool, -) -> Result<(), Box> { - run_worker_picker(runtime_command, workspace_root, all, true).await -} - -pub(crate) async fn run_worker_picker( - runtime_command: WorkerRuntimeCommand, - workspace_root: PathBuf, - all: bool, - include_stopped: bool, -) -> Result<(), Box> { - // Pick a Worker in its own inline viewport, dropping the viewport before - // attaching/restoring so each phase gets fresh vertical room. - let picker_options = if all { - picker::PickerOptions::all() - } else { - picker::PickerOptions::workspace(workspace_root) - } - .with_stopped(include_stopped); - let (worker_name, socket_override) = match picker::run(picker_options).await? { - PickerOutcome::Picked { - worker_name, - socket_override, - } => (worker_name, socket_override), - PickerOutcome::Cancelled => return Ok(()), - }; - run_worker_name(worker_name, socket_override, runtime_command).await -} - -pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool { - error.is::() || error.is::() -} - -pub(crate) async fn run_spawn( - resume_from: Option, - worker_name: Option, - profile: Option, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), Box> { - #[cfg(feature = "e2e-test")] - if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { - let mut terminal = enter_fullscreen()?; - terminal.clear()?; - let fixture_worker_name = worker_name.unwrap_or_else(|| "e2e-rewind".to_string()); - let result = run_e2e_rewind_fixture(&mut terminal, fixture_worker_name).await; - let _ = leave_fullscreen(&mut terminal); - return result; - } - - let ready = match spawn::run(resume_from, worker_name, profile, runtime_command.clone()).await? - { - SpawnOutcome::Ready(r) => r, - SpawnOutcome::Cancelled => return Ok(()), - }; - - let SpawnReady { - worker_name, - socket_path, - } = ready; - - let mut terminal = enter_fullscreen()?; - let result = run(&mut terminal, worker_name, &socket_path, runtime_command).await; - - // Leave alt-screen explicitly before `main`'s terminal restore path. - let _ = execute!( - terminal.backend_mut(), - DisableMouseCapture, - LeaveAlternateScreen - ); - - result -} - fn enter_fullscreen() -> Result> { let mut stdout = io::stdout(); // Enable button-event tracking so the transcript can own drag selection; @@ -542,19 +303,6 @@ pub(crate) fn enter_dashboard_fullscreen() -> Result Result<(), Box> { - // Re-enable the same least-intrusive wheel mouse mode after returning from - // nested inline screens. - execute!( - terminal.backend_mut(), - EnterAlternateScreen, - EnableSinglePodMouseCapture - )?; - Ok(()) -} - fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> { execute!( terminal.backend_mut(), @@ -567,35 +315,6 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io:: leave_fullscreen(terminal) } -async fn run( - terminal: &mut ConsoleTerminal, - worker_name: String, - socket_path: &std::path::Path, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), Box> { - let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root); - - match WorkerClient::connect(socket_path).await { - Ok(client) => { - app.connected = true; - // The Worker sends `Event::Snapshot` automatically on connect; - // no explicit method call is required to fetch history. - let mut connection = ConsoleConnection::LegacySocket(client); - run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await?; - } - Err(e) => { - app.push_error(format!( - "Failed to connect to {}: {e}", - socket_path.display() - )); - terminal.draw(|f| ui::draw(f, &mut app))?; - run_disconnected(&mut app)?; - } - } - Ok(()) -} - type TerminalEventResult = io::Result; const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50); @@ -861,14 +580,13 @@ async fn drain_terminal_events( app: &mut App, client: &mut ConsoleConnection, term_rx: &mut mpsc::UnboundedReceiver, - runtime_command: Option<&WorkerRuntimeCommand>, ) -> Result> { let mut handled = false; for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { match term_rx.try_recv() { Ok(event) => { handled = true; - handle_terminal_event(app, client, event?, runtime_command).await?; + handle_terminal_event(app, client, event?).await?; if app.quit { break; } @@ -908,7 +626,6 @@ async fn run_loop( terminal: &mut Terminal>, app: &mut App, client: &mut ConsoleConnection, - runtime_command: Option, ) -> Result<(), Box> { let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; @@ -919,8 +636,7 @@ async fn run_loop( break; } - let handled_term_event = - drain_terminal_events(app, client, &mut term_rx, runtime_command.as_ref()).await?; + let handled_term_event = drain_terminal_events(app, client, &mut term_rx).await?; if app.quit { break; } @@ -932,7 +648,7 @@ async fn run_loop( match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { LoopInput::Terminal(term_event) => { - handle_terminal_event(app, client, term_event?, runtime_command.as_ref()).await?; + handle_terminal_event(app, client, term_event?).await?; } LoopInput::Worker(event) => match event { Some(ev) => { @@ -958,7 +674,6 @@ async fn handle_terminal_event( app: &mut App, client: &mut ConsoleConnection, event: TermEvent, - _runtime_command: Option<&WorkerRuntimeCommand>, ) -> Result<(), Box> { match event { TermEvent::Key(key) => { @@ -980,19 +695,6 @@ async fn handle_terminal_event( Ok(()) } -fn run_disconnected(_app: &mut App) -> Result<(), Box> { - loop { - if event::poll(std::time::Duration::from_millis(100))? - && let TermEvent::Key(key) = event::read()? - && let KeyCode::Char('c') = key.code - && key.modifiers.contains(KeyModifiers::CONTROL) - { - break; - } - } - Ok(()) -} - /// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so /// hand-rolling through long histories isn't tedious, but slow enough /// that a single notch doesn't blow past the section the user is diff --git a/crates/tui/src/dashboard/mod.rs b/crates/tui/src/dashboard/mod.rs deleted file mode 100644 index e56e272b..00000000 --- a/crates/tui/src/dashboard/mod.rs +++ /dev/null @@ -1,5377 +0,0 @@ -use std::collections::BTreeSet; -use std::error::Error; -use std::fmt; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use client::ticket_role::{ - TicketIntakeHandoff, TicketRef, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, - TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_worker, - launch_ticket_role_worker_with_options, plan_ticket_role_launch, -}; -use client::{ - SpawnConfig, WorkerProcessLaunchOptions, WorkerRuntimeCommand, spawn_worker, - spawn_worker_with_options, -}; -use crossterm::event::{ - Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, - poll, read, -}; -use protocol::stream::{JsonLineReader, JsonLineWriter}; -use protocol::{ErrorCode, Event, Method, Segment, WorkerStatus}; -use ratatui::Frame; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Layout, Position, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; -use session_store::FsStore; -use session_store::FsWorkerStore; -use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig}; -use ticket::{ - LocalTicketBackend, MarkdownText, TicketBackend, TicketIdOrSlug, TicketStateChange, - TicketWorkflowState, -}; -use tokio::net::UnixStream; -use unicode_width::UnicodeWidthStr; - -use crate::composer_keys::{ComposerEditAction, composer_edit_action}; -use crate::input::InputBuffer; -use crate::role_session_registry::{ - PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult, -}; -use crate::worker_list::{ - StoredMetadataState, WorkerList, WorkerListEntry, WorkerVisibilitySource, - read_reachable_live_worker_infos, read_stored_worker_infos, -}; -#[cfg(not(feature = "e2e-test"))] -use crate::workspace_panel::build_workspace_panel; -#[cfg(feature = "e2e-test")] -use crate::workspace_panel::build_workspace_panel_with_e2e_timings; -use crate::workspace_panel::{ - ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus, - CompanionWorkerPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, - OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorWorkerPresence, PanelRow, - PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus, - WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row, - companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle, - local_claim_status_for_pod, ticket_config_availability, workspace_companion_worker_name, - workspace_orchestrator_worker_name, workspace_orchestrator_worker_presence, -}; - -mod render; - -use render::{PanelListRow, row_hit_boxes}; - -const MAX_ENTRIES: usize = 50; -const CLOSED_VISIBLE_ROWS: usize = 3; -const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); -const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); -const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100); -const PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS: usize = 4_000; - -#[derive(Debug)] -pub(crate) enum DashboardError { - Io(io::Error), - Store(session_store::StoreError), - NoWorkers, -} - -impl std::fmt::Display for DashboardError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(e) => write!(f, "io error: {e}"), - Self::Store(e) => write!(f, "session store error: {e}"), - Self::NoWorkers => write!( - f, - "no Tickets or Workers found — create a Ticket with `yoi ticket create` or restore a Worker with `yoi resume`" - ), - } - } -} - -impl std::error::Error for DashboardError {} - -impl From for DashboardError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } -} - -impl From for DashboardError { - fn from(e: session_store::StoreError) -> Self { - Self::Store(e) - } -} - -pub(crate) enum DashboardOutcome { - Quit, - Open(OpenWorkerRequest), -} - -pub(crate) async fn launch( - runtime_command: WorkerRuntimeCommand, - include_stopped: bool, -) -> Result<(), Box> { - let mut app = load_app(runtime_command.clone(), include_stopped).await?; - let mut terminal = crate::console::enter_dashboard_fullscreen()?; - loop { - match run_loop(&mut terminal, &mut app).await? { - DashboardOutcome::Quit => { - crate::console::leave_dashboard_fullscreen(&mut terminal)?; - return Ok(()); - } - DashboardOutcome::Open(request) => { - let worker_name = request.worker_name.clone(); - let console_request = crate::console::DashboardConsoleOpenRequest { - worker_name: request.worker_name, - socket_override: request.socket_override, - }; - let result = crate::console::open_from_dashboard( - &mut terminal, - console_request, - runtime_command.clone(), - ) - .await; - if let Err(error) = finish_nested_console_open(&mut app, &worker_name, result) { - crate::console::leave_dashboard_fullscreen(&mut terminal)?; - return Err(error); - } - } - } - } -} - -fn finish_nested_console_open( - app: &mut DashboardApp, - worker_name: &str, - result: Result<(), Box>, -) -> Result<(), Box> { - match result { - Ok(()) => { - app.finish_open(worker_name, Ok(())); - Ok(()) - } - Err(error) if crate::console::is_recoverable_dashboard_open_error(error.as_ref()) => { - app.finish_open(worker_name, Err(error.as_ref())); - Ok(()) - } - Err(error) => Err(error), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct OpenWorkerRequest { - pub(crate) worker_name: String, - pub(crate) socket_override: Option, -} - -pub(crate) async fn load_app( - runtime_command: WorkerRuntimeCommand, - include_stopped: bool, -) -> Result { - Ok(DashboardApp::loading(runtime_command, include_stopped)) -} - -async fn run_loop( - terminal: &mut Terminal>, - app: &mut DashboardApp, -) -> Result { - if app.panel.rows.is_empty() - && app.panel.header.diagnostics.is_empty() - && app.enter_reload.is_none() - { - return Err(DashboardError::NoWorkers); - } - - let mut pending_reload = PendingReload::default(); - let mut pending_queue_attention_notice = PendingQueueAttentionNotice::default(); - let mut deferred_enter_reload = app.enter_reload.take(); - let mut next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; - #[cfg(feature = "e2e-test")] - let mut emitted_panel_ready = false; - - loop { - if let Some(result) = pending_queue_attention_notice.finish_if_ready().await { - app.finish_orchestrator_queue_attention_notice(result); - } - if let Some(result) = pending_reload.finish_if_ready().await { - app.apply_reload_result(result); - if let Some(request) = app.prepare_orchestrator_queue_attention_notice() { - pending_queue_attention_notice.start(request); - } - } - - terminal.draw(|f| render::draw(f, app))?; - #[cfg(feature = "e2e-test")] - { - if !emitted_panel_ready { - // `panel_ready` is a first-visible-frame signal only. E2E tests that need - // list/data readiness must wait for a concrete `rows_rendered` fixture row. - crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({})); - emitted_panel_ready = true; - } - // Emit every drawn row snapshot separately so tests can assert data-backed row - // readiness without conflating it with the first frame. - app.emit_rows_rendered(); - } - - if let Some(mode) = deferred_enter_reload.take() { - if pending_reload.start(mode, app.include_stopped) { - app.refreshing = true; - } - } - - let now = Instant::now(); - if now >= next_poll { - pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped); - next_poll = now + DASHBOARD_POLL_INTERVAL; - continue; - } - - let event_wait = TERMINAL_EVENT_POLL_INTERVAL.min(next_poll.saturating_duration_since(now)); - if !poll(event_wait)? { - continue; - } - - match read()? { - TermEvent::Key(key) => match app.handle_key(key) { - DashboardAction::None => {} - DashboardAction::Quit => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit("panel", "quit_requested", serde_json::json!({})); - abort_panel_background_work_for_quit( - &mut pending_reload, - &mut pending_queue_attention_notice, - ); - return Ok(DashboardOutcome::Quit); - } - DashboardAction::Open => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "action_requested", - serde_json::json!({ "action": "open" }), - ); - if let Some(request) = app.prepare_open() { - terminal.draw(|f| render::draw(f, app))?; - return Ok(DashboardOutcome::Open(request)); - } - } - DashboardAction::DispatchTicketAction(request) => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "action_requested", - serde_json::json!({ "action": "ticket_action" }), - ); - pending_reload.abort(); - pending_queue_attention_notice.abort(); - terminal.draw(|f| render::draw(f, app))?; - let result = dispatch_ticket_action(request).await; - app.finish_ticket_action_dispatch(result); - if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) - { - app.refreshing = true; - } - next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; - } - DashboardAction::ReturnReadyTicketToPlanning(request) => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "action_requested", - serde_json::json!({ "action": "return_ready_ticket_to_planning" }), - ); - pending_reload.abort(); - pending_queue_attention_notice.abort(); - terminal.draw(|f| render::draw(f, app))?; - match dispatch_ready_ticket_planning_return(request).await { - Ok(outcome) => { - match app.finish_ready_ticket_planning_return_success(outcome) { - ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request) => { - terminal.draw(|f| render::draw(f, app))?; - let planning_notice = app.notice.clone().unwrap_or_default(); - let result = launch_intake_with_handoff(request).await; - app.finish_ready_ticket_planning_return_with_intake_launch( - planning_notice, - result, - ); - } - ReadyTicketPlanningReturnAfterMutation::OpenClaim(request) => { - terminal.draw(|f| render::draw(f, app))?; - return Ok(DashboardOutcome::Open(request)); - } - ReadyTicketPlanningReturnAfterMutation::None => {} - } - } - Err(error) => app.finish_ready_ticket_planning_return_error(error), - } - if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) - { - app.refreshing = true; - } - next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; - } - DashboardAction::LaunchIntake(request) => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "action_requested", - serde_json::json!({ "action": "launch_intake" }), - ); - pending_reload.abort(); - pending_queue_attention_notice.abort(); - terminal.draw(|f| render::draw(f, app))?; - let result = launch_intake_with_handoff(request).await; - app.finish_intake_launch(result); - if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) - { - app.refreshing = true; - } - next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; - } - DashboardAction::SendCompanion(request) => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "action_requested", - serde_json::json!({ "action": "send_companion" }), - ); - pending_reload.abort(); - pending_queue_attention_notice.abort(); - terminal.draw(|f| render::draw(f, app))?; - let result = dispatch_companion_message(request).await; - app.finish_companion_send(result); - if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped) - { - app.refreshing = true; - } - next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; - } - }, - TermEvent::Paste(text) => app.input.insert_paste(text), - TermEvent::Mouse(mouse) => { - app.handle_mouse_event(mouse); - } - TermEvent::Resize(_, _) => {} - _ => {} - } - } -} - -struct PendingReload { - handle: Option>>, -} - -impl PendingReload { - fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode, include_stopped: bool) -> bool { - if self.handle.is_some() { - return false; - } - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "background_task_started", - serde_json::json!({ - "task": "reload", - "lifecycle_mode": format!("{lifecycle_mode:?}"), - }), - ); - self.handle = Some(tokio::spawn(async move { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::hold_background_task_if_requested("reload").await; - load_dashboard_snapshot(None, lifecycle_mode, include_stopped).await - })); - true - } - - #[cfg(test)] - fn start_with_handle( - &mut self, - handle: tokio::task::JoinHandle>, - ) -> bool { - if self.handle.is_some() { - handle.abort(); - return false; - } - self.handle = Some(handle); - true - } - - async fn finish_if_ready(&mut self) -> Option> { - if !self.handle.as_ref()?.is_finished() { - return None; - } - let handle = self.handle.take()?; - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "background_task_finished", - serde_json::json!({ "task": "reload" }), - ); - Some(match handle.await { - Ok(result) => result, - Err(e) => Err(DashboardError::Io(io::Error::other(format!( - "reload task failed: {e}" - )))), - }) - } - - fn abort(&mut self) { - if let Some(handle) = self.handle.take() { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "background_task_aborted", - serde_json::json!({ "task": "reload" }), - ); - handle.abort(); - } - } -} - -impl Default for PendingReload { - fn default() -> Self { - Self { handle: None } - } -} - -impl Drop for PendingReload { - fn drop(&mut self) { - self.abort(); - } -} - -struct PendingQueueAttentionNotice { - handle: Option>, -} - -impl PendingQueueAttentionNotice { - fn start(&mut self, request: OrchestratorQueueAttentionNoticeRequest) -> bool { - if self.handle.is_some() { - return false; - } - self.handle = Some(tokio::spawn(async move { - dispatch_orchestrator_queue_attention_notice(request).await - })); - true - } - - #[cfg(test)] - fn start_with_handle( - &mut self, - handle: tokio::task::JoinHandle, - ) -> bool { - if self.handle.is_some() { - handle.abort(); - return false; - } - self.handle = Some(handle); - true - } - - async fn finish_if_ready(&mut self) -> Option { - if !self.handle.as_ref()?.is_finished() { - return None; - } - let handle = self.handle.take()?; - Some(match handle.await { - Ok(result) => result, - Err(e) => OrchestratorQueueAttentionNoticeResult::failed( - String::new(), - format!("queue-attention notice task failed: {e}"), - ), - }) - } - - fn abort(&mut self) { - if let Some(handle) = self.handle.take() { - handle.abort(); - } - } -} - -impl Default for PendingQueueAttentionNotice { - fn default() -> Self { - Self { handle: None } - } -} - -impl Drop for PendingQueueAttentionNotice { - fn drop(&mut self) { - self.abort(); - } -} - -fn abort_panel_background_work_for_quit( - pending_reload: &mut PendingReload, - pending_queue_attention_notice: &mut PendingQueueAttentionNotice, -) { - pending_reload.abort(); - pending_queue_attention_notice.abort(); -} - -fn default_store_dir() -> Result { - manifest::paths::sessions_dir().ok_or_else(|| { - DashboardError::Io(io::Error::new( - io::ErrorKind::NotFound, - "could not resolve sessions directory", - )) - }) -} - -fn default_worker_metadata_dir() -> Result { - manifest::paths::data_dir() - .map(|dir| dir.join("workers")) - .ok_or_else(|| { - DashboardError::Io(io::Error::new( - io::ErrorKind::NotFound, - "could not resolve worker state directory", - )) - }) -} - -#[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum OpenEligibility { - OpenNow, - Disabled, -} - -#[derive(Debug)] -pub(crate) struct IntakeLaunchRequest { - context: TicketRoleLaunchContext, - runtime_command: WorkerRuntimeCommand, - peer_registration: IntakePeerRegistrationRequest, - registry_update: IntakeRegistryUpdate, -} - -#[derive(Debug, Clone)] -pub(crate) enum IntakeRegistryUpdate { - RecordSession { - registry_root: PathBuf, - worker_name: String, - origin: RoleSessionOrigin, - related_tickets: Vec, - }, - ClaimTicket { - registry_root: PathBuf, - ticket_id: String, - ticket_slug: Option, - worker_name: String, - }, - ClaimLaunchedTicket { - registry_root: PathBuf, - ticket_id: String, - ticket_slug: Option, - }, -} - -#[derive(Debug)] -pub(crate) struct ReadyTicketPlanningReturnRequest { - workspace_root: PathBuf, - ticket_id: String, - ticket_key: String, - user_instruction: String, - followup: ReadyTicketPlanningReturnFollowup, -} - -#[derive(Debug)] -pub(crate) enum ReadyTicketPlanningReturnFollowup { - LaunchIntake(IntakeLaunchRequest), - NotifyLiveClaimedIntake { - worker_name: String, - socket_path: PathBuf, - }, - OpenRestorableClaimedIntake(OpenWorkerRequest), - BlockedByStaleClaim { - worker_name: String, - }, -} - -#[derive(Debug)] -struct ReadyTicketPlanningReturnOutcome { - notice: String, - followup: ReadyTicketPlanningReturnAfterMutation, -} - -#[derive(Debug)] -enum ReadyTicketPlanningReturnAfterMutation { - LaunchIntake(IntakeLaunchRequest), - OpenClaim(OpenWorkerRequest), - None, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum IntakePeerRegistrationRequest { - Register { - workspace_orchestrator_worker: String, - }, - Skip { - reason: String, - }, -} - -#[derive(Debug, Clone)] -pub(crate) struct IntakeLaunchOutcome { - launch: TicketRoleLaunchResult, - peer_registration: IntakePeerRegistrationStatus, - registry_warning: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum IntakePeerRegistrationStatus { - Registered { - workspace_orchestrator_worker: String, - }, - Warning { - message: String, - }, -} - -impl IntakePeerRegistrationStatus { - fn warning(message: impl Into) -> Self { - Self::Warning { - message: bounded_panel_diagnostic(message.into()), - } - } -} - -pub(crate) type IntakeLaunchResult = Result; - -pub(crate) async fn dispatch_companion_message( - request: CompanionSendRequest, -) -> Result { - let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(&request.socket_path)) - .await - .map_err(|_| CompanionSendError::Rejected { - worker_name: request.worker_name.clone(), - message: "connect timed out".to_string(), - })? - .map_err(|source| CompanionSendError::Connect { - worker_name: request.worker_name.clone(), - source, - })?; - let (read_half, write_half) = stream.into_split(); - let mut reader = JsonLineReader::new(read_half); - let mut writer = JsonLineWriter::new(write_half); - - loop { - let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()) - .await - .map_err(|_| CompanionSendError::Rejected { - worker_name: request.worker_name.clone(), - message: "initial Snapshot timed out".to_string(), - })? - .map_err(|source| CompanionSendError::Read { - worker_name: request.worker_name.clone(), - source, - })?; - match event { - Some(Event::Snapshot { .. }) => break, - Some(Event::Alert(_)) => continue, - Some(Event::Error { message, .. }) => { - return Err(CompanionSendError::Rejected { - worker_name: request.worker_name, - message, - }); - } - Some(_) => continue, - None => { - return Err(CompanionSendError::Closed { - worker_name: request.worker_name, - }); - } - } - } - - tokio::time::timeout( - SOCKET_OP_TIMEOUT, - writer.write(&Method::Run { - input: request.segments, - }), - ) - .await - .map_err(|_| CompanionSendError::Rejected { - worker_name: request.worker_name.clone(), - message: "write timed out".to_string(), - })? - .map_err(|source| CompanionSendError::Write { - worker_name: request.worker_name.clone(), - source, - })?; - - loop { - match tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()).await { - Ok(Ok(Some(Event::UserMessage { .. }))) => { - return Ok(CompanionSendOutcome { - notice: format!("Sent to Companion {}.", request.worker_name), - }); - } - Ok(Ok(Some(Event::Error { message, .. }))) => { - return Err(CompanionSendError::Rejected { - worker_name: request.worker_name, - message, - }); - } - Ok(Ok(Some(Event::Snapshot { .. } | Event::Alert(_)))) => continue, - Ok(Ok(Some(_))) => continue, - Ok(Ok(None)) => { - return Err(CompanionSendError::Closed { - worker_name: request.worker_name, - }); - } - Ok(Err(source)) => { - return Err(CompanionSendError::Read { - worker_name: request.worker_name, - source, - }); - } - Err(_) => { - return Err(CompanionSendError::Rejected { - worker_name: request.worker_name, - message: "acceptance read timed out".to_string(), - }); - } - } - } -} - -async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunchResult { - let (options, workspace_orchestrator_worker, skip_warning) = - match request.peer_registration.clone() { - IntakePeerRegistrationRequest::Register { - workspace_orchestrator_worker, - } => ( - TicketRoleLaunchOptions::default() - .with_pre_run_peer_registration(workspace_orchestrator_worker.clone()), - Some(workspace_orchestrator_worker), - None, - ), - IntakePeerRegistrationRequest::Skip { reason } => ( - TicketRoleLaunchOptions::default(), - None, - Some(IntakePeerRegistrationStatus::warning(format!( - "handoff peer registration skipped: {reason}" - ))), - ), - }; - let launch = launch_ticket_role_worker_with_options( - request.context, - request.runtime_command, - |_| {}, - options, - ) - .await?; - let registry_warning = - commit_intake_registry_update(request.registry_update, Some(&launch.plan.worker_name)); - let peer_registration = match (workspace_orchestrator_worker, skip_warning) { - (_, Some(warning)) => warning, - (Some(workspace_orchestrator_worker), None) if launch.pre_run_warnings.is_empty() => { - IntakePeerRegistrationStatus::Registered { - workspace_orchestrator_worker, - } - } - (Some(_), None) => IntakePeerRegistrationStatus::warning( - launch - .pre_run_warnings - .iter() - .map(|warning| warning.message.as_str()) - .collect::>() - .join("; "), - ), - (None, None) => IntakePeerRegistrationStatus::warning( - "handoff peer registration skipped: no Orchestrator target", - ), - }; - Ok(IntakeLaunchOutcome { - launch, - peer_registration, - registry_warning, - }) -} - -fn commit_intake_registry_update( - update: IntakeRegistryUpdate, - launched_worker_name: Option<&str>, -) -> Option { - match update { - IntakeRegistryUpdate::RecordSession { - registry_root, - worker_name, - origin, - related_tickets, - } => PanelRegistryStore::from_root(registry_root) - .record_session( - worker_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::ClaimTicket { - registry_root, - ticket_id, - ticket_slug, - worker_name, - } => match PanelRegistryStore::from_root(registry_root).claim_ticket( - &ticket_id, - ticket_slug.as_deref(), - &worker_name, - TicketRole::Intake.as_str(), - ) { - Ok(TicketClaimResult::Claimed) | Ok(TicketClaimResult::AlreadyOwned(_)) => None, - Err(error) => Some(bounded_panel_diagnostic(format!( - "local Ticket Intake claim could not be committed after launch acceptance: {error}" - ))), - }, - IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root, - ticket_id, - ticket_slug, - } => { - let Some(worker_name) = launched_worker_name else { - return Some( - "local Ticket Intake claim could not be committed after launch acceptance: missing launched Worker name" - .to_string(), - ); - }; - match PanelRegistryStore::from_root(registry_root).claim_ticket( - &ticket_id, - ticket_slug.as_deref(), - worker_name, - TicketRole::Intake.as_str(), - ) { - Ok(TicketClaimResult::Claimed) | Ok(TicketClaimResult::AlreadyOwned(_)) => None, - Err(error) => Some(bounded_panel_diagnostic(format!( - "local Ticket Intake claim could not be committed after launch acceptance: {error}" - ))), - } - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct PanelDiagnostic { - title: String, - details: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -struct OrchestratorWorkSet { - active_inprogress: Vec, - queued: Vec, - fingerprint: String, -} - -impl OrchestratorWorkSet { - fn is_empty(&self) -> bool { - self.active_inprogress.is_empty() && self.queued.is_empty() - } - - fn has_active_inprogress(&self) -> bool { - !self.active_inprogress.is_empty() - } - - fn planned_queued_ids(&self) -> BTreeSet { - self.queued.iter().map(|item| item.id.clone()).collect() - } - - fn actionable_queued(&self) -> Vec<&OrchestratorQueuedWorkItem> { - self.queued - .iter() - .filter(|item| item.waiting_reason.is_none()) - .collect() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestratorActiveWorkItem { - id: String, - title: String, - status: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestratorQueuedWorkItem { - id: String, - resource_key: Option, - title: String, - classification: OrchestratorQueuedClassification, - waiting_reason: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OrchestratorQueuedClassification { - NewQueued, - PlannedQueued, -} - -impl OrchestratorQueuedClassification { - fn as_str(self) -> &'static str { - match self { - Self::NewQueued => "new_queued", - Self::PlannedQueued => "planned_queued", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestratorQueueAttentionFreshness { - fingerprint: String, - updated_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestratorQueueAttentionNotice { - message: String, - fingerprint: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestratorQueueAttentionNoticeRequest { - worker_name: String, - socket_path: PathBuf, - notice: OrchestratorQueueAttentionNotice, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestratorQueueAttentionNoticeResult { - fingerprint: String, - updated_at: String, - error: Option, -} - -impl OrchestratorQueueAttentionNoticeResult { - fn sent(fingerprint: String, updated_at: String) -> Self { - Self { - fingerprint, - updated_at, - error: None, - } - } - - fn failed(fingerprint: String, error: impl Into) -> Self { - Self { - fingerprint, - updated_at: String::new(), - error: Some(error.into()), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PanelRowHitBox { - rect: Rect, - key: PanelRowKey, -} - -impl PanelRowHitBox { - fn contains(&self, column: u16, row: u16) -> bool { - column >= self.rect.x - && column < self.rect.x.saturating_add(self.rect.width) - && row >= self.rect.y - && row < self.rect.y.saturating_add(self.rect.height) - } -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eRowKey { - kind: &'static str, - id: String, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eRect { - x: u16, - y: u16, - width: u16, - height: u16, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eRenderedRow { - key: PanelE2eRowKey, - title: String, - status: Option, - action: Option<&'static str>, - disabled_reason: Option, - local_state: Option, - overlay_state: Option, - overlay_detail: Option, - rect: PanelE2eRect, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eRowsRendered { - selected: Option, - header: PanelE2eDashboardHeader, - rows: Vec, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eDashboardHeader { - ticket_configured: bool, - companion: Option, - orchestrator: Option, - diagnostics: Vec, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eCompanionState { - worker_name: String, - status: &'static str, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eOrchestratorState { - worker_name: String, - status: &'static str, - detail: Option, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] -struct PanelE2eDashboardContentReady { - snapshot: PanelE2eDashboardSnapshot, - categories: PanelE2eDashboardCategories, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, Serialize)] -struct PanelE2eDashboardSnapshot { - header: PanelE2eDashboardHeader, - rows: Vec, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] -struct PanelE2eDashboardCategories { - ticket_rows: usize, - ready_ticket_rows: usize, - planning_ticket_rows: usize, - worker_rows: usize, - actionable_rows: usize, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] -struct PanelE2eSourceTiming { - source: &'static str, - elapsed_ms: u128, -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] -struct PanelE2eDashboardSourceBreakdown { - total_elapsed_ms: u128, - sources: Vec, - ticket_rows: usize, - worker_rows: usize, - diagnostics: usize, -} - -#[cfg(feature = "e2e-test")] -fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { - match key { - PanelRowKey::Ticket(id) => PanelE2eRowKey { - kind: "ticket", - id: id.clone(), - }, - PanelRowKey::InvalidTicket(label) => PanelE2eRowKey { - kind: "invalid_ticket", - id: label.clone(), - }, - PanelRowKey::TicketIntakeWorker { - ticket_id, - worker_name, - } => PanelE2eRowKey { - kind: "ticket_intake_pod", - id: format!("{ticket_id}:{worker_name}"), - }, - PanelRowKey::Worker(name) => PanelE2eRowKey { - kind: "worker", - id: name.clone(), - }, - } -} - -#[cfg(feature = "e2e-test")] -fn panel_e2e_rect(rect: Rect) -> PanelE2eRect { - PanelE2eRect { - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - } -} - -#[cfg(feature = "e2e-test")] -fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashboardCategories { - PanelE2eDashboardCategories { - ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(), - ready_ticket_rows: rows - .iter() - .filter(|row| row.key.kind == "ticket" && row.local_state.as_deref() == Some("ready")) - .count(), - planning_ticket_rows: rows - .iter() - .filter(|row| { - row.key.kind == "ticket" && row.local_state.as_deref() == Some("planning") - }) - .count(), - worker_rows: rows.iter().filter(|row| row.key.kind == "worker").count(), - actionable_rows: rows.iter().filter(|row| row.action.is_some()).count(), - } -} - -#[cfg(feature = "e2e-test")] -fn panel_e2e_dashboard_header(panel: &WorkspacePanelViewModel) -> PanelE2eDashboardHeader { - PanelE2eDashboardHeader { - ticket_configured: panel.header.ticket_configured, - companion: panel - .header - .companion - .as_ref() - .map(|state| PanelE2eCompanionState { - worker_name: state.worker_name.clone(), - status: state.status.label(), - }), - orchestrator: panel - .header - .orchestrator - .as_ref() - .map(|state| PanelE2eOrchestratorState { - worker_name: state.worker_name.clone(), - status: state.status.label(), - detail: state.detail.clone(), - }), - diagnostics: panel.header.diagnostics.clone(), - } -} - -#[cfg(feature = "e2e-test")] -fn panel_e2e_dashboard_content_is_ready( - snapshot: &PanelE2eDashboardSnapshot, - categories: &PanelE2eDashboardCategories, -) -> bool { - snapshot.header.ticket_configured - && snapshot.header.companion.is_some() - && snapshot.header.orchestrator.is_some() - && categories.ready_ticket_rows > 0 - && categories.planning_ticket_rows > 0 - && categories.worker_rows > 0 - && snapshot.rows.iter().any(|row| { - row.key.kind == "ticket" - && row.local_state.as_deref() == Some("ready") - && row.overlay_state.is_some() - && row.action.is_some() - && row.disabled_reason.is_some() - }) - && snapshot.rows.iter().any(|row| { - row.key.kind == "ticket" - && row.local_state.as_deref() == Some("planning") - && row.action.is_some() - && row.disabled_reason.is_some() - }) -} - -pub(crate) struct DashboardApp { - pub(crate) list: WorkerList, - pub(crate) panel: WorkspacePanelViewModel, - pub(crate) input: InputBuffer, - selected_row: Option, - row_hit_boxes: Vec, - composer_target: ComposerTarget, - notice: Option, - panel_diagnostic: Option, - panel_diagnostic_open: bool, - sending: bool, - refreshing: bool, - enter_reload: Option, - runtime_command: WorkerRuntimeCommand, - include_stopped: bool, - last_companion_lifecycle_failure: Option, - last_orchestrator_lifecycle_failure: Option, - orchestrator_work_set: OrchestratorWorkSet, - orchestrator_queue_attention: Option, - #[cfg(feature = "e2e-test")] - emitted_dashboard_content_ready: bool, -} - -impl DashboardApp { - fn loading(runtime_command: WorkerRuntimeCommand, include_stopped: bool) -> Self { - let workspace_root = current_workspace_root(); - let mut panel = WorkspacePanelViewModel::empty(&workspace_root); - panel - .header - .diagnostics - .push("Loading workspace dashboard…".to_string()); - Self { - list: WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - Vec::new(), - Vec::new(), - None, - MAX_ENTRIES, - ), - panel, - input: InputBuffer::new(), - selected_row: None, - row_hit_boxes: Vec::new(), - composer_target: ComposerTarget::Companion, - notice: None, - panel_diagnostic: None, - panel_diagnostic_open: false, - sending: false, - refreshing: true, - enter_reload: Some(OrchestratorLifecycleMode::Ensure { - runtime_command: runtime_command.clone(), - }), - runtime_command, - include_stopped, - last_companion_lifecycle_failure: None, - last_orchestrator_lifecycle_failure: None, - orchestrator_work_set: OrchestratorWorkSet::default(), - orchestrator_queue_attention: None, - #[cfg(feature = "e2e-test")] - emitted_dashboard_content_ready: false, - } - } - - fn apply_reload_result(&mut self, result: Result) { - self.refreshing = false; - match result { - Ok(snapshot) => self.apply_reloaded_snapshot(snapshot), - Err(error) => { - self.notice = Some(format!("Refresh failed: {error}")); - } - } - } - - #[cfg(test)] - fn apply_reloaded_list(&mut self, mut list: WorkerList) { - list.selected_name = self - .list - .selected_name - .clone() - .filter(|name| list.entries.iter().any(|entry| entry.name == *name)); - let panel = build_workspace_panel(¤t_workspace_root(), &list); - self.apply_reloaded_snapshot(DashboardSnapshot { list, panel }); - } - - fn apply_reloaded_snapshot(&mut self, mut snapshot: DashboardSnapshot) { - self.apply_companion_lifecycle_memory(&mut snapshot.panel); - self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel); - let previous_selected_pod = self.list.selected_name.clone(); - snapshot.list.selected_name = previous_selected_pod.filter(|name| { - snapshot - .list - .entries - .iter() - .any(|entry| entry.name == *name) - }); - let previous_row = self.selected_row.clone(); - self.list = snapshot.list; - self.panel = snapshot.panel; - self.selected_row = previous_row; - self.ensure_selection_visible(); - self.ensure_composer_target_available(); - self.refresh_orchestrator_work_set(); - self.apply_orchestrator_work_set_detail(); - } - - fn prepare_orchestrator_queue_attention_notice( - &mut self, - ) -> Option { - let target = orchestrator_queue_attention_notice_target(&self.panel, &self.list)?; - if self.orchestrator_work_set.is_empty() { - self.refresh_orchestrator_work_set(); - } - let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) { - Ok(Some(notice)) => notice, - Ok(None) => return None, - Err(error) => { - self.notice = Some(format!( - "Orchestrator queued-work attention not delivered: {error}" - )); - return None; - } - }; - if self - .orchestrator_queue_attention - .as_ref() - .is_some_and(|freshness| freshness.fingerprint == notice.fingerprint) - { - self.apply_orchestrator_work_set_detail(); - return None; - } - Some(OrchestratorQueueAttentionNoticeRequest { - worker_name: target.worker_name, - socket_path: target.socket_path, - notice, - }) - } - - fn finish_orchestrator_queue_attention_notice( - &mut self, - result: OrchestratorQueueAttentionNoticeResult, - ) { - if let Some(error) = result.error { - self.notice = Some(format!( - "Orchestrator queued-work attention not delivered: {error}" - )); - return; - } - self.orchestrator_queue_attention = Some(OrchestratorQueueAttentionFreshness { - fingerprint: result.fingerprint, - updated_at: result.updated_at, - }); - self.apply_orchestrator_work_set_detail(); - } - - fn refresh_orchestrator_work_set(&mut self) { - let previous_planned = self.orchestrator_work_set.planned_queued_ids(); - self.orchestrator_work_set = derive_orchestrator_work_set(&self.panel, previous_planned); - } - - fn apply_orchestrator_work_set_detail(&mut self) { - let detail = orchestrator_work_set_detail( - &self.orchestrator_work_set, - self.orchestrator_queue_attention.as_ref(), - ); - apply_orchestrator_detail(&mut self.panel, detail); - } - - fn apply_companion_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) { - let Some(state) = panel.header.companion.as_ref() else { - self.last_companion_lifecycle_failure = None; - return; - }; - - match state.status { - CompanionPanelStatus::Unavailable => { - self.last_companion_lifecycle_failure = - companion_lifecycle_failure_from_panel(panel); - } - CompanionPanelStatus::Live - | CompanionPanelStatus::Spawned - | CompanionPanelStatus::Restored => { - self.last_companion_lifecycle_failure = None; - } - CompanionPanelStatus::Missing | CompanionPanelStatus::Stopped => { - if let Some(previous) = self.last_companion_lifecycle_failure.clone() { - if previous.worker_name == state.worker_name { - panel.header.companion = Some(previous.clone()); - append_unique_diagnostic(panel, previous.detail.as_deref()); - } else { - self.last_companion_lifecycle_failure = None; - } - } - } - } - } - - fn apply_orchestrator_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) { - let Some(state) = panel.header.orchestrator.as_ref() else { - self.last_orchestrator_lifecycle_failure = None; - return; - }; - - match state.status { - OrchestratorPanelStatus::Unavailable => { - self.last_orchestrator_lifecycle_failure = - orchestrator_lifecycle_failure_from_panel(panel); - } - OrchestratorPanelStatus::Live - | OrchestratorPanelStatus::Spawned - | OrchestratorPanelStatus::Restored => { - self.last_orchestrator_lifecycle_failure = None; - } - OrchestratorPanelStatus::Missing | OrchestratorPanelStatus::Stopped => { - if let Some(previous) = self.last_orchestrator_lifecycle_failure.clone() { - if previous.worker_name == state.worker_name { - panel.header.orchestrator = Some(previous.clone()); - append_unique_diagnostic(panel, previous.detail.as_deref()); - } else { - self.last_orchestrator_lifecycle_failure = None; - } - } - } - } - } - - fn selected_panel_row(&self) -> Option<&PanelRow> { - self.selected_row - .as_ref() - .and_then(|key| self.panel.row(key)) - } - - fn selected_ticket_action(&self) -> Option { - self.selected_panel_row() - .filter(|row| row.is_ticket_action()) - .and_then(|row| row.next_action) - } - - fn selected_pod_entry(&self) -> Option<&WorkerListEntry> { - let name = self - .selected_row - .as_ref() - .and_then(PanelRowKey::worker_name)?; - self.list.entries.iter().find(|entry| entry.name == name) - } - - #[cfg(test)] - pub(crate) fn selected_open_eligibility(&self) -> OpenEligibility { - match self.selected_pod_entry() { - Some(entry) if entry.actions.can_open => OpenEligibility::OpenNow, - _ => OpenEligibility::Disabled, - } - } - - pub(crate) fn select_next(&mut self) { - let visible = visible_panel_keys(&self.panel, &self.list); - if visible.is_empty() { - self.selected_row = None; - self.list.selected_name = None; - return; - } - let next_pos = match self - .selected_row - .as_ref() - .and_then(|key| visible.iter().position(|visible_key| visible_key == key)) - { - Some(selected_pos) => (selected_pos + 1).min(visible.len() - 1), - None => 0, - }; - self.select_panel_key(visible[next_pos].clone()); - } - - pub(crate) fn select_prev(&mut self) { - let visible = visible_panel_keys(&self.panel, &self.list); - if visible.is_empty() { - self.selected_row = None; - self.list.selected_name = None; - return; - } - let prev_pos = match self - .selected_row - .as_ref() - .and_then(|key| visible.iter().position(|visible_key| visible_key == key)) - { - Some(selected_pos) => selected_pos.saturating_sub(1), - None => 0, - }; - self.select_panel_key(visible[prev_pos].clone()); - } - - fn handle_mouse_event(&mut self, event: MouseEvent) -> bool { - if self.panel_diagnostic_open { - return false; - } - match event.kind { - MouseEventKind::ScrollDown => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "mouse_wheel", - serde_json::json!({ - "column": event.column, - "row": event.row, - "direction": "down", - }), - ); - self.select_next(); - return true; - } - MouseEventKind::ScrollUp => { - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "mouse_wheel", - serde_json::json!({ - "column": event.column, - "row": event.row, - "direction": "up", - }), - ); - self.select_prev(); - return true; - } - MouseEventKind::Down(MouseButton::Left) => {} - _ => return false, - } - let Some(key) = self - .row_hit_boxes - .iter() - .find(|hit| hit.contains(event.column, event.row)) - .map(|hit| hit.key.clone()) - else { - return false; - }; - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "mouse_click", - serde_json::json!({ - "column": event.column, - "row": event.row, - "target": panel_e2e_row_key(&key), - }), - ); - self.select_panel_key(key); - true - } - - fn set_row_hit_boxes(&mut self, rows: &[PanelListRow], area: Rect) { - self.row_hit_boxes = row_hit_boxes(rows, area); - } - - #[cfg(feature = "e2e-test")] - fn emit_rows_rendered(&mut self) { - let rows: Vec<_> = self - .row_hit_boxes - .iter() - .map(|hit| { - let panel_row = self.panel.row(&hit.key); - let ( - title, - status, - action, - disabled_reason, - local_state, - overlay_state, - overlay_detail, - ) = match panel_row { - Some(row) => { - let ticket = row.ticket.as_ref(); - ( - row.title.clone(), - Some(row.status.clone()), - row.next_action.map(NextUserAction::label), - row.disabled_reason.clone(), - ticket.map(|ticket| ticket.workflow_state.as_str().to_string()), - ticket - .and_then(|ticket| ticket.orchestration_overlay.as_ref()) - .map(|overlay| overlay.workflow_state.as_str().to_string()), - ticket - .and_then(|ticket| ticket.orchestration_overlay.as_ref()) - .map(|overlay| { - format!( - "{}:{}", - overlay.source, - overlay.workflow_state.as_str() - ) - }), - ) - } - None => match &hit.key { - PanelRowKey::Worker(name) => { - (name.clone(), None, None, None, None, None, None) - } - PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => { - (id.clone(), None, None, None, None, None, None) - } - PanelRowKey::TicketIntakeWorker { worker_name, .. } => { - (worker_name.clone(), None, None, None, None, None, None) - } - }, - }; - PanelE2eRenderedRow { - key: panel_e2e_row_key(&hit.key), - title, - status, - action, - disabled_reason, - local_state, - overlay_state, - overlay_detail, - rect: panel_e2e_rect(hit.rect), - } - }) - .collect(); - let selected = self.selected_row.as_ref().map(panel_e2e_row_key); - let header = panel_e2e_dashboard_header(&self.panel); - crate::e2e_observer::emit( - "panel", - "rows_rendered", - PanelE2eRowsRendered { - selected: selected.clone(), - header: header.clone(), - rows: rows.clone(), - }, - ); - if !self.emitted_dashboard_content_ready { - let categories = panel_e2e_dashboard_categories(&rows); - let snapshot = PanelE2eDashboardSnapshot { header, rows }; - if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) { - crate::e2e_observer::emit( - "panel", - "dashboard_content_ready", - PanelE2eDashboardContentReady { - snapshot, - categories, - }, - ); - self.emitted_dashboard_content_ready = true; - } - } - } - - fn ensure_selection_visible(&mut self) { - let visible = visible_panel_keys(&self.panel, &self.list); - let Some(selected_key) = self.selected_row.as_ref() else { - self.list.selected_name = None; - return; - }; - if visible - .iter() - .any(|visible_key| visible_key == selected_key) - { - match selected_key { - PanelRowKey::Worker(name) => self.list.selected_name = Some(name.clone()), - PanelRowKey::Ticket(_) - | PanelRowKey::InvalidTicket(_) - | PanelRowKey::TicketIntakeWorker { .. } => self.list.selected_name = None, - } - } else { - self.selected_row = None; - self.list.selected_name = None; - } - } - - fn select_panel_key(&mut self, key: PanelRowKey) { - match &key { - PanelRowKey::Worker(name) => self.list.selected_name = Some(name.clone()), - PanelRowKey::Ticket(_) - | PanelRowKey::InvalidTicket(_) - | PanelRowKey::TicketIntakeWorker { .. } => self.list.selected_name = None, - } - #[cfg(feature = "e2e-test")] - let selected_key = key.clone(); - self.selected_row = Some(key); - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "selection_changed", - serde_json::json!({ "selected": panel_e2e_row_key(&selected_key) }), - ); - } - - fn clear_panel_selection(&mut self) { - self.selected_row = None; - self.list.selected_name = None; - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "selection_changed", - serde_json::json!({ "selected": serde_json::Value::Null }), - ); - } - - fn ensure_composer_target_available(&mut self) { - if !self.panel.composer.is_available(self.composer_target) { - self.composer_target = ComposerTarget::Companion; - } - } - - pub(crate) fn cycle_composer_target(&mut self) { - let targets = &self.panel.composer.available_targets; - if targets.len() <= 1 { - self.composer_target = ComposerTarget::Companion; - self.notice = Some( - "Ticket Intake target is unavailable without usable Ticket config.".to_string(), - ); - return; - } - let current = targets - .iter() - .position(|target| *target == self.composer_target) - .unwrap_or(0); - let next = targets[(current + 1) % targets.len()]; - self.composer_target = next; - self.notice = Some(format!("Composer target: {}", next.label())); - } - - pub(crate) fn composer_target(&self) -> ComposerTarget { - self.composer_target - } - - pub(crate) fn prepare_open(&mut self) -> Option { - let (worker_name, socket_override, progress) = { - let entry = match self.selected_pod_entry() { - Some(entry) => entry, - None => { - self.notice = Some(selected_ticket_notice(self.selected_panel_row())); - return None; - } - }; - if !entry.actions.can_open { - self.notice = Some("Selected Worker cannot be opened from this view.".to_string()); - return None; - } - let progress = if entry.live.as_ref().is_some_and(|live| live.reachable) { - "Attaching to" - } else if entry.stored.is_some() { - "Restoring/opening" - } else { - "Opening" - }; - ( - entry.name.clone(), - entry.attach_socket_path().map(PathBuf::from), - progress, - ) - }; - self.notice = Some(format!("{progress} {worker_name}…")); - Some(OpenWorkerRequest { - worker_name, - socket_override, - }) - } - - pub(crate) fn finish_open( - &mut self, - worker_name: &str, - result: Result<(), &dyn std::fmt::Display>, - ) { - match result { - Ok(()) => { - self.notice = Some(format!( - "Returned from {worker_name}. Refreshing workspace…" - )); - } - Err(error) => { - self.notice = Some(format!( - "Open failed for {worker_name}: {error}. Refreshing workspace…" - )); - } - } - self.refreshing = true; - self.enter_reload = Some(OrchestratorLifecycleMode::Observe); - } - - fn composer_is_blank(&self) -> bool { - segments_are_blank(&self.input.submit_segments()) - } - - pub(crate) fn prepare_companion_send(&mut self) -> Option { - let segments = self.input.submit_segments(); - if segments_are_blank(&segments) { - self.notice = Some("Composer is empty.".to_string()); - return None; - } - let Some(companion) = self.panel.header.companion.as_ref() else { - self.notice = Some("Workspace Companion is unavailable; draft kept.".to_string()); - return None; - }; - if matches!( - companion.status, - CompanionPanelStatus::Unavailable - | CompanionPanelStatus::Missing - | CompanionPanelStatus::Stopped - ) { - let detail = companion - .detail - .as_deref() - .unwrap_or("workspace Companion is not live yet"); - self.notice = Some(bounded_panel_diagnostic(format!( - "Companion {} is {}: {detail}; draft kept.", - companion.worker_name, - companion.status.label() - ))); - return None; - } - let Some(entry) = self - .list - .entries - .iter() - .find(|entry| entry.name == companion.worker_name) - else { - self.notice = Some(format!( - "Companion {} is not in the current Worker list; refresh and retry. Draft kept.", - companion.worker_name - )); - return None; - }; - let Some(live) = entry.live.as_ref().filter(|live| live.reachable) else { - self.notice = Some(format!( - "Companion {} is not reachable; refresh and retry. Draft kept.", - companion.worker_name - )); - return None; - }; - if live.status == Some(WorkerStatus::Running) { - self.notice = Some(format!( - "Companion {} is busy; wait for it to become idle or open it for inspection. Draft kept.", - companion.worker_name - )); - return None; - } - self.sending = true; - self.notice = Some(format!("Sending to Companion {}…", companion.worker_name)); - Some(CompanionSendRequest { - worker_name: companion.worker_name.clone(), - socket_path: live.socket_path.clone(), - segments, - }) - } - - pub(crate) fn finish_companion_send( - &mut self, - result: Result, - ) { - self.sending = false; - match result { - Ok(outcome) => { - self.input.clear(); - self.notice = Some(outcome.notice); - } - Err(error) => { - self.notice = Some(bounded_panel_diagnostic(error.to_string())); - } - } - } - - pub(crate) fn prepare_ticket_action_dispatch(&mut self) -> Option { - 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 inline action.".to_string()); - return None; - } - _ => { - self.notice = Some("No Ticket action is selected.".to_string()); - return None; - } - }; - let Some(action) = row.next_action else { - self.notice = Some("Selected Ticket row has no inline action.".to_string()); - return None; - }; - let ticket_id = { - let Some(ticket) = row.ticket.as_ref() else { - self.notice = Some("No Ticket action is selected.".to_string()); - return None; - }; - ticket.id.clone() - }; - let orchestrator = ticket_action_orchestrator_target(&self.panel, &self.list); - self.sending = true; - self.notice = Some(format!( - "Dispatching {} for Ticket {}…", - action.label(), - ticket_id - )); - Some(TicketActionRequest { - workspace_root: current_workspace_root(), - ticket_id, - action, - orchestrator, - }) - } - - fn set_panel_diagnostic( - &mut self, - title: impl Into, - details: impl Into, - ) -> String { - let title = title.into(); - let details = details.into(); - self.panel_diagnostic = Some(PanelDiagnostic { - title: title.clone(), - details: details.clone(), - }); - self.panel_diagnostic_open = false; - format!("{} (F2 details)", bounded_panel_diagnostic(&details)) - } - - pub(crate) fn finish_ticket_action_dispatch( - &mut self, - result: Result, - ) { - self.sending = false; - self.notice = Some(match result { - Ok(outcome) => outcome.notice, - Err(error) => match error { - TicketActionError::Stale(message) => { - self.set_panel_diagnostic("Ticket action rejected", message) - } - TicketActionError::BackendConfig(error) | TicketActionError::Ticket(error) => { - self.set_panel_diagnostic("Ticket action failed", error) - } - }, - }); - } - - pub(crate) fn prepare_intake_launch(&mut self) -> Option { - if !self - .panel - .composer - .is_available(ComposerTarget::TicketIntake) - { - self.composer_target = ComposerTarget::Companion; - self.notice = Some( - "Ticket Intake target is unavailable without usable Ticket config.".to_string(), - ); - return None; - } - let body = Segment::flatten_to_text(&self.input.submit_segments()); - if body.trim().is_empty() { - self.notice = Some("Ticket Intake input is empty; type a request first.".to_string()); - return None; - } - let mut context = - TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); - let worker_name = unique_preticket_intake_worker_name(); - context.worker_name = Some(worker_name.clone()); - context.user_instruction = Some(body); - 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(), - worker_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.worker_name.clone(), - self.panel.header.workspace_label.clone(), - )); - if orchestrator_status_is_peer_reachable(orchestrator.status) { - IntakePeerRegistrationRequest::Register { - workspace_orchestrator_worker: orchestrator.worker_name.clone(), - } - } else { - IntakePeerRegistrationRequest::Skip { - reason: format!( - "workspace Orchestrator {} is {}; launch input still carries the auditable handoff target", - orchestrator.worker_name, - orchestrator.status.label() - ), - } - } - } - 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 { - if self.sending { - self.notice = Some( - "Ticket Intake launch is already in progress; wait for it to finish before retrying." - .to_string(), - ); - return None; - } - 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_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) { - Ok(ticket_key) => ticket_key.to_string(), - Err(error) => { - self.notice = Some(error); - return None; - } - }; - let mut context = - TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); - context.ticket = Some(TicketRef::id(ticket_id.clone())); - context.user_instruction = Some(format!( - "Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions." - )); - 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.worker_name, &self.list); - self.notice = Some(existing_ticket_claim_notice( - &ticket_key, - &claim.worker_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.worker_name = Some(planned.worker_name.clone()); - let worker_name = planned.worker_name.clone(); - let peer_registration = self.prepare_intake_peer_registration(&mut context); - self.sending = true; - self.notice = Some(format!( - "Launching Ticket Intake for {} as {}…", - ticket_key, planned.worker_name - )); - Some(IntakeLaunchRequest { - context, - runtime_command: self.runtime_command.clone(), - peer_registration, - registry_update: IntakeRegistryUpdate::ClaimTicket { - registry_root: store.root().to_path_buf(), - ticket_id, - ticket_slug: None, - worker_name, - }, - }) - } - - pub(crate) fn prepare_ready_ticket_planning_return( - &mut self, - ) -> Option { - if self.sending { - self.notice = Some( - "Ticket refinement return is already in progress; wait for it to finish before retrying." - .to_string(), - ); - return None; - } - if !self - .panel - .composer - .is_available(ComposerTarget::TicketIntake) - { - self.composer_target = ComposerTarget::Companion; - self.notice = Some( - "Ticket Intake target is unavailable without usable Ticket config.".to_string(), - ); - return None; - } - let body = Segment::flatten_to_text(&self.input.submit_segments()); - let user_instruction = bounded_refinement_instruction(body.trim()); - if user_instruction.is_empty() { - self.notice = Some( - "Type refinement instructions with the Ticket Planning target before returning a ready Ticket to planning." - .to_string(), - ); - return None; - } - 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 refinement return action.".to_string()); - return None; - } - _ => { - self.notice = - Some("Select a ready Ticket row before returning it to planning.".to_string()); - return None; - } - }; - if row.next_action != Some(NextUserAction::Queue) { - self.notice = Some( - "Only ready Ticket rows can be returned to planning from the Ticket Planning target." - .to_string(), - ); - return None; - } - let Some(ticket) = row.ticket.as_ref() else { - self.notice = - Some("Select a ready Ticket row before returning it to planning.".to_string()); - return None; - }; - let ticket_id = ticket.id.clone(); - let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) { - Ok(ticket_key) => ticket_key.to_string(), - Err(error) => { - self.notice = Some(error); - return None; - } - }; - if ticket.workflow_state != TicketWorkflowState::Ready { - self.notice = Some(format!( - "Ticket {} is {}; expected ready before returning to planning.", - ticket_key, - ticket.workflow_state.as_str() - )); - return None; - } - - let workspace_root = current_workspace_root(); - let store = match PanelRegistryStore::default_for_workspace(&workspace_root) { - Ok(store) => store, - Err(error) => { - self.notice = Some(format!("Ticket Intake registry unavailable: {error}")); - return None; - } - }; - let followup = match store.claim_for_ticket(&ticket_id) { - Ok(Some(claim)) => match local_claim_status_for_pod(&claim.worker_name, &self.list) { - TicketLocalClaimStatus::Live => match self - .list - .entries - .iter() - .find(|entry| entry.name == claim.worker_name) - .and_then(WorkerListEntry::attach_socket_path) - { - Some(socket_path) => { - ReadyTicketPlanningReturnFollowup::NotifyLiveClaimedIntake { - worker_name: claim.worker_name, - socket_path: socket_path.to_path_buf(), - } - } - None => ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { - worker_name: claim.worker_name, - }, - }, - TicketLocalClaimStatus::Restorable => { - ReadyTicketPlanningReturnFollowup::OpenRestorableClaimedIntake( - OpenWorkerRequest { - worker_name: claim.worker_name, - socket_override: None, - }, - ) - } - TicketLocalClaimStatus::Stale => { - ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { - worker_name: claim.worker_name, - } - } - }, - Ok(None) => { - let mut context = - TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake); - context.ticket = Some(TicketRef::id(ticket_id.clone())); - context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction( - &ticket_key, - &user_instruction, - )); - let peer_registration = self.prepare_intake_peer_registration(&mut context); - ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { - context, - runtime_command: self.runtime_command.clone(), - peer_registration, - registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: store.root().to_path_buf(), - ticket_id: ticket_id.clone(), - ticket_slug: None, - }, - }) - } - Err(error) => { - self.notice = Some(format!("Ticket claim diagnostic required: {error}")); - return None; - } - }; - - self.sending = true; - self.notice = Some(format!( - "Returning ready Ticket {} to planning for refinement…", - ticket_key - )); - Some(ReadyTicketPlanningReturnRequest { - workspace_root, - ticket_id, - ticket_key, - user_instruction, - followup, - }) - } - - fn finish_ready_ticket_planning_return_error(&mut self, error: TicketActionError) { - self.sending = false; - self.notice = Some(match error { - TicketActionError::Stale(message) => { - self.set_panel_diagnostic("Ticket planning return rejected", message) - } - TicketActionError::BackendConfig(error) | TicketActionError::Ticket(error) => { - self.set_panel_diagnostic("Ticket planning return failed", error) - } - }); - } - - fn finish_ready_ticket_planning_return_success( - &mut self, - outcome: ReadyTicketPlanningReturnOutcome, - ) -> ReadyTicketPlanningReturnAfterMutation { - self.sending = false; - self.input.clear(); - self.notice = Some(outcome.notice); - outcome.followup - } - - fn finish_ready_ticket_planning_return_with_intake_launch( - &mut self, - planning_notice: String, - result: IntakeLaunchResult, - ) { - self.sending = false; - self.input.clear(); - match result { - Ok(result) => { - let worker_name = result.launch.plan.worker_name; - let peer_notice = match result.peer_registration { - IntakePeerRegistrationStatus::Registered { - workspace_orchestrator_worker, - } => { - format!(" Handoff peer registered with {workspace_orchestrator_worker}.") - } - IntakePeerRegistrationStatus::Warning { message } => { - 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!( - "{planning_notice} Launched Ticket Intake Worker {worker_name}.{peer_notice}{registry_notice}" - ))); - } - Err(error) => { - self.notice = Some(self.set_panel_diagnostic( - "Ticket Intake launch failed after planning return", - format!( - "{planning_notice} Intake launch/restore failed after Ticket was returned to planning; instruction was recorded in the Ticket thread. {}", - error - ), - )); - } - } - } - - pub(crate) fn finish_intake_launch(&mut self, result: IntakeLaunchResult) { - self.sending = false; - match result { - Ok(result) => { - let worker_name = result.launch.plan.worker_name; - self.input.clear(); - let peer_notice = match result.peer_registration { - IntakePeerRegistrationStatus::Registered { - workspace_orchestrator_worker, - } => { - format!(" Handoff peer registered with {workspace_orchestrator_worker}.") - } - IntakePeerRegistrationStatus::Warning { message } => { - 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 Worker {worker_name}.{peer_notice}{registry_notice}" - ))); - } - Err(error) => { - self.notice = Some(format!( - "Intake launch failed; composer kept: {}", - bounded_panel_diagnostic(error.to_string()) - )); - } - } - } - - fn apply_composer_edit_action(&mut self, action: ComposerEditAction) { - match action { - ComposerEditAction::InsertChar(c) => self.input.insert_char(c), - ComposerEditAction::InsertNewline => self.input.insert_newline(), - ComposerEditAction::DeleteBefore => self.input.delete_before(), - ComposerEditAction::DeleteAfter => self.input.delete_after(), - ComposerEditAction::DeleteWordBefore => self.input.delete_word_before(), - ComposerEditAction::MoveLeft => self.input.move_left(), - ComposerEditAction::MoveRight => self.input.move_right(), - ComposerEditAction::MoveWordLeft => self.input.move_word_left(), - ComposerEditAction::MoveWordRight => self.input.move_word_right(), - ComposerEditAction::MoveStart => self.input.move_start(), - ComposerEditAction::MoveHome => self.input.move_home(), - ComposerEditAction::MoveEnd => self.input.move_end(), - ComposerEditAction::MoveUp => self.input.move_up(), - ComposerEditAction::MoveDown => self.input.move_down(), - } - } - - fn handle_key(&mut self, key: KeyEvent) -> DashboardAction { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if self.panel_diagnostic_open { - return match key.code { - KeyCode::Esc | KeyCode::F(2) => { - self.panel_diagnostic_open = false; - DashboardAction::None - } - KeyCode::Char('d') if ctrl => DashboardAction::Quit, - KeyCode::Char('c') if ctrl => DashboardAction::Quit, - _ => DashboardAction::None, - }; - } - - let composer_action = composer_edit_action(key); - if let Some(action) = composer_action { - if action.is_modifier_action() { - self.apply_composer_edit_action(action); - return DashboardAction::None; - } - } - - match key.code { - KeyCode::F(2) => { - if self.panel_diagnostic.is_some() { - self.panel_diagnostic_open = true; - } else { - self.notice = Some("No Panel diagnostic details yet".to_string()); - } - DashboardAction::None - } - KeyCode::Char('d') if ctrl => DashboardAction::Quit, - KeyCode::Char('c') if ctrl => DashboardAction::Quit, - KeyCode::Esc => { - self.clear_panel_selection(); - self.notice = Some( - "Row selection cleared; composer draft and target are unchanged.".to_string(), - ); - DashboardAction::None - } - KeyCode::Tab => { - // Completion owns Tab before panel target switching when a - // completion popup exists. The workspace Dashboard currently has - // no completion source, so this is the target switch path. - self.cycle_composer_target(); - DashboardAction::None - } - KeyCode::Up if self.composer_is_blank() => { - self.select_prev(); - DashboardAction::None - } - KeyCode::Down if self.composer_is_blank() => { - self.select_next(); - DashboardAction::None - } - KeyCode::Enter - if self.composer_is_blank() - && self.selected_ticket_action() == Some(NextUserAction::Clarify) => - { - self.prepare_existing_ticket_intake_launch() - .map(DashboardAction::LaunchIntake) - .unwrap_or(DashboardAction::None) - } - KeyCode::Enter - if self.composer_is_blank() && self.selected_ticket_action().is_some() => - { - self.prepare_ticket_action_dispatch() - .map(DashboardAction::DispatchTicketAction) - .unwrap_or(DashboardAction::None) - } - KeyCode::Enter if self.composer_is_blank() => DashboardAction::Open, - KeyCode::Enter - if self.composer_target == ComposerTarget::TicketIntake - && self.selected_ticket_action() == Some(NextUserAction::Queue) => - { - self.prepare_ready_ticket_planning_return() - .map(DashboardAction::ReturnReadyTicketToPlanning) - .unwrap_or(DashboardAction::None) - } - KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake => self - .prepare_intake_launch() - .map(DashboardAction::LaunchIntake) - .unwrap_or(DashboardAction::None), - KeyCode::Enter => self - .prepare_companion_send() - .map(DashboardAction::SendCompanion) - .unwrap_or(DashboardAction::None), - _ => { - if let Some(action) = composer_action { - self.apply_composer_edit_action(action); - } - DashboardAction::None - } - } - } -} - -enum DashboardAction { - None, - Quit, - Open, - DispatchTicketAction(TicketActionRequest), - ReturnReadyTicketToPlanning(ReadyTicketPlanningReturnRequest), - LaunchIntake(IntakeLaunchRequest), - SendCompanion(CompanionSendRequest), -} - -#[derive(Debug, Clone)] -struct DashboardSnapshot { - list: WorkerList, - panel: WorkspacePanelViewModel, -} - -fn companion_lifecycle_failure_from_panel( - panel: &WorkspacePanelViewModel, -) -> Option { - let state = panel.header.companion.as_ref()?; - if state.status == CompanionPanelStatus::Unavailable && state.detail.is_some() { - Some(state.clone()) - } else { - None - } -} - -fn orchestrator_lifecycle_failure_from_panel( - panel: &WorkspacePanelViewModel, -) -> Option { - let state = panel.header.orchestrator.as_ref()?; - if state.status == OrchestratorPanelStatus::Unavailable && state.detail.is_some() { - Some(state.clone()) - } else { - None - } -} - -fn append_unique_diagnostic(panel: &mut WorkspacePanelViewModel, diagnostic: Option<&str>) { - let Some(diagnostic) = diagnostic else { - return; - }; - if !panel - .header - .diagnostics - .iter() - .any(|existing| existing == diagnostic) - { - panel.header.diagnostics.push(diagnostic.to_string()); - } -} - -#[derive(Debug, Clone)] -enum OrchestratorLifecycleMode { - Ensure { - runtime_command: WorkerRuntimeCommand, - }, - Observe, -} - -async fn load_dashboard_snapshot( - selected_name: Option, - lifecycle_mode: OrchestratorLifecycleMode, - include_stopped: bool, -) -> Result { - let workspace_root = current_workspace_root(); - #[cfg(feature = "e2e-test")] - let load_started = Instant::now(); - #[cfg(feature = "e2e-test")] - let mut source_timings = Vec::new(); - let companion_worker_name = workspace_companion_worker_name(&workspace_root); - let list_selected_name = selected_name - .clone() - .or_else(|| Some(companion_worker_name.clone())); - - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - let mut list = - load_worker_list(list_selected_name.clone(), MAX_ENTRIES, include_stopped).await?; - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "pod_metadata_status_probe.initial", - elapsed_ms: source_started.elapsed().as_millis(), - }); - - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - let companion_presence = companion_pod_presence(&companion_worker_name, &list); - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "companion.presence.from_initial_list", - elapsed_ms: source_started.elapsed().as_millis(), - }); - - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - let companion = match lifecycle_mode.clone() { - OrchestratorLifecycleMode::Ensure { runtime_command } => { - ensure_workspace_companion( - &workspace_root, - companion_worker_name, - companion_presence, - runtime_command, - ) - .await - } - OrchestratorLifecycleMode::Observe => { - observe_workspace_companion(companion_worker_name, companion_presence) - } - }; - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "companion.lifecycle", - elapsed_ms: source_started.elapsed().as_millis(), - }); - if companion.reload_workers { - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES, include_stopped).await?; - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "pod_metadata_status_probe.after_companion_reload", - elapsed_ms: source_started.elapsed().as_millis(), - }); - } - - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - let config = ticket_config_availability(&workspace_root); - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "ticket_config_probe", - elapsed_ms: source_started.elapsed().as_millis(), - }); - - let orchestrator_worker_name = workspace_orchestrator_worker_name(&workspace_root); - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - let orchestrator_presence = match &config { - TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, - TicketConfigAvailability::Usable => Some(workspace_orchestrator_worker_presence( - &orchestrator_worker_name, - &list, - )), - }; - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "orchestrator.presence.from_initial_list", - elapsed_ms: source_started.elapsed().as_millis(), - }); - - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - let orchestrator = match lifecycle_mode { - OrchestratorLifecycleMode::Ensure { runtime_command } => { - ensure_workspace_orchestrator( - &workspace_root, - config, - orchestrator_worker_name, - orchestrator_presence, - runtime_command, - ) - .await - } - OrchestratorLifecycleMode::Observe => { - observe_workspace_orchestrator(config, orchestrator_worker_name, orchestrator_presence) - } - }; - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "orchestrator.lifecycle", - elapsed_ms: source_started.elapsed().as_millis(), - }); - if orchestrator.reload_workers { - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - list = load_worker_list(list_selected_name, MAX_ENTRIES, include_stopped).await?; - #[cfg(feature = "e2e-test")] - source_timings.push(PanelE2eSourceTiming { - source: "pod_metadata_status_probe.after_orchestrator_reload", - elapsed_ms: source_started.elapsed().as_millis(), - }); - } - - #[cfg(feature = "e2e-test")] - let source_started = Instant::now(); - #[cfg(feature = "e2e-test")] - let (mut panel, panel_source_timings) = - build_workspace_panel_with_e2e_timings(&workspace_root, &list); - #[cfg(not(feature = "e2e-test"))] - let mut panel = build_workspace_panel(&workspace_root, &list); - panel.header.companion = companion.state; - panel.header.diagnostics.extend(companion.diagnostics); - panel.header.orchestrator = orchestrator.state; - panel.header.diagnostics.extend(orchestrator.diagnostics); - #[cfg(feature = "e2e-test")] - { - source_timings.push(PanelE2eSourceTiming { - source: "workspace_panel.build.total", - elapsed_ms: source_started.elapsed().as_millis(), - }); - source_timings.extend(panel_source_timings.into_iter().map(|timing| { - PanelE2eSourceTiming { - source: timing.source, - elapsed_ms: timing.elapsed_ms, - } - })); - } - - #[cfg(feature = "e2e-test")] - crate::e2e_observer::emit( - "panel", - "dashboard_source_breakdown", - PanelE2eDashboardSourceBreakdown { - total_elapsed_ms: load_started.elapsed().as_millis(), - sources: source_timings, - ticket_rows: panel - .rows - .iter() - .filter(|row| row.is_ticket_action()) - .count(), - worker_rows: list.entries.len(), - diagnostics: panel.header.diagnostics.len(), - }, - ); - Ok(DashboardSnapshot { list, panel }) -} - -#[derive(Debug, Clone)] -struct CompanionLifecycleReport { - state: Option, - diagnostics: Vec, - reload_workers: bool, -} - -impl CompanionLifecycleReport { - fn with_state(state: CompanionPanelState) -> Self { - Self { - state: Some(state), - diagnostics: Vec::new(), - reload_workers: false, - } - } - - fn unavailable(worker_name: String, detail: String) -> Self { - let detail = bounded_panel_diagnostic(detail); - Self { - state: Some(CompanionPanelState::new( - worker_name, - CompanionPanelStatus::Unavailable, - Some(detail.clone()), - )), - diagnostics: vec![detail], - reload_workers: false, - } - } - - fn mark_reload(mut self) -> Self { - self.reload_workers = true; - self - } -} - -async fn ensure_workspace_companion( - workspace_root: &Path, - worker_name: String, - presence: CompanionWorkerPresence, - runtime_command: WorkerRuntimeCommand, -) -> CompanionLifecycleReport { - match decide_companion_lifecycle(&presence) { - CompanionLifecyclePlan::ReportLive => CompanionLifecycleReport::with_state( - CompanionPanelState::new(worker_name, CompanionPanelStatus::Live, None), - ), - CompanionLifecyclePlan::Restore => { - match restore_workspace_companion_pod( - workspace_root, - &worker_name, - runtime_command.clone(), - ) - .await - { - Ok(()) => CompanionLifecycleReport::with_state(CompanionPanelState::new( - worker_name, - CompanionPanelStatus::Restored, - Some("restored existing Worker state".to_string()), - )) - .mark_reload(), - Err(error) => CompanionLifecycleReport::unavailable( - worker_name, - format!("could not restore workspace Companion: {error}"), - ), - } - } - CompanionLifecyclePlan::Spawn => { - match spawn_workspace_companion_pod(workspace_root, &worker_name, runtime_command).await - { - Ok(()) => CompanionLifecycleReport::with_state(CompanionPanelState::new( - worker_name, - CompanionPanelStatus::Spawned, - Some("launched with default Companion profile".to_string()), - )) - .mark_reload(), - Err(error) => CompanionLifecycleReport::unavailable( - worker_name, - format!("could not spawn workspace Companion: {error}"), - ), - } - } - CompanionLifecyclePlan::Unavailable(message) => { - CompanionLifecycleReport::unavailable(worker_name, message) - } - } -} - -fn observe_workspace_companion( - worker_name: String, - presence: CompanionWorkerPresence, -) -> CompanionLifecycleReport { - match presence { - CompanionWorkerPresence::Live => CompanionLifecycleReport::with_state( - CompanionPanelState::new(worker_name, CompanionPanelStatus::Live, None), - ), - CompanionWorkerPresence::Restorable => CompanionLifecycleReport::with_state( - CompanionPanelState::new(worker_name, CompanionPanelStatus::Stopped, None), - ), - CompanionWorkerPresence::Missing => CompanionLifecycleReport::with_state( - CompanionPanelState::new(worker_name, CompanionPanelStatus::Missing, None), - ), - CompanionWorkerPresence::Unavailable(message) => { - CompanionLifecycleReport::unavailable(worker_name, message) - } - } -} - -#[derive(Debug, Clone)] -struct OrchestratorLifecycleReport { - state: Option, - diagnostics: Vec, - reload_workers: bool, -} - -impl OrchestratorLifecycleReport { - fn skipped() -> Self { - Self { - state: None, - diagnostics: Vec::new(), - reload_workers: false, - } - } - - fn with_state(state: OrchestratorPanelState) -> Self { - Self { - state: Some(state), - diagnostics: Vec::new(), - reload_workers: false, - } - } - - fn unavailable(worker_name: String, detail: String) -> Self { - let detail = bounded_panel_diagnostic(detail); - Self { - state: Some(OrchestratorPanelState::new( - worker_name, - OrchestratorPanelStatus::Unavailable, - Some(detail.clone()), - )), - diagnostics: vec![detail], - reload_workers: false, - } - } - - fn mark_reload(mut self) -> Self { - self.reload_workers = true; - self - } -} - -async fn ensure_workspace_orchestrator( - workspace_root: &Path, - config: TicketConfigAvailability, - worker_name: String, - presence: Option, - runtime_command: WorkerRuntimeCommand, -) -> OrchestratorLifecycleReport { - orchestrator_lifecycle( - workspace_root, - config, - worker_name, - presence, - runtime_command, - ) - .await -} - -fn observe_workspace_orchestrator( - config: TicketConfigAvailability, - worker_name: String, - presence: Option, -) -> OrchestratorLifecycleReport { - if matches!(config, TicketConfigAvailability::Absent) { - return OrchestratorLifecycleReport::skipped(); - } - if let TicketConfigAvailability::Unusable(message) = config { - return OrchestratorLifecycleReport::unavailable( - worker_name, - format!("Ticket config is unusable; workspace Orchestrator not observed: {message}"), - ); - } - match presence.unwrap_or(OrchestratorWorkerPresence::Missing) { - OrchestratorWorkerPresence::Live => OrchestratorLifecycleReport::with_state( - OrchestratorPanelState::new(worker_name, OrchestratorPanelStatus::Live, None), - ), - OrchestratorWorkerPresence::Restorable => OrchestratorLifecycleReport::with_state( - OrchestratorPanelState::new(worker_name, OrchestratorPanelStatus::Stopped, None), - ), - OrchestratorWorkerPresence::Missing => OrchestratorLifecycleReport::with_state( - OrchestratorPanelState::new(worker_name, OrchestratorPanelStatus::Missing, None), - ), - OrchestratorWorkerPresence::Unavailable(message) => { - OrchestratorLifecycleReport::unavailable(worker_name, message) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestrationWorktreeLayout { - path: PathBuf, - branch: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum OrchestrationWorktreeStatus { - Created, - Reused, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestrationWorktreeReady { - layout: OrchestrationWorktreeLayout, - status: OrchestrationWorktreeStatus, -} - -fn orchestration_worktree_layout_for_config( - workspace_root: &Path, - orchestration: &TicketOrchestrationConfig, -) -> OrchestrationWorktreeLayout { - OrchestrationWorktreeLayout { - path: workspace_root - .join(orchestration.worktree_dir()) - .join(orchestration.worktree_name()), - branch: orchestration.effective_branch_name().to_string(), - } -} - -#[cfg(test)] -fn orchestration_worktree_layout(workspace_root: &Path) -> OrchestrationWorktreeLayout { - OrchestrationWorktreeLayout { - path: workspace_root.join(".worktree").join("orchestration"), - branch: "orchestration".to_string(), - } -} - -fn resolved_orchestration_worktree_layout( - workspace_root: &Path, -) -> Result { - let config = TicketConfig::load_workspace(workspace_root) - .map_err(|err| format!("failed to load ticket config for orchestration worktree: {err}"))?; - let branch = config.orchestration.effective_branch_name(); - GitBranchName::new(branch.to_string()) - .map_err(|message| format!("invalid orchestration branch `{branch}`: {message}"))?; - Ok(orchestration_worktree_layout_for_config( - workspace_root, - &config.orchestration, - )) -} - -fn build_orchestrator_launch_context( - original_workspace_root: &Path, - orchestration_workspace_root: &Path, - worker_name: &str, -) -> TicketRoleLaunchContext { - let mut context = TicketRoleLaunchContext::new( - original_workspace_root.to_path_buf(), - TicketRole::Orchestrator, - ) - .with_cwd(orchestration_workspace_root.to_path_buf()) - .with_original_workspace_root(original_workspace_root.to_path_buf()) - .with_target_workspace_root(original_workspace_root.to_path_buf()); - context.worker_name = Some(worker_name.to_string()); - context.user_instruction = Some( - "Workspace Dashboard opened for this Ticket-enabled workspace. Coordinate Ticket routing and wait for explicit follow-up before spawning role Workers." - .to_string(), - ); - context -} - -fn ensure_orchestration_worktree( - workspace_root: &Path, -) -> Result { - let layout = resolved_orchestration_worktree_layout(workspace_root)?; - if layout.path.exists() { - if !layout.path.is_dir() { - return Err(format!( - "orchestration worktree path exists but is not a directory: {}", - layout.path.display() - )); - } - if git_inside_worktree(&layout.path) { - validate_existing_orchestration_worktree(workspace_root, &layout)?; - if !git_worktree_clean(&layout.path) { - return Err(format!( - "orchestration worktree is dirty; refusing automatic reuse: {}", - layout.path.display() - )); - } - return Ok(OrchestrationWorktreeReady { - layout, - status: OrchestrationWorktreeStatus::Reused, - }); - } - return Err(format!( - "orchestration worktree path exists but is not a Git worktree: {}", - layout.path.display() - )); - } - - if let Some(parent) = layout.path.parent() { - std::fs::create_dir_all(parent).map_err(|error| { - format!( - "could not create orchestration worktree parent {}: {error}", - parent.display() - ) - })?; - } - - let branch_exists = git_branch_exists(workspace_root, &layout.branch)?; - let mut command = Command::new("git"); - command - .arg("-C") - .arg(workspace_root) - .arg("worktree") - .arg("add"); - if branch_exists { - command.arg(&layout.path).arg(&layout.branch); - } else { - command - .arg(&layout.path) - .arg("-b") - .arg(&layout.branch) - .arg("HEAD"); - } - run_git_command(command, "create orchestration worktree")?; - - Ok(OrchestrationWorktreeReady { - layout, - status: OrchestrationWorktreeStatus::Created, - }) -} - -fn prepare_orchestration_worktree_for_restore( - workspace_root: &Path, -) -> Result { - let layout = resolved_orchestration_worktree_layout(workspace_root)?; - if !layout.path.exists() { - return Err(format!( - "orchestration worktree is missing; cannot restore existing Worker state: {}", - layout.path.display() - )); - } - if !layout.path.is_dir() { - return Err(format!( - "orchestration worktree path exists but is not a directory: {}", - layout.path.display() - )); - } - if !git_inside_worktree(&layout.path) { - return Err(format!( - "orchestration worktree path exists but is not a Git worktree: {}", - layout.path.display() - )); - } - validate_existing_orchestration_worktree(workspace_root, &layout)?; - Ok(OrchestrationWorktreeReady { - layout, - status: OrchestrationWorktreeStatus::Reused, - }) -} - -fn validate_existing_orchestration_worktree( - workspace_root: &Path, - layout: &OrchestrationWorktreeLayout, -) -> Result<(), String> { - let expected_top_level = layout.path.canonicalize().map_err(|error| { - format!( - "could not canonicalize orchestration worktree path {}: {error}", - layout.path.display() - ) - })?; - let actual_top_level = git_top_level(&layout.path)?; - if actual_top_level != expected_top_level { - return Err(format!( - "orchestration path {} is inside Git worktree {}, but is not the worktree root", - layout.path.display(), - actual_top_level.display() - )); - } - - let current_branch = git_current_branch(&layout.path)?; - if current_branch.as_deref() != Some(layout.branch.as_str()) { - return Err(format!( - "orchestration worktree {} is on branch {:?}, expected {}", - layout.path.display(), - current_branch, - layout.branch - )); - } - - let original_common = git_common_dir(workspace_root)?; - let worktree_common = git_common_dir(&layout.path)?; - if original_common != worktree_common { - return Err(format!( - "orchestration worktree {} belongs to a different Git repository ({} != {})", - layout.path.display(), - worktree_common.display(), - original_common.display() - )); - } - Ok(()) -} - -fn git_top_level(path: &Path) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(path) - .arg("rev-parse") - .arg("--show-toplevel") - .output() - .map_err(|error| { - format!( - "could not query Git top-level for {}: {error}", - path.display() - ) - })?; - if !output.status.success() { - return Err(format!( - "could not query Git top-level for {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string()) - .canonicalize() - .map_err(|error| { - format!( - "could not canonicalize Git top-level for {}: {error}", - path.display() - ) - }) -} - -fn git_current_branch(path: &Path) -> Result, String> { - let output = Command::new("git") - .arg("-C") - .arg(path) - .arg("branch") - .arg("--show-current") - .output() - .map_err(|error| { - format!( - "could not query current branch for {}: {error}", - path.display() - ) - })?; - if !output.status.success() { - return Err(format!( - "could not query current branch for {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok((!branch.is_empty()).then_some(branch)) -} - -fn git_common_dir(path: &Path) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(path) - .arg("rev-parse") - .arg("--git-common-dir") - .output() - .map_err(|error| { - format!( - "could not query Git common dir for {}: {error}", - path.display() - ) - })?; - if !output.status.success() { - return Err(format!( - "could not query Git common dir for {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let raw = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim().to_string()); - let common = if raw.is_absolute() { - raw - } else { - path.join(raw) - }; - common.canonicalize().map_err(|error| { - format!( - "could not canonicalize Git common dir {}: {error}", - common.display() - ) - }) -} - -fn git_inside_worktree(path: &Path) -> bool { - Command::new("git") - .arg("-C") - .arg(path) - .arg("rev-parse") - .arg("--is-inside-work-tree") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn git_worktree_clean(path: &Path) -> bool { - Command::new("git") - .arg("-C") - .arg(path) - .arg("status") - .arg("--porcelain") - .output() - .map(|output| output.status.success() && output.stdout.is_empty()) - .unwrap_or(false) -} - -fn git_branch_exists(workspace_root: &Path, branch: &str) -> Result { - let status = Command::new("git") - .arg("-C") - .arg(workspace_root) - .arg("show-ref") - .arg("--verify") - .arg("--quiet") - .arg(format!("refs/heads/{branch}")) - .status() - .map_err(|error| format!("could not query orchestration branch `{branch}`: {error}"))?; - Ok(status.success()) -} - -fn run_git_command(mut command: Command, action: &str) -> Result<(), String> { - let output = command - .output() - .map_err(|error| format!("could not run git to {action}: {error}"))?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let detail = if stderr.is_empty() { stdout } else { stderr }; - Err(format!("git failed to {action}: {detail}")) -} - -async fn orchestrator_lifecycle( - workspace_root: &Path, - config: TicketConfigAvailability, - worker_name: String, - presence: Option, - runtime_command: WorkerRuntimeCommand, -) -> OrchestratorLifecycleReport { - if matches!(config, TicketConfigAvailability::Absent) { - return OrchestratorLifecycleReport::skipped(); - } - let presence = presence.unwrap_or(OrchestratorWorkerPresence::Missing); - match decide_orchestrator_lifecycle(&config, &presence) { - OrchestratorLifecyclePlan::SkipNoTicketConfig => OrchestratorLifecycleReport::skipped(), - OrchestratorLifecyclePlan::ReportLive => OrchestratorLifecycleReport::with_state( - OrchestratorPanelState::new(worker_name, OrchestratorPanelStatus::Live, None), - ), - OrchestratorLifecyclePlan::Restore => { - match prepare_orchestration_worktree_for_restore(workspace_root) { - Ok(worktree) => { - match restore_workspace_orchestrator_worker( - workspace_root, - &worktree.layout.path, - &worker_name, - runtime_command.clone(), - ) - .await - { - Ok(()) => { - OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new( - worker_name, - OrchestratorPanelStatus::Restored, - Some(format!( - "restored existing Worker state in orchestration worktree {} on branch {}", - worktree.layout.path.display(), - worktree.layout.branch - )), - )) - .mark_reload() - } - Err(error) => OrchestratorLifecycleReport::unavailable( - worker_name, - format!("could not restore workspace Orchestrator: {error}"), - ), - } - } - Err(error) => OrchestratorLifecycleReport::unavailable( - worker_name, - format!("could not prepare orchestration worktree for restore: {error}"), - ), - } - } - OrchestratorLifecyclePlan::Spawn => match ensure_orchestration_worktree(workspace_root) { - Ok(worktree) => { - let worktree_note = match worktree.status { - OrchestrationWorktreeStatus::Created => format!( - "created orchestration worktree {} on branch {}", - worktree.layout.path.display(), - worktree.layout.branch - ), - OrchestrationWorktreeStatus::Reused => format!( - "reused orchestration worktree {} on branch {}", - worktree.layout.path.display(), - worktree.layout.branch - ), - }; - match spawn_workspace_orchestrator_worker( - workspace_root, - &worktree.layout.path, - &worker_name, - runtime_command, - ) - .await - { - Ok(profile) => { - OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new( - worker_name, - OrchestratorPanelStatus::Spawned, - Some(format!("launched with profile {profile}; {worktree_note}")), - )) - .mark_reload() - } - Err(error) => OrchestratorLifecycleReport::unavailable( - worker_name, - format!("could not spawn workspace Orchestrator: {error}"), - ), - } - } - Err(error) => OrchestratorLifecycleReport::unavailable( - worker_name, - format!("could not prepare orchestration worktree: {error}"), - ), - }, - OrchestratorLifecyclePlan::Unavailable(message) => { - OrchestratorLifecycleReport::unavailable(worker_name, message) - } - } -} - -async fn restore_workspace_companion_pod( - workspace_root: &Path, - worker_name: &str, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), client::SpawnError> { - let config = SpawnConfig { - runtime_command, - worker_name: worker_name.to_string(), - profile: None, - workspace_root: workspace_root.to_path_buf(), - cwd: None, - resume_from: None, - }; - spawn_worker(config, |_| {}).await.map(|_| ()) -} - -async fn spawn_workspace_companion_pod( - workspace_root: &Path, - worker_name: &str, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), client::SpawnError> { - let config = SpawnConfig { - runtime_command, - worker_name: worker_name.to_string(), - profile: None, - workspace_root: workspace_root.to_path_buf(), - cwd: None, - resume_from: None, - }; - spawn_worker(config, |_| {}).await.map(|_| ()) -} - -async fn restore_workspace_orchestrator_worker( - original_workspace_root: &Path, - workspace_root: &Path, - worker_name: &str, - runtime_command: WorkerRuntimeCommand, -) -> Result<(), client::SpawnError> { - let config = SpawnConfig { - runtime_command, - worker_name: worker_name.to_string(), - profile: None, - workspace_root: original_workspace_root.to_path_buf(), - cwd: Some(workspace_root.to_path_buf()), - resume_from: None, - }; - spawn_worker_with_options( - config, - WorkerProcessLaunchOptions::default().with_hidden_arg("--ticket-role", "orchestrator"), - |_| {}, - ) - .await - .map(|_| ()) -} - -async fn spawn_workspace_orchestrator_worker( - original_workspace_root: &Path, - orchestration_workspace_root: &Path, - worker_name: &str, - runtime_command: WorkerRuntimeCommand, -) -> Result { - let context = build_orchestrator_launch_context( - original_workspace_root, - orchestration_workspace_root, - worker_name, - ); - let result = launch_ticket_role_worker(context, runtime_command, |_| {}).await?; - Ok(result.plan.profile) -} - -fn current_workspace_root() -> PathBuf { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) -} - -fn orchestrator_status_is_peer_reachable(status: OrchestratorPanelStatus) -> bool { - matches!( - status, - OrchestratorPanelStatus::Live - | OrchestratorPanelStatus::Restored - | OrchestratorPanelStatus::Spawned - ) -} - -fn unique_preticket_intake_worker_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, - worker_name: &str, - status: TicketLocalClaimStatus, -) -> String { - match status { - TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => format!( - "Ticket {ticket_id} is already claimed by local Intake Worker {worker_name} ({}); open that Worker instead of starting a second Intake.", - status.label() - ), - TicketLocalClaimStatus::Stale => format!( - "Ticket {ticket_id} has a stale local Intake claim for {worker_name}; explicit reclaim/diagnostic is required before starting a replacement." - ), - } -} - -async fn load_worker_list( - selected_name: Option, - max_entries: usize, - include_stopped: bool, -) -> Result { - let store_dir = default_store_dir()?; - let store = FsStore::new(&store_dir)?; - let worker_metadata_store = - FsWorkerStore::new(default_worker_metadata_dir()?).map_err(io::Error::other)?; - let stored = read_stored_worker_infos(&store, &worker_metadata_store)?; - let live = read_reachable_live_worker_infos(&store) - .await - .unwrap_or_default(); - let mut list = WorkerList::from_workspace_sources( - WorkerVisibilitySource::ResumePicker, - stored, - live, - selected_name, - max_entries, - ¤t_workspace_root(), - ); - if !include_stopped { - list.retain_live_entries(); - } - Ok(list) -} - -#[derive(Debug, Clone)] -pub(crate) struct CompanionSendRequest { - pub(crate) worker_name: String, - pub(crate) socket_path: PathBuf, - pub(crate) segments: Vec, -} - -#[derive(Debug, Clone)] -pub(crate) struct CompanionSendOutcome { - pub(crate) notice: String, -} - -#[derive(Debug)] -pub(crate) enum CompanionSendError { - Connect { - worker_name: String, - source: std::io::Error, - }, - Write { - worker_name: String, - source: std::io::Error, - }, - Read { - worker_name: String, - source: std::io::Error, - }, - Rejected { - worker_name: String, - message: String, - }, - Closed { - worker_name: String, - }, -} - -impl fmt::Display for CompanionSendError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Connect { - worker_name, - source, - } => { - write!(f, "Companion {worker_name} is unreachable: {source}") - } - Self::Write { - worker_name, - source, - } => { - write!(f, "Failed to send to Companion {worker_name}: {source}") - } - Self::Read { - worker_name, - source, - } => { - write!( - f, - "Failed while waiting for Companion {worker_name}: {source}" - ) - } - Self::Rejected { - worker_name, - message, - } => { - write!(f, "Companion {worker_name} rejected the message: {message}") - } - Self::Closed { worker_name } => { - write!( - f, - "Companion {worker_name} closed the socket before accepting the message" - ) - } - } - } -} - -impl std::error::Error for CompanionSendError {} - -#[derive(Debug, Clone)] -pub(crate) struct TicketActionRequest { - workspace_root: PathBuf, - ticket_id: String, - action: NextUserAction, - orchestrator: Option, -} - -#[derive(Debug, Clone)] -struct OrchestratorNotifyTarget { - worker_name: String, - socket_path: PathBuf, -} - -fn orchestrator_queue_attention_notice_target( - panel: &WorkspacePanelViewModel, - list: &WorkerList, -) -> Option { - let orchestrator = panel.header.orchestrator.as_ref()?; - if !matches!(orchestrator.status, OrchestratorPanelStatus::Live) { - return None; - } - let entry = list - .entries - .iter() - .find(|entry| entry.name == orchestrator.worker_name)?; - if !entry.actions.can_open { - return None; - } - let live = entry.live.as_ref()?; - if !live.reachable || live.status != Some(WorkerStatus::Idle) { - return None; - } - Some(OrchestratorNotifyTarget { - worker_name: orchestrator.worker_name.clone(), - socket_path: live.socket_path.clone(), - }) -} - -fn derive_orchestrator_work_set( - panel: &WorkspacePanelViewModel, - previous_planned: BTreeSet, -) -> OrchestratorWorkSet { - let active_inprogress = panel - .rows - .iter() - .filter_map(|row| { - let ticket = row.ticket.as_ref()?; - if let Some(overlay) = ticket - .orchestration_overlay - .as_ref() - .filter(|overlay| overlay.workflow_state == TicketWorkflowState::InProgress) - { - Some(OrchestratorActiveWorkItem { - id: ticket.id.clone(), - title: ticket.title.clone(), - status: format!( - "local: {} · {}: {}", - ticket.workflow_state.as_str(), - overlay.source, - overlay.workflow_state.as_str() - ), - }) - } else if ticket.workflow_state == TicketWorkflowState::InProgress { - Some(OrchestratorActiveWorkItem { - id: ticket.id.clone(), - title: ticket.title.clone(), - status: ticket.workflow_state.as_str().to_string(), - }) - } else { - None - } - }) - .collect::>(); - let active_wait = if active_inprogress.is_empty() { - None - } else { - Some(format!( - "waiting for active_inprogress: {}", - active_inprogress - .iter() - .map(|item| item.id.as_str()) - .collect::>() - .join(", ") - )) - }; - let queued = panel - .rows - .iter() - .filter_map(|row| { - let ticket = row.ticket.as_ref()?; - if ticket.workflow_state != TicketWorkflowState::Queued { - return None; - } - let duplicate_guard = queued_duplicate_guard(ticket, row); - let waiting_reason = active_wait - .clone() - .or_else(|| { - ticket - .blocked_reason - .as_ref() - .map(|reason| format!("blocked by Ticket relation diagnostics: {reason}")) - }) - .or(duplicate_guard); - let classification = - if waiting_reason.is_some() || previous_planned.contains(&ticket.id) { - OrchestratorQueuedClassification::PlannedQueued - } else { - OrchestratorQueuedClassification::NewQueued - }; - Some(OrchestratorQueuedWorkItem { - id: ticket.id.clone(), - resource_key: ticket.resource_key.clone(), - title: ticket.title.clone(), - classification, - waiting_reason, - }) - }) - .collect::>(); - let fingerprint = orchestrator_work_set_fingerprint(&active_inprogress, &queued); - OrchestratorWorkSet { - active_inprogress, - queued, - fingerprint, - } -} - -fn queued_duplicate_guard( - ticket: &crate::workspace_panel::TicketPanelEntry, - row: &PanelRow, -) -> Option { - let mut guards = Vec::new(); - if let Some(claim) = ticket.local_claim.as_ref().filter(|claim| { - matches!( - claim.status, - TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable - ) - }) { - guards.push(format!( - "local {} claim {} ({})", - claim.role, - claim.worker_name, - claim.status.label() - )); - } - for worker in ticket - .related_workers - .iter() - .chain(row.related_workers.iter()) - { - if !guards.iter().any(|guard| guard.contains(worker)) { - guards.push(format!("related worker/worktree {worker}")); - } - } - if let Some(overlay) = ticket.orchestration_overlay.as_ref() { - guards.push(format!( - "{} worktree overlay shows state {}", - overlay.source, - overlay.workflow_state.as_str() - )); - } - if guards.is_empty() { - None - } else { - Some(format!( - "waiting on existing role/session or visible worker/worktree before duplicate start: {}", - guards.join(", ") - )) - } -} - -fn orchestrator_work_set_fingerprint( - active: &[OrchestratorActiveWorkItem], - queued: &[OrchestratorQueuedWorkItem], -) -> String { - let active = active - .iter() - .map(|item| format!("active:{}:{}", item.id, item.status)) - .collect::>() - .join("|"); - let queued = queued - .iter() - .map(|item| { - format!( - "queued:{}:{}:{}", - item.id, - item.classification.as_str(), - item.waiting_reason.as_deref().unwrap_or("actionable") - ) - }) - .collect::>() - .join("|"); - format!("active=[{active}];queued=[{queued}]") -} - -fn orchestrator_queue_attention_notice( - work_set: &OrchestratorWorkSet, -) -> Result, &'static str> { - if work_set.has_active_inprogress() { - return Ok(None); - } - let actionable = work_set.actionable_queued(); - if actionable.is_empty() { - return Ok(None); - } - let waiting = work_set - .queued - .iter() - .filter(|item| item.waiting_reason.is_some()); - let tickets = actionable - .into_iter() - .chain(waiting) - .map(|item| { - let resource_key = item - .resource_key - .clone() - .ok_or("queued Ticket is missing its required resource key")?; - worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone()) - .map_err(|_| "queued Ticket has an invalid resource key") - }) - .collect::, _>>()?; - let context = worker::OrchestratorQueueAttentionContext::new(tickets); - let message = render_orchestrator_queue_attention_template(&context) - .map_err(|_| "queued-work attention prompt rendering failed")?; - let fingerprint = format!("idle-queue:{}", work_set.fingerprint); - Ok(Some(OrchestratorQueueAttentionNotice { - message, - fingerprint, - })) -} - -fn render_orchestrator_queue_attention_template( - context: &worker::OrchestratorQueueAttentionContext, -) -> Result { - worker::PromptCatalog::builtins_only()? - .orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context) -} - -fn orchestrator_work_set_detail( - work_set: &OrchestratorWorkSet, - freshness: Option<&OrchestratorQueueAttentionFreshness>, -) -> Option { - if work_set.is_empty() { - return freshness.map(|freshness| { - format!( - "queued-work attention last sent at {} (idle auto-run notify)", - freshness.updated_at - ) - }); - } - if work_set.has_active_inprogress() { - let active = work_set - .active_inprogress - .iter() - .take(3) - .map(|item| item.id.as_str()) - .collect::>() - .join(", "); - let queued = work_set - .queued - .iter() - .take(3) - .map(|item| match item.waiting_reason.as_deref() { - Some(reason) => format!("{} ({reason})", item.id), - None => item.id.clone(), - }) - .collect::>() - .join(", "); - return Some(bounded_panel_diagnostic(format!( - "queued-work attention suppressed; active_inprogress: {active}; planned queued: {queued}" - ))); - } - let actionable = work_set.actionable_queued(); - let waiting = work_set.queued.len().saturating_sub(actionable.len()); - if !actionable.is_empty() { - let classes = actionable - .iter() - .take(3) - .map(|item| format!("{} ({})", item.id, item.classification.as_str())) - .collect::>() - .join(", "); - let sent = freshness - .map(|freshness| format!("; last sent at {}", freshness.updated_at)) - .unwrap_or_default(); - return Some(bounded_panel_diagnostic(format!( - "queued-work attention pending: {classes}; waiting queued: {waiting}{sent}" - ))); - } - let waiting = work_set - .queued - .iter() - .take(3) - .map(|item| match item.waiting_reason.as_deref() { - Some(reason) => format!("{} ({reason})", item.id), - None => item.id.clone(), - }) - .collect::>() - .join(", "); - Some(bounded_panel_diagnostic(format!( - "queued-work attention waiting: {waiting}" - ))) -} - -fn apply_orchestrator_detail(panel: &mut WorkspacePanelViewModel, detail: Option) { - let Some(orchestrator) = panel.header.orchestrator.as_mut() else { - return; - }; - if matches!(orchestrator.status, OrchestratorPanelStatus::Unavailable) { - return; - } - if let Some(detail) = detail { - orchestrator.detail = Some(detail); - } -} - -fn bounded_progress_text(input: &str, max_chars: usize) -> String { - let mut output = String::new(); - for (idx, ch) in input.chars().enumerate() { - if idx >= max_chars { - output.push('…'); - return output; - } - let sanitized = if ch.is_control() && ch != '\n' && ch != '\t' { - ' ' - } else { - ch - }; - output.push(sanitized); - } - output -} - -fn progress_notice_timestamp() -> String { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(duration) => format!("unix:{}", duration.as_secs()), - Err(_) => "unix:0".to_string(), - } -} - -async fn dispatch_orchestrator_queue_attention_notice( - request: OrchestratorQueueAttentionNoticeRequest, -) -> OrchestratorQueueAttentionNoticeResult { - let fingerprint = request.notice.fingerprint.clone(); - match send_notify_only(&request.socket_path, request.notice.message, true).await { - Ok(()) => { - OrchestratorQueueAttentionNoticeResult::sent(fingerprint, progress_notice_timestamp()) - } - Err(err) => OrchestratorQueueAttentionNoticeResult::failed( - fingerprint, - format!("{}: {}", request.worker_name, err), - ), - } -} - -fn bounded_refinement_instruction(input: &str) -> String { - bounded_progress_text(input, PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS) - .trim() - .to_string() -} - -fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> { - let resource_key = resource_key.ok_or_else(|| { - "Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry." - .to_string() - })?; - let sequence = resource_key.strip_prefix("T-").filter(|sequence| { - !sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit()) - }); - sequence.map(|_| resource_key).ok_or_else(|| { - "Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry." - .to_string() - }) -} - -fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String { - format!( - "Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n" - ) -} - -fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String { - format!( - "Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}" - ) -} - -fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String { - format!( - "Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}" - ) -} - -async fn dispatch_ready_ticket_planning_return( - request: ReadyTicketPlanningReturnRequest, -) -> Result { - match ticket_config_availability(&request.workspace_root) { - TicketConfigAvailability::Usable => {} - TicketConfigAvailability::Absent => { - return Err(TicketActionError::Stale( - "Ticket config is absent; workspace Dashboard no longer exposes Ticket actions" - .to_string(), - )); - } - TicketConfigAvailability::Unusable(message) => { - return Err(TicketActionError::Stale(format!( - "Ticket config is unusable; workspace Dashboard no longer exposes Ticket actions: {message}" - ))); - } - } - let config = TicketConfig::load_workspace(&request.workspace_root) - .map_err(|error| TicketActionError::BackendConfig(error.to_string()))?; - let backend = LocalTicketBackend::new(config.backend_root()) - .with_record_language(config.ticket_record_language()); - let id = TicketIdOrSlug::Id(request.ticket_id.clone()); - let ticket = backend - .show(id.clone()) - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - let ticket_key = - required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?; - if ticket.meta.workflow_state != TicketWorkflowState::Ready { - return Err(TicketActionError::Stale(format!( - "Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.", - ticket_key, - ticket.meta.workflow_state.as_str() - ))); - } - let mut change = TicketStateChange::new( - TicketWorkflowState::Ready.as_str(), - TicketWorkflowState::Planning.as_str(), - "panel_return_to_planning", - MarkdownText::from(build_ready_ticket_refinement_thread_body( - ticket_key, - &request.user_instruction, - )), - ); - change.author = Some("workspace-panel".to_string()); - backend - .set_workflow_state(id, change) - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - - let notice = match request.followup { - ReadyTicketPlanningReturnFollowup::LaunchIntake(request) => { - ReadyTicketPlanningReturnOutcome { - notice: format!( - "Ticket {} returned to planning for refinement; launching Ticket Intake…", - ticket_key - ), - followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request), - } - } - ReadyTicketPlanningReturnFollowup::NotifyLiveClaimedIntake { - worker_name, - socket_path, - } => { - let message = - build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction); - match send_notify_only(&socket_path, message, true).await { - Ok(()) => ReadyTicketPlanningReturnOutcome { - notice: format!( - "Ticket {} returned to planning for refinement; notified live Intake Worker {}.", - ticket_key, worker_name - ), - followup: ReadyTicketPlanningReturnAfterMutation::None, - }, - Err(error) => ReadyTicketPlanningReturnOutcome { - notice: bounded_panel_diagnostic(format!( - "Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}", - ticket_key, worker_name, error - )), - followup: ReadyTicketPlanningReturnAfterMutation::None, - }, - } - } - ReadyTicketPlanningReturnFollowup::OpenRestorableClaimedIntake(request) => { - let worker_name = request.worker_name.clone(); - ReadyTicketPlanningReturnOutcome { - notice: format!( - "Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…", - ticket_key, worker_name - ), - followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request), - } - } - ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { worker_name } => { - ReadyTicketPlanningReturnOutcome { - notice: bounded_panel_diagnostic(format!( - "Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.", - ticket_key, worker_name - )), - followup: ReadyTicketPlanningReturnAfterMutation::None, - } - } - }; - Ok(notice) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TicketActionOutcome { - notice: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum TicketActionError { - BackendConfig(String), - Ticket(String), - Stale(String), -} - -impl std::fmt::Display for TicketActionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::BackendConfig(message) => write!(f, "Ticket action unavailable: {message}"), - Self::Ticket(message) => write!(f, "Ticket action failed: {message}"), - Self::Stale(message) => write!(f, "Ticket action rejected: {message}"), - } - } -} - -impl std::error::Error for TicketActionError {} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum OrchestratorNotificationOutcome { - Sent { worker_name: String }, - Skipped(String), - Warning(String), -} - -impl OrchestratorNotificationOutcome { - fn sentence(&self) -> String { - match self { - Self::Sent { worker_name } => format!("workspace Orchestrator {worker_name} notified"), - Self::Skipped(reason) => format!("workspace Orchestrator not notified: {reason}"), - Self::Warning(message) => { - format!("workspace Orchestrator notification warning: {message}") - } - } - } -} - -fn ticket_action_orchestrator_target( - panel: &WorkspacePanelViewModel, - list: &WorkerList, -) -> Option { - let orchestrator = panel.header.orchestrator.as_ref()?; - if !orchestrator_status_is_peer_reachable(orchestrator.status) { - return None; - } - let entry = list - .entries - .iter() - .find(|entry| entry.name == orchestrator.worker_name)?; - if !entry.actions.can_open { - return None; - } - let live = entry.live.as_ref()?; - if !live.reachable { - return None; - } - Some(OrchestratorNotifyTarget { - worker_name: orchestrator.worker_name.clone(), - socket_path: live.socket_path.clone(), - }) -} - -async fn dispatch_ticket_action( - request: TicketActionRequest, -) -> Result { - match ticket_config_availability(&request.workspace_root) { - TicketConfigAvailability::Usable => {} - TicketConfigAvailability::Absent => { - return Err(TicketActionError::Stale( - "Ticket config is absent; workspace Dashboard no longer exposes Ticket actions" - .to_string(), - )); - } - TicketConfigAvailability::Unusable(message) => { - return Err(TicketActionError::Stale(format!( - "Ticket config is unusable; workspace Dashboard no longer exposes Ticket actions: {message}" - ))); - } - } - let config = TicketConfig::load_workspace(&request.workspace_root) - .map_err(|error| TicketActionError::BackendConfig(error.to_string()))?; - let backend = LocalTicketBackend::new(config.backend_root()) - .with_record_language(config.ticket_record_language()) - .with_target_authority(Arc::new(DashboardTicketTargetAuthority { - workspace_root: request.workspace_root.clone(), - })); - if request.action == NextUserAction::Close { - return dispatch_panel_close(&backend, &request.ticket_id); - } - let authority_pods = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - Vec::new(), - Vec::new(), - None, - 0, - ); - let current_row = build_current_ticket_row(&backend, &request.ticket_id, &authority_pods) - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - let current_ticket = current_row - .ticket - .as_ref() - .ok_or_else(|| TicketActionError::Stale("current row is not a Ticket".to_string()))?; - let current_action = current_row.next_action.ok_or_else(|| { - TicketActionError::Stale("current Ticket no longer has an inline action".to_string()) - })?; - if current_action != request.action { - return Err(TicketActionError::Stale(format!( - "current action is {} but selected action was {}; reload and retry", - current_action.label(), - request.action.label() - ))); - } - - match request.action { - NextUserAction::Queue => { - dispatch_panel_queue( - &request.workspace_root, - &backend, - &request.ticket_id, - request.orchestrator, - current_ticket, - ) - .await - } - NextUserAction::Close => unreachable!("Close action is handled before row dispatch"), - NextUserAction::Clarify | NextUserAction::OpenWorker | NextUserAction::Wait => { - Ok(TicketActionOutcome { - notice: format!( - "{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.", - request.action.label(), - current_ticket.id - ), - }) - } - } -} - -async fn dispatch_panel_queue( - workspace_root: &Path, - backend: &LocalTicketBackend, - ticket_id: &str, - orchestrator: Option, - current_ticket: &crate::workspace_panel::TicketPanelEntry, -) -> Result { - if current_ticket.workflow_state != TicketWorkflowState::Ready { - return Err(TicketActionError::Stale(format!( - "Queue handoff check `root-ticket-state` failed for Ticket {ticket_id} at {}: state is {}, expected ready; reload and retry", - backend.root().display(), - current_ticket.workflow_state.as_str() - ))); - } - - let preflight = prepare_panel_queue_handoff(workspace_root, backend, ticket_id)?; - let root_merge = sync_orchestration_to_root_before_queue(&preflight)?; - ensure_ticket_state( - backend, - ticket_id, - TicketWorkflowState::Ready, - "root-ticket-state-after-orchestration-merge", - &preflight.root_top_level, - )?; - let queue_outcome = backend - .queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel") - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - let expected_queue_tickets = preflight - .queue_tickets - .iter() - .cloned() - .collect::>(); - let actual_queue_tickets = queue_outcome - .queued_tickets - .iter() - .cloned() - .collect::>(); - if actual_queue_tickets != expected_queue_tickets { - return Err(TicketActionError::Stale(format!( - "Queue dependency plan changed after confirmation for Ticket {ticket_id}; reload and retry" - ))); - } - let commit = commit_panel_queue_ticket_record(&preflight)?; - let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?; - verify_panel_queue_synced(&preflight, &commit)?; - let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await; - Ok(TicketActionOutcome { - notice: format!( - "Queued Ticket closure [{}]; root Queue commit {}; {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.", - queue_outcome.queued_tickets.join(", "), - commit.sha, - root_merge.sentence(), - sync.sentence(), - notification.sentence() - ), - }) -} - -struct DashboardTicketTargetAuthority { - workspace_root: PathBuf, -} - -impl ticket::TicketTargetAuthority for DashboardTicketTargetAuthority { - fn resolve_target( - &self, - _workspace_id: &str, - repository_id: Option<&str>, - ref_selector: Option<&str>, - ) -> ticket::Result { - let repository_id = repository_id.unwrap_or("main"); - if repository_id != "main" { - return Err(ticket::TicketError::UnknownTargetRepository( - repository_id.to_string(), - )); - } - let ref_selector = ref_selector.unwrap_or("HEAD"); - git_capture( - &self.workspace_root, - &[ - "rev-parse", - "--verify", - &format!("{ref_selector}^{{commit}}"), - ], - "resolve Queue Ticket target", - ) - .map_err(|reason| ticket::TicketError::InvalidTargetSelector { - repository_id: repository_id.to_string(), - selector: ref_selector.to_string(), - reason, - })?; - Ok(ticket::ResolvedTicketTarget { - repository_id: repository_id.to_string(), - ref_selector: ref_selector.to_string(), - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PanelQueueHandoffPreflight { - ticket_id: String, - root_top_level: PathBuf, - orchestration: OrchestrationWorktreeLayout, - queue_tickets: Vec, - ticket_record_dirs: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PanelQueueCommit { - sha: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PanelQueueRootMerge { - branch: String, - head: String, - merge_commit: Option, -} - -impl PanelQueueRootMerge { - fn sentence(&self) -> String { - match &self.merge_commit { - Some(commit) => format!( - "merged orchestration branch {} ({}) into root before Queue: {}", - self.branch, self.head, commit - ), - None => format!( - "orchestration branch {} ({}) already present in root", - self.branch, self.head - ), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PanelQueueSync { - path: PathBuf, - branch: String, - head: String, -} - -impl PanelQueueSync { - fn sentence(&self) -> String { - format!( - "ff-only synced {} ({}) to {}", - self.path.display(), - self.branch, - self.head - ) - } -} - -fn prepare_panel_queue_handoff( - workspace_root: &Path, - backend: &LocalTicketBackend, - ticket_id: &str, -) -> Result { - let root_top_level = git_top_level(workspace_root).map_err(|message| { - queue_check_failed("root-worktree-identity", ticket_id, workspace_root, message) - })?; - let expected_root = workspace_root.canonicalize().map_err(|error| { - queue_check_failed( - "root-worktree-identity", - ticket_id, - workspace_root, - format!("could not canonicalize root workspace path: {error}"), - ) - })?; - if root_top_level != expected_root { - return Err(queue_check_failed( - "root-worktree-identity", - ticket_id, - workspace_root, - format!( - "Git top-level is {}, expected root workspace {}", - root_top_level.display(), - expected_root.display() - ), - )); - } - - let root_branch = git_current_branch(&root_top_level).map_err(|message| { - queue_check_failed("root-branch", ticket_id, &root_top_level, message) - })?; - let _root_branch = root_branch.ok_or_else(|| { - queue_check_failed( - "root-branch", - ticket_id, - &root_top_level, - "root workspace is detached; expected merge target branch".to_string(), - ) - })?; - ensure_git_effective_user(&root_top_level).map_err(|message| { - queue_check_failed("root-git-user", ticket_id, &root_top_level, message) - })?; - - let orchestration = - resolved_orchestration_worktree_layout(&root_top_level).map_err(|message| { - queue_check_failed( - "orchestration-branch-config", - ticket_id, - &root_top_level, - message, - ) - })?; - if !orchestration.path.exists() { - return Err(queue_check_failed( - "orchestration-worktree-identity", - ticket_id, - &orchestration.path, - "dedicated orchestration worktree is missing; open the Panel with Orchestrator support before Queue".to_string(), - )); - } - validate_existing_orchestration_worktree(&root_top_level, &orchestration).map_err( - |message| { - queue_check_failed( - "orchestration-worktree-identity", - ticket_id, - &orchestration.path, - message, - ) - }, - )?; - ensure_git_clean("orchestration-clean", ticket_id, &orchestration.path)?; - - let orchestration_branch = git_current_branch(&orchestration.path).map_err(|message| { - queue_check_failed( - "orchestration-branch", - ticket_id, - &orchestration.path, - message, - ) - })?; - if orchestration_branch.as_deref() != Some(orchestration.branch.as_str()) { - return Err(queue_check_failed( - "orchestration-branch", - ticket_id, - &orchestration.path, - format!( - "orchestration branch is {:?}, expected {}", - orchestration_branch, orchestration.branch - ), - )); - } - - let root_common = git_common_dir(&root_top_level).map_err(|message| { - queue_check_failed("shared-common-dir", ticket_id, &root_top_level, message) - })?; - let orchestration_common = git_common_dir(&orchestration.path).map_err(|message| { - queue_check_failed("shared-common-dir", ticket_id, &orchestration.path, message) - })?; - if root_common != orchestration_common { - return Err(queue_check_failed( - "shared-common-dir", - ticket_id, - &orchestration.path, - format!( - "orchestration common dir {} differs from root common dir {}", - orchestration_common.display(), - root_common.display() - ), - )); - } - - ensure_ticket_state( - backend, - ticket_id, - TicketWorkflowState::Ready, - "root-ticket-state", - &root_top_level, - )?; - - let dependency_check = backend - .dependency_check(TicketIdOrSlug::Id(ticket_id.to_owned())) - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - if !dependency_check.queue_guard.can_queue_for_orchestrator { - return Err(queue_check_failed( - "dependency-queue-plan", - ticket_id, - &root_top_level, - dependency_check - .queue_guard - .blocked_reason - .or(dependency_check.queue_guard.reason) - .unwrap_or_else(|| "Queue dependency validation failed".to_string()), - )); - } - let queue_tickets = dependency_check.queue_tickets; - let mut ticket_record_dirs = Vec::with_capacity(queue_tickets.len()); - for queue_ticket in &queue_tickets { - let ticket_record_dir = backend.root().join(queue_ticket); - if !ticket_record_dir.join("item.md").is_file() { - return Err(queue_check_failed( - "target-ticket-record", - &queue_ticket, - &ticket_record_dir, - "Queue Ticket item.md is missing".to_string(), - )); - } - let clean_stage = if queue_ticket == ticket_id { - "root-ticket-clean" - } else { - "queue-dependency-clean" - }; - ensure_git_path_clean( - clean_stage, - &queue_ticket, - &root_top_level, - &ticket_record_dir, - )?; - ticket_record_dirs.push(ticket_record_dir); - } - - Ok(PanelQueueHandoffPreflight { - ticket_id: ticket_id.to_string(), - root_top_level, - orchestration, - queue_tickets, - ticket_record_dirs, - }) -} - -fn sync_orchestration_to_root_before_queue( - preflight: &PanelQueueHandoffPreflight, -) -> Result { - let orchestration_head = - git_rev_parse(&preflight.orchestration.path, "HEAD").map_err(|message| { - queue_check_failed( - "root-orchestration-merge", - &preflight.ticket_id, - &preflight.orchestration.path, - message, - ) - })?; - let root_head = git_rev_parse(&preflight.root_top_level, "HEAD").map_err(|message| { - queue_check_failed( - "root-orchestration-merge", - &preflight.ticket_id, - &preflight.root_top_level, - message, - ) - })?; - if ensure_git_ancestor(&preflight.root_top_level, &orchestration_head, &root_head).is_ok() { - return Ok(PanelQueueRootMerge { - branch: preflight.orchestration.branch.clone(), - head: orchestration_head, - merge_commit: None, - }); - } - - let mut merge = Command::new("git"); - merge - .arg("-C") - .arg(&preflight.root_top_level) - .arg("merge") - .arg("--autostash") - .arg("--no-ff") - .arg(&preflight.orchestration.branch) - .arg("-m") - .arg(format!( - "merge: sync orchestration before queue {}", - preflight.ticket_id - )); - if let Err(message) = - run_git_command(merge, "merge orchestration branch into root before Queue") - { - let mut abort = Command::new("git"); - abort - .arg("-C") - .arg(&preflight.root_top_level) - .arg("merge") - .arg("--abort"); - let abort_result = run_git_command(abort, "abort failed orchestration pre-Queue merge"); - let detail = match abort_result { - Ok(()) => format!( - "could not merge orchestration branch {} ({}) into root before Queue; merge was aborted: {}", - preflight.orchestration.branch, orchestration_head, message - ), - Err(abort_error) => format!( - "could not merge orchestration branch {} ({}) into root before Queue, and merge abort failed: {}; original merge error: {}", - preflight.orchestration.branch, orchestration_head, abort_error, message - ), - }; - return Err(queue_check_failed( - "root-orchestration-merge", - &preflight.ticket_id, - &preflight.root_top_level, - detail, - )); - } - - let merge_commit = git_rev_parse(&preflight.root_top_level, "HEAD").map_err(|message| { - queue_check_failed( - "root-orchestration-merge", - &preflight.ticket_id, - &preflight.root_top_level, - message, - ) - })?; - Ok(PanelQueueRootMerge { - branch: preflight.orchestration.branch.clone(), - head: orchestration_head, - merge_commit: Some(merge_commit), - }) -} - -fn commit_panel_queue_ticket_record( - preflight: &PanelQueueHandoffPreflight, -) -> Result { - let ticket_rels = preflight - .ticket_record_dirs - .iter() - .map(|ticket_record_dir| { - path_relative_to_root( - &preflight.root_top_level, - ticket_record_dir, - "target-ticket-record", - &preflight.ticket_id, - ) - }) - .collect::, _>>()?; - let mut add = Command::new("git"); - add.arg("-C") - .arg(&preflight.root_top_level) - .arg("add") - .arg("--") - .args(&ticket_rels); - run_git_command(add, "stage Queue Ticket records").map_err(|message| { - queue_check_failed( - "queue-commit-stage", - &preflight.ticket_id, - &preflight.root_top_level, - message, - ) - })?; - - let staged = git_capture( - &preflight.root_top_level, - &["diff", "--cached", "--name-only"], - "list staged Queue Ticket files", - ) - .map_err(|message| { - queue_check_failed( - "queue-commit-pathscope", - &preflight.ticket_id, - &preflight.root_top_level, - message, - ) - })?; - let allowed = ticket_rels - .iter() - .map(|path| format!("{}/", git_path_string(path).trim_end_matches('/'))) - .collect::>(); - let staged_paths = staged - .lines() - .filter(|line| !line.trim().is_empty()) - .collect::>(); - if staged_paths.is_empty() - || staged_paths - .iter() - .any(|path| !allowed.iter().any(|root| path.starts_with(root))) - { - return Err(queue_check_failed( - "queue-commit-pathscope", - &preflight.ticket_id, - &preflight.root_top_level, - "Queue mutation staged no Ticket records or included files outside the confirmed dependency closure" - .to_string(), - )); - } - let message = format!( - "chore: queue Ticket dependency closure {}", - preflight.ticket_id - ); - let mut commit = Command::new("git"); - commit - .arg("-C") - .arg(&preflight.root_top_level) - .arg("commit") - .arg("--no-verify") - .arg("-m") - .arg(message) - .arg("--") - .args(&ticket_rels); - run_git_command(commit, "commit Queue Ticket records").map_err(|message| { - queue_check_failed( - "queue-commit-create", - &preflight.ticket_id, - &preflight.root_top_level, - message, - ) - })?; - let sha = git_rev_parse(&preflight.root_top_level, "HEAD").map_err(|message| { - queue_check_failed( - "queue-commit-create", - &preflight.ticket_id, - &preflight.root_top_level, - message, - ) - })?; - Ok(PanelQueueCommit { sha }) -} - -fn sync_panel_queue_to_orchestration( - preflight: &PanelQueueHandoffPreflight, - commit: &PanelQueueCommit, -) -> Result { - ensure_git_clean( - "orchestration-clean-before-sync", - &preflight.ticket_id, - &preflight.orchestration.path, - )?; - let mut merge = Command::new("git"); - merge - .arg("-C") - .arg(&preflight.orchestration.path) - .arg("merge") - .arg("--ff-only") - .arg(&commit.sha); - run_git_command( - merge, - "ff-only sync Queue commit into orchestration worktree", - ) - .map_err(|message| { - queue_check_failed( - "orchestration-ff-only-sync", - &preflight.ticket_id, - &preflight.orchestration.path, - message, - ) - })?; - let head = git_rev_parse(&preflight.orchestration.path, "HEAD").map_err(|message| { - queue_check_failed( - "orchestration-ff-only-sync", - &preflight.ticket_id, - &preflight.orchestration.path, - message, - ) - })?; - Ok(PanelQueueSync { - path: preflight.orchestration.path.clone(), - branch: preflight.orchestration.branch.clone(), - head, - }) -} - -fn verify_panel_queue_synced( - preflight: &PanelQueueHandoffPreflight, - commit: &PanelQueueCommit, -) -> Result<(), TicketActionError> { - let head = git_rev_parse(&preflight.orchestration.path, "HEAD").map_err(|message| { - queue_check_failed( - "orchestration-sync-verify", - &preflight.ticket_id, - &preflight.orchestration.path, - message, - ) - })?; - ensure_git_ancestor(&preflight.orchestration.path, &commit.sha, &head).map_err(|message| { - queue_check_failed( - "orchestration-sync-verify", - &preflight.ticket_id, - &preflight.orchestration.path, - format!( - "orchestration HEAD {head} does not contain Queue commit {}: {message}", - commit.sha - ), - ) - })?; - let config = TicketConfig::load_workspace(&preflight.orchestration.path).map_err(|error| { - queue_check_failed( - "orchestration-ticket-state", - &preflight.ticket_id, - &preflight.orchestration.path, - error.to_string(), - ) - })?; - let backend = LocalTicketBackend::new(config.backend_root()) - .with_record_language(config.ticket_record_language()); - ensure_ticket_state( - &backend, - &preflight.ticket_id, - TicketWorkflowState::Queued, - "orchestration-ticket-state", - &preflight.orchestration.path, - ) -} - -fn ensure_ticket_state( - backend: &LocalTicketBackend, - ticket_id: &str, - expected: TicketWorkflowState, - check: &'static str, - path: &Path, -) -> Result<(), TicketActionError> { - let ticket = backend - .show(TicketIdOrSlug::Id(ticket_id.to_string())) - .map_err(|error| queue_check_failed(check, ticket_id, path, error.to_string()))?; - if ticket.meta.workflow_state != expected { - return Err(queue_check_failed( - check, - ticket_id, - path, - format!( - "state is {}, expected {}", - ticket.meta.workflow_state.as_str(), - expected.as_str() - ), - )); - } - Ok(()) -} - -fn ensure_git_path_clean( - check: &'static str, - ticket_id: &str, - root: &Path, - path: &Path, -) -> Result<(), TicketActionError> { - let rel = path_relative_to_root(root, path, check, ticket_id)?; - let rel_string = git_path_string(&rel); - let output = Command::new("git") - .arg("-C") - .arg(root) - .arg("status") - .arg("--porcelain") - .arg("--untracked-files=normal") - .arg("--") - .arg(&rel) - .output() - .map_err(|error| { - queue_check_failed( - check, - ticket_id, - path, - format!("git status failed: {error}"), - ) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(queue_check_failed( - check, - ticket_id, - path, - format!("git status failed: {}", stderr.trim()), - )); - } - let status = String::from_utf8_lossy(&output.stdout); - if !status.trim().is_empty() { - return Err(queue_check_failed( - check, - ticket_id, - path, - format!( - "target Ticket record {rel_string} has pre-existing changes; commit, stash, or revert them before Queue:\n{}", - status.trim_end() - ), - )); - } - Ok(()) -} - -fn ensure_git_clean( - check: &'static str, - ticket_id: &str, - path: &Path, -) -> Result<(), TicketActionError> { - let status = git_status_porcelain(path) - .map_err(|message| queue_check_failed(check, ticket_id, path, message))?; - if status.is_empty() { - return Ok(()); - } - let detail = status.into_iter().take(6).collect::>().join("; "); - Err(queue_check_failed( - check, - ticket_id, - path, - format!("worktree is dirty: {detail}"), - )) -} - -fn ensure_git_effective_user(path: &Path) -> Result<(), String> { - let name = git_capture(path, &["config", "user.name"], "read git user.name")?; - let email = git_capture(path, &["config", "user.email"], "read git user.email")?; - if name.trim().is_empty() || email.trim().is_empty() { - return Err("git user.name and user.email must be configured before the Panel creates a Queue commit".to_string()); - } - Ok(()) -} - -fn git_rev_parse(path: &Path, rev: &str) -> Result { - git_capture(path, &["rev-parse", rev], "resolve Git revision") -} - -fn git_status_porcelain(path: &Path) -> Result, String> { - let output = git_capture( - path, - &["status", "--porcelain", "--untracked-files=normal"], - "read Git status", - )?; - Ok(output.lines().map(|line| line.to_string()).collect()) -} - -fn ensure_git_ancestor(path: &Path, ancestor: &str, descendant: &str) -> Result<(), String> { - let status = Command::new("git") - .arg("-C") - .arg(path) - .arg("merge-base") - .arg("--is-ancestor") - .arg(ancestor) - .arg(descendant) - .status() - .map_err(|error| format!("could not run git merge-base --is-ancestor: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "git merge-base --is-ancestor {ancestor} {descendant} exited with {status}" - )) - } -} - -fn git_capture(path: &Path, args: &[&str], action: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .output() - .map_err(|error| { - format!( - "could not run git to {action} at {}: {error}", - path.display() - ) - })?; - if output.status.success() { - return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let detail = if stderr.is_empty() { stdout } else { stderr }; - Err(format!( - "git failed to {action} at {}: {detail}", - path.display() - )) -} - -fn path_relative_to_root( - root: &Path, - path: &Path, - check: &'static str, - ticket_id: &str, -) -> Result { - let canonical = path.canonicalize().map_err(|error| { - queue_check_failed( - check, - ticket_id, - path, - format!("could not canonicalize path: {error}"), - ) - })?; - canonical - .strip_prefix(root) - .map(PathBuf::from) - .map_err(|_| { - queue_check_failed( - check, - ticket_id, - path, - format!( - "path {} is outside root Git top-level {}", - canonical.display(), - root.display() - ), - ) - }) -} - -fn git_path_string(path: &Path) -> String { - path.components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - .join("/") -} - -fn queue_check_failed( - check: &'static str, - ticket_id: &str, - path: &Path, - message: impl Into, -) -> TicketActionError { - TicketActionError::Stale(format!( - "Queue handoff check `{check}` failed for Ticket {ticket_id} at {}: {}", - path.display(), - message.into() - )) -} - -fn dispatch_panel_close( - backend: &LocalTicketBackend, - ticket_id: &str, -) -> Result { - let ticket = backend - .show(TicketIdOrSlug::Id(ticket_id.to_owned())) - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - if let Some(blocker) = panel_close_blocker(&ticket) { - return Err(TicketActionError::Stale(blocker)); - } - - let resolution = panel_close_resolution(&ticket, backend.record_language()); - backend - .close(TicketIdOrSlug::Id(ticket_id.to_owned()), resolution) - .map_err(|error| TicketActionError::Ticket(error.to_string()))?; - - Ok(TicketActionOutcome { - notice: format!( - "Closed Ticket {}; deterministic resolution recorded because state was already done.", - ticket.meta.id - ), - }) -} - -fn panel_close_blocker(ticket: &ticket::Ticket) -> Option { - let ticket_id = ticket.meta.id.as_str(); - if ticket.meta.workflow_state == TicketWorkflowState::Closed { - return Some(format!( - "Close blocked for Ticket {ticket_id}: state is already closed; no close was recorded." - )); - } - if ticket.meta.workflow_state != TicketWorkflowState::Done { - return Some(format!( - "Close blocked for Ticket {ticket_id}: state is {}, expected done; no close was recorded.", - ticket.meta.workflow_state.as_str() - )); - } - if ticket.resolution.is_some() { - return Some(format!( - "Close blocked for Ticket {ticket_id}: resolution.md already exists; no close was recorded." - )); - } - None -} - -fn panel_close_resolution( - ticket: &ticket::Ticket, - record_language: Option<&str>, -) -> ticket::MarkdownText { - if is_japanese_ticket_record_language(record_language) { - ticket::MarkdownText::new(format!( - "Ticket `{}` (`{}`) はすでに `state: done` に到達していたため、workspace Dashboard から close しました。\n\nこの Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n", - ticket.meta.id, ticket.meta.title - )) - } else { - ticket::MarkdownText::new(format!( - "Closed from the workspace Dashboard because Ticket `{}` (`{}`) had already reached `state: done`.\n\nNo implementation work, state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n", - ticket.meta.id, ticket.meta.title - )) - } -} - -fn is_japanese_ticket_record_language(language: Option<&str>) -> bool { - let Some(language) = language else { - return false; - }; - let language = language.trim(); - language.eq_ignore_ascii_case("japanese") - || language.eq_ignore_ascii_case("ja") - || language.eq_ignore_ascii_case("ja-JP") - || language.contains("日本語") -} - -fn orchestrator_queue_notification_message( - ticket: &crate::workspace_panel::TicketPanelEntry, -) -> String { - let title = ticket.title.replace(['\r', '\n'], " "); - format!( - "Workspace Dashboard Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/WorkerSpawn implementation side effects. After inprogress acceptance, create the delegated implementation worktree with tracked `.yoi` project records visible and generated/local/runtime/log/lock/secret-like `.yoi` paths excluded, then run sibling coder/reviewer Workers through typed Ticket role launch surfaces. After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.", - ticket.id, - title.trim() - ) -} - -async fn notify_workspace_orchestrator( - target: Option, - ticket: &crate::workspace_panel::TicketPanelEntry, -) -> OrchestratorNotificationOutcome { - let Some(target) = target else { - return OrchestratorNotificationOutcome::Skipped( - "no live reachable Orchestrator socket is available".to_string(), - ); - }; - let message = orchestrator_queue_notification_message(ticket); - match send_notify_only(&target.socket_path, message, true).await { - Ok(()) => OrchestratorNotificationOutcome::Sent { - worker_name: target.worker_name, - }, - Err(error) => OrchestratorNotificationOutcome::Warning(format!( - "{} at {}: {}", - target.worker_name, - target.socket_path.display(), - error - )), - } -} - -async fn send_notify_only( - socket: &Path, - message: String, - auto_run: bool, -) -> Result<(), NotifySendError> { - let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket)) - .await - .map_err(|_| NotifySendError::Io("connect timed out".into()))? - .map_err(|e| NotifySendError::Io(format!("connect: {e}")))?; - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - - loop { - let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::()) - .await - .map_err(|_| NotifySendError::Io("read initial Snapshot timed out".into()))? - .map_err(|e| NotifySendError::Io(format!("read initial Snapshot: {e}")))?; - match event { - Some(Event::Snapshot { .. }) => break, - Some(Event::Alert(_)) => continue, - Some(Event::Error { code, message }) => { - return Err(NotifySendError::Rejected { code, message }); - } - Some(_) => continue, - None => { - return Err(NotifySendError::Io( - "connection closed before initial Snapshot".into(), - )); - } - } - } - - tokio::time::timeout( - SOCKET_OP_TIMEOUT, - writer.write(&Method::Notify { message, auto_run }), - ) - .await - .map_err(|_| NotifySendError::Io("write timed out".into()))? - .map_err(|e| NotifySendError::Io(format!("write: {e}"))) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum NotifySendError { - Rejected { code: ErrorCode, message: String }, - Io(String), -} - -impl std::fmt::Display for NotifySendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Rejected { code, message } => { - write!(f, "target rejected method ({code:?}): {message}") - } - Self::Io(message) => write!(f, "{message}"), - } - } -} - -impl std::error::Error for NotifySendError {} - -fn segments_are_blank(segments: &[Segment]) -> bool { - segments.iter().all(|segment| match segment { - Segment::Text { content } => content.trim().is_empty(), - _ => false, - }) -} - -fn selected_ticket_notice(row: Option<&PanelRow>) -> String { - match row { - Some(row) if row.is_ticket_action() => { - let action = row.next_action.map(NextUserAction::label).unwrap_or("View"); - format!( - "Enter dispatches {action} for Ticket '{}' after re-checking current Ticket authority.", - row.title - ) - } - Some(row) if row.kind == PanelRowKind::TicketIntakeWorker => row - .disabled_reason - .clone() - .or_else(|| row.key_hint.clone()) - .unwrap_or_else(|| { - "Open/attach this Ticket's Intake Worker from the associated row.".to_string() - }), - Some(row) if row.kind == PanelRowKind::InvalidTicket => row - .disabled_reason - .clone() - .or_else(|| row.key_hint.clone()) - .unwrap_or_else(|| "Invalid Ticket record placeholder has no actions.".to_string()), - _ => "No Worker is selected.".to_string(), - } -} - -fn row_status_label(entry: &WorkerListEntry) -> (&'static str, Style) { - if let Some(live) = entry.live.as_ref() { - if !live.reachable { - return ( - "unreachable", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ); - } - return match live.status { - Some(WorkerStatus::Idle) => ( - "live idle", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Some(WorkerStatus::Running) => ( - "live running", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Some(WorkerStatus::Paused) => ( - "live paused", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Some(WorkerStatus::Stopped) => ("live stopped", Style::default().fg(Color::DarkGray)), - None => ("live", Style::default().fg(Color::DarkGray)), - }; - } - if entry - .stored - .as_ref() - .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_))) - { - return ( - "corrupt", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ); - } - ("stopped/restorable", Style::default().fg(Color::Yellow)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DashboardSectionKind { - Pending, - Working, - Closed, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DashboardSection { - kind: DashboardSectionKind, - entries: Vec, -} - -impl DashboardSection { - fn hidden_count(&self) -> usize { - self.entries - .len() - .saturating_sub(visible_section_len(self.kind, self.entries.len())) - } -} - -fn classify_entry(entry: &WorkerListEntry) -> DashboardSectionKind { - if entry.live.is_some() { - if entry.actions.can_send_now { - DashboardSectionKind::Pending - } else { - DashboardSectionKind::Working - } - } else { - DashboardSectionKind::Closed - } -} - -fn sectioned_entries(list: &WorkerList) -> Vec { - let mut pending = DashboardSection { - kind: DashboardSectionKind::Pending, - entries: Vec::new(), - }; - let mut working = DashboardSection { - kind: DashboardSectionKind::Working, - entries: Vec::new(), - }; - let mut closed = DashboardSection { - kind: DashboardSectionKind::Closed, - entries: Vec::new(), - }; - - for (index, entry) in list.entries.iter().enumerate() { - match classify_entry(entry) { - DashboardSectionKind::Pending => pending.entries.push(index), - DashboardSectionKind::Working => working.entries.push(index), - DashboardSectionKind::Closed => closed.entries.push(index), - } - } - - vec![pending, working, closed] -} - -fn visible_entry_indices(list: &WorkerList) -> Vec { - sectioned_entries(list) - .into_iter() - .flat_map(|section| visible_section_indices(§ion)) - .collect() -} - -fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &WorkerList) -> Vec { - let mut keys = panel - .rows - .iter() - .filter(|row| row.is_ticket_section_row()) - .map(|row| row.key.clone()) - .collect::>(); - keys.extend( - visible_entry_indices(list) - .into_iter() - .filter_map(|index| list.entries.get(index)) - .map(|entry| PanelRowKey::Worker(entry.name.clone())), - ); - keys -} - -fn visible_section_indices(section: &DashboardSection) -> Vec { - section - .entries - .iter() - .copied() - .take(visible_section_len(section.kind, section.entries.len())) - .collect() -} - -fn visible_section_len(kind: DashboardSectionKind, len: usize) -> usize { - match kind { - DashboardSectionKind::Pending | DashboardSectionKind::Working => len, - DashboardSectionKind::Closed => len.min(CLOSED_VISIBLE_ROWS), - } -} - -fn section_header_line( - kind: DashboardSectionKind, - total: usize, - hidden: usize, - width: u16, -) -> Line<'static> { - let label = match kind { - DashboardSectionKind::Pending => "pending", - DashboardSectionKind::Working => "working", - DashboardSectionKind::Closed => "closed", - }; - let detail = if hidden > 0 { - format!(" {total} total, +{hidden} hidden") - } else { - String::new() - }; - let text = render::truncate_with_ellipsis(&format!("--{label}{detail}---"), width as usize); - Line::from(Span::styled( - text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - )) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DashboardLayoutState { - title: Rect, - list: Rect, - boundary: Rect, - target_status: Rect, - input: Rect, - actionbar: Rect, - list_draws_own_separator: bool, -} - -fn dashboard_layout(area: Rect, input_height: u16) -> DashboardLayoutState { - let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(input_height), - Constraint::Length(1), - ]) - .split(area); - - DashboardLayoutState { - title: chunks[0], - list: chunks[1], - boundary: chunks[2], - target_status: chunks[3], - input: chunks[4], - actionbar: chunks[5], - list_draws_own_separator: false, - } -} - -// Rendering and layout composition live in dashboard/render.rs. -#[cfg(test)] -mod tests; diff --git a/crates/tui/src/dashboard/render.rs b/crates/tui/src/dashboard/render.rs deleted file mode 100644 index 417620cd..00000000 --- a/crates/tui/src/dashboard/render.rs +++ /dev/null @@ -1,784 +0,0 @@ -use super::*; - -pub(super) fn draw(frame: &mut Frame<'_>, app: &mut DashboardApp) { - let area = frame.area(); - let input_content_width = area.width.saturating_sub(2).max(1); - let mut input_render = app.input.render(input_content_width); - let input_height = input_area_height(&input_render, area.height); - app.input - .apply_cursor_viewport(&mut input_render, input_height); - let layout = dashboard_layout(area, input_height); - - draw_title(frame, app, layout.title); - draw_list(frame, app, layout.list); - draw_separator(frame, layout.boundary); - draw_target_status(frame, app, layout.target_status); - draw_input(frame, &input_render, layout.input); - draw_actionbar(frame, app, layout.actionbar); - if app.panel_diagnostic_open { - render_panel_diagnostic(frame, app, area); - } -} - -pub(super) fn panel_diagnostic_area(area: Rect) -> Rect { - let width = if area.width <= 20 { - area.width - } else { - area.width.saturating_sub(4).min(100).max(20) - }; - let height = if area.height <= 8 { - area.height - } else { - area.height.saturating_sub(4).min(24).max(8) - }; - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new(x, y, width, height) -} - -pub(super) fn render_panel_diagnostic(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { - let Some(diagnostic) = app.panel_diagnostic.as_ref() else { - return; - }; - let popup_area = panel_diagnostic_area(area); - let title = format!(" {} ", diagnostic.title); - let text = format!("{}\n\nF2/Esc: close", diagnostic.details); - let paragraph = Paragraph::new(text) - .block(Block::default().title(title).borders(Borders::ALL)) - .wrap(Wrap { trim: false }); - frame.render_widget(Clear, popup_area); - frame.render_widget(paragraph, popup_area); -} - -pub(super) fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { - let needed = render.lines.len().max(1) as u16; - let cap = (terminal_height / 3).max(1).min(10); - needed.clamp(1, cap) -} - -pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { - frame.render_widget(Paragraph::new(title_line(app)), area); -} - -pub(super) fn title_line(app: &DashboardApp) -> Line<'static> { - let mut spans = vec![Span::styled( - "workspace dashboard", - Style::default().add_modifier(Modifier::BOLD), - )]; - if let Some(companion) = &app.panel.header.companion { - spans.push(Span::styled( - " · companion ", - Style::default().fg(Color::DarkGray), - )); - spans.push(Span::styled( - companion.status.label(), - companion_status_style(companion.status), - )); - if let Some(detail) = companion.detail.as_deref() { - spans.push(Span::styled( - format!(" ({detail})"), - Style::default().fg(Color::DarkGray), - )); - } - } - if let Some(orchestrator) = &app.panel.header.orchestrator { - spans.push(Span::styled( - " · orchestrator ", - Style::default().fg(Color::DarkGray), - )); - spans.push(Span::styled( - orchestrator.status.label(), - orchestrator_status_style(orchestrator.status), - )); - } - Line::from(spans) -} - -pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style { - match status { - CompanionPanelStatus::Live - | CompanionPanelStatus::Restored - | CompanionPanelStatus::Spawned => Style::default().fg(Color::Green), - CompanionPanelStatus::Stopped | CompanionPanelStatus::Missing => { - Style::default().fg(Color::Yellow) - } - CompanionPanelStatus::Unavailable => Style::default().fg(Color::Red), - } -} - -pub(super) fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style { - match status { - OrchestratorPanelStatus::Live - | OrchestratorPanelStatus::Restored - | OrchestratorPanelStatus::Spawned => Style::default().fg(Color::Green), - OrchestratorPanelStatus::Stopped | OrchestratorPanelStatus::Missing => { - Style::default().fg(Color::Yellow) - } - OrchestratorPanelStatus::Unavailable => Style::default().fg(Color::Red), - } -} - -pub(super) fn draw_list(frame: &mut Frame<'_>, app: &mut DashboardApp, area: Rect) { - if area.width == 0 || area.height == 0 { - app.row_hit_boxes.clear(); - return; - } - let rows = list_rows(app, area.width, area.height); - app.set_row_hit_boxes(&rows, area); - let lines = rows.into_iter().map(|row| row.line).collect::>(); - Paragraph::new(lines).render(area, frame.buffer_mut()); -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct PanelListRow { - pub(super) line: Line<'static>, - pub(super) key: Option, -} - -impl PanelListRow { - fn inert(line: Line<'static>) -> Self { - Self { line, key: None } - } - - fn selectable(line: Line<'static>, key: PanelRowKey) -> Self { - Self { - line, - key: Some(key), - } - } -} - -#[cfg(test)] -pub(super) fn list_lines(app: &DashboardApp, width: u16, height: u16) -> Vec> { - list_rows(app, width, height) - .into_iter() - .map(|row| row.line) - .collect() -} - -pub(super) fn list_rows(app: &DashboardApp, width: u16, height: u16) -> Vec { - let sections = sectioned_entries(&app.list); - let selected = app.selected_row.as_ref(); - let diagnostic_rows = panel_diagnostic_lines(&app.panel, width) - .into_iter() - .map(PanelListRow::inert) - .collect::>(); - let action_rows = panel_action_rows(&app.panel, selected, width); - let live_rows = sections - .iter() - .filter(|section| section.kind != DashboardSectionKind::Closed) - .flat_map(|section| section_rows(&app.list, section, selected, width)) - .collect::>(); - let closed_rows = sections - .iter() - .find(|section| section.kind == DashboardSectionKind::Closed) - .map(|section| section_rows(&app.list, section, selected, width)) - .unwrap_or_default(); - - let available = height as usize; - let diagnostic_len = diagnostic_rows.len().min(available); - let remaining_after_diagnostics = available.saturating_sub(diagnostic_len); - let action_len = action_rows.len().min(remaining_after_diagnostics); - let remaining_after_actions = remaining_after_diagnostics.saturating_sub(action_len); - let closed_len = closed_rows.len().min(remaining_after_actions); - let live_len = live_rows - .len() - .min(remaining_after_actions.saturating_sub(closed_len)); - let spacer_len = available.saturating_sub(diagnostic_len + action_len + live_len + closed_len); - - let mut rows = Vec::with_capacity(available); - rows.extend(diagnostic_rows.into_iter().take(diagnostic_len)); - rows.extend(action_rows.into_iter().take(action_len)); - rows.extend(live_rows.into_iter().take(live_len)); - rows.extend( - std::iter::repeat_with(|| PanelListRow::inert(Line::from(Span::raw("")))).take(spacer_len), - ); - rows.extend(closed_rows.into_iter().take(closed_len)); - rows -} - -pub(super) fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec { - if area.width == 0 || area.height == 0 { - return Vec::new(); - } - - let mut hit_boxes: Vec = Vec::new(); - for (offset, row) in rows.iter().enumerate() { - let Some(key) = row.key.clone() else { - continue; - }; - let Some(y) = area.y.checked_add(offset as u16) else { - continue; - }; - if y >= area.y.saturating_add(area.height) { - continue; - } - if let Some(last) = hit_boxes.last_mut() { - if last.key == key - && last.rect.x == area.x - && last.rect.width == area.width - && last.rect.y.saturating_add(last.rect.height) == y - { - last.rect.height = last.rect.height.saturating_add(1); - continue; - } - } - hit_boxes.push(PanelRowHitBox { - rect: Rect::new(area.x, y, area.width, 1), - key, - }); - } - hit_boxes -} - -pub(super) fn panel_diagnostic_lines( - panel: &WorkspacePanelViewModel, - width: u16, -) -> Vec> { - panel - .header - .diagnostics - .iter() - .map(|diagnostic| { - Line::from(vec![ - Span::styled("⚠ ", Style::default().fg(Color::Yellow)), - Span::styled( - truncate_with_ellipsis(diagnostic, width.saturating_sub(2) as usize), - Style::default().fg(Color::Yellow), - ), - ]) - }) - .collect() -} - -pub(super) fn panel_action_rows( - panel: &WorkspacePanelViewModel, - selected: Option<&PanelRowKey>, - width: u16, -) -> Vec { - let rows = panel - .rows - .iter() - .filter(|row| row.is_ticket_section_row()) - .collect::>(); - if rows.is_empty() { - return Vec::new(); - } - let mut lines = Vec::with_capacity((rows.len() * 2) + 1); - lines.push(PanelListRow::inert(panel_action_header_line( - rows.len(), - width, - ))); - for row in rows { - for line in panel_row_lines(row, selected == Some(&row.key), width) { - lines.push(PanelListRow::selectable(line, row.key.clone())); - } - } - lines -} - -pub(super) fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { - let detail = if total == 1 { - " 1 row".to_string() - } else { - format!(" {total} rows") - }; - let text = truncate_with_ellipsis(&format!("--tickets{detail}---"), width as usize); - Line::from(Span::styled( - text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - )) -} - -pub(super) const TICKET_STATE_COLUMN_WIDTH: usize = 10; -pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18; - -pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec> { - if row.kind == PanelRowKind::TicketIntakeWorker { - vec![panel_intake_child_line(row, selected, width)] - } else { - vec![ - panel_row_title_line(row, selected, width), - panel_row_detail_line(row, selected, width), - ] - } -} - -pub(super) fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { - let title_style = if selected { - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Magenta) - }; - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_ticket_primary_marker_span(&mut spans, selected, &mut remaining); - push_column_span( - &mut spans, - &row.status, - TICKET_STATE_COLUMN_WIDTH, - panel_priority_style(row.priority), - &mut remaining, - ); - push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); - - Line::from(spans) -} - -pub(super) fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { - let title_style = if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Cyan) - }; - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_intake_child_marker_span(&mut spans, selected, &mut remaining); - push_column_span( - &mut spans, - &row.status, - TICKET_STATE_COLUMN_WIDTH, - intake_status_style(&row.status), - &mut remaining, - ); - push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining); - - Line::from(spans) -} - -pub(super) fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_ticket_detail_marker_span(&mut spans, selected, &mut remaining); - push_bounded_span( - &mut spans, - "meta ", - Style::default().fg(Color::DarkGray), - &mut remaining, - ); - push_bounded_span( - &mut spans, - &panel_ticket_detail(row), - ticket_detail_style(row), - &mut remaining, - ); - - Line::from(spans) -} - -pub(super) fn push_ticket_primary_marker_span( - spans: &mut Vec>, - selected: bool, - remaining: &mut usize, -) { - let (marker, style) = if selected { - ( - "▶ ", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ) - } else { - (" ", Style::default().fg(Color::DarkGray)) - }; - push_bounded_span(spans, marker, style, remaining); -} - -pub(super) fn push_ticket_detail_marker_span( - spans: &mut Vec>, - selected: bool, - remaining: &mut usize, -) { - let (marker, style) = if selected { - ( - "│ ", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ) - } else { - (" ", Style::default().fg(Color::DarkGray)) - }; - push_bounded_span(spans, marker, style, remaining); -} - -pub(super) fn push_intake_child_marker_span( - spans: &mut Vec>, - selected: bool, - remaining: &mut usize, -) { - let (marker, style) = if selected { - ( - " ▶ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - } else { - (" └ ", Style::default().fg(Color::DarkGray)) - }; - push_bounded_span(spans, marker, style, remaining); -} - -pub(super) fn panel_ticket_detail(row: &PanelRow) -> String { - if row.kind == PanelRowKind::InvalidTicket { - let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()]; - if let Some(reason) = panel_ticket_reason(row) { - parts.push(format!("Reason: {reason}")); - } - return parts.join(" · "); - } - - if row.kind == PanelRowKind::TicketIntakeWorker { - let mut parts = row - .subtitle - .as_ref() - .map(|subtitle| vec![subtitle.clone()]) - .unwrap_or_else(|| vec![panel_ticket_reference(row)]); - if let Some(action) = row.next_action { - parts.push(format!("Action: {}", action.label())); - } - if let Some(reason) = panel_ticket_reason(row) { - parts.push(format!("Reason: {reason}")); - } - return parts.join(" · "); - } - - let mut parts = vec![panel_ticket_reference(row)]; - if let Some(overlay_detail) = panel_ticket_overlay_detail(row) { - parts.push(overlay_detail); - } - if let Some(blocked_reason) = row - .ticket - .as_ref() - .and_then(|ticket| ticket.blocked_reason.as_deref()) - { - parts.push(format!("Dependencies: {blocked_reason}")); - } else { - parts.push("Gate: clear".to_string()); - } - if let Some(action) = row.next_action { - parts.push(format!( - "Action: {}", - panel_ticket_action_label(row, action) - )); - } - if let Some(reason) = panel_ticket_reason(row) { - parts.push(format!("Reason: {reason}")); - } - parts.join(" · ") -} - -pub(super) fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str { - if action == NextUserAction::Wait - && row - .ticket - .as_ref() - .and_then(|ticket| ticket.blocked_reason.as_ref()) - .is_some() - { - "queue disabled" - } else { - action.label() - } -} - -pub(super) fn panel_ticket_overlay_detail(row: &PanelRow) -> Option { - let ticket = row.ticket.as_ref()?; - let overlay = ticket.orchestration_overlay.as_ref()?; - let mut detail = format!( - "Overlay: local {} · {} {}", - ticket.workflow_state.as_str(), - overlay.source, - overlay.workflow_state.as_str() - ); - if matches!( - overlay.workflow_state, - TicketWorkflowState::Done | TicketWorkflowState::Closed - ) { - detail.push_str(" · merge pending"); - } - Some(detail) -} - -pub(super) fn panel_ticket_reason(row: &PanelRow) -> Option<&str> { - row.disabled_reason - .as_deref() - .or_else(|| row.key_hint.as_deref()) -} - -pub(super) fn ticket_detail_style(row: &PanelRow) -> Style { - if row.kind == PanelRowKind::InvalidTicket { - return Style::default().fg(Color::Yellow); - } - if row - .ticket - .as_ref() - .and_then(|ticket| ticket.blocked_reason.as_ref()) - .is_some() - { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::DarkGray) - } -} - -pub(super) fn panel_ticket_reference(row: &PanelRow) -> String { - row.ticket - .as_ref() - .map(|ticket| { - ticket - .resource_key - .clone() - .unwrap_or_else(|| "resource key unavailable".to_string()) - }) - .unwrap_or_else(|| match &row.key { - PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), - PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(), - PanelRowKey::Worker(name) => name.clone(), - }) -} - -pub(super) fn push_column_span( - spans: &mut Vec>, - value: &str, - column_width: usize, - style: Style, - remaining: &mut usize, -) { - if *remaining == 0 { - return; - } - let mut content = padded_cell(value, column_width); - content.push(' '); - push_bounded_span(spans, &content, style, remaining); -} - -pub(super) fn push_bounded_span( - spans: &mut Vec>, - value: &str, - style: Style, - remaining: &mut usize, -) { - if *remaining == 0 || value.is_empty() { - return; - } - let content = truncate_with_ellipsis(value, *remaining); - *remaining = remaining.saturating_sub(content.width()); - spans.push(Span::styled(content, style)); -} - -pub(super) fn padded_cell(value: &str, width: usize) -> String { - let mut cell = truncate_with_ellipsis(value, width); - let padding = width.saturating_sub(cell.width()); - cell.extend(std::iter::repeat_n(' ', padding)); - cell -} - -pub(super) fn panel_priority_style(priority: ActionPriority) -> Style { - match priority { - ActionPriority::ReadyForQueue => Style::default().fg(Color::Green), - ActionPriority::ActiveWork => Style::default().fg(Color::Cyan), - ActionPriority::Background => Style::default().fg(Color::DarkGray), - } -} - -pub(super) fn intake_status_style(status: &str) -> Style { - match status { - "live" => Style::default().fg(Color::Green), - "restorable" => Style::default().fg(Color::Yellow), - "stale" => Style::default().fg(Color::DarkGray), - _ => Style::default().fg(Color::Cyan), - } -} - -pub(super) fn section_rows( - list: &WorkerList, - section: &DashboardSection, - selected: Option<&PanelRowKey>, - width: u16, -) -> Vec { - let visible = visible_section_indices(section); - if visible.is_empty() { - return Vec::new(); - } - - let mut rows = Vec::with_capacity(visible.len() + 1); - rows.push(PanelListRow::inert(section_header_line( - section.kind, - section.entries.len(), - section.hidden_count(), - width, - ))); - for index in visible { - if let Some(entry) = list.entries.get(index) { - let key = PanelRowKey::Worker(entry.name.clone()); - let selected = selected == Some(&key); - rows.push(PanelListRow::selectable( - row_line(entry, selected, width), - key, - )); - } - } - rows -} - -pub(super) fn row_line(entry: &WorkerListEntry, selected: bool, width: u16) -> Line<'static> { - let marker = if selected { "▶ " } else { " " }; - let name_style = if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Cyan) - }; - let (status, status_style) = row_status_label(entry); - let mut spans = Vec::new(); - let mut remaining = width as usize; - - push_bounded_span( - &mut spans, - marker, - if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }, - &mut remaining, - ); - push_column_span( - &mut spans, - status, - POD_STATUS_COLUMN_WIDTH, - status_style, - &mut remaining, - ); - push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining); - - Line::from(spans) -} - -pub(super) fn draw_separator(frame: &mut Frame<'_>, area: Rect) { - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - "─".repeat(area.width as usize), - Style::default().fg(Color::DarkGray), - ))), - area, - ); -} - -pub(super) fn draw_target_status(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { - frame.render_widget(Paragraph::new(target_status_line(app)), area); -} - -pub(super) fn target_status_line(_app: &DashboardApp) -> Line<'static> { - Line::from(Span::raw("")) -} - -pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) { - let mut lines: Vec> = Vec::with_capacity(render.lines.len()); - for (i, src) in render.lines.iter().enumerate() { - let absolute_row = render.viewport_start_row as usize + i; - let prefix = if absolute_row == 0 { "> " } else { " " }; - let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))]; - spans.extend(src.spans.iter().cloned()); - lines.push(Line::from(spans)); - } - frame.render_widget(Paragraph::new(lines), area); - - let cursor_x = area.x + 2 + render.cursor_col; - let cursor_y = area.y + render.cursor_row; - if cursor_y < area.y + area.height { - frame.set_cursor_position(Position::new(cursor_x, cursor_y)); - } -} - -pub(super) fn actionbar_left_text(app: &DashboardApp) -> String { - if app.sending && app.composer_target() == ComposerTarget::TicketIntake { - "launching Ticket Intake…".to_string() - } else if app.sending { - "working…".to_string() - } else if app.refreshing { - match app.notice.as_deref() { - Some(notice) if notice.contains("Refreshing") || notice.contains("refreshing") => { - notice.to_string() - } - Some(notice) => format!("{notice} Refreshing workspace…"), - None => "Refreshing workspace…".to_string(), - } - } else if let Some(notice) = app.notice.as_deref() { - notice.to_string() - } else { - String::new() - } -} - -pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str { - if app.panel_diagnostic_open { - "F2/Esc close details" - } else if app.panel_diagnostic.is_some() { - "F2 details" - } else { - "" - } -} - -pub(super) fn draw_actionbar(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) { - let left = actionbar_left_text(app); - let right = actionbar_right_text(app); - let left_width = area - .width - .saturating_sub(right.width() as u16) - .saturating_sub(2) as usize; - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - truncate_with_ellipsis(&left, left_width), - Style::default().fg(Color::DarkGray), - ))), - area, - ); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - right, - Style::default().fg(Color::DarkGray), - ))) - .alignment(ratatui::layout::Alignment::Right), - area, - ); -} - -pub(super) fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { - if max_width == 0 { - return String::new(); - } - if s.width() <= max_width { - return s.to_string(); - } - if max_width == 1 { - return "…".to_string(); - } - let mut out = String::new(); - let mut width = 0usize; - for c in s.chars() { - let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); - if width + cw > max_width - 1 { - break; - } - out.push(c); - width += cw; - } - out.push('…'); - out -} diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs deleted file mode 100644 index 32be9393..00000000 --- a/crates/tui/src/dashboard/tests.rs +++ /dev/null @@ -1,3562 +0,0 @@ -use super::render::*; -use super::*; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; -#[test] -fn orchestration_worktree_layout_is_stable_under_original_workspace_root() { - let root = Path::new("/tmp/Yoi Workspace"); - let layout = orchestration_worktree_layout(root); - assert_eq!( - layout.path, - PathBuf::from("/tmp/Yoi Workspace/.worktree/orchestration") - ); - assert_eq!(layout.branch, "orchestration"); -} - -#[test] -fn orchestrator_launch_context_uses_original_root_for_runtime_workspace_and_worktree_cwd() { - let original = PathBuf::from("/repo/yoi"); - let orchestration = original - .join(".worktree") - .join("orchestration") - .join("yoi-orchestrator"); - let context = build_orchestrator_launch_context(&original, &orchestration, "yoi-orchestrator"); - assert_eq!(context.workspace_root, original); - assert_eq!(context.cwd.as_deref(), Some(orchestration.as_path())); - assert_eq!( - context.original_workspace_root.as_deref(), - Some(original.as_path()) - ); - assert_eq!( - context.target_workspace_root.as_deref(), - Some(original.as_path()) - ); -} - -#[test] -fn invalid_existing_orchestration_path_is_diagnostic_not_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - std::fs::create_dir_all(&root).unwrap(); - let layout = orchestration_worktree_layout(&root); - std::fs::create_dir_all(&layout.path).unwrap(); - std::fs::write(layout.path.join("keep.txt"), "do not delete").unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("not a Git worktree")); - assert!(layout.path.join("keep.txt").exists()); -} - -#[test] -fn ensure_orchestration_worktree_creates_and_reuses_git_worktree() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - std::fs::create_dir_all(&root).unwrap(); - run_test_git(&root, &["init"]).unwrap(); - std::fs::write(root.join("README.md"), "repo").unwrap(); - run_test_git(&root, &["add", "README.md"]).unwrap(); - run_test_git( - &root, - &[ - "-c", - "user.email=test@example.invalid", - "-c", - "user.name=Yoi Test", - "commit", - "-m", - "init", - ], - ) - .unwrap(); - - let created = ensure_orchestration_worktree(&root).unwrap(); - assert_eq!(created.status, OrchestrationWorktreeStatus::Created); - assert!(created.layout.path.exists()); - assert!(git_inside_worktree(&created.layout.path)); - - let reused = ensure_orchestration_worktree(&root).unwrap(); - assert_eq!(reused.status, OrchestrationWorktreeStatus::Reused); - assert_eq!(reused.layout, created.layout); - - std::fs::write(created.layout.path.join("dirty.txt"), "dirty").unwrap(); - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("dirty")); - assert!(created.layout.path.join("dirty.txt").exists()); -} - -#[test] -fn ensure_and_restore_use_configured_orchestration_layout() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - write_test_ticket_config( - &root, - r#" -[ticket.orchestration] -branch = "orchestration/custom-panel" -worktree_dir = "custom-worktrees" -worktree_name = "panel" -"#, - ); - run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap(); - run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); - - let resolved = resolved_orchestration_worktree_layout(&root).unwrap(); - assert_eq!(resolved.branch, "orchestration/custom-panel"); - assert!(resolved.path.ends_with("custom-worktrees/panel")); - - let created = ensure_orchestration_worktree(&root).unwrap(); - assert_eq!(created.status, OrchestrationWorktreeStatus::Created); - assert_eq!(created.layout, resolved); - let branch = run_test_git_output(&created.layout.path, &["branch", "--show-current"]).unwrap(); - assert_eq!(branch.trim(), "orchestration/custom-panel"); - - let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); - assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); - assert_eq!(restored.layout, created.layout); -} - -#[test] -fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - std::fs::create_dir_all(&root).unwrap(); - write_test_ticket_config( - &root, - r#" -[ticket.orchestration] -branch = "orchestration/bad:branch" -"#, - ); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("failed to load ticket config")); - assert!(err.contains("git branch name")); - assert!(!root.join(".worktree").exists()); -} - -#[test] -fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - write_test_ticket_config( - &root, - r#" -[ticket.orchestration] -branch = "orchestration/custom-panel" -"#, - ); - run_test_git(&root, &["add", ".yoi/workspace.toml"]).unwrap(); - run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); - let layout = resolved_orchestration_worktree_layout(&root).unwrap(); - run_test_git( - &root, - &[ - "worktree", - "add", - &layout.path.display().to_string(), - "-b", - "orchestration/other-panel", - "HEAD", - ], - ) - .unwrap(); - - let err = prepare_orchestration_worktree_for_restore(&root).unwrap_err(); - assert!(err.contains("expected orchestration/custom-panel")); - let branch = run_test_git_output(&layout.path, &["branch", "--show-current"]).unwrap(); - assert_eq!(branch.trim(), "orchestration/other-panel"); -} - -#[test] -fn restore_uses_existing_orchestration_worktree_even_when_dirty() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let created = ensure_orchestration_worktree(&root).unwrap(); - std::fs::write(created.layout.path.join("orchestrator-notes.txt"), "dirty").unwrap(); - - let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); - - assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); - assert_eq!(restored.layout.path, created.layout.path); - assert_ne!(restored.layout.path, root); - assert!(restored.layout.path.ends_with(".worktree/orchestration")); -} - -#[test] -fn existing_wrong_branch_worktree_is_rejected_without_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let layout = orchestration_worktree_layout(&root); - run_test_git( - &root, - &[ - "worktree", - "add", - &layout.path.display().to_string(), - "-b", - "wrong-branch", - "HEAD", - ], - ) - .unwrap(); - std::fs::write(layout.path.join("keep.txt"), "keep").unwrap(); - run_test_git(&layout.path, &["add", "keep.txt"]).unwrap(); - run_test_git( - &layout.path, - &[ - "-c", - "user.email=test@example.invalid", - "-c", - "user.name=Yoi Test", - "commit", - "-m", - "keep", - ], - ) - .unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("expected orchestration")); - assert!(layout.path.join("keep.txt").exists()); -} - -#[test] -fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let layout = orchestration_worktree_layout(&root); - std::fs::create_dir_all(&layout.path).unwrap(); - init_test_repo(&layout.path); - run_test_git(&layout.path, &["checkout", "-b", &layout.branch]).unwrap(); - std::fs::write(layout.path.join("unrelated.txt"), "keep").unwrap(); - run_test_git(&layout.path, &["add", "unrelated.txt"]).unwrap(); - run_test_git( - &layout.path, - &[ - "-c", - "user.email=test@example.invalid", - "-c", - "user.name=Yoi Test", - "commit", - "-m", - "unrelated", - ], - ) - .unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("different Git repository")); - assert!(layout.path.join("unrelated.txt").exists()); -} - -fn write_test_ticket_config(root: &Path, content: &str) { - let config_dir = root.join(".yoi"); - std::fs::create_dir_all(&config_dir).unwrap(); - std::fs::write(config_dir.join("workspace.toml"), content).unwrap(); -} - -fn init_test_repo(root: &Path) { - std::fs::create_dir_all(root).unwrap(); - run_test_git(root, &["init"]).unwrap(); - run_test_git(root, &["config", "user.email", "test@example.invalid"]).unwrap(); - run_test_git(root, &["config", "user.name", "Yoi Test"]).unwrap(); - std::fs::write(root.join("README.md"), "repo").unwrap(); - run_test_git(root, &["add", "README.md"]).unwrap(); - run_test_git(root, &["commit", "-m", "init"]).unwrap(); -} - -#[test] -fn inherited_parent_worktree_directory_is_rejected_without_cleanup() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("repo"); - init_test_repo(&root); - let layout = orchestration_worktree_layout(&root); - run_test_git(&root, &["checkout", "-b", &layout.branch]).unwrap(); - std::fs::create_dir_all(&layout.path).unwrap(); - std::fs::write(layout.path.join("plain.txt"), "keep").unwrap(); - - let err = ensure_orchestration_worktree(&root).unwrap_err(); - assert!(err.contains("not the worktree root")); - assert!(layout.path.join("plain.txt").exists()); -} - -fn run_test_git(root: &Path, args: &[&str]) -> Result<(), String> { - let mut command = Command::new("git"); - command.arg("-C").arg(root).args(args); - run_git_command(command, "run test git") -} - -fn run_test_git_output(root: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(root) - .args(args) - .output() - .map_err(|error| format!("could not run test git: {error}"))?; - if !output.status.success() { - return Err(format!( - "git failed to run test git: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -use crate::worker_list::{ - LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerEntrySummary, -}; -use std::fs; -use tempfile::TempDir; -use ticket::{ - LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, TicketEventKind, - TicketWorkflowState, -}; - -fn ticket_workspace( - title: &str, - state: TicketWorkflowState, - configure: impl FnOnce(&mut NewTicket), -) -> (TempDir, String, LocalTicketBackend) { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join(".yoi")).unwrap(); - fs::write( - temp.path().join(".gitignore"), - ".worktree/\n.yoi/tickets/.ticket-backend.lock\n", - ) - .unwrap(); - fs::write( - temp.path().join(".yoi/.gitignore"), - "tickets/.ticket-backend.lock\n", - ) - .unwrap(); - fs::write( - temp.path().join(".yoi/workspace.toml"), - "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", - ) - .unwrap(); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let mut input = NewTicket::new(title); - input.body = MarkdownText::from("Ready for panel action"); - input.workflow_state = Some(state); - configure(&mut input); - let ticket = backend.create(input).unwrap(); - (temp, ticket.id, backend) -} - -fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { - ticket_workspace(title, TicketWorkflowState::Ready, |_| {}) -} - -fn ready_ticket_git_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { - let (temp, ticket_id, backend) = ready_ticket_workspace(title); - run_test_git(temp.path(), &["init"]).unwrap(); - run_test_git( - temp.path(), - &["config", "user.email", "test@example.invalid"], - ) - .unwrap(); - run_test_git(temp.path(), &["config", "user.name", "Yoi Test"]).unwrap(); - run_test_git(temp.path(), &["add", "."]).unwrap(); - run_test_git(temp.path(), &["commit", "-m", "seed tickets"]).unwrap(); - ensure_orchestration_worktree(temp.path()).unwrap(); - (temp, ticket_id, backend) -} - -fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) { - ticket_workspace(title, TicketWorkflowState::Done, |_| {}) -} - -fn request_for(temp: &TempDir, ticket_id: String, action: NextUserAction) -> TicketActionRequest { - TicketActionRequest { - workspace_root: temp.path().to_path_buf(), - ticket_id, - action, - orchestrator: None, - } -} - -fn planning_return_request( - temp: &TempDir, - ticket_id: String, - instruction: &str, -) -> ReadyTicketPlanningReturnRequest { - ReadyTicketPlanningReturnRequest { - workspace_root: temp.path().to_path_buf(), - ticket_id, - ticket_key: "T-482".to_string(), - user_instruction: instruction.to_string(), - followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { - worker_name: "stale-intake".to_string(), - }, - } -} - -#[tokio::test] -async fn ready_ticket_planning_return_records_instruction_and_returns_to_planning_without_queueing() -{ - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-ready"); - - let outcome = dispatch_ready_ticket_planning_return(planning_return_request( - &temp, - ticket_id.clone(), - "please add acceptance detail before queueing", - )) - .await - .unwrap(); - - assert!(outcome.notice.contains("returned to planning")); - assert!(outcome.notice.contains("instruction was recorded")); - assert!(matches!( - outcome.followup, - ReadyTicketPlanningReturnAfterMutation::None - )); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); - assert!(ticket.meta.queued_by.is_none()); - assert!(ticket.meta.queued_at.is_none()); - let state_change = ticket - .events - .iter() - .find(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("planning") - }) - .expect("ready -> planning state_changed event is recorded"); - assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); - assert!( - state_change - .body - .as_str() - .contains("please add acceptance detail") - ); - assert!(state_change.body.as_str().contains("not Queue routing")); - assert!( - state_change - .body - .as_str() - .contains("must not start implementation") - ); -} - -#[tokio::test] -async fn ready_ticket_planning_return_rejects_stale_non_ready_ticket() { - let (temp, ticket_id, backend) = - ticket_workspace("panel-refine-stale", TicketWorkflowState::Planning, |_| {}); - - let error = dispatch_ready_ticket_planning_return(planning_return_request( - &temp, - ticket_id.clone(), - "refine please", - )) - .await - .unwrap_err(); - - assert!(error.to_string().contains("expected ready")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); - assert!( - ticket - .events - .iter() - .all(|event| !(event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("planning"))) - ); -} - -#[test] -fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launch() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); - panel.rows.push(panel_test_ticket_row( - "20260608-000123-ready", - "Ready Ticket", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel(empty_test_list(), panel); - app.select_next(); - app.cycle_composer_target(); - app.input.insert_str("clarify expected behavior"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - DashboardAction::ReturnReadyTicketToPlanning(request) => request, - _ => panic!("ready Ticket row with Ticket Intake text should return to planning"), - }; - - assert_eq!(request.ticket_id, "20260608-000123-ready"); - assert_eq!(request.ticket_key, "T-1"); - assert_eq!(request.user_instruction, "clarify expected behavior"); - assert!(matches!( - request.followup, - ReadyTicketPlanningReturnFollowup::LaunchIntake(_) - )); - assert!(app.sending); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Returning ready Ticket") - ); - assert_eq!(input_text(&app), "clarify expected behavior"); -} - -#[tokio::test] -async fn planning_return_with_launch_followup_changes_state_before_launch_followup() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-launch"); - let request = ReadyTicketPlanningReturnRequest { - workspace_root: temp.path().to_path_buf(), - ticket_id: ticket_id.clone(), - ticket_key: "T-482".to_string(), - user_instruction: "launch intake after state change".to_string(), - followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { - context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake), - runtime_command: WorkerRuntimeCommand::for_executable("/tmp/yoi"), - peer_registration: IntakePeerRegistrationRequest::Skip { - reason: "test".to_string(), - }, - registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: temp.path().join(".yoi/local-role-sessions"), - ticket_id: ticket_id.clone(), - ticket_slug: None, - }, - }), - }; - - let outcome = dispatch_ready_ticket_planning_return(request) - .await - .unwrap(); - - assert!(matches!( - outcome.followup, - ReadyTicketPlanningReturnAfterMutation::LaunchIntake(_) - )); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning); -} - -#[tokio::test] -async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue"); - let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap(); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap(); - - let root_head_after = git_rev_parse(temp.path(), "HEAD").unwrap(); - let layout = orchestration_worktree_layout(temp.path()); - let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap(); - assert_ne!(root_head_after, root_head_before); - assert_eq!(orchestration_head, root_head_after); - assert!(outcome.notice.contains("Queued Ticket")); - assert!(outcome.notice.contains(&root_head_after)); - assert!(outcome.notice.contains("root Queue commit")); - assert!(outcome.notice.contains("ff-only synced")); - assert!( - outcome - .notice - .contains("Orchestrator routing is authorized") - ); - assert!(outcome.notice.contains("queued -> inprogress acceptance")); - assert!(!outcome.notice.contains("No implementation was started")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); - assert!(ticket.meta.queued_at.is_some()); - let state_change = ticket - .events - .iter() - .find(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("queued") - }) - .expect("queue state_changed event is recorded"); - assert_eq!(state_change.author.as_deref(), Some("workspace-panel")); - let orchestration_backend = LocalTicketBackend::new(layout.path.join(".yoi/tickets")); - let orchestration_ticket = orchestration_backend - .show(TicketIdOrSlug::Id(ticket_id)) - .unwrap(); - assert_eq!( - orchestration_ticket.meta.workflow_state, - TicketWorkflowState::Queued - ); -} - -#[tokio::test] -async fn ticket_queue_action_allows_unrelated_dirty_root_changes() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-root"); - fs::write(temp.path().join("README.md"), "dirty root notes\n").unwrap(); - fs::write(temp.path().join("dirty.txt"), "dirty").unwrap(); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap(); - - assert!(outcome.notice.contains("Queued Ticket")); - assert!(temp.path().join("dirty.txt").is_file()); - let root_status = git_status_porcelain(temp.path()).unwrap(); - assert!(root_status.iter().any(|line| line.contains("README.md"))); - assert!(root_status.iter().any(|line| line.contains("dirty.txt"))); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); -} - -#[tokio::test] -async fn ticket_queue_action_blocks_preexisting_target_ticket_changes() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-ticket"); - fs::write( - backend.root().join(&ticket_id).join("thread.md"), - "local uncommitted ticket edit\n", - ) - .unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap_err(); - let message = error.to_string(); - - assert!(message.contains("root-ticket-clean")); - assert!(message.contains(&ticket_id)); - assert!(message.contains("pre-existing changes")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.meta.queued_by.is_none()); -} - -#[tokio::test] -async fn ticket_queue_action_merges_orchestration_branch_before_queue() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-diverged"); - let layout = orchestration_worktree_layout(temp.path()); - fs::write(layout.path.join("orchestrator-only.txt"), "diverged").unwrap(); - run_test_git(&layout.path, &["add", "orchestrator-only.txt"]).unwrap(); - run_test_git(&layout.path, &["commit", "-m", "orchestrator-only"]).unwrap(); - let orchestration_commit = git_rev_parse(&layout.path, "HEAD").unwrap(); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap(); - - let root_head = git_rev_parse(temp.path(), "HEAD").unwrap(); - let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap(); - assert_eq!(root_head, orchestration_head); - assert!(temp.path().join("orchestrator-only.txt").is_file()); - assert!(outcome.notice.contains("merged orchestration branch")); - assert!(outcome.notice.contains(&orchestration_commit)); - assert!(outcome.notice.contains("root Queue commit")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel")); -} - -#[tokio::test] -async fn ticket_queue_action_blocks_conflicting_orchestration_merge_without_mutation() { - let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-conflict"); - let layout = orchestration_worktree_layout(temp.path()); - fs::write(temp.path().join("README.md"), "root change\n").unwrap(); - run_test_git(temp.path(), &["add", "README.md"]).unwrap(); - run_test_git(temp.path(), &["commit", "-m", "root-change"]).unwrap(); - fs::write(layout.path.join("README.md"), "orchestration change\n").unwrap(); - run_test_git(&layout.path, &["add", "README.md"]).unwrap(); - run_test_git(&layout.path, &["commit", "-m", "orchestration-change"]).unwrap(); - let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap_err(); - let message = error.to_string(); - - assert!(message.contains("root-orchestration-merge")); - assert!(message.contains("merge was aborted")); - assert!(message.contains(&ticket_id)); - assert_eq!( - git_rev_parse(temp.path(), "HEAD").unwrap(), - root_head_before - ); - assert!(git_status_porcelain(temp.path()).unwrap().is_empty()); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.meta.queued_by.is_none()); -} - -#[tokio::test] -async fn ticket_close_action_blocks_non_done_ticket_without_mutation() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-not-done"); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("state is ready")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.resolution.is_none()); -} - -#[tokio::test] -async fn ticket_action_rejects_stale_absent_config_without_mutation() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config"); - fs::remove_file(temp.path().join(".yoi/workspace.toml")).unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("Ticket config is absent")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready); - assert!(ticket.meta.queued_by.is_none()); - assert!(!ticket.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged && event.state_field.as_deref() == Some("state") - })); -} - -#[tokio::test] -async fn ticket_close_action_closes_done_ticket_with_deterministic_resolution() { - let (temp, ticket_id, backend) = done_ticket_workspace("panel-close"); - - let outcome = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) - .await - .unwrap(); - - assert!(outcome.notice.contains("Closed Ticket")); - assert!(outcome.notice.contains("state was already done")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); - let resolution = ticket - .resolution - .as_ref() - .expect("Dashboard Close records resolution.md") - .as_str(); - assert!(resolution.contains("state: done")); - assert!(resolution.contains("No implementation work")); - assert!(resolution.contains("state change")); - assert!(resolution.contains("worker invocation")); - assert!(ticket.events.iter().any(|event| { - event.kind == TicketEventKind::Close && event.body.as_str().contains("workspace Dashboard") - })); -} - -#[tokio::test] -async fn ticket_close_action_blocks_existing_resolution_without_moving_ticket() { - let (temp, ticket_id, backend) = done_ticket_workspace("panel-close-resolution"); - fs::write( - temp.path() - .join(".yoi/tickets") - .join(&ticket_id) - .join("resolution.md"), - "Already resolved\n", - ) - .unwrap(); - - let error = - dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("resolution.md already exists")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); - assert_eq!( - ticket.resolution.as_ref().unwrap().as_str(), - "Already resolved\n" - ); -} - -#[tokio::test] -async fn ticket_review_action_does_not_silently_approve() { - let (temp, ticket_id, backend) = ready_ticket_workspace("panel-review"); - backend - .add_event( - TicketIdOrSlug::Id(ticket_id.clone()), - NewTicketEvent::new(TicketEventKind::ImplementationReport, "implemented"), - ) - .unwrap(); - - let error = dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Wait)) - .await - .unwrap_err(); - - assert!(error.to_string().contains("current action is Queue")); - let _ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap(); -} - -#[test] -fn ticket_queue_notification_message_carries_routing_contract() { - let row = panel_test_ticket_row( - "00001KTTW04W2", - "Route queued\nTicket", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "queued", - ); - let ticket = row.ticket.as_ref().unwrap(); - - let message = orchestrator_queue_notification_message(ticket); - - assert!(message.contains("Ticket `00001KTTW04W2`, title `Route queued Ticket`")); - assert!(message.contains("human authorized Orchestrator routing")); - assert!(message.contains("not an unattended scheduler")); - assert!(message.contains("Read the Ticket")); - assert!(message.contains("inspect current Orchestrator workspace state")); - assert!(message.contains("transition state queued -> inprogress")); - assert!(message.contains("before any worktree/WorkerSpawn implementation side effects")); - assert!(message.contains("After inprogress acceptance")); - assert!(message.contains("implementation worktree")); - assert!(message.contains("tracked `.yoi` project records visible")); - assert!(message.contains("generated/local/runtime/log/lock/secret-like `.yoi` paths excluded")); - assert!(message.contains("sibling coder/reviewer Workers")); - assert!(message.contains( - "integrate the implementation branch into the orchestration branch automatically" - )); - assert!(message.contains("validate in the Orchestrator worktree")); - assert!(message.contains("clean up only child implementation worktrees/branches")); - assert!(message.contains("Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace")); - assert!(message.contains("If blocked, record a concise reason")); - assert!(message.contains("leave the Ticket queued or return it to planning")); - assert!(!message.contains("Do not start implementation directly")); -} - -#[tokio::test] -async fn ticket_queue_notification_sends_notify_when_socket_available() { - let temp = TempDir::new().unwrap(); - let socket_path = temp.path().join("orchestrator.sock"); - let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - writer - .write(&Event::Snapshot { - entries: Vec::new(), - greeting: protocol::Greeting { - worker_name: "test-orchestrator".to_string(), - cwd: temp.path().display().to_string(), - provider: "test".to_string(), - model: "test".to_string(), - scope_summary: "test".to_string(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - }, - status: WorkerStatus::Idle, - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - reader.next::().await.unwrap().unwrap() - }); - - send_notify_only(&socket_path, "Dashboard Queue".to_string(), true) - .await - .unwrap(); - let method = server.await.unwrap(); - assert!(matches!( - method, - Method::Notify { message, auto_run: true } if message == "Dashboard Queue" - )); -} - -#[tokio::test] -async fn send_notify_only_can_deliver_weak_notification_without_auto_run() { - let temp = TempDir::new().unwrap(); - let socket_path = temp.path().join("companion.sock"); - let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let (reader, writer) = stream.into_split(); - let mut reader = JsonLineReader::new(reader); - let mut writer = JsonLineWriter::new(writer); - writer - .write(&Event::Snapshot { - entries: Vec::new(), - greeting: protocol::Greeting { - worker_name: "yoi".to_string(), - cwd: temp.path().display().to_string(), - provider: "test".to_string(), - model: "test".to_string(), - scope_summary: "test".to_string(), - tools: Vec::new(), - context_window: 0, - context_tokens: 0, - }, - status: WorkerStatus::Idle, - in_flight: Default::default(), - internal_workers: Vec::new(), - }) - .await - .unwrap(); - reader.next::().await.unwrap().unwrap() - }); - - send_notify_only(&socket_path, "Dashboard progress".to_string(), false) - .await - .unwrap(); - let method = server.await.unwrap(); - assert!(matches!( - method, - Method::Notify { message, auto_run: false } if message == "Dashboard progress" - )); -} - -#[test] -fn no_ticket_selection_keeps_enter_pod_centric() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::Open - )); - assert!(app.prepare_ticket_action_dispatch().is_none()); - assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected.")); -} - -#[test] -fn workspace_panel_initial_display_does_not_auto_select_visible_rows() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("alpha", WorkerStatus::Idle)], - None, - 10, - ), - panel, - ); - - assert!(visible_panel_keys(&app.panel, &app.list).len() > 1); - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); -} - -#[test] -fn workspace_panel_clear_selection_survives_reload_and_keeps_draft() { - let mut app = test_app(vec![live_info_with_updated_at( - "alpha", - WorkerStatus::Idle, - 10, - )]); - app.select_next(); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Worker("alpha".to_string())) - ); - app.input.insert_str("draft survives"); - - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - DashboardAction::None - )); - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); - - app.apply_reloaded_list(WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info_with_updated_at( - "alpha", - WorkerStatus::Running, - 20, - )], - None, - 10, - )); - - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); - assert_eq!(input_text(&app), "draft survives"); -} - -#[test] -fn workspace_panel_no_selection_ticket_intake_submit_uses_global_intake() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel(empty_test_list(), panel); - app.cycle_composer_target(); - app.input.insert_str("new planning request"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - DashboardAction::LaunchIntake(request) => request, - _ => panic!("no selection should launch global Intake"), - }; - - assert!(request.context.ticket.is_none()); - assert_eq!( - request.context.user_instruction.as_deref(), - Some("new planning request") - ); -} - -#[test] -fn workspace_panel_keyboard_navigation_explicitly_creates_selection() { - let mut app = test_app(vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("beta", WorkerStatus::Idle), - ]); - assert!(app.selected_row.is_none()); - - app.select_next(); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Worker("alpha".to_string())) - ); - - app.select_next(); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Worker("beta".to_string())) - ); -} - -#[test] -fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join(".yoi")).unwrap(); - fs::write( - temp.path().join(".yoi/workspace.toml"), - "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n", - ) - .unwrap(); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let mut ticket = NewTicket::new("Ready Ticket"); - ticket.workflow_state = Some(TicketWorkflowState::Ready); - backend.create(ticket).unwrap(); - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("idle", WorkerStatus::Idle)], - None, - 10, - ); - let panel = build_workspace_panel(temp.path(), &list); - let mut app = app_with_panel(list, panel); - assert!(app.selected_row.is_none()); - app.select_next(); - - assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); - let lines = list_lines(&app, 100, 6) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - let ticket_line = lines - .iter() - .position(|line| line.contains("Ready Ticket")) - .unwrap(); - let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap(); - assert!(ticket_line < pod_line); - - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "idle"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - let open = app.prepare_open().unwrap(); - assert_eq!(open.worker_name, "idle"); - assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock"))); - - app.input.insert_str("draft after ticket row"); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::None - )); - assert!(!app.sending); - assert_eq!(input_text(&app), "draft after ticket row"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Workspace Companion is unavailable") - ); -} - -#[test] -fn row_hit_testing_maps_only_visible_selectable_rows() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - panel.rows.push(panel_test_ticket_row( - "TICKET-2", - "Queued", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )); - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("alpha", WorkerStatus::Idle)], - None, - 10, - ); - let app = app_with_panel(list, panel); - - let rows = list_rows(&app, 80, 8); - let boxes = row_hit_boxes(&rows, Rect::new(3, 5, 80, 8)); - - assert_eq!(boxes.len(), 3); - assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into())); - assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 2)); - assert_eq!(boxes[1].key, PanelRowKey::Ticket("TICKET-2".into())); - assert_eq!(boxes[1].rect, Rect::new(3, 8, 80, 2)); - assert_eq!(boxes[2].key, PanelRowKey::Worker("alpha".into())); - assert_eq!(boxes[2].rect, Rect::new(3, 11, 80, 1)); - assert!(boxes.iter().all(|hit| !hit.contains(2, hit.rect.y))); -} - -#[test] -fn mouse_click_selects_panel_row_for_blank_enter_action() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - panel.rows.push(panel_test_ticket_row( - "TICKET-2", - "Queued", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )); - let mut app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![], - None, - 10, - ), - panel, - ); - let rows = list_rows(&app, 80, 6); - app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6)); - - assert!(app.handle_mouse_event(left_click(2, 3))); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Ticket("TICKET-2".into())) - ); - app.selected_row = None; - assert!(app.handle_mouse_event(left_click(2, 4))); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Ticket("TICKET-2".into())) - ); - assert_eq!(app.selected_panel_row().unwrap().title, "Queued"); - assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait)); - let selected_title = - plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]); - assert!(selected_title.starts_with("▶ queued")); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2" - )); -} - -#[test] -fn mouse_non_row_click_is_noop_and_preserves_composer_draft() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![], - None, - 10, - ), - panel, - ); - let rows = list_rows(&app, 80, 6); - app.set_row_hit_boxes(&rows, Rect::new(10, 4, 80, 6)); - app.input.insert_paste("draft".into()); - let selected = app.selected_row.clone(); - - assert!(!app.handle_mouse_event(left_click(9, 5))); - assert_eq!(app.selected_row, selected); - assert_eq!(input_text(&app), "draft"); -} - -#[test] -fn mouse_click_does_not_override_existing_composer_keyboard_behavior() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - panel.rows.push(panel_test_ticket_row( - "TICKET-2", - "Queued", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )); - let mut app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![], - None, - 10, - ), - panel, - ); - let rows = list_rows(&app, 80, 6); - app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6)); - - assert!(app.handle_mouse_event(left_click(2, 4))); - assert_eq!( - app.selected_row, - Some(PanelRowKey::Ticket("TICKET-2".into())) - ); - app.input.insert_paste("hello".into()); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::None - )); - assert_eq!(input_text(&app), "hello"); - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - DashboardAction::None - )); - assert_eq!(app.selected_row, None); - assert_eq!(input_text(&app), "hello"); - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - DashboardAction::None - )); - assert_eq!(app.selected_row, None); -} - -#[test] -fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.companion = Some(CompanionPanelState::new( - "yoi", - CompanionPanelStatus::Live, - None, - )); - panel.rows.push(panel_test_ticket_row( - "00001KTWPE3KQ", - "Queue Me", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("yoi", WorkerStatus::Idle)], - None, - 10, - ); - let mut app = app_with_panel(list, panel); - app.select_next(); - app.input.insert_str("draft to companion"); - - assert_eq!( - app.selected_ticket_action(), - Some(NextUserAction::Queue), - "selected row remains a Ticket action row" - ); - let actionbar_left = actionbar_left_text(&app); - let actionbar_right = actionbar_right_text(&app); - let target_status = plain_line(&target_status_line(&app)); - - assert_eq!(actionbar_left, ""); - assert_eq!(actionbar_right, ""); - assert_eq!(target_status, ""); - let selected_title = - plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]); - assert!(selected_title.starts_with("▶ ready")); -} - -#[test] -fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank() { - let mut app = test_app(vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("beta", WorkerStatus::Idle), - ]); - assert!(app.selected_row.is_none()); - - for c in ['j', 'k', 'o', 'r'] { - assert!(matches!( - app.handle_key(key(KeyCode::Char(c))), - DashboardAction::None - )); - } - - assert_eq!(input_text(&app), "jkor"); - assert!(app.selected_row.is_none()); - - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - DashboardAction::None - )); - assert_eq!(input_text(&app), "jkor"); - assert!(app.selected_row.is_none()); - - app.input.clear(); - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - DashboardAction::None - )); - assert_eq!(input_text(&app), ""); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - - assert!(matches!( - app.handle_key(key(KeyCode::Down)), - DashboardAction::None - )); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - - assert!(matches!( - app.handle_key(key(KeyCode::Up)), - DashboardAction::None - )); - assert_eq!(input_text(&app), ""); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); -} - -#[test] -fn dashboard_selection_changes_preserve_composer_contents() { - let mut app = test_app(vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("beta", WorkerStatus::Idle), - ]); - app.input.insert_str("draft message"); - let before = input_text(&app); - - app.select_next(); - - assert_eq!(input_text(&app), before); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); -} - -#[test] -fn dashboard_poll_reload_preserves_selection_composer_and_notice() { - let mut app = test_app(vec![ - live_info_with_updated_at("alpha", WorkerStatus::Idle, 10), - live_info_with_updated_at("beta", WorkerStatus::Idle, 20), - ]); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - app.input.insert_str("draft survives polling"); - app.notice = Some("keep this notice".to_string()); - let refreshed = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![ - live_info_with_updated_at("gamma", WorkerStatus::Idle, 60), - live_info_with_updated_at("alpha", WorkerStatus::Running, 50), - live_info_with_updated_at("beta", WorkerStatus::Idle, 40), - ], - None, - 10, - ); - - app.apply_reloaded_list(refreshed); - - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - assert_eq!( - app.list - .selected_entry() - .unwrap() - .live - .as_ref() - .unwrap() - .status, - Some(WorkerStatus::Idle) - ); - assert_eq!(input_text(&app), "draft survives polling"); - assert_eq!(app.notice.as_deref(), Some("keep this notice")); -} - -#[test] -fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() { - let mut app = test_app(vec![ - live_info_with_updated_at("alpha", WorkerStatus::Idle, 10), - live_info_with_updated_at("beta", WorkerStatus::Running, 20), - ]); - app.select_next(); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - let refreshed = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![stopped_info_with_updated_at("closed", 30)], - vec![live_info_with_updated_at("alpha", WorkerStatus::Idle, 40)], - None, - 10, - ); - - app.apply_reloaded_list(refreshed); - - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); - assert_eq!(visible_entry_indices(&app.list), vec![0, 1]); -} - -#[test] -fn dashboard_poll_reload_error_keeps_previous_list_and_composer() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.input.insert_str("keep draft"); - - app.apply_reload_result(Err(DashboardError::Io(io::Error::other("boom")))); - - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert_eq!(input_text(&app), "keep draft"); - let notice = app.notice.as_deref().unwrap(); - assert!(notice.contains("Refresh failed")); - assert!(notice.contains("boom")); -} - -#[test] -fn dashboard_orchestrator_failure_persists_over_plain_observe_missing() { - let detail = "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), - ); - - app.apply_reloaded_snapshot(DashboardSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); - assert_eq!(orchestrator.detail.as_deref(), Some(detail)); - assert_eq!( - app.panel - .header - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.as_str() == detail) - .count(), - 1 - ); -} - -#[test] -fn dashboard_orchestrator_plain_missing_remains_when_no_prior_failure_exists() { - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - ); - - app.apply_reloaded_snapshot(DashboardSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); - assert!(orchestrator.detail.is_none()); - assert!(app.panel.header.diagnostics.is_empty()); -} - -#[test] -fn dashboard_orchestrator_failure_clears_after_live_lifecycle() { - let detail = "could not spawn workspace Orchestrator: delegated scope conflicts with writer"; - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)), - ); - - app.apply_reloaded_snapshot(DashboardSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Live, None), - }); - assert_eq!( - app.panel.header.orchestrator.as_ref().unwrap().status, - OrchestratorPanelStatus::Live - ); - - app.apply_reloaded_snapshot(DashboardSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing); - assert!(orchestrator.detail.is_none()); -} - -#[test] -fn dashboard_orchestrator_failure_supersedes_prior_failure() { - let old_detail = "could not spawn workspace Orchestrator: old scope conflict"; - let new_detail = "could not restore workspace Orchestrator: socket refused"; - let mut app = app_with_panel( - empty_test_list(), - panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(old_detail)), - ); - - app.apply_reloaded_snapshot(DashboardSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(new_detail)), - }); - app.apply_reloaded_snapshot(DashboardSnapshot { - list: empty_test_list(), - panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None), - }); - - let orchestrator = app.panel.header.orchestrator.as_ref().unwrap(); - assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable); - assert_eq!(orchestrator.detail.as_deref(), Some(new_detail)); - assert!( - !app.panel - .header - .diagnostics - .iter() - .any(|diagnostic| diagnostic == old_detail) - ); - assert!( - app.panel - .header - .diagnostics - .iter() - .any(|diagnostic| diagnostic == new_detail) - ); -} - -#[tokio::test] -async fn dashboard_poll_reload_does_not_overlap_in_flight_reload() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - let mut pending = PendingReload::default(); - - assert!(pending.start_with_handle(tokio::spawn(async { - tokio::time::sleep(Duration::from_millis(10)).await; - Err(DashboardError::Io(io::Error::other("boom"))) - }))); - assert!(!pending.start_with_handle(tokio::spawn(async { - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("beta", WorkerStatus::Idle)], - None, - 10, - ); - Ok(DashboardSnapshot { - panel: WorkspacePanelViewModel::empty(Path::new("test")), - list, - }) - }))); - assert!(pending.finish_if_ready().await.is_none()); - - tokio::time::sleep(Duration::from_millis(20)).await; - let result = pending.finish_if_ready().await.unwrap(); - app.apply_reload_result(result); - - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert!(app.notice.as_deref().unwrap().contains("Refresh failed")); -} - -#[tokio::test] -async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() { - struct DropFlag(Arc); - impl Drop for DropFlag { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - let reload_cancelled = Arc::new(AtomicBool::new(false)); - let notice_cancelled = Arc::new(AtomicBool::new(false)); - let mut pending_reload = PendingReload::default(); - let mut pending_notice = PendingQueueAttentionNotice::default(); - let (reload_started_tx, reload_started_rx) = tokio::sync::oneshot::channel(); - let (notice_started_tx, notice_started_rx) = tokio::sync::oneshot::channel(); - - let reload_flag = Arc::clone(&reload_cancelled); - assert!(pending_reload.start_with_handle(tokio::spawn(async move { - let _drop_flag = DropFlag(reload_flag); - let _ = reload_started_tx.send(()); - std::future::pending::<()>().await; - Err(DashboardError::Io(io::Error::other( - "unreachable reload completion", - ))) - }))); - - let notice_flag = Arc::clone(¬ice_cancelled); - assert!(pending_notice.start_with_handle(tokio::spawn(async move { - let _drop_flag = DropFlag(notice_flag); - let _ = notice_started_tx.send(()); - std::future::pending::<()>().await; - OrchestratorQueueAttentionNoticeResult::failed( - "unreachable".to_string(), - "unreachable notice completion", - ) - }))); - reload_started_rx.await.expect("reload task should start"); - notice_started_rx.await.expect("notice task should start"); - - tokio::time::timeout(Duration::from_millis(20), async { - abort_panel_background_work_for_quit(&mut pending_reload, &mut pending_notice); - }) - .await - .expect("quit abort should not wait for background task completion"); - - tokio::time::timeout(Duration::from_millis(100), async { - while !(reload_cancelled.load(Ordering::SeqCst) && notice_cancelled.load(Ordering::SeqCst)) - { - tokio::task::yield_now().await; - } - }) - .await - .expect("quit abort should cancel reload and notice tasks"); - - assert!(pending_reload.finish_if_ready().await.is_none()); - assert!(pending_notice.finish_if_ready().await.is_none()); -} - -#[test] -fn dashboard_idle_live_selected_target_is_open_eligible() { - let mut app = test_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.select_next(); - - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); -} - -#[test] -fn dashboard_status_label_for_live_without_reported_status_is_softened() { - let mut live = live_info("probing", WorkerStatus::Idle); - live.status = None; - let app = test_app(vec![live]); - - let (label, _) = row_status_label(app.list.selected_entry().unwrap()); - - assert_eq!(label, "live"); -} - -#[test] -fn dashboard_status_labels_preserve_explicit_live_statuses() { - for (status, expected_label) in [ - (WorkerStatus::Idle, "live idle"), - (WorkerStatus::Running, "live running"), - (WorkerStatus::Paused, "live paused"), - ] { - let app = test_app(vec![live_info("worker", status)]); - let (label, _) = row_status_label(app.list.selected_entry().unwrap()); - - assert_eq!(label, expected_label); - } -} - -#[test] -fn dashboard_title_omits_redundant_key_hint_guidance() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.ticket_configured = true; - panel.header.companion = Some(CompanionPanelState::new( - "yoi", - CompanionPanelStatus::Live, - Some("idle".to_string()), - )); - let app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("yoi", WorkerStatus::Idle)], - None, - 10, - ), - panel, - ); - - let title = plain_line(&title_line(&app)); - - assert!(title.contains("workspace dashboard")); - assert!(title.contains("companion live")); - assert!(!title.contains("Row selection")); - assert!(!title.contains("blank Enter")); - assert!(!title.contains("Tab target")); -} - -#[test] -fn panel_ticket_rows_render_state_title_then_detail_line() { - let row = panel_test_ticket_row( - "00001KTX1QMG9", - "Workspace Dashboard composer targets", - ActionPriority::ActiveWork, - NextUserAction::Wait, - "inprogress", - ); - - let lines = panel_row_lines(&row, true, 160); - let (title, detail) = (&lines[0], &lines[1]); - let title_line = plain_line(&title); - let detail_line = plain_line(&detail); - let state_start = 2; - let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; - let row_id = row.ticket.as_ref().unwrap().id.as_str(); - let resource_key = row - .ticket - .as_ref() - .unwrap() - .resource_key - .as_deref() - .unwrap(); - - assert!(title_line.starts_with("▶ ")); - assert!(detail_line.starts_with("│ meta ")); - assert!(!title_line.contains(row_id)); - assert_eq!(display_column(&title_line, "inprogress"), state_start); - assert_eq!( - display_column(&title_line, "Workspace Dashboard composer targets"), - title_start - ); - assert!(detail_line.contains(resource_key)); - assert!(detail_line.contains("Gate: clear")); - assert!(detail_line.contains("Action: Wait")); -} - -#[test] -fn panel_ticket_non_selected_rows_align_with_selected_marker_space() { - let row = panel_test_ticket_row( - "00001KTTB479X", - "Long Ticket title that should be rendered after short columns", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - ); - - let lines = panel_row_lines(&row, false, 160); - let (title, detail) = (&lines[0], &lines[1]); - let title_line = plain_line(&title); - let detail_line = plain_line(&detail); - let state_start = 2; - let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; - - assert!(title_line.starts_with(" ready")); - assert!(detail_line.starts_with(" meta T-1")); - assert_eq!(display_column(&title_line, "ready"), state_start); - assert_eq!( - display_column(&title_line, "Long Ticket title"), - title_start - ); -} - -#[test] -fn panel_ticket_title_truncates_after_state_column() { - let row = panel_test_ticket_row( - "00001KTTB479X", - "Very long Ticket title that should truncate only after the state column", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - ); - - let lines = panel_row_lines(&row, false, 42); - let (title, detail) = (&lines[0], &lines[1]); - let title_line = plain_line(&title); - let detail_line = plain_line(&detail); - let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1; - - assert_eq!(title_line.width(), 42); - assert_eq!(display_column(&title_line, "Very long Ticket"), title_start); - assert!(title_line.ends_with('…')); - assert_eq!(detail_line.width(), 42); - assert!(detail_line.starts_with(" meta T-1 · Gate: clear")); - assert!(detail_line.ends_with('…')); -} - -#[test] -fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() { - let mut row = panel_test_ticket_row( - "00001OVERLAY", - "Overlay column regression", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ); - row.kind = PanelRowKind::Review; - row.status = "q→done".to_string(); - row.disabled_reason = Some( - "orchestration worktree overlay shows Ticket state done; local state remains queued" - .to_string(), - ); - row.ticket.as_mut().unwrap().orchestration_overlay = - Some(crate::workspace_panel::TicketStateOverlay { - source: "orchestration".to_string(), - workflow_state: TicketWorkflowState::Done, - }); - - let lines = panel_row_lines(&row, false, 160); - let title_line = plain_line(&lines[0]); - let detail_line = plain_line(&lines[1]); - let state_start = 2; - let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1; - - assert!(row.status.width() <= TICKET_STATE_COLUMN_WIDTH); - assert_eq!(display_column(&title_line, "q→done"), state_start); - assert_eq!( - display_column(&title_line, "Overlay column regression"), - title_start - ); - assert!(!title_line.contains("orchestration")); - assert!(detail_line.contains("Overlay: local queued · orchestration done · merge pending")); -} - -#[test] -fn ready_ticket_with_dependency_context_keeps_queue_action_available() { - let mut row = panel_test_ticket_row( - "00001WAITING", - "Ready with dependency context", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - ); - row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string()); - - let lines = panel_row_lines(&row, true, 160); - let detail = &lines[1]; - let detail_line = plain_line(&detail); - - assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on")); - assert!(detail_line.contains("Action: Queue")); - assert!(!detail_line.contains("Queue disabled")); -} - -#[test] -fn panel_ticket_intake_child_rows_render_as_indented_single_line() { - let row = panel_test_intake_child_row( - "00001TICKET", - "intake-live", - TicketLocalClaimStatus::Live, - Some(NextUserAction::OpenWorker), - ); - - let lines = panel_row_lines(&row, false, 160); - assert_eq!(lines.len(), 1); - let line = plain_line(&lines[0]); - let status_start = 4; - let title_start = status_start + TICKET_STATE_COLUMN_WIDTH + 1; - - assert!(line.starts_with(" └ live")); - assert_eq!(display_column(&line, "live"), status_start); - assert_eq!( - display_column(&line, "Intake Worker: intake-live"), - title_start - ); - assert!(!line.starts_with(" live")); - - let selected_line = plain_line(&panel_row_lines(&row, true, 160)[0]); - assert!(selected_line.starts_with(" ▶ live")); - assert!(selected_line.contains("Intake Worker: intake-live")); -} - -#[test] -fn selected_ticket_intake_child_keeps_row_marker_without_status_line() { - let ticket_id = "00001TICKET"; - let worker_name = "intake-live"; - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_intake_child_row( - ticket_id, - worker_name, - TicketLocalClaimStatus::Live, - Some(NextUserAction::OpenWorker), - )); - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info(worker_name, WorkerStatus::Idle)], - None, - 10, - ); - let mut app = app_with_panel(list, panel); - app.select_panel_key(PanelRowKey::TicketIntakeWorker { - ticket_id: ticket_id.to_string(), - worker_name: worker_name.to_string(), - }); - - let status = plain_line(&target_status_line(&app)); - - assert_eq!(status, ""); - - let title_line = plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]); - assert!(title_line.starts_with(" ▶ live")); - assert!(title_line.contains("Intake")); -} - -#[test] -fn panel_worker_rows_use_aligned_columns_before_worker_name() { - let app = test_app(vec![ - live_info("companion", WorkerStatus::Idle), - live_info("very-long-background-worker-name", WorkerStatus::Running), - ]); - let idle = app - .list - .entries - .iter() - .find(|entry| entry.name == "companion") - .unwrap(); - let running = app - .list - .entries - .iter() - .find(|entry| entry.name == "very-long-background-worker-name") - .unwrap(); - - let idle_line = plain_line(&row_line(idle, false, 120)); - let running_line = plain_line(&row_line(running, false, 120)); - let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; - - assert!(!running_line.starts_with(" very-long-background-worker-name")); - assert_eq!(display_column(&idle_line, "live idle"), 2); - assert_eq!(display_column(&running_line, "live running"), 2); - assert_eq!(display_column(&idle_line, "companion"), name_start); - assert_eq!( - display_column(&running_line, "very-long-background-worker-name"), - name_start - ); -} - -#[test] -fn panel_worker_name_truncates_after_status() { - let app = test_app(vec![live_info( - "very-long-background-worker-name-that-keeps-going", - WorkerStatus::Running, - )]); - let entry = app.list.selected_entry().unwrap(); - - let line = plain_line(&row_line(entry, false, 58)); - let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1; - - assert_eq!(line.width(), 58); - assert_eq!(display_column(&line, "live running"), 2); - assert_eq!(display_column(&line, "very-long"), name_start); - assert!(line.ends_with('…')); -} - -#[test] -fn dashboard_running_paused_and_stopped_targets_are_open_eligible() { - let mut app = test_app(vec![ - live_info("running", WorkerStatus::Running), - live_info("paused", WorkerStatus::Paused), - ]); - let stopped = stopped_info("stopped"); - app.list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![stopped], - vec![ - live_info_with_updated_at("running", WorkerStatus::Running, 30), - live_info_with_updated_at("paused", WorkerStatus::Paused, 20), - ], - Some("running".to_string()), - 10, - ); - app.selected_row = None; - app.ensure_selection_visible(); - - assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "running"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "paused"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "stopped"); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow); -} - -#[test] -fn dashboard_sections_classify_pending_working_and_closed() { - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![stopped_info_with_updated_at("closed", 60)], - vec![ - live_info_with_updated_at("idle", WorkerStatus::Idle, 50), - live_info_with_updated_at("running", WorkerStatus::Running, 40), - live_info_with_updated_at("paused", WorkerStatus::Paused, 30), - ], - Some("idle".to_string()), - 10, - ); - - let sections = sectioned_entries(&list); - - assert_eq!(section_names(&list, §ions[0]), vec!["idle"]); - assert_eq!( - section_names(&list, §ions[1]), - vec!["running", "paused"] - ); - assert_eq!(section_names(&list, §ions[2]), vec!["closed"]); -} - -#[test] -fn dashboard_closed_section_is_limited_to_three_visible_rows() { - let list = closed_list(5, Some("closed-0")); - let visible = visible_entry_indices(&list) - .into_iter() - .map(|index| list.entries[index].name.clone()) - .collect::>(); - let sections = sectioned_entries(&list); - let closed = sections - .iter() - .find(|section| section.kind == DashboardSectionKind::Closed) - .unwrap(); - let app = app_with_list(list); - let lines = list_lines(&app, 80, 8) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - - assert_eq!(visible, vec!["closed-0", "closed-1", "closed-2"]); - assert_eq!(closed.hidden_count(), 2); - assert!( - lines - .iter() - .any(|line| line.contains("closed 5 total, +2 hidden")) - ); - assert!(lines.iter().any(|line| line.contains("closed-2"))); - assert!(!lines.iter().any(|line| line.contains("closed-3"))); -} - -#[test] -fn dashboard_selection_follows_visible_section_order_without_hidden_closed_rows() { - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - (0..5) - .map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index)) - .collect(), - vec![ - live_info_with_updated_at("running", WorkerStatus::Running, 70), - live_info_with_updated_at("idle", WorkerStatus::Idle, 60), - ], - Some("idle".to_string()), - 20, - ); - let mut app = app_with_list(list); - - assert!(app.selected_row.is_none()); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "idle"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "running"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-0"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-1"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); - app.select_next(); - assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); -} - -#[test] -fn dashboard_selection_does_not_default_to_orchestrator_only_row() { - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("test-orchestrator", WorkerStatus::Idle)], - None, - 10, - ); - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - OrchestratorPanelStatus::Live, - None, - )); - let app = app_with_panel(list, panel); - - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); - assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled); -} - -#[test] -fn dashboard_selection_has_no_default_when_workspace_orchestrator_worker_exists() { - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![ - live_info_with_updated_at("test-orchestrator", WorkerStatus::Idle, 80), - live_info_with_updated_at("worker", WorkerStatus::Idle, 70), - ], - None, - 10, - ); - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - OrchestratorPanelStatus::Live, - None, - )); - let app = app_with_panel(list, panel); - - assert!(app.selected_row.is_none()); - assert!(app.list.selected_name.is_none()); -} - -#[test] -fn dashboard_list_renders_workspace_diagnostics_before_rows() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel - .header - .diagnostics - .push("Ticket config is unusable".to_string()); - let app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![live_info("idle", WorkerStatus::Idle)], - None, - 10, - ), - panel, - ); - let lines = list_lines(&app, 80, 4) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - - assert!(lines[0].contains("Ticket config is unusable")); - assert!(lines.iter().any(|line| line.contains("idle"))); -} - -#[test] -fn dashboard_list_pins_closed_section_below_live_flexible_area() { - let list = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - (0..3) - .map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index)) - .collect(), - vec![ - live_info_with_updated_at("running", WorkerStatus::Running, 70), - live_info_with_updated_at("idle", WorkerStatus::Idle, 60), - ], - Some("idle".to_string()), - 20, - ); - let app = app_with_list(list); - let lines = list_lines(&app, 80, 12) - .into_iter() - .map(|line| plain_line(&line)) - .collect::>(); - - assert!(lines[0].contains("pending")); - assert!(lines[2].contains("working")); - assert!(lines[4].is_empty()); - assert!(lines[8].contains("closed")); - assert!(lines[11].contains("closed-2")); -} - -#[test] -fn dashboard_layout_uses_single_boundary_separator_between_list_and_composer() { - let layout = dashboard_layout(Rect::new(0, 0, 80, 24), 1); - - assert_eq!(layout.boundary.height, 1); - assert!(!layout.list_draws_own_separator); - assert_eq!(layout.boundary.y, layout.list.y + layout.list.height); - assert_eq!( - layout.target_status.y, - layout.boundary.y + layout.boundary.height - ); -} - -#[test] -fn dashboard_companion_submit_routes_to_workspace_companion_not_selected_pod() { - let mut app = companion_app( - vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("yoi", WorkerStatus::Idle), - ], - CompanionPanelStatus::Live, - ); - let alpha_index = app - .list - .entries - .iter() - .position(|entry| entry.name == "alpha") - .unwrap(); - app.list.select_index(alpha_index); - app.input.insert_str("send to companion"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - DashboardAction::SendCompanion(request) => request, - _ => panic!("Companion target should send to the workspace Companion"), - }; - - assert_eq!(request.worker_name, "yoi"); - assert_eq!(request.socket_path, PathBuf::from("/tmp/yoi.sock")); - assert!(app.sending); - assert_eq!(input_text(&app), "send to companion"); - assert!(app.notice.as_deref().unwrap().contains("Companion yoi")); -} - -#[test] -fn dashboard_companion_submit_unavailable_keeps_composer_contents() { - let mut app = companion_app(vec![], CompanionPanelStatus::Missing); - app.input.insert_str("keep me"); - let before = input_text(&app); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::None - )); - - assert_eq!(input_text(&app), before); - assert!(!app.sending); - assert!(app.notice.as_deref().unwrap().contains("draft kept")); -} - -#[test] -fn dashboard_companion_submit_empty_reports_empty_composer() { - let mut app = companion_app( - vec![live_info("yoi", WorkerStatus::Idle)], - CompanionPanelStatus::Live, - ); - - assert!(app.prepare_companion_send().is_none()); - - assert_eq!(input_text(&app), ""); - assert!(!app.sending); - assert_eq!(app.notice.as_deref(), Some("Composer is empty.")); -} - -#[test] -fn dashboard_companion_finish_success_clears_composer() { - let mut app = companion_app( - vec![live_info("yoi", WorkerStatus::Idle)], - CompanionPanelStatus::Live, - ); - app.input.insert_str("done"); - app.sending = true; - - app.finish_companion_send(Ok(CompanionSendOutcome { - notice: "Sent to Companion yoi.".to_string(), - })); - - assert_eq!(input_text(&app), ""); - assert!(!app.sending); - assert_eq!(app.notice.as_deref(), Some("Sent to Companion yoi.")); -} - -#[test] -fn dashboard_open_request_keeps_dashboard_state_for_nested_console() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.input.insert_str("draft survives open"); - app.select_next(); - - let request = app.prepare_open().unwrap(); - - assert_eq!(request.worker_name, "alpha"); - assert_eq!( - request.socket_override, - Some(PathBuf::from("/tmp/alpha.sock")) - ); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert_eq!(input_text(&app), "draft survives open"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Attaching to alpha") - ); -} - -#[test] -fn dashboard_open_failure_keeps_composer_and_sets_notice() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.input.insert_str("keep this draft"); - let before = input_text(&app); - let error = io::Error::other("boom"); - - app.finish_open("alpha", Err(&error)); - - assert_eq!(input_text(&app), before); - assert_eq!(app.list.selected_entry().unwrap().name, "alpha"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Open failed for alpha") - ); - assert!(app.refreshing); - assert!(matches!( - app.enter_reload, - Some(OrchestratorLifecycleMode::Observe) - )); -} - -#[test] -fn dashboard_loading_app_defers_initial_snapshot_to_enter_reload() { - let app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi"), false); - - assert!(app.panel.rows.is_empty()); - assert!( - app.panel - .header - .diagnostics - .iter() - .any(|diagnostic| diagnostic.contains("Loading workspace dashboard")) - ); - assert!(app.refreshing); - assert!(matches!( - app.enter_reload, - Some(OrchestratorLifecycleMode::Ensure { .. }) - )); -} - -#[test] -fn dashboard_nested_console_success_continues_without_dropping_state() { - let mut app = test_app(vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("beta", WorkerStatus::Idle), - ]); - app.select_next(); - app.select_next(); - app.input.insert_str("keep this draft"); - app.panel_diagnostic = Some(PanelDiagnostic { - title: "diagnostic stays".to_string(), - details: "details stay".to_string(), - }); - app.panel_diagnostic_open = true; - - finish_nested_console_open(&mut app, "beta", Ok(())).unwrap(); - - assert_eq!(input_text(&app), "keep this draft"); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - assert_eq!( - app.panel_diagnostic - .as_ref() - .map(|diagnostic| (diagnostic.title.as_str(), diagnostic.details.as_str(),)), - Some(("diagnostic stays", "details stay")) - ); - assert!(app.panel_diagnostic_open); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Returned from beta. Refreshing workspace") - ); - assert!(app.refreshing); - assert!(matches!( - app.enter_reload, - Some(OrchestratorLifecycleMode::Observe) - )); -} - -#[test] -fn dashboard_nested_console_recoverable_failure_continues_without_dropping_state() { - let mut app = test_app(vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("beta", WorkerStatus::Idle), - ]); - app.select_next(); - app.select_next(); - app.input.insert_str("keep this draft"); - app.panel_diagnostic = Some(PanelDiagnostic { - title: "diagnostic stays".to_string(), - details: "details stay".to_string(), - }); - app.panel_diagnostic_open = true; - let error = crate::spawn::SpawnError::Io(io::Error::other("spawn failed")); - - finish_nested_console_open(&mut app, "beta", Err(Box::new(error))).unwrap(); - - assert_eq!(input_text(&app), "keep this draft"); - assert_eq!(app.list.selected_entry().unwrap().name, "beta"); - assert_eq!( - app.panel_diagnostic - .as_ref() - .map(|diagnostic| (diagnostic.title.as_str(), diagnostic.details.as_str(),)), - Some(("diagnostic stays", "details stay")) - ); - assert!(app.panel_diagnostic_open); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Open failed for beta: io error: spawn failed. Refreshing workspace") - ); - assert!(app.refreshing); - assert!(matches!( - app.enter_reload, - Some(OrchestratorLifecycleMode::Observe) - )); -} - -#[test] -fn dashboard_nested_console_nonrecoverable_failure_bubbles_without_state_finish() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.input.insert_str("keep this draft"); - app.notice = Some("opening alpha".to_string()); - let error = io::Error::other("fatal console error"); - - let err = finish_nested_console_open(&mut app, "alpha", Err(Box::new(error))).unwrap_err(); - - assert_eq!(err.to_string(), "fatal console error"); - assert_eq!(input_text(&app), "keep this draft"); - assert_eq!(app.notice.as_deref(), Some("opening alpha")); - assert!(!app.refreshing); - assert!(app.enter_reload.is_none()); -} - -#[test] -fn dashboard_open_disabled_target_stays_in_dashboard() { - let mut live = live_info("unreachable", WorkerStatus::Idle); - live.reachable = false; - live.status = None; - let mut app = test_app(vec![live]); - app.select_next(); - - assert!(app.prepare_open().is_none()); - assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); -} - -#[test] -fn dashboard_empty_enter_uses_open_action() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.select_next(); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::Open - )); - let request = app.prepare_open().unwrap(); - - assert_eq!(request.worker_name, "alpha"); - assert_eq!( - request.socket_override, - Some(PathBuf::from("/tmp/alpha.sock")) - ); - assert_eq!(input_text(&app), ""); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Attaching to alpha") - ); -} - -#[test] -fn dashboard_whitespace_only_enter_uses_open_action() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.select_next(); - app.input.insert_str(" \n\t"); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::Open - )); - let request = app.prepare_open().unwrap(); - - assert_eq!(request.worker_name, "alpha"); - assert_eq!(input_text(&app), " \n\t"); -} - -#[test] -fn dashboard_non_empty_enter_reports_companion_unavailable() { - let mut app = test_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.input.insert_str("keep this draft"); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::None - )); - - assert_eq!(input_text(&app), "keep this draft"); - assert!(!app.sending); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Workspace Companion is unavailable") - ); -} - -#[test] -fn dashboard_alt_enter_inserts_newline_without_companion_send() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.input.insert_str("first line"); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), - DashboardAction::None - )); - - assert_eq!(input_text(&app), "first line\n"); - assert!(!app.sending); - assert!(app.notice.is_none()); -} - -#[test] -fn dashboard_alt_enter_on_blank_pod_selection_inserts_newline_without_opening() { - let mut app = test_app(vec![live_info("alpha", WorkerStatus::Idle)]); - let selected_before = app.selected_row.clone(); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), - DashboardAction::None - )); - - assert_eq!(input_text(&app), "\n"); - assert_eq!(app.selected_row, selected_before); - assert!(app.notice.is_none()); -} - -#[test] -fn dashboard_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch() { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.rows.push(panel_test_ticket_row( - "TICKET-1", - "Ready", - ActionPriority::ReadyForQueue, - NextUserAction::Queue, - "ready", - )); - let mut app = app_with_panel( - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![], - None, - 10, - ), - panel, - ); - app.select_next(); - let selected_before = app.selected_row.clone(); - assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue)); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)), - DashboardAction::None - )); - - assert_eq!(input_text(&app), "\n"); - assert_eq!(app.selected_row, selected_before); - assert!(!app.sending); - assert!(app.notice.is_none()); -} - -#[test] -fn dashboard_composer_shared_word_motion_and_delete_keys() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.input.insert_str("hello world"); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Left, KeyModifiers::CONTROL)), - DashboardAction::None - )); - assert!(matches!( - app.handle_key(key(KeyCode::Char('!'))), - DashboardAction::None - )); - assert_eq!(input_text(&app), "hello !world"); - - assert!(matches!( - app.handle_key(modified_key(KeyCode::Right, KeyModifiers::CONTROL)), - DashboardAction::None - )); - assert!(matches!( - app.handle_key(modified_key(KeyCode::Char('w'), KeyModifiers::CONTROL)), - DashboardAction::None - )); - assert_eq!(input_text(&app), "hello !"); -} - -#[test] -fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() { - let mut app = ticket_enabled_app(vec![live_info("alpha", WorkerStatus::Idle)]); - app.select_next(); - app.input.insert_str("draft message"); - - assert!(app.selected_row.is_some()); - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - DashboardAction::None - )); - assert!(app.selected_row.is_none()); - assert_eq!(input_text(&app), "draft message"); - assert!( - app.notice - .as_deref() - .unwrap() - .contains("Row selection cleared") - ); - assert!(matches!( - app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)), - DashboardAction::Quit - )); -} - -#[test] -fn dashboard_composer_target_switch_preserves_typed_text() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.input.insert_str("draft intake request"); - - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - let selected_before = app.selected_row.clone(); - assert!(matches!( - app.handle_key(key(KeyCode::Tab)), - DashboardAction::None - )); - - assert!(matches!( - app.composer_target(), - ComposerTarget::TicketIntake - )); - assert_eq!(app.selected_row, selected_before); - assert_eq!(input_text(&app), "draft intake request"); -} - -#[test] -fn dashboard_ctrl_t_does_not_switch_composer_target() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.input.insert_str("draft intake request"); - - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - assert!(matches!( - app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)), - DashboardAction::None - )); - - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - assert_eq!(input_text(&app), "draft intake request"); -} - -#[test] -fn dashboard_no_ticket_workspace_exposes_only_companion_target() { - let mut app = test_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.input.insert_str("draft message"); - - app.cycle_composer_target(); - - assert_eq!( - app.panel.composer.available_targets, - vec![ComposerTarget::Companion] - ); - assert!(matches!(app.composer_target(), ComposerTarget::Companion)); - assert_eq!(input_text(&app), "draft message"); - assert!(app.notice.as_deref().unwrap().contains("unavailable")); -} - -#[test] -fn dashboard_blank_ticket_intake_enter_uses_selected_row_and_preserves_input() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str(" \n\t"); - - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::Open - )); - - assert!(matches!( - app.composer_target(), - ComposerTarget::TicketIntake - )); - assert!(!app.sending); - assert_eq!(input_text(&app), " \n\t"); - assert!( - !app.notice - .as_deref() - .unwrap_or_default() - .contains("input is empty") - ); -} - -#[test] -fn dashboard_ticket_intake_enter_builds_launch_request_not_direct_send() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str("please intake this work"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - DashboardAction::LaunchIntake(request) => request, - _ => panic!("Ticket Intake target should launch Intake"), - }; - - assert_eq!(request.context.role, TicketRole::Intake); - assert_eq!( - request.context.user_instruction.as_deref(), - Some("please intake this work") - ); - assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi")); - assert_eq!( - request.context.intake_handoff, - Some(TicketIntakeHandoff::new("test-orchestrator", "test")) - ); - assert_eq!( - request.peer_registration, - IntakePeerRegistrationRequest::Register { - workspace_orchestrator_worker: "test-orchestrator".to_string() - } - ); - assert!(app.sending); - assert!(app.notice.as_deref().unwrap().contains("Launching")); - assert_eq!(input_text(&app), "please intake this work"); -} - -#[test] -fn dashboard_ticket_intake_handoff_skips_peer_registration_when_orchestrator_not_live() { - let mut app = ticket_enabled_app_with_orchestrator( - vec![live_info("idle", WorkerStatus::Idle)], - OrchestratorPanelStatus::Unavailable, - ); - app.cycle_composer_target(); - app.input.insert_str("please intake this work"); - - let request = match app.handle_key(key(KeyCode::Enter)) { - DashboardAction::LaunchIntake(request) => request, - _ => panic!("Ticket Intake target should launch Intake"), - }; - - assert_eq!( - request.context.intake_handoff, - Some(TicketIntakeHandoff::new("test-orchestrator", "test")) - ); - match request.peer_registration { - IntakePeerRegistrationRequest::Skip { reason } => { - assert!(reason.contains("test-orchestrator")); - assert!(reason.contains("unavailable")); - } - other => panic!("expected peer registration skip, got {other:?}"), - } -} - -#[test] -fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str("please intake this work"); - app.sending = true; - - app.finish_intake_launch(Ok(IntakeLaunchOutcome { - launch: TicketRoleLaunchResult { - plan: client::ticket_role::TicketRoleLaunchPlan { - workspace_root: PathBuf::from("/tmp/workspace"), - cwd: None, - original_workspace_root: PathBuf::from("/tmp/workspace"), - target_workspace_root: PathBuf::from("/tmp/workspace"), - implementation_worktree_root: PathBuf::from("/tmp/workspace/.worktree"), - role: TicketRole::Intake, - worker_name: "intake-worker".to_string(), - profile: "builtin:companion".to_string(), - launch_prompt_ref: None, - run_segments: vec![], - }, - ready: client::SpawnReady { - worker_name: "intake-worker".to_string(), - socket_path: PathBuf::from("/tmp/intake.sock"), - }, - acceptance_evidence: client::ticket_role::TicketRoleLaunchAcceptanceEvidence { - worker_name: "intake-worker".to_string(), - accepted_run_segments: 0, - event: client::ticket_role::TicketRoleLaunchAcceptanceEvent::UserMessage, - }, - pre_run_warnings: vec![], - }, - peer_registration: IntakePeerRegistrationStatus::Registered { - workspace_orchestrator_worker: "test-orchestrator".to_string(), - }, - registry_warning: None, - })); - - assert!(!app.sending); - assert_eq!(input_text(&app), ""); - let notice = app.notice.as_deref().unwrap(); - assert!(notice.contains("intake-worker")); - assert!(notice.contains("Handoff peer registered")); -} - -#[test] -fn dashboard_ticket_intake_finish_failure_keeps_composer() { - let mut app = ticket_enabled_app(vec![live_info("idle", WorkerStatus::Idle)]); - app.cycle_composer_target(); - app.input.insert_str("please keep this"); - app.sending = true; - - app.finish_intake_launch(Err(TicketRoleLaunchError::EmptyWorkerName)); - - assert!(!app.sending); - assert_eq!(input_text(&app), "please keep this"); - assert!(app.notice.as_deref().unwrap().contains("composer kept")); -} - -#[test] -fn intake_registry_update_claim_is_durable_only_after_commit() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - let update = IntakeRegistryUpdate::ClaimTicket { - registry_root: root, - ticket_id: "20260608-000000-existing".to_string(), - ticket_slug: Some("existing".to_string()), - worker_name: "existing-intake".to_string(), - }; - - assert!( - store - .claim_for_ticket("20260608-000000-existing") - .unwrap() - .is_none(), - "holding a pending Intake registry update must not persist a Ticket claim" - ); - - assert!(commit_intake_registry_update(update.clone(), None).is_none()); - assert!( - store - .claim_for_ticket("20260608-000000-existing") - .unwrap() - .is_some(), - "the claim is persisted only by the post-acceptance commit step" - ); - - assert!(commit_intake_registry_update(update, None).is_none()); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.claims.len(), 1); - assert_eq!(snapshot.sessions.len(), 1); - assert_eq!(snapshot.sessions[0].worker_name, "existing-intake"); - assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); - assert_eq!(snapshot.sessions[0].related_tickets.len(), 1); - assert_eq!( - snapshot.sessions[0].related_tickets[0].id, - "20260608-000000-existing" - ); -} - -#[test] -fn intake_registry_claims_launched_ticket_with_accepted_worker_name() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - let update = IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: root, - ticket_id: "20260608-000000-ready".to_string(), - ticket_slug: None, - }; - - assert!(commit_intake_registry_update(update, Some("launched-intake")).is_none()); - - let claim = store - .claim_for_ticket("20260608-000000-ready") - .unwrap() - .expect("launched Intake Worker is claimed after accepted launch"); - assert_eq!(claim.worker_name, "launched-intake"); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.claims.len(), 1); - assert_eq!(snapshot.sessions.len(), 1); - assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim); - assert_eq!(snapshot.sessions[0].worker_name, "launched-intake"); -} - -#[test] -fn intake_registry_launched_ticket_claim_without_worker_name_is_diagnostic() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - - let warning = commit_intake_registry_update( - IntakeRegistryUpdate::ClaimLaunchedTicket { - registry_root: root, - ticket_id: "20260608-000000-ready".to_string(), - ticket_slug: None, - }, - None, - ) - .expect("missing launched Worker name should be diagnostic"); - - assert!(warning.contains("missing launched Worker name")); - assert!( - store - .claim_for_ticket("20260608-000000-ready") - .unwrap() - .is_none() - ); -} - -#[test] -fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() { - let temp = TempDir::new().unwrap(); - let root = temp.path().join("registry"); - let store = PanelRegistryStore::from_root(root.clone()); - store - .claim_ticket( - "20260608-000001-existing", - Some("existing"), - "first-intake", - TicketRole::Intake.as_str(), - ) - .unwrap(); - - let warning = commit_intake_registry_update( - IntakeRegistryUpdate::ClaimTicket { - registry_root: root, - ticket_id: "20260608-000001-existing".to_string(), - ticket_slug: Some("existing".to_string()), - worker_name: "second-intake".to_string(), - }, - None, - ) - .expect("conflicting post-success claim should be reported"); - - assert!(warning.contains("could not be committed")); - let claim = store - .claim_for_ticket("20260608-000001-existing") - .unwrap() - .unwrap(); - assert_eq!(claim.worker_name, "first-intake"); - let snapshot = store.snapshot().unwrap(); - assert_eq!(snapshot.claims.len(), 1); - assert_eq!(snapshot.sessions.len(), 1); - assert_eq!(snapshot.sessions[0].worker_name, "first-intake"); -} - -#[test] -fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() { - let mut app = test_app(vec![unreachable_live_info("unreachable")]); - app.select_next(); - assert!(matches!( - app.handle_key(key(KeyCode::Enter)), - DashboardAction::Open - )); - assert!(app.prepare_open().is_none()); - - assert!(app.notice.as_deref().unwrap().contains("cannot be opened")); -} - -#[test] -fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - app.refresh_orchestrator_work_set(); - - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("idle orchestrator should receive queued-work attention"); - - assert_eq!(request.worker_name, "test-orchestrator"); - assert!( - request - .notice - .message - .starts_with("Queued Tickets require attention:") - ); - assert!(request.notice.message.contains("- T-1 — Queued work")); - assert!( - request - .notice - .message - .contains("Reread the current Ticket state before acting") - ); - assert!( - !request - .notice - .message - .contains(&app.panel.header.workspace_label) - ); - for hidden in [ - "00001QUEUE", - "Workspace:", - "workspace_id", - "new_queued", - "bounded", - "queued -> inprogress", - ] { - assert!(!request.notice.message.contains(hidden), "leaked {hidden}"); - } -} - -#[test] -fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - let mut row = panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ); - row.ticket.as_mut().unwrap().resource_key = None; - app.panel.rows = vec![row]; - app.refresh_orchestrator_work_set(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - assert_eq!( - app.notice.as_deref(), - Some( - "Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key" - ) - ); -} - -#[test] -fn queued_attention_truncates_only_when_tickets_are_omitted() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1) - .map(|index| { - let mut row = panel_test_ticket_row( - &format!("opaque-{index}"), - &format!("Queued work {index}"), - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ); - row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}")); - row - }) - .collect(); - app.refresh_orchestrator_work_set(); - - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("bounded queued-work attention"); - - assert!(request.notice.message.contains("- T-20 — Queued work 20")); - assert!(!request.notice.message.contains("T-21")); - assert!( - request - .notice - .message - .contains("were omitted from this notice: 1") - ); - assert!(!request.notice.message.contains("opaque-")); -} - -#[test] -fn active_inprogress_suppresses_queued_attention_and_retains_waiting_reason() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - app.panel.rows = vec![ - panel_test_ticket_row( - "00001ACTIVE", - "Active work", - ActionPriority::Background, - NextUserAction::Wait, - "inprogress", - ), - panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ), - ]; - app.refresh_orchestrator_work_set(); - app.apply_orchestrator_work_set_detail(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - let queued = app - .orchestrator_work_set - .queued - .iter() - .find(|item| item.id == "00001QUEUE") - .expect("queued item retained"); - assert_eq!( - queued.classification, - OrchestratorQueuedClassification::PlannedQueued - ); - assert!( - queued - .waiting_reason - .as_deref() - .unwrap() - .contains("active_inprogress") - ); - assert!( - app.panel - .header - .orchestrator - .as_ref() - .unwrap() - .detail - .as_deref() - .unwrap() - .contains("suppressed") - ); -} - -#[test] -fn planned_queued_prompts_when_active_work_clears() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - app.panel.rows = vec![ - panel_test_ticket_row( - "00001ACTIVE", - "Active work", - ActionPriority::Background, - NextUserAction::Wait, - "inprogress", - ), - panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ), - ]; - app.refresh_orchestrator_work_set(); - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - app.refresh_orchestrator_work_set(); - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("planned queued work should prompt after active work clears"); - - assert!(request.notice.message.contains("- T-1 — Queued work")); - assert!(!request.notice.message.contains("planned_queued")); - assert!(!request.notice.message.contains("00001QUEUE")); - assert!( - !request - .notice - .message - .contains("waiting for active_inprogress") - ); -} - -#[test] -fn queued_attention_is_suppressed_when_existing_claim_prevents_duplicate_start() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - let mut row = panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - ); - row.ticket.as_mut().unwrap().local_claim = - Some(crate::workspace_panel::TicketLocalClaimEntry { - worker_name: "coder-00001QUEUE".to_string(), - role: "coder".to_string(), - status: TicketLocalClaimStatus::Live, - }); - row.related_workers.push("reviewer-00001QUEUE".to_string()); - app.panel.rows = vec![row]; - app.refresh_orchestrator_work_set(); - app.apply_orchestrator_work_set_detail(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); - let waiting = app.orchestrator_work_set.queued[0] - .waiting_reason - .as_deref() - .unwrap(); - assert!(waiting.contains("duplicate start")); - assert!(waiting.contains("coder-00001QUEUE")); -} - -#[test] -fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); - app.orchestrator_work_set = OrchestratorWorkSet::default(); - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - - let request = app - .prepare_orchestrator_queue_attention_notice() - .expect("queued ticket state should be rediscovered safely"); - - assert!(request.notice.message.contains("- T-1 — Queued work")); - assert!(!request.notice.message.contains("new_queued")); - assert!(!request.notice.message.contains("00001QUEUE")); -} - -#[test] -fn queued_attention_requires_idle_orchestrator_to_avoid_duplicate_rekick() { - let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Running)]); - app.panel.rows = vec![panel_test_ticket_row( - "00001QUEUE", - "Queued work", - ActionPriority::Background, - NextUserAction::Wait, - "queued", - )]; - app.refresh_orchestrator_work_set(); - - assert!(app.prepare_orchestrator_queue_attention_notice().is_none()); -} - -fn test_app(live: Vec) -> DashboardApp { - app_with_list(WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - live, - None, - 10, - )) -} - -fn companion_app(live: Vec, status: CompanionPanelStatus) -> DashboardApp { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.companion = Some(CompanionPanelState::new("yoi", status, None)); - app_with_panel( - WorkerList::from_sources(WorkerVisibilitySource::ResumePicker, vec![], live, None, 10), - panel, - ) -} - -fn ticket_enabled_app(live: Vec) -> DashboardApp { - ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live) -} - -fn ticket_enabled_app_with_orchestrator( - live: Vec, - orchestrator_status: OrchestratorPanelStatus, -) -> DashboardApp { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); - panel.header.companion = Some(CompanionPanelState::new( - "yoi", - CompanionPanelStatus::Live, - None, - )); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - orchestrator_status, - None, - )); - app_with_panel( - WorkerList::from_sources(WorkerVisibilitySource::ResumePicker, vec![], live, None, 10), - panel, - ) -} - -fn app_with_list(list: WorkerList) -> DashboardApp { - app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test"))) -} - -fn empty_test_list() -> WorkerList { - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![], - None, - 10, - ) -} - -fn panel_with_orchestrator( - status: OrchestratorPanelStatus, - detail: Option<&str>, -) -> WorkspacePanelViewModel { - let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); - panel.header.orchestrator = Some(OrchestratorPanelState::new( - "test-orchestrator", - status, - detail.map(str::to_string), - )); - if let Some(detail) = detail { - panel.header.diagnostics.push(detail.to_string()); - } - panel -} - -fn app_with_panel(list: WorkerList, panel: WorkspacePanelViewModel) -> DashboardApp { - let last_companion_lifecycle_failure = companion_lifecycle_failure_from_panel(&panel); - let last_orchestrator_lifecycle_failure = orchestrator_lifecycle_failure_from_panel(&panel); - let mut app = DashboardApp { - list, - panel, - input: InputBuffer::new(), - selected_row: None, - row_hit_boxes: Vec::new(), - composer_target: ComposerTarget::Companion, - notice: None, - panel_diagnostic: None, - panel_diagnostic_open: false, - sending: false, - refreshing: false, - enter_reload: None, - runtime_command: WorkerRuntimeCommand::for_executable("/tmp/yoi"), - include_stopped: false, - last_companion_lifecycle_failure, - last_orchestrator_lifecycle_failure, - orchestrator_work_set: OrchestratorWorkSet::default(), - orchestrator_queue_attention: None, - }; - app.ensure_selection_visible(); - app.ensure_composer_target_available(); - app.refresh_orchestrator_work_set(); - app.apply_orchestrator_work_set_detail(); - app -} - -fn panel_test_ticket_row( - id: &str, - title: &str, - priority: ActionPriority, - next_action: NextUserAction, - state: &str, -) -> PanelRow { - let ticket = crate::workspace_panel::TicketPanelEntry { - id: id.to_string(), - resource_key: Some("T-1".to_string()), - title: title.to_string(), - priority: "P2".to_string(), - workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning), - workflow_state_explicit: true, - orchestration_overlay: None, - next_action: Some(next_action), - updated_at: None, - latest_event_kind: Some("implementation_report".to_string()), - latest_event_excerpt: Some("latest event stays out of the primary row".to_string()), - blocked_reason: None, - related_workers: Vec::new(), - local_claim: None, - intake_workers: Vec::new(), - }; - PanelRow { - key: PanelRowKey::Ticket(ticket.id.clone()), - kind: crate::workspace_panel::PanelRowKind::Ticket, - title: title.to_string(), - subtitle: Some("id · priority · latest event".to_string()), - status: state.to_string(), - priority, - next_action: Some(next_action), - ticket: Some(ticket), - related_workers: Vec::new(), - disabled_reason: None, - key_hint: Some("Enter".to_string()), - } -} - -fn panel_test_intake_child_row( - ticket_id: &str, - worker_name: &str, - status: TicketLocalClaimStatus, - next_action: Option, -) -> PanelRow { - PanelRow { - key: PanelRowKey::TicketIntakeWorker { - ticket_id: ticket_id.to_string(), - worker_name: worker_name.to_string(), - }, - kind: PanelRowKind::TicketIntakeWorker, - title: format!("Intake Worker: {worker_name}"), - subtitle: Some(format!("Intake claim for Ticket {ticket_id}")), - status: status.label().to_string(), - priority: match status { - TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => { - ActionPriority::ActiveWork - } - TicketLocalClaimStatus::Stale => ActionPriority::Background, - }, - next_action, - ticket: None, - related_workers: vec![worker_name.to_string()], - disabled_reason: (status == TicketLocalClaimStatus::Stale) - .then(|| "claim metadata is stale".to_string()), - key_hint: Some(format!("Ticket {ticket_id} Intake Worker {worker_name}")), - } -} - -fn closed_list(count: usize, selected: Option<&str>) -> WorkerList { - WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - (0..count) - .map(|index| { - stopped_info_with_updated_at(&format!("closed-{index}"), 100 - index as u64) - }) - .collect(), - vec![], - selected.map(str::to_string), - count.max(1), - ) -} - -fn live_info(worker_name: &str, status: WorkerStatus) -> LiveWorkerInfo { - live_info_with_updated_at(worker_name, status, 0) -} - -fn unreachable_live_info(worker_name: &str) -> LiveWorkerInfo { - let mut live = live_info(worker_name, WorkerStatus::Idle); - live.reachable = false; - live.status = None; - live -} - -fn live_info_with_updated_at( - worker_name: &str, - status: WorkerStatus, - updated_at: u64, -) -> LiveWorkerInfo { - LiveWorkerInfo { - worker_name: worker_name.to_string(), - socket_path: PathBuf::from(format!("/tmp/{worker_name}.sock")), - status: Some(status), - reachable: true, - segment_id: None, - summary: WorkerEntrySummary { - active_session_id: None, - active_segment_id: None, - updated_at, - preview: None, - }, - } -} - -fn stopped_info(worker_name: &str) -> StoredWorkerInfo { - stopped_info_with_updated_at(worker_name, 10) -} - -fn stopped_info_with_updated_at(worker_name: &str, updated_at: u64) -> StoredWorkerInfo { - StoredWorkerInfo { - worker_name: worker_name.to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at, - workspace_root: None, - preview: None, - } -} - -fn section_names<'a>(list: &'a WorkerList, section: &DashboardSection) -> Vec<&'a str> { - section - .entries - .iter() - .map(|index| list.entries[*index].name.as_str()) - .collect() -} - -#[test] -fn ticket_action_error_records_f2_diagnostic_details() { - let mut app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi"), false); - let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/dashboard.rs"; - - app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string()))); - - assert!(app.notice.as_deref().unwrap().contains("F2 details")); - let diagnostic = app.panel_diagnostic.as_ref().expect("diagnostic"); - assert_eq!(diagnostic.title, "Ticket action rejected"); - assert_eq!(diagnostic.details, long_error); - assert!(!app.panel_diagnostic_open); - - assert!(matches!( - app.handle_key(key(KeyCode::F(2))), - DashboardAction::None - )); - assert!(app.panel_diagnostic_open); - assert!(matches!( - app.handle_key(key(KeyCode::Esc)), - DashboardAction::None - )); - assert!(!app.panel_diagnostic_open); -} - -#[test] -fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() { - const INTERNAL_ID: &str = "00001KZVNXFNK"; - let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback."); - let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback."); - let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback."); - - for projection in [&thread, &launch, ¬ify] { - assert!(projection.contains("T-482")); - assert!(!projection.contains(INTERNAL_ID)); - } -} - -#[test] -fn ticket_handoff_fails_closed_without_canonical_resource_key() { - assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482")); - for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] { - assert!(required_ticket_handoff_key(invalid).is_err()); - } -} - -fn plain_line(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() -} - -fn display_column(text: &str, needle: &str) -> usize { - let byte_index = text.find(needle).unwrap(); - text[..byte_index].width() -} - -fn input_text(app: &DashboardApp) -> String { - Segment::flatten_to_text(&app.input.submit_segments()) -} - -fn key(code: KeyCode) -> KeyEvent { - modified_key(code, KeyModifiers::NONE) -} - -fn left_click(column: u16, row: u16) -> MouseEvent { - MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column, - row, - modifiers: KeyModifiers::NONE, - } -} - -fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { - KeyEvent::new(code, modifiers) -} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index f5f895b5..724b9fc0 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -8,25 +8,19 @@ mod command; mod composer_history; mod composer_keys; mod console; -mod dashboard; #[cfg(feature = "e2e-test")] mod e2e_observer; mod input; pub mod keys; mod markdown; -mod picker; -mod role_session_registry; mod scroll; pub mod setup_model; -mod spawn; mod standalone_picker; mod task; mod text_selection; mod tool; mod ui; mod view_mode; -mod worker_list; -mod workspace_panel; use std::io; use std::path::PathBuf; @@ -35,9 +29,8 @@ use std::process::ExitCode; use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste}; use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; -use session_store::SegmentId; -use client::{Target, WorkerConnectionSelector, WorkerListRequest, WorkerSpawn}; +use client::{Target, WorkerConnectionSelector, WorkerListRequest}; #[derive(Debug)] pub struct LaunchOptions { @@ -48,6 +41,7 @@ pub struct LaunchOptions { #[derive(Debug, Clone)] pub enum LaunchMode { + /// Start one client-owned in-process Standalone Worker. Spawn { worker_name: Option, profile: Option, @@ -55,38 +49,18 @@ pub enum LaunchMode { /// Restore one client-owned standalone session. The current cwd is the default scope; /// `include_all` opts into all standalone sessions under the same client data root. StandaloneResume { include_all: bool }, - /// `yoi --worker `: attach to a live Worker by name if possible; - /// otherwise launch the Worker runtime command with `--worker ` so it - /// resumes from name-keyed state or creates a fresh same-name Worker. - WorkerName { - worker_name: String, - socket_override: Option, - }, - /// `yoi workers` / `yoi --backend `: list workers through the selected - /// connection target, then attach to the selected Worker. + /// List Backend Workers and attach to the selected Worker. Workers { runtime_id: Option, include_stopped: bool, - all: bool, }, - /// `yoi --backend --runtime-id --worker-id `: open one Worker - /// through the selected connection target. + /// Open one Backend Worker through the selected connection target. OpenWorker { runtime_id: String, worker_id: String, }, - /// `yoi resume`: open the Worker picker, then attach to the selected live Worker - /// or restore the selected stopped Worker by name. Without `--all`, the picker - /// is scoped to the current runtime workspace. - Resume { all: bool }, - /// `yoi --session `: skip the picker, go straight to the - /// resume name dialog with `id` baked in. - ResumeWithSession { - id: SegmentId, - worker_name: Option, - }, - /// `yoi panel`: open the workspace Dashboard from the current workspace. - Panel { include_stopped: bool }, + /// Open the Backend Workspace dashboard. + Panel, } struct TerminalModeGuard { @@ -163,12 +137,14 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { worker_name, profile, } => match target.spawn_worker() { - Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { - console::run_spawn(None, worker_name, profile, runtime_command).await - } - Ok(WorkerSpawn::Standalone { state_dir }) => { - console::run_standalone(workspace_root.clone(), state_dir, worker_name, profile) - .await + Ok(spawn) => { + console::run_standalone( + workspace_root.clone(), + spawn.state_dir, + worker_name, + profile, + ) + .await } Err(e) => Err(Box::new(e) as Box), }, @@ -179,45 +155,17 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(error) => Err(Box::new(error) as Box), } } - LaunchMode::WorkerName { - worker_name, - socket_override, - } => match target.worker_by_name() { - Ok(worker_by_name) => { - console::run_worker_name( - worker_name, - socket_override, - worker_by_name.runtime_command, - ) - .await - } - Err(e) => Err(Box::new(e) as Box), - }, LaunchMode::Workers { runtime_id, include_stopped, - all, } => match target.list_workers(if include_stopped { WorkerListRequest::with_stopped(runtime_id) } else { WorkerListRequest::new(runtime_id) }) { Ok(worker_list) => { - if let Some(target) = worker_list.backend_target { - backend_worker_picker::run(target, worker_list.include_stopped).await - } else if let Some(runtime_command) = worker_list.local_runtime_command { - console::run_worker_picker( - runtime_command, - workspace_root.clone(), - all, - worker_list.include_stopped, - ) + backend_worker_picker::run(worker_list.backend_target, worker_list.include_stopped) .await - } else { - Err(Box::new(io::Error::other( - "worker list target did not include a local or backend source", - )) as Box) - } } Err(e) => Err(Box::new(e) as Box), }, @@ -228,32 +176,12 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Ok(connection) => console::run_backend_runtime(connection.target).await, Err(e) => Err(Box::new(e) as Box), }, - LaunchMode::Resume { all } => match target.resume_worker() { - Ok(resume) => { - console::run_resume(resume.runtime_command, workspace_root.clone(), all).await + LaunchMode::Panel => match target.dashboard() { + Ok(dashboard) => { + backend_dashboard::launch(dashboard.base_url, dashboard.workspace_id).await } Err(e) => Err(Box::new(e) as Box), }, - LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() { - Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { - console::run_spawn(Some(id), worker_name, None, runtime_command).await - } - Ok(WorkerSpawn::Standalone { .. }) => Err(Box::new(io::Error::new( - io::ErrorKind::Unsupported, - "Standalone session restore is not implemented", - )) as Box), - Err(e) => Err(Box::new(e) as Box), - }, - LaunchMode::Panel { include_stopped } => match target.dashboard() { - Ok(client::Dashboard::Local { runtime_command }) => { - dashboard::launch(runtime_command, include_stopped).await - } - Ok(client::Dashboard::Backend { - base_url, - workspace_id, - }) => backend_dashboard::launch(base_url, workspace_id).await, - Err(e) => Err(Box::new(e) as Box), - }, }; // Always restore the terminal first so any pending eprintln below @@ -272,14 +200,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { ExitCode::SUCCESS } Err(e) => { - // SpawnError has already been painted into the inline - // viewport's final frame, so it's already visible in the - // user's scrollback — printing it again would be a noisy - // duplicate. Other errors (worker-name failures, terminal setup - // hiccups, etc.) need surfacing here. - if e.downcast_ref::().is_none() { - eprintln!("yoi: {e}"); - } + eprintln!("yoi: {e}"); #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); ExitCode::FAILURE diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs deleted file mode 100644 index ac1eba95..00000000 --- a/crates/tui/src/picker.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! Inline-viewport "pick a Worker to attach or restore" UX. -//! -//! Reads live Worker allocations from the runtime registry and stopped Worker state -//! from the session-store worker metadata name-keyed metadata. Picking a live row attaches to -//! its socket; picking a stopped row restores via the Worker runtime command. - -use std::io; -use std::path::PathBuf; -use std::time::Duration; - -use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Layout}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; -use ratatui::{Frame, TerminalOptions, Viewport}; -use session_store::FsStore; -use session_store::FsWorkerStore; - -use crate::worker_list::{ - LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry, - WorkerVisibilitySource, live_socket_for_worker as worker_list_live_socket_for_worker, - read_reachable_live_worker_infos, read_stored_worker_infos, -}; - -const MAX_ROWS: usize = 10; -const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; - -#[derive(Debug)] -pub enum PickerError { - Io(io::Error), - Store(session_store::StoreError), - NoWorkers { all: bool }, -} - -impl std::fmt::Display for PickerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(e) => write!(f, "io error: {e}"), - Self::Store(e) => write!(f, "session store error: {e}"), - Self::NoWorkers { all: true } => write!( - f, - "no workers found — start a fresh Worker with `yoi` and try again" - ), - Self::NoWorkers { all: false } => write!( - f, - "no workers found in this workspace — use `yoi resume --all` to list all host/data-dir Workers" - ), - } - } -} - -impl std::error::Error for PickerError {} - -impl From for PickerError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } -} - -impl From for PickerError { - fn from(e: session_store::StoreError) -> Self { - Self::Store(e) - } -} - -pub enum PickerOutcome { - /// User picked a Worker. `socket_override` is set for live rows when the - /// runtime registry knows the exact socket path; stopped rows leave it - /// empty so the caller restores by spawning the Worker runtime command. - Picked { - worker_name: String, - socket_override: Option, - }, - Cancelled, -} - -#[derive(Debug, Clone)] -pub(crate) struct PickerOptions { - scope: PickerScope, - include_stopped: bool, -} - -impl PickerOptions { - pub(crate) fn workspace(workspace_root: PathBuf) -> Self { - Self { - scope: PickerScope::Workspace(workspace_root), - include_stopped: true, - } - } - - pub(crate) fn all() -> Self { - Self { - scope: PickerScope::All, - include_stopped: true, - } - } - - pub(crate) fn with_stopped(mut self, include_stopped: bool) -> Self { - self.include_stopped = include_stopped; - self - } -} - -#[derive(Debug, Clone)] -enum PickerScope { - Workspace(PathBuf), - All, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WorkerRowState { - Live, - Stopped, - Corrupt, -} - -impl WorkerRowState { - fn label(self) -> &'static str { - match self { - Self::Live => "live", - Self::Stopped => "stopped", - Self::Corrupt => "corrupt", - } - } - - fn style(self) -> Style { - match self { - Self::Live => Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - Self::Stopped => Style::default().fg(Color::Yellow), - Self::Corrupt => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - } - } -} - -fn list_for_options( - options: &PickerOptions, - stored_workers: Vec, - live_workers: Vec, -) -> WorkerList { - let stored_workers = if options.include_stopped { - stored_workers - } else { - Vec::new() - }; - match &options.scope { - PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources( - WorkerVisibilitySource::ResumePicker, - stored_workers, - live_workers, - None, - MAX_ROWS, - workspace_root, - ), - PickerScope::All => WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - stored_workers, - live_workers, - None, - MAX_ROWS, - ), - } -} - -pub async fn run(options: PickerOptions) -> Result { - let store_dir = default_store_dir()?; - let store = FsStore::new(&store_dir)?; - let worker_metadata_store = - FsWorkerStore::new(default_worker_metadata_dir()?).map_err(io::Error::other)?; - let stored_workers = read_stored_worker_infos(&store, &worker_metadata_store)?; - let live_workers = read_reachable_live_worker_infos(&store) - .await - .unwrap_or_default(); - let mut list = list_for_options(&options, stored_workers, live_workers); - if list.entries.is_empty() { - return Err(PickerError::NoWorkers { - all: matches!(options.scope, PickerScope::All), - }); - } - - let mut terminal = make_inline_terminal()?; - loop { - terminal.draw(|f| draw(f, &list))?; - match poll_event()? { - None => continue, - Some(Action::Up) => { - let selected = list.selected_index().saturating_sub(1); - list.select_index(selected); - } - Some(Action::Down) => { - let selected = list.selected_index(); - if selected + 1 < list.entries.len() { - list.select_index(selected + 1); - } - } - Some(Action::Submit) => { - close_viewport(&mut terminal)?; - let entry = list.selected_entry().expect("non-empty worker list"); - return Ok(PickerOutcome::Picked { - worker_name: entry.name.clone(), - socket_override: entry.attach_socket_path().map(PathBuf::from), - }); - } - Some(Action::Cancel) => { - close_viewport(&mut terminal)?; - return Ok(PickerOutcome::Cancelled); - } - } - } -} - -/// Park the cursor at the very bottom of the picker's inline viewport and emit -/// one newline before dropping the terminal. This keeps any next inline viewport -/// from drawing over the lower picker rows. -fn close_viewport(terminal: &mut Terminal>) -> io::Result<()> { - let area = terminal.get_frame().area(); - let last_row = area.bottom().saturating_sub(1); - terminal.set_cursor_position((0, last_row))?; - use std::io::Write; - let mut out = io::stdout(); - out.write_all(b"\r\n")?; - out.flush()?; - Ok(()) -} - -fn default_store_dir() -> Result { - manifest::paths::sessions_dir().ok_or_else(|| { - PickerError::Io(io::Error::new( - io::ErrorKind::NotFound, - "could not resolve sessions directory \ - (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)", - )) - }) -} - -fn default_worker_metadata_dir() -> Result { - manifest::paths::data_dir() - .map(|dir| dir.join("workers")) - .ok_or_else(|| { - PickerError::Io(io::Error::new( - io::ErrorKind::NotFound, - "could not resolve worker state directory \ - (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)", - )) - }) -} - -pub(crate) fn live_socket_for_worker(worker_name: &str) -> Option { - worker_list_live_socket_for_worker(worker_name) -} - -fn make_inline_terminal() -> io::Result>> { - let backend = CrosstermBackend::new(io::stdout()); - Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(VIEWPORT_LINES), - }, - ) -} - -enum Action { - Up, - Down, - Submit, - Cancel, -} - -fn poll_event() -> io::Result> { - if !event::poll(Duration::from_millis(100))? { - return Ok(None); - } - match event::read()? { - TermEvent::Key(k) if k.kind != KeyEventKind::Release => { - let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); - Ok(match k.code { - KeyCode::Up => Some(Action::Up), - KeyCode::Down => Some(Action::Down), - KeyCode::Char('k') if !ctrl => Some(Action::Up), - KeyCode::Char('j') if !ctrl => Some(Action::Down), - KeyCode::Enter => Some(Action::Submit), - KeyCode::Esc => Some(Action::Cancel), - KeyCode::Char('c') if ctrl => Some(Action::Cancel), - _ => None, - }) - } - _ => Ok(None), - } -} - -fn draw(f: &mut Frame<'_>, list: &WorkerList) { - let area = f.area(); - let mut constraints: Vec = Vec::with_capacity(list.entries.len() + 3); - constraints.push(Constraint::Length(1)); // title - for _ in &list.entries { - constraints.push(Constraint::Length(1)); - } - constraints.push(Constraint::Length(1)); // hint - constraints.push(Constraint::Length(1)); // spacer - let layout = Layout::vertical(constraints).split(area); - - f.render_widget( - Paragraph::new(Line::from(vec![Span::styled( - picker_title(), - Style::default().add_modifier(Modifier::BOLD), - )])), - layout[0], - ); - - let selected = list.selected_index(); - for (i, entry) in list.entries.iter().enumerate() { - f.render_widget( - Paragraph::new(row_line(entry, i == selected)), - layout[i + 1], - ); - } - - f.render_widget( - Paragraph::new(Line::from(vec![ - Span::raw(" "), - Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)), - Span::raw(" select "), - Span::styled("[enter]", Style::default().fg(Color::Green)), - Span::raw(" open/restore "), - Span::styled("[esc]", Style::default().fg(Color::Yellow)), - Span::raw(" cancel"), - ])), - layout[list.entries.len() + 1], - ); -} - -fn picker_title() -> &'static str { - "resume worker pick a worker" -} - -fn row_line(entry: &WorkerListEntry, selected: bool) -> Line<'_> { - let marker = if selected { "▶ " } else { " " }; - let name_style = if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Cyan) - }; - let preview_style = if selected { - Style::default().fg(Color::White) - } else { - Style::default().fg(Color::DarkGray) - }; - let state = row_state(entry); - let _visibility = entry.visibility; - let _source_kinds = &entry.source_kinds; - - let mut spans = vec![ - Span::raw(marker), - Span::styled(entry.name.as_str(), name_style), - Span::raw(" "), - Span::styled(format!("[{}]", state.label()), state.style()), - Span::raw(" "), - Span::styled( - format_updated_at(entry.summary.updated_at), - Style::default().fg(Color::DarkGray), - ), - Span::raw(" "), - Span::styled(debug_ids(entry), Style::default().fg(Color::DarkGray)), - ]; - if let Some(preview) = entry.summary.preview.as_ref() { - spans.push(Span::raw(" ")); - spans.push(Span::styled(preview.as_str(), preview_style)); - } - Line::from(spans) -} - -fn row_state(entry: &WorkerListEntry) -> WorkerRowState { - if entry.live.as_ref().is_some_and(|live| live.reachable) { - return WorkerRowState::Live; - } - if entry - .stored - .as_ref() - .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_))) - { - return WorkerRowState::Corrupt; - } - WorkerRowState::Stopped -} - -fn format_updated_at(updated_at: u64) -> String { - if updated_at == 0 { - "updated: —".to_string() - } else { - format!("updated: {updated_at}") - } -} - -fn debug_ids(entry: &WorkerListEntry) -> String { - let session = entry - .summary - .active_session_id - .map(short_id) - .unwrap_or_else(|| "--------".to_string()); - let segment = entry - .summary - .active_segment_id - .map(short_id) - .unwrap_or_else(|| "--------".to_string()); - format!("s:{session} g:{segment}") -} - -fn short_id(id: T) -> String { - id.to_string().chars().take(8).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn picker_title_names_pods_not_sessions() { - assert_eq!(picker_title(), "resume worker pick a worker"); - } - - #[test] - fn picker_no_pods_message_mentions_all_for_workspace_scope() { - let message = PickerError::NoWorkers { all: false }.to_string(); - assert!(message.contains("no workers found in this workspace")); - assert!(message.contains("yoi resume --all")); - } - - #[test] - fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() { - let message = PickerError::NoWorkers { all: true }.to_string(); - assert!(message.contains("start a fresh Worker with `yoi`")); - assert!(!message.contains("yoi resume --all")); - } - - #[test] - fn picker_workspace_options_filter_by_workspace_metadata() { - let list = list_for_options( - &PickerOptions::workspace(PathBuf::from("/workspace/current")), - vec![ - stored_pod("current", Some("/workspace/current"), 3), - stored_pod("other", Some("/workspace/other"), 2), - stored_pod("legacy", None, 1), - ], - vec![], - ); - - let names: Vec<_> = list - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect(); - assert_eq!(names, vec!["current"]); - } - - #[test] - fn picker_all_options_include_host_wide_and_legacy_pods() { - let list = list_for_options( - &PickerOptions::all(), - vec![ - stored_pod("current", Some("/workspace/current"), 3), - stored_pod("other", Some("/workspace/other"), 2), - stored_pod("legacy", None, 1), - ], - vec![], - ); - - let names: Vec<_> = list - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect(); - assert_eq!(names, vec!["current", "other", "legacy"]); - } - - fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredWorkerInfo { - StoredWorkerInfo { - worker_name: name.to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at, - workspace_root: workspace_root.map(PathBuf::from), - preview: None, - } - } - - #[test] - fn picker_row_shows_live_pending_preview_and_runtime_segment_id() { - let segment_id = session_store::new_segment_id(); - let entry = WorkerList::from_sources( - WorkerVisibilitySource::ResumePicker, - vec![], - vec![crate::worker_list::LiveWorkerInfo { - worker_name: "pending".to_string(), - socket_path: PathBuf::from("/tmp/pending.sock"), - status: Some(protocol::WorkerStatus::Idle), - reachable: true, - segment_id: Some(segment_id), - summary: crate::worker_list::WorkerEntrySummary::default(), - }], - None, - 10, - ) - .entries - .into_iter() - .next() - .unwrap(); - - let text = row_line(&entry, false) - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - - assert!(text.contains("[live]")); - assert!(text.contains("[live, pending segment]")); - assert!(text.contains(&format!("g:{}", short_id(segment_id)))); - } -} diff --git a/crates/tui/src/role_session_registry.rs b/crates/tui/src/role_session_registry.rs deleted file mode 100644 index c6a80d66..00000000 --- a/crates/tui/src/role_session_registry.rs +++ /dev/null @@ -1,556 +0,0 @@ -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, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct RoleSessionRegistry { - pub version: u32, - pub workspace_root: String, - pub sessions: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct RoleSessionRecord { - pub role: String, - pub worker_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, - #[serde(default)] - pub related_tickets: Vec, -} - -#[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, -} - -#[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, - pub worker_name: String, - pub role: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct PanelRegistrySnapshot { - pub sessions: Vec, - pub claims: Vec, -} - -#[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.worker_name, claim.role - ), - } - } -} - -impl std::error::Error for PanelRegistryError {} - -impl From for PanelRegistryError { - fn from(error: io::Error) -> Self { - Self::Io(error) - } -} - -impl From for PanelRegistryError { - fn from(error: serde_json::Error) -> Self { - Self::Json(error) - } -} - -impl PanelRegistryStore { - pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result { - 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, 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) -> Self { - Self { - root: root.into(), - workspace_root: None, - } - } - - pub(crate) fn root(&self) -> &Path { - &self.root - } - - pub(crate) fn snapshot(&self) -> Result { - 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 { - 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, - worker_name: impl Into, - role: impl Into, - origin: RoleSessionOrigin, - session_id: Option, - related_tickets: impl IntoIterator, - ) -> Result<(), PanelRegistryError> { - let worker_name = worker_name.into(); - let role = role.into(); - let related_tickets: Vec = related_tickets.into_iter().collect(); - self.update_registry(|registry| { - let now = now_timestamp_string(); - let mut tickets: BTreeSet = registry - .sessions - .get(&worker_name) - .map(|record| record.related_tickets.iter().cloned().collect()) - .unwrap_or_default(); - tickets.extend(related_tickets); - let created_at = registry - .sessions - .get(&worker_name) - .map(|record| record.created_at.clone()) - .unwrap_or_else(|| now.clone()); - registry.sessions.insert( - worker_name.clone(), - RoleSessionRecord { - role, - worker_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>, - worker_name: &str, - role: &str, - ) -> Result { - 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), - worker_name: worker_name.to_string(), - role: role.to_string(), - }; - match self.create_claim_file(&claim_path, &claim) { - Ok(()) => { - if let Err(error) = self.record_session( - worker_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.worker_name == worker_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 { - 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, 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(®istry) - } - - fn acquire_registry_lock(&self) -> Result { - 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, PanelRegistryError> { - let mut claims: Vec = 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::() - .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.worker_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.worker_name == "ticket-intake-preticket") - .unwrap(); - let shared = snapshot - .sessions - .iter() - .find(|session| session.worker_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()), - }, - ] - ); - } -} diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs deleted file mode 100644 index 8f925c26..00000000 --- a/crates/tui/src/spawn.rs +++ /dev/null @@ -1,756 +0,0 @@ -//! Inline-viewport "spawn Worker and attach" UX. -//! -//! Rendered at the user's current cursor position when `yoi` is invoked -//! with no positional argument. Uses user-configured and bundled Profile -//! choices plus bundled profiles, defaults to the builtin profile, prompts for -//! the Worker's name, and on confirmation launches the Worker runtime command as an -//! independent process. Once the process reports its socket via the -//! `YOI-READY` stderr line, the dialog hands control back so main can -//! switch the terminal to alternate-screen mode. -//! -//! The viewport's last frame stays in the terminal's scrollback so the -//! user has a record of what was spawned (or why a spawn failed). - -use std::io; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use client::{SpawnConfig, WorkerRuntimeCommand, spawn_worker}; -use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; -use manifest::ProfileDiscovery; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Layout}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; -use ratatui::{Frame, TerminalOptions, Viewport}; -use session_store::SegmentId; - -const VIEWPORT_LINES: u16 = 6; - -pub struct SpawnReady { - pub worker_name: String, - pub socket_path: PathBuf, -} - -pub enum SpawnOutcome { - Ready(SpawnReady), - Cancelled, -} - -#[derive(Debug)] -pub enum SpawnError { - Io(io::Error), - Spawn(client::SpawnError), -} - -impl std::fmt::Display for SpawnError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Io(e) => write!(f, "io error: {e}"), - Self::Spawn(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for SpawnError {} - -impl From for SpawnError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } -} - -impl From for SpawnError { - fn from(e: client::SpawnError) -> Self { - Self::Spawn(e) - } -} - -type InlineTerminal = Terminal>; - -/// Source session for a resume run. `None` = fresh spawn (current -/// behaviour); `Some(id)` swaps the dialog into "Resume Worker" mode and -/// passes `--session ` to the spawned Worker runtime child. -pub async fn run( - resume_from: Option, - worker_name: Option, - profile: Option, - runtime_command: WorkerRuntimeCommand, -) -> Result { - let defaults = load_spawn_defaults()?; - let mut profile_choices = if resume_from.is_some() { - Vec::new() - } else { - defaults.profile_choices - }; - let profile_index = initial_profile_index( - &mut profile_choices, - profile.as_deref(), - defaults.default_profile_index, - ); - - let selected_name = worker_name.unwrap_or(defaults.default_name); - let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty(); - let mut form = Form { - cwd: defaults.cwd.clone(), - scope_origin: defaults.scope_origin, - name_cursor: selected_name.chars().count(), - name: selected_name, - message: None, - editing: true, - resume_from, - profile_choices, - profile_index, - }; - - let mut terminal = make_inline_terminal()?; - - // Phase 1: confirm / cancel. - if !immediate { - loop { - terminal.draw(|f| draw_form(f, &form))?; - match poll_event()? { - None => continue, - Some(Action::Submit) => { - if form.name.trim().is_empty() { - form.message = Some(("name is required".to_string(), MessageKind::Error)); - continue; - } - break; - } - Some(Action::Cancel) => { - form.editing = false; - form.message = Some(("cancelled".to_string(), MessageKind::Info)); - terminal.draw(|f| draw_form(f, &form))?; - drop(terminal); - return Ok(SpawnOutcome::Cancelled); - } - Some(Action::Char(c)) => form.insert_char(c), - Some(Action::Backspace) => form.backspace(), - Some(Action::Delete) => form.delete_forward(), - Some(Action::Left) => form.move_left(), - Some(Action::Right) => form.move_right(), - Some(Action::Home) => form.name_cursor = 0, - Some(Action::End) => form.name_cursor = form.name.chars().count(), - Some(Action::ProfileNext) => form.cycle_profile_next(), - Some(Action::ProfilePrev) => form.cycle_profile_prev(), - } - } - } else if form.name.trim().is_empty() { - return Err(SpawnError::Io(io::Error::new( - io::ErrorKind::InvalidInput, - "name is required", - ))); - } - - // Phase 2: launch worker and wait for ready line. Drop the cursor - // out of the name field — subsequent frames are passive status - // updates, not input — so the cursor doesn't end up parked there - // when the inline terminal is finally dropped. - form.editing = false; - form.message = Some(("starting worker...".to_string(), MessageKind::Progress)); - terminal.draw(|f| draw_form(f, &form))?; - - match wait_for_ready(&mut terminal, &mut form, &runtime_command).await { - Ok(ready) => { - form.message = Some(( - format!("ready: {} attaching...", ready.worker_name), - MessageKind::Ok, - )); - terminal.draw(|f| draw_form(f, &form))?; - drop(terminal); - Ok(SpawnOutcome::Ready(ready)) - } - Err(e) => { - form.message = Some((e.to_string(), MessageKind::Error)); - let _ = terminal.draw(|f| draw_form(f, &form)); - drop(terminal); - Err(e) - } - } -} - -/// Launch a Worker runtime command with `--worker ` without opening the name dialog. The child Worker -/// resolves persisted Worker metadata if present, or creates a fresh same-name Worker -/// from the default profile. -pub async fn run_worker_name( - worker_name: String, - runtime_command: WorkerRuntimeCommand, -) -> Result { - let defaults = load_spawn_defaults()?; - let mut form = form_for_worker_name(worker_name, defaults); - let mut terminal = make_inline_terminal()?; - terminal.draw(|f| draw_form(f, &form))?; - - match wait_for_ready(&mut terminal, &mut form, &runtime_command).await { - Ok(ready) => { - form.message = Some(( - format!("ready: {} attaching...", ready.worker_name), - MessageKind::Ok, - )); - terminal.draw(|f| draw_form(f, &form))?; - drop(terminal); - Ok(SpawnOutcome::Ready(ready)) - } - Err(e) => { - form.message = Some((e.to_string(), MessageKind::Error)); - let _ = terminal.draw(|f| draw_form(f, &form)); - drop(terminal); - Err(e) - } - } -} - -struct SpawnDefaults { - cwd: PathBuf, - scope_origin: ScopeOrigin, - default_name: String, - default_profile_index: usize, - profile_choices: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ProfileChoice { - selector: Option, - label: String, - is_default: bool, -} - -fn load_spawn_defaults() -> Result { - let cwd = std::env::current_dir().map_err(SpawnError::Io)?; - - let default_name = cwd - .file_name() - .and_then(|s| s.to_str()) - .map(sanitise_default_name) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "worker".to_string()); - - let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd); - - Ok(SpawnDefaults { - cwd, - scope_origin: ScopeOrigin::FromProfile, - default_name, - default_profile_index, - profile_choices, - }) -} - -fn profile_choices_for_cwd(cwd: &Path) -> (Vec, usize) { - let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else { - return (Vec::new(), 0); - }; - - let mut choices = Vec::new(); - for entry in registry.entries() { - let mut label = entry.qualified_name(); - if entry.is_default { - label.push_str(" (default)"); - } - if let Some(description) = entry.description.as_deref() { - label.push_str(" — "); - label.push_str(description); - } - choices.push(ProfileChoice { - selector: Some(entry.qualified_name()), - label, - is_default: entry.is_default, - }); - } - - let default_index = choices - .iter() - .position(|choice| choice.is_default) - .unwrap_or(0); - (choices, default_index) -} - -fn initial_profile_index( - choices: &mut Vec, - explicit_profile: Option<&str>, - default_index: usize, -) -> usize { - let Some(selector) = explicit_profile else { - return default_index.min(choices.len().saturating_sub(1)); - }; - if let Some(index) = choices - .iter() - .position(|choice| choice.selector.as_deref() == Some(selector)) - { - return index; - } - choices.push(ProfileChoice { - selector: Some(selector.to_string()), - label: selector.to_string(), - is_default: false, - }); - choices.len() - 1 -} - -fn form_for_worker_name(worker_name: String, defaults: SpawnDefaults) -> Form { - Form { - cwd: defaults.cwd, - scope_origin: defaults.scope_origin, - name_cursor: worker_name.chars().count(), - name: worker_name, - message: Some(("resuming worker...".to_string(), MessageKind::Progress)), - editing: false, - resume_from: None, - profile_choices: Vec::new(), - profile_index: 0, - } -} - -fn make_inline_terminal() -> io::Result { - let backend = CrosstermBackend::new(io::stdout()); - Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(VIEWPORT_LINES), - }, - ) -} - -enum Action { - Submit, - Cancel, - Char(char), - Backspace, - Delete, - Left, - Right, - Home, - End, - ProfileNext, - ProfilePrev, -} - -fn poll_event() -> io::Result> { - if !event::poll(Duration::from_millis(100))? { - return Ok(None); - } - match event::read()? { - TermEvent::Key(k) if k.kind != KeyEventKind::Release => { - let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); - Ok(match k.code { - KeyCode::Enter => Some(Action::Submit), - KeyCode::Esc => Some(Action::Cancel), - KeyCode::Char('c') if ctrl => Some(Action::Cancel), - KeyCode::Char('a') if ctrl => Some(Action::Home), - KeyCode::Char('e') if ctrl => Some(Action::End), - KeyCode::Char('u') if ctrl => Some(Action::Cancel), - KeyCode::Backspace => Some(Action::Backspace), - KeyCode::Delete => Some(Action::Delete), - KeyCode::Left => Some(Action::Left), - KeyCode::Right => Some(Action::Right), - KeyCode::Up | KeyCode::BackTab => Some(Action::ProfilePrev), - KeyCode::Down | KeyCode::Tab => Some(Action::ProfileNext), - KeyCode::Home => Some(Action::Home), - KeyCode::End => Some(Action::End), - KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)), - _ => None, - }) - } - _ => Ok(None), - } -} - -fn is_safe_name_char(c: char) -> bool { - // Filesystem-safe; worker.name becomes a runtime-dir name. - c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') -} - -fn sanitise_default_name(s: &str) -> String { - s.chars() - .map(|c| if is_safe_name_char(c) { c } else { '-' }) - .collect() -} - -async fn wait_for_ready( - terminal: &mut InlineTerminal, - form: &mut Form, - runtime_command: &WorkerRuntimeCommand, -) -> Result { - let config = SpawnConfig { - runtime_command: runtime_command.clone(), - worker_name: form.name.clone(), - profile: form.selected_profile_selector(), - workspace_root: form.cwd.clone(), - cwd: None, - resume_from: form.resume_from, - }; - let ready = spawn_worker(config, |line| { - form.message = Some((line.to_string(), MessageKind::Progress)); - let _ = terminal.draw(|f| draw_form(f, form)); - }) - .await?; - Ok(SpawnReady { - worker_name: ready.worker_name, - socket_path: ready.socket_path, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MessageKind { - Info, - Ok, - Error, - Progress, -} - -enum ScopeOrigin { - FromProfile, -} - -struct Form { - cwd: PathBuf, - /// Display label for the scope row in the dialog. - scope_origin: ScopeOrigin, - name: String, - /// Cursor position counted in **chars**, not bytes — `name` - /// currently only accepts ASCII so the two coincide, but we keep - /// char-based bookkeeping in case we relax `is_safe_name_char`. - name_cursor: usize, - message: Option<(String, MessageKind)>, - /// True while the dialog is accepting name input. Drives whether - /// the rendered frame parks the terminal cursor inside the name - /// field — when false (post-confirm / cancel / failure frames) the - /// cursor stays out so it does not collide with the shell prompt - /// after the inline terminal is dropped. - editing: bool, - /// `Some(id)` flips the dialog into "Resume Worker" mode: the title - /// switches, the source session is shown to the user, and the - /// child worker is launched with `--session ` so it restores - /// from `id` and appends to the same session log. - resume_from: Option, - /// Optional profile choices passed with `--profile` for - /// fresh spawns. This is not used for resume/attach flows because those must - /// restore Worker state rather than re-evaluate a profile source. - profile_choices: Vec, - profile_index: usize, -} - -impl Form { - fn insert_char(&mut self, c: char) { - let byte = self.char_offset_to_byte(self.name_cursor); - self.name.insert(byte, c); - self.name_cursor += 1; - } - - fn backspace(&mut self) { - if self.name_cursor == 0 { - return; - } - let end = self.char_offset_to_byte(self.name_cursor); - let start = self.char_offset_to_byte(self.name_cursor - 1); - self.name.replace_range(start..end, ""); - self.name_cursor -= 1; - } - - fn delete_forward(&mut self) { - let total = self.name.chars().count(); - if self.name_cursor >= total { - return; - } - let start = self.char_offset_to_byte(self.name_cursor); - let end = self.char_offset_to_byte(self.name_cursor + 1); - self.name.replace_range(start..end, ""); - } - - fn move_left(&mut self) { - if self.name_cursor > 0 { - self.name_cursor -= 1; - } - } - - fn move_right(&mut self) { - let total = self.name.chars().count(); - if self.name_cursor < total { - self.name_cursor += 1; - } - } - - fn selected_profile(&self) -> Option<&ProfileChoice> { - self.profile_choices - .get(self.profile_index) - .filter(|choice| choice.selector.is_some()) - } - - fn selected_profile_selector(&self) -> Option { - self.selected_profile() - .and_then(|choice| choice.selector.clone()) - } - - fn cycle_profile_next(&mut self) { - if self.profile_choices.is_empty() { - return; - } - self.profile_index = (self.profile_index + 1) % self.profile_choices.len(); - self.message = None; - } - - fn cycle_profile_prev(&mut self) { - if self.profile_choices.is_empty() { - return; - } - self.profile_index = if self.profile_index == 0 { - self.profile_choices.len() - 1 - } else { - self.profile_index - 1 - }; - self.message = None; - } - - fn char_offset_to_byte(&self, char_off: usize) -> usize { - self.name - .char_indices() - .nth(char_off) - .map(|(b, _)| b) - .unwrap_or(self.name.len()) - } -} - -fn draw_form(f: &mut Frame<'_>, form: &Form) { - let area = f.area(); - let layout = Layout::vertical([ - Constraint::Length(1), // title - Constraint::Length(1), // name field - Constraint::Length(1), // context (profile or scope default) - Constraint::Length(1), // hint - Constraint::Length(1), // message - Constraint::Length(1), // spacer - ]) - .split(area); - - let title_text = match form.resume_from { - Some(id) => format!("resume worker session: {}", short_segment(id)), - None => "spawn worker".to_string(), - }; - let title = Paragraph::new(Line::from(vec![Span::styled( - title_text, - Style::default().add_modifier(Modifier::BOLD), - )])); - f.render_widget(title, layout[0]); - - f.render_widget(Paragraph::new(name_line(form)), layout[1]); - f.render_widget(Paragraph::new(context_line(form)), layout[2]); - f.render_widget(Paragraph::new(hint_line()), layout[3]); - f.render_widget(Paragraph::new(message_line(form)), layout[4]); - - if form.editing { - // Place the cursor inside the name field while the user is - // editing. Skipped on post-confirm frames so the inline - // viewport's drop leaves the cursor at the bottom of the - // rendered area rather than parked on the name line, which - // would let the shell prompt (or any later eprintln) clobber - // the rendered name field after exit. - let cursor_col = 2 + "name: ".len() + form.name_cursor; - f.set_cursor_position((layout[1].x + cursor_col as u16, layout[1].y)); - } -} - -/// First 8 hex digits of a UUID — short enough to skim, long enough -/// to disambiguate inside a 10-row picker. -pub(crate) fn short_segment(id: SegmentId) -> String { - let s = id.to_string(); - s.chars().take(8).collect() -} - -fn name_line(form: &Form) -> Line<'_> { - Line::from(vec![ - Span::raw(" "), - Span::styled("name: ", Style::default().fg(Color::DarkGray)), - Span::styled( - form.name.as_str(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - ]) -} - -fn context_line(form: &Form) -> Line<'_> { - if let Some(profile) = form.profile_choices.get(form.profile_index) { - return Line::from(vec![ - Span::raw(" "), - Span::styled("profile: ", Style::default().fg(Color::DarkGray)), - Span::styled(profile.label.as_str(), Style::default().fg(Color::Green)), - Span::styled( - " (tab/down to change)", - Style::default().fg(Color::DarkGray), - ), - ]); - } - - match form.scope_origin { - ScopeOrigin::FromProfile => Line::from(vec![ - Span::raw(" "), - Span::styled("scope: ", Style::default().fg(Color::DarkGray)), - Span::styled("from selected profile", Style::default().fg(Color::Green)), - ]), - } -} - -fn hint_line() -> Line<'static> { - Line::from(vec![Span::styled( - " enter spawn · tab/down next profile · shift-tab/up prev · esc cancel", - Style::default().fg(Color::DarkGray), - )]) -} - -fn message_line(form: &Form) -> Line<'_> { - let Some((text, kind)) = form.message.as_ref() else { - return Line::from(""); - }; - let style = match kind { - MessageKind::Info => Style::default().fg(Color::DarkGray), - MessageKind::Ok => Style::default().fg(Color::Green), - MessageKind::Error => Style::default().fg(Color::Red), - MessageKind::Progress => Style::default().fg(Color::Yellow), - }; - Line::from(vec![Span::raw(" "), Span::styled(text.as_str(), style)]) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn form(name: &str) -> Form { - Form { - cwd: PathBuf::from("/work/example"), - scope_origin: ScopeOrigin::FromProfile, - name: name.to_string(), - name_cursor: name.chars().count(), - message: None, - editing: true, - resume_from: None, - profile_choices: Vec::new(), - profile_index: 0, - } - } - - #[test] - fn worker_name_form_restores_or_creates_by_worker_name() { - let defaults = SpawnDefaults { - cwd: PathBuf::from("/work/example"), - scope_origin: ScopeOrigin::FromProfile, - default_name: "ignored".to_string(), - default_profile_index: 0, - profile_choices: Vec::new(), - }; - let f = form_for_worker_name("agent".to_string(), defaults); - - assert_eq!(f.name, "agent"); - assert_eq!(f.name_cursor, "agent".chars().count()); - assert_eq!(f.resume_from, None); - assert!(!f.editing); - assert_eq!( - f.message, - Some(("resuming worker...".to_string(), MessageKind::Progress)) - ); - } - - #[test] - fn profile_choices_ignore_repository_local_profile_registry() { - let temp = tempfile::tempdir().unwrap(); - let project = temp.path().join("project"); - let yoi = project.join(".yoi"); - std::fs::create_dir_all(&yoi).unwrap(); - std::fs::write( - yoi.join("profiles.toml"), - "default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n", - ) - .unwrap(); - - let (choices, default_index) = profile_choices_for_cwd(&project); - assert_eq!(default_index, 0); - assert!( - choices - .iter() - .all(|choice| { choice.selector.as_deref() != Some("project:coder") }) - ); - assert!( - choices - .iter() - .any(|choice| { choice.selector.as_deref() == Some("builtin:companion") }) - ); - } - - #[test] - fn profile_cycle_selects_only_discovered_profiles() { - let mut form = form("coder"); - form.profile_choices = vec![ - ProfileChoice { - selector: Some("project:coder".to_string()), - label: "project:coder (default)".to_string(), - is_default: true, - }, - ProfileChoice { - selector: Some("user:reviewer".to_string()), - label: "user:reviewer".to_string(), - is_default: false, - }, - ]; - form.profile_index = 0; - - assert_eq!( - form.selected_profile_selector().as_deref(), - Some("project:coder") - ); - form.cycle_profile_next(); - assert_eq!( - form.selected_profile_selector().as_deref(), - Some("user:reviewer") - ); - form.cycle_profile_next(); - assert_eq!( - form.selected_profile_selector().as_deref(), - Some("project:coder") - ); - form.cycle_profile_prev(); - assert_eq!( - form.selected_profile_selector().as_deref(), - Some("user:reviewer") - ); - } - - #[test] - fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() { - let mut choices = Vec::new(); - let selected = initial_profile_index(&mut choices, Some("coder"), 0); - assert_eq!(selected, 0); - assert_eq!(choices[0].selector.as_deref(), Some("coder")); - assert_eq!(choices[0].label, "coder"); - } - - #[test] - fn name_input_handles_insert_backspace_and_cursor() { - let mut f = form(""); - for c in "abc".chars() { - f.insert_char(c); - } - assert_eq!(f.name, "abc"); - assert_eq!(f.name_cursor, 3); - - f.move_left(); - f.move_left(); - f.insert_char('X'); - assert_eq!(f.name, "aXbc"); - - f.backspace(); - assert_eq!(f.name, "abc"); - assert_eq!(f.name_cursor, 1); - - f.delete_forward(); - assert_eq!(f.name, "ac"); - } - - #[test] - fn sanitise_default_name_replaces_unsafe_chars() { - assert_eq!(sanitise_default_name("my project!"), "my-project-"); - assert_eq!(sanitise_default_name("ok-name_2.0"), "ok-name_2.0"); - } -} diff --git a/crates/tui/src/worker_list.rs b/crates/tui/src/worker_list.rs deleted file mode 100644 index 49236489..00000000 --- a/crates/tui/src/worker_list.rs +++ /dev/null @@ -1,1310 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fs::File; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use client::WorkerClient; -use manifest::paths; -use protocol::{Event, WorkerStatus}; -use serde::Deserialize; -use session_store::{FsStore, SegmentId, SessionId}; -use session_store::{WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore}; - -#[derive(Debug, Clone)] -pub(crate) struct WorkerList { - pub entries: Vec, - pub selected_name: Option, -} - -impl WorkerList { - pub(crate) fn from_sources( - source: WorkerVisibilitySource, - stored: Vec, - live: Vec, - selected_name: Option, - max_entries: usize, - ) -> Self { - let mut entries_by_name: BTreeMap = BTreeMap::new(); - - for stored_info in stored { - let name = stored_info.worker_name.clone(); - entries_by_name - .entry(name.clone()) - .or_insert_with(|| WorkerListEntry::new(name, source)) - .merge_stored(stored_info); - } - - for live_info in live { - let name = live_info.worker_name.clone(); - entries_by_name - .entry(name.clone()) - .or_insert_with(|| WorkerListEntry::new(name, source)) - .merge_live(live_info); - } - - let mut entries: Vec = entries_by_name.into_values().collect(); - for entry in &mut entries { - entry.finalize(); - } - entries.sort_by(|a, b| { - b.has_reachable_live() - .cmp(&a.has_reachable_live()) - .then_with(|| b.summary.updated_at.cmp(&a.summary.updated_at)) - .then_with(|| a.name.cmp(&b.name)) - }); - entries.truncate(max_entries); - - let selected_name = selected_name - .filter(|name| entries.iter().any(|entry| entry.name == *name)) - .or_else(|| entries.first().map(|entry| entry.name.clone())); - - Self { - entries, - selected_name, - } - } - - pub(crate) fn from_workspace_sources( - source: WorkerVisibilitySource, - stored: Vec, - live: Vec, - selected_name: Option, - max_entries: usize, - workspace_root: &Path, - ) -> Self { - let current_workspace = workspace_root_key(workspace_root); - let mut current_names = BTreeSet::new(); - let stored: Vec<_> = stored - .into_iter() - .filter(|info| { - let matches = info - .workspace_root - .as_deref() - .is_some_and(|root| workspace_root_key(root) == current_workspace); - if matches { - current_names.insert(info.worker_name.clone()); - } - matches - }) - .collect(); - let live = live - .into_iter() - .filter(|info| current_names.contains(&info.worker_name)) - .collect(); - Self::from_sources(source, stored, live, selected_name, max_entries) - } - - pub(crate) fn filter_for_workspace(&self, workspace_root: &Path) -> Self { - let current_workspace = workspace_root_key(workspace_root); - let entries: Vec<_> = self - .entries - .iter() - .filter(|entry| entry_belongs_to_workspace(entry, ¤t_workspace)) - .cloned() - .collect(); - let selected_name = self - .selected_name - .as_ref() - .filter(|name| entries.iter().any(|entry| entry.name == **name)) - .cloned() - .or_else(|| entries.first().map(|entry| entry.name.clone())); - Self { - entries, - selected_name, - } - } - - pub(crate) fn selected_index(&self) -> usize { - self.selected_name - .as_ref() - .and_then(|name| self.entries.iter().position(|entry| entry.name == *name)) - .unwrap_or(0) - } - - pub(crate) fn select_index(&mut self, index: usize) { - self.selected_name = self.entries.get(index).map(|entry| entry.name.clone()); - } - - pub(crate) fn retain_live_entries(&mut self) { - self.entries.retain(|entry| entry.live.is_some()); - if !self - .selected_name - .as_ref() - .is_some_and(|selected| self.entries.iter().any(|entry| entry.name == *selected)) - { - self.selected_name = self.entries.first().map(|entry| entry.name.clone()); - } - } - - pub(crate) fn selected_entry(&self) -> Option<&WorkerListEntry> { - let index = self.selected_index(); - self.entries.get(index) - } -} - -fn workspace_root_key(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - -fn entry_belongs_to_workspace(entry: &WorkerListEntry, current_workspace: &Path) -> bool { - entry - .stored - .as_ref() - .and_then(|stored| stored.workspace_root.as_deref()) - .is_some_and(|root| workspace_root_key(root) == current_workspace) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkerVisibilitySource { - ResumePicker, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkerListSourceKind { - RuntimeRegistry, - StoredMetadata, -} - -#[derive(Debug, Clone)] -pub(crate) struct WorkerListEntry { - pub name: String, - pub visibility: WorkerVisibilitySource, - pub source_kinds: Vec, - pub live: Option, - pub stored: Option, - pub summary: WorkerEntrySummary, - pub actions: WorkerEntryActions, - pub diagnostics: Vec, -} - -impl WorkerListEntry { - fn new(name: String, visibility: WorkerVisibilitySource) -> Self { - Self { - name, - visibility, - source_kinds: Vec::new(), - live: None, - stored: None, - summary: WorkerEntrySummary::default(), - actions: WorkerEntryActions::default(), - diagnostics: Vec::new(), - } - } - - fn merge_live(&mut self, live: LiveWorkerInfo) { - if !self - .source_kinds - .contains(&WorkerListSourceKind::RuntimeRegistry) - { - self.source_kinds - .push(WorkerListSourceKind::RuntimeRegistry); - } - if live.summary.updated_at > self.summary.updated_at { - self.summary.updated_at = live.summary.updated_at; - } - if self.summary.active_session_id.is_none() { - self.summary.active_session_id = live.summary.active_session_id; - } - if self.summary.active_segment_id.is_none() { - self.summary.active_segment_id = live.summary.active_segment_id.or(live.segment_id); - } - if self.summary.preview.is_none() { - self.summary.preview = live.summary.preview.clone(); - } - self.live = Some(live); - } - - fn merge_stored(&mut self, stored: StoredWorkerInfo) { - if !self - .source_kinds - .contains(&WorkerListSourceKind::StoredMetadata) - { - self.source_kinds.push(WorkerListSourceKind::StoredMetadata); - } - if stored.updated_at > self.summary.updated_at { - self.summary.updated_at = stored.updated_at; - } - if self.summary.active_session_id.is_none() { - self.summary.active_session_id = stored.active_session_id; - } - if self.summary.active_segment_id.is_none() { - self.summary.active_segment_id = stored.active_segment_id; - } - if self.summary.preview.is_none() { - self.summary.preview = stored.preview.clone(); - } - self.stored = Some(stored); - } - - fn finalize(&mut self) { - self.fill_live_pending_preview(); - self.diagnostics = build_diagnostics(self); - self.actions = build_actions(self); - } - - fn has_reachable_live(&self) -> bool { - self.live.as_ref().is_some_and(|live| live.reachable) - } - - fn fill_live_pending_preview(&mut self) { - if !self.has_reachable_live() || self.summary.updated_at != 0 { - return; - } - let preview_is_pending = self.summary.preview.as_deref() == Some("[pending segment]"); - let preview_is_incomplete = self.summary.preview.is_none() || preview_is_pending; - if preview_is_incomplete && (self.summary.active_segment_id.is_some() || preview_is_pending) - { - self.summary.preview = Some("[live, pending segment]".to_string()); - } - } - - pub(crate) fn attach_socket_path(&self) -> Option<&Path> { - self.live - .as_ref() - .filter(|live| live.reachable) - .map(|live| live.socket_path.as_path()) - } -} - -#[derive(Debug, Clone)] -pub(crate) struct LiveWorkerInfo { - pub worker_name: String, - pub socket_path: PathBuf, - pub status: Option, - pub reachable: bool, - pub segment_id: Option, - pub summary: WorkerEntrySummary, -} - -#[derive(Debug, Clone)] -pub(crate) struct StoredWorkerInfo { - pub worker_name: String, - pub metadata_state: StoredMetadataState, - pub active_session_id: Option, - pub active_segment_id: Option, - pub updated_at: u64, - pub workspace_root: Option, - pub preview: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum StoredMetadataState { - Present, - Corrupt(String), -} - -#[derive(Debug, Clone, Default)] -pub(crate) struct WorkerEntrySummary { - pub active_session_id: Option, - pub active_segment_id: Option, - pub updated_at: u64, - pub preview: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct WorkerEntryActions { - pub can_open: bool, - pub can_restore: bool, - pub can_send_now: bool, - pub can_queue_send: bool, - pub disabled_reason: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WorkerEntryDiagnostic { - pub kind: WorkerEntryDiagnosticKind, - pub message: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkerEntryDiagnosticKind { - StoredMetadataCorrupt, - LiveUnreachable, - MissingStoredMetadata, - MissingLiveStatus, -} - -pub(crate) fn read_stored_worker_infos( - store: &FsStore, - worker_metadata_store: &impl WorkerMetadataStore, -) -> Result, io::Error> { - let mut records = Vec::new(); - for worker_name in worker_metadata_store - .list_names() - .map_err(io::Error::other)? - { - let info = match worker_metadata_store.read_by_name(&worker_name) { - Ok(Some(metadata)) => stored_info_from_metadata(store, worker_name, metadata), - Ok(None) => corrupt_stored_info( - worker_name, - "metadata disappeared during discovery".to_string(), - ), - Err(e) => corrupt_stored_info(worker_name, e.to_string()), - }; - records.push(info); - } - Ok(records) -} - -pub(crate) fn read_live_worker_infos() -> Result, io::Error> { - let path = paths::worker_allocation_path().ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "could not resolve worker allocation path", - ) - })?; - let table = match read_worker_allocation_table(&path) { - Ok(table) => table, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err), - }; - Ok(table - .allocations - .into_iter() - .map(|allocation| LiveWorkerInfo { - worker_name: allocation.worker_name, - socket_path: allocation.socket, - status: None, - reachable: false, - segment_id: allocation.segment_id, - summary: WorkerEntrySummary::default(), - }) - .collect()) -} - -fn read_worker_allocation_table(path: &Path) -> Result { - let mut file = File::open(path)?; - fs4::fs_std::FileExt::lock_shared(&file)?; - let mut contents = String::new(); - let read_result = file.read_to_string(&mut contents); - let unlock_result = fs4::fs_std::FileExt::unlock(&file); - read_result?; - unlock_result?; - - if contents.trim().is_empty() { - return Ok(WorkerAllocationTable::default()); - } - serde_json::from_str(&contents).map_err(io::Error::other) -} - -#[derive(Debug, Default, Deserialize)] -struct WorkerAllocationTable { - #[serde(default)] - allocations: Vec, -} - -#[derive(Debug, Deserialize)] -struct WorkerAllocationRecord { - worker_name: String, - socket: PathBuf, - #[serde(default)] - segment_id: Option, -} - -pub(crate) async fn read_reachable_live_worker_infos( - store: &FsStore, -) -> Result, io::Error> { - let records = read_live_worker_infos()?; - probe_reachable_live_worker_infos(store, records).await -} - -async fn probe_reachable_live_worker_infos( - _store: &FsStore, - records: Vec, -) -> Result, io::Error> { - let mut handles = Vec::with_capacity(records.len()); - for record in records { - handles.push(tokio::spawn(probe_live_worker_info(record))); - } - - let mut reachable = Vec::with_capacity(handles.len()); - for handle in handles { - let result = handle - .await - .map_err(|e| io::Error::other(format!("live status probe task failed: {e}")))?; - let Ok(record) = result else { - continue; - }; - reachable.push(record); - } - Ok(reachable) -} - -async fn probe_live_worker_info(mut record: LiveWorkerInfo) -> Result { - let status = probe_live_status(&record.socket_path).await?; - record.reachable = true; - record.status = status; - Ok(record) -} - -pub(crate) fn live_socket_for_worker(worker_name: &str) -> Option { - read_live_worker_infos() - .ok()? - .into_iter() - .find(|worker| worker.worker_name == worker_name) - .map(|worker| worker.socket_path) -} - -fn stored_info_from_metadata( - store: &FsStore, - worker_name: String, - metadata: WorkerMetadata, -) -> StoredWorkerInfo { - let active = metadata.active; - let active_session_id = active.as_ref().map(|a| a.session_id); - let active_segment_id = active.as_ref().and_then(|a| a.segment_id); - let summary = summarize_metadata(store, active.as_ref()); - - StoredWorkerInfo { - worker_name, - metadata_state: StoredMetadataState::Present, - active_session_id, - active_segment_id, - updated_at: summary.updated_at, - workspace_root: metadata.workspace_root, - preview: summary.preview, - } -} - -fn corrupt_stored_info(worker_name: String, message: String) -> StoredWorkerInfo { - StoredWorkerInfo { - worker_name, - metadata_state: StoredMetadataState::Corrupt(message.clone()), - active_session_id: None, - active_segment_id: None, - updated_at: 0, - workspace_root: None, - preview: Some(format!("metadata: {}", trim_one_line(&message, 48))), - } -} - -const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(200); - -async fn probe_live_status(socket_path: &Path) -> Result, io::Error> { - let mut client = WorkerClient::connect(socket_path).await?; - let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT; - - loop { - if tokio::time::Instant::now() >= deadline { - return Ok(None); - } - match tokio::time::timeout_at(deadline, client.next_event()).await { - Ok(Some(event)) => { - if let Some(status) = status_from_event(&event) { - return Ok(Some(status)); - } - } - Ok(None) | Err(_) => return Ok(None), - } - } -} - -fn status_from_event(event: &Event) -> Option { - match event { - Event::Snapshot { status, .. } | Event::Status { status } => Some(*status), - _ => None, - } -} - -#[derive(Debug, Clone)] -struct SegmentSummary { - updated_at: u64, - preview: Option, -} - -fn summarize_metadata(_store: &FsStore, active: Option<&WorkerActiveSegmentRef>) -> SegmentSummary { - let Some(active) = active else { - return SegmentSummary { - updated_at: 0, - preview: None, - }; - }; - match active.segment_id { - Some(segment_id) => SegmentSummary { - updated_at: 0, - preview: Some(format!("active segment {segment_id}")), - }, - None => SegmentSummary { - updated_at: 0, - preview: Some("[pending segment]".to_string()), - }, - } -} - -fn build_diagnostics(entry: &WorkerListEntry) -> Vec { - let mut diagnostics = Vec::new(); - - if let Some(stored) = entry.stored.as_ref() { - if let StoredMetadataState::Corrupt(message) = &stored.metadata_state { - diagnostics.push(WorkerEntryDiagnostic { - kind: WorkerEntryDiagnosticKind::StoredMetadataCorrupt, - message: format!("metadata: {}", trim_one_line(message, 80)), - }); - } - } else if entry.live.is_some() { - diagnostics.push(WorkerEntryDiagnostic { - kind: WorkerEntryDiagnosticKind::MissingStoredMetadata, - message: "no stored worker metadata".to_string(), - }); - } - - if let Some(live) = entry.live.as_ref() { - if !live.reachable { - diagnostics.push(WorkerEntryDiagnostic { - kind: WorkerEntryDiagnosticKind::LiveUnreachable, - message: format!("socket unreachable: {}", live.socket_path.display()), - }); - } else if live.status.is_none() { - diagnostics.push(WorkerEntryDiagnostic { - kind: WorkerEntryDiagnosticKind::MissingLiveStatus, - message: "live worker status was not reported".to_string(), - }); - } - } - - diagnostics -} - -fn build_actions(entry: &WorkerListEntry) -> WorkerEntryActions { - let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable); - let stored_restorable = entry - .stored - .as_ref() - .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Present)); - let live_status = entry.live.as_ref().and_then(|live| live.status); - - let can_restore = stored_restorable && !live_reachable; - let can_open = live_reachable || stored_restorable; - let can_send_now = live_reachable && live_status == Some(WorkerStatus::Idle); - let can_queue_send = live_reachable && live_status == Some(WorkerStatus::Running); - let disabled_reason = if can_open { - None - } else if entry.live.is_some() { - Some("live worker is unreachable".to_string()) - } else if entry.stored.is_some() { - Some("stored worker metadata is corrupt".to_string()) - } else { - Some("no live or stored worker state".to_string()) - }; - - WorkerEntryActions { - can_open, - can_restore, - can_send_now, - can_queue_send, - disabled_reason, - } -} - -fn trim_one_line(s: &str, max_chars: usize) -> String { - let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect(); - if collapsed.chars().count() <= max_chars { - collapsed - } else { - let truncated: String = collapsed.chars().take(max_chars - 1).collect(); - format!("{truncated}…") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use agen::llm_client::types::RequestConfig; - use protocol::stream::JsonLineWriter; - use session_store::FsWorkerStore; - use session_store::{LogEntry, Store, new_segment_id, new_session_id}; - use session_store::{WorkerActiveSegmentRef, WorkerMetadataStore}; - use tempfile::tempdir; - use tokio::net::UnixListener; - use tokio::sync::Barrier; - - const SOURCE: WorkerVisibilitySource = WorkerVisibilitySource::ResumePicker; - - #[test] - fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() { - let dir = tempdir().unwrap(); - let store = FsStore::new(dir.path()).unwrap(); - let session = new_session_id(); - let segment = new_segment_id(); - - append_start(&store, session, segment, 10); - append_user( - &store, - session, - segment, - 100, - "session log text should not be scanned", - ); - - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![metadata_info(&store, "stored", session, segment)], - vec![], - None, - 10, - )); - - assert_eq!(entry.name, "stored"); - assert_eq!(entry.summary.updated_at, 0); - assert_eq!( - entry.summary.preview.as_deref(), - Some(format!("active segment {segment}").as_str()) - ); - } - - #[test] - fn reachable_live_rows_sort_before_stopped_rows_before_truncation() { - let stopped = (0..10) - .map(|index| stopped_info_with_updated_at(&format!("stopped-{index}"), 1_000 - index)) - .collect::>(); - let live = live_info_with_updated_at("live-pending", WorkerStatus::Idle, 0); - - let entries = WorkerList::from_sources(SOURCE, stopped, vec![live], None, 10).entries; - - assert_eq!(entries.len(), 10); - assert_eq!(entries[0].name, "live-pending"); - assert!(entries.iter().all(|entry| entry.name != "stopped-9")); - } - - #[test] - fn reachable_live_sort_does_not_promote_unreachable_registry_allocations() { - let mut unreachable = live_info_with_updated_at("unreachable", WorkerStatus::Idle, 0); - unreachable.reachable = false; - unreachable.status = None; - - let entries = WorkerList::from_sources( - SOURCE, - vec![stopped_info_with_updated_at("stopped", 100)], - vec![unreachable], - None, - 10, - ) - .entries; - - assert_eq!(entries[0].name, "stopped"); - assert_eq!(entries[1].name, "unreachable"); - } - - #[test] - fn live_pending_with_runtime_segment_is_attach_only_and_gets_pending_preview() { - let session_id = new_session_id(); - let runtime_segment_id = new_segment_id(); - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![pending_metadata_info("pending", session_id)], - vec![live_info_with_segment( - "pending", - WorkerStatus::Idle, - runtime_segment_id, - )], - None, - 10, - )); - - assert_eq!(entry.name, "pending"); - assert_eq!(entry.summary.active_session_id, Some(session_id)); - assert_eq!(entry.summary.active_segment_id, Some(runtime_segment_id)); - assert_eq!( - entry.summary.preview.as_deref(), - Some("[live, pending segment]") - ); - assert!(entry.actions.can_open); - assert!(!entry.actions.can_restore); - assert_eq!( - entry.attach_socket_path(), - Some(Path::new("/tmp/pending.sock")) - ); - } - - #[test] - fn live_only_runtime_segment_is_attach_only_and_not_restorable() { - let runtime_segment_id = new_segment_id(); - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![], - vec![live_info_with_segment( - "runtime-only", - WorkerStatus::Idle, - runtime_segment_id, - )], - None, - 10, - )); - - assert_eq!(entry.summary.active_segment_id, Some(runtime_segment_id)); - assert_eq!( - entry.summary.preview.as_deref(), - Some("[live, pending segment]") - ); - assert!(entry.actions.can_open); - assert!(!entry.actions.can_restore); - assert_eq!( - entry.attach_socket_path(), - Some(Path::new("/tmp/runtime-only.sock")) - ); - } - - #[test] - fn retain_live_entries_removes_stored_only_rows_and_reselects() { - let dir = tempdir().unwrap(); - let store = FsStore::new(dir.path()).unwrap(); - let session_id = new_session_id(); - let segment_id = new_segment_id(); - append_start(&store, session_id, segment_id, 10); - let mut list = WorkerList::from_sources( - SOURCE, - vec![metadata_info(&store, "stored", session_id, segment_id)], - vec![live_info("live", WorkerStatus::Idle)], - Some("stored".to_string()), - 10, - ); - - list.retain_live_entries(); - - assert_eq!(list.entries.len(), 1); - assert_eq!(list.entries[0].name, "live"); - assert_eq!(list.selected_entry().unwrap().name, "live"); - } - - #[test] - fn stored_only_row_can_restore_and_open_but_not_direct_send() { - let dir = tempdir().unwrap(); - let store = FsStore::new(dir.path()).unwrap(); - let session_id = new_session_id(); - let segment_id = new_segment_id(); - append_start(&store, session_id, segment_id, 10); - - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![metadata_info(&store, "stored", session_id, segment_id)], - vec![], - None, - 10, - )); - - assert_eq!(entry.name, "stored"); - assert_eq!(entry.visibility, SOURCE); - assert_eq!( - entry.source_kinds, - vec![WorkerListSourceKind::StoredMetadata] - ); - assert!(entry.live.is_none()); - assert!(entry.stored.is_some()); - assert!(entry.actions.can_open); - assert!(entry.actions.can_restore); - assert!(!entry.actions.can_send_now); - assert!(!entry.actions.can_queue_send); - } - - #[test] - fn live_idle_reachable_row_can_open_and_send_now() { - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![], - vec![live_info("live", WorkerStatus::Idle)], - None, - 10, - )); - - assert_eq!(entry.name, "live"); - assert_eq!(entry.visibility, SOURCE); - assert_eq!( - entry.source_kinds, - vec![WorkerListSourceKind::RuntimeRegistry] - ); - assert!(entry.actions.can_open); - assert!(!entry.actions.can_restore); - assert!(entry.actions.can_send_now); - assert!(!entry.actions.can_queue_send); - assert_eq!( - entry.attach_socket_path(), - Some(Path::new("/tmp/live.sock")) - ); - } - - #[test] - fn live_reachable_row_without_reported_status_can_open_but_not_send_now() { - let mut live = live_info("live", WorkerStatus::Idle); - live.status = None; - live.reachable = true; - - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![], - vec![live], - None, - 10, - )); - - assert!(entry.actions.can_open); - assert!(!entry.actions.can_restore); - assert!(!entry.actions.can_send_now); - assert!(!entry.actions.can_queue_send); - assert_eq!( - entry.attach_socket_path(), - Some(Path::new("/tmp/live.sock")) - ); - assert!( - !entry - .diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == WorkerEntryDiagnosticKind::LiveUnreachable) - ); - } - - #[test] - fn live_running_reachable_row_can_open_but_not_send_now() { - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![], - vec![live_info("live", WorkerStatus::Running)], - None, - 10, - )); - - assert!(entry.actions.can_open); - assert!(!entry.actions.can_restore); - assert!(!entry.actions.can_send_now); - assert!(entry.actions.can_queue_send); - } - - #[test] - fn live_unreachable_row_has_diagnostic_and_cannot_open() { - let mut live = live_info("live", WorkerStatus::Idle); - live.reachable = false; - live.status = None; - - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![], - vec![live], - None, - 10, - )); - - assert!(!entry.actions.can_open); - assert!(!entry.actions.can_restore); - assert!(!entry.actions.can_send_now); - assert!(!entry.actions.can_queue_send); - assert_eq!( - entry.actions.disabled_reason.as_deref(), - Some("live worker is unreachable") - ); - assert_eq!(entry.attach_socket_path(), None); - assert!(entry.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == WorkerEntryDiagnosticKind::LiveUnreachable - && diagnostic.message.contains("/tmp/live.sock") - })); - } - - #[test] - fn status_extraction_skips_alert_before_snapshot() { - let events = [ - Event::Alert(protocol::Alert { - level: protocol::AlertLevel::Warn, - source: protocol::AlertSource::Worker, - message: "warming up".to_string(), - timestamp_ms: 0, - }), - Event::Snapshot { - entries: vec![], - greeting: test_greeting(), - status: WorkerStatus::Idle, - in_flight: Default::default(), - internal_workers: Vec::new(), - }, - ]; - - let status = events.iter().find_map(status_from_event); - assert_eq!(status, Some(WorkerStatus::Idle)); - } - - #[tokio::test] - async fn live_status_probes_run_concurrently() { - let store_dir = tempdir().unwrap(); - let store = FsStore::new(store_dir.path()).unwrap(); - let socket_dir = tempdir().unwrap(); - let probe_count = 3; - let barrier = Arc::new(Barrier::new(probe_count)); - let mut records = Vec::new(); - let mut servers = Vec::new(); - - for index in 0..probe_count { - let worker_name = format!("worker-{index}"); - let socket_path = socket_dir.path().join(format!("{worker_name}.sock")); - let listener = UnixListener::bind(&socket_path).unwrap(); - let barrier = Arc::clone(&barrier); - servers.push(tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - barrier.wait().await; - let mut writer = JsonLineWriter::new(stream); - writer - .write(&Event::Status { - status: WorkerStatus::Idle, - }) - .await - .unwrap(); - })); - records.push(live_probe_record(&worker_name, socket_path)); - } - - let records = tokio::time::timeout( - LIVE_STATUS_PROBE_TIMEOUT * 3, - probe_reachable_live_worker_infos(&store, records), - ) - .await - .expect("status probes should complete") - .unwrap(); - - assert_eq!(records.len(), probe_count); - assert!(records.iter().all(|record| record.reachable)); - assert!( - records - .iter() - .all(|record| record.status == Some(WorkerStatus::Idle)) - ); - for server in servers { - server.await.unwrap(); - } - } - - #[tokio::test] - async fn live_status_probe_timeout_still_marks_socket_reachable() { - let store_dir = tempdir().unwrap(); - let store = FsStore::new(store_dir.path()).unwrap(); - let socket_dir = tempdir().unwrap(); - let socket_path = socket_dir.path().join("silent.sock"); - let listener = UnixListener::bind(&socket_path).unwrap(); - let server = tokio::spawn(async move { - let (_stream, _) = listener.accept().await.unwrap(); - std::future::pending::<()>().await; - }); - - let records = probe_reachable_live_worker_infos( - &store, - vec![live_probe_record("silent", socket_path.clone())], - ) - .await - .unwrap(); - - assert_eq!(records.len(), 1); - assert_eq!(records[0].worker_name, "silent"); - assert!(records[0].reachable); - assert_eq!(records[0].status, None); - assert_eq!(records[0].socket_path, socket_path); - server.abort(); - } - - #[test] - fn corrupt_stored_metadata_has_diagnostic() { - let entry = single_entry(WorkerList::from_sources( - SOURCE, - vec![corrupt_stored_info( - "broken".to_string(), - "expected value".to_string(), - )], - vec![], - None, - 10, - )); - - assert_eq!(entry.name, "broken"); - assert!(!entry.actions.can_open); - assert!(entry.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == WorkerEntryDiagnosticKind::StoredMetadataCorrupt - && diagnostic.message.contains("expected value") - })); - assert!( - entry - .summary - .preview - .as_deref() - .unwrap() - .contains("expected value") - ); - } - - #[test] - fn selected_worker_name_is_kept_after_rebuild() { - let first = WorkerList::from_sources( - SOURCE, - vec![], - vec![ - live_info("alpha", WorkerStatus::Idle), - live_info("beta", WorkerStatus::Idle), - ], - Some("alpha".to_string()), - 10, - ); - assert_eq!(first.selected_entry().unwrap().name, "alpha"); - - let rebuilt = WorkerList::from_sources( - SOURCE, - vec![], - vec![ - live_info_with_updated_at("beta", WorkerStatus::Idle, 20), - live_info_with_updated_at("alpha", WorkerStatus::Idle, 10), - ], - first.selected_name.clone(), - 10, - ); - - assert_eq!(rebuilt.entries[0].name, "beta"); - assert_eq!(rebuilt.selected_entry().unwrap().name, "alpha"); - assert_eq!(rebuilt.selected_index(), 1); - } - - #[test] - fn read_stored_worker_infos_reports_corrupt_metadata() { - let dir = tempdir().unwrap(); - let store = FsStore::new(dir.path()).unwrap(); - let worker_metadata_store = FsWorkerStore::new(dir.path().join("workers")).unwrap(); - let worker_metadata_dir = dir.path().join("workers").join("broken"); - std::fs::create_dir_all(&worker_metadata_dir).unwrap(); - std::fs::write(worker_metadata_dir.join("metadata.json"), "{not-json").unwrap(); - - let records = read_stored_worker_infos(&store, &worker_metadata_store).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].worker_name, "broken"); - assert!(matches!( - records[0].metadata_state, - StoredMetadataState::Corrupt(_) - )); - } - - #[test] - fn read_stored_worker_infos_reads_metadata() { - let dir = tempdir().unwrap(); - let store = FsStore::new(dir.path()).unwrap(); - let worker_metadata_store = FsWorkerStore::new(dir.path().join("workers")).unwrap(); - let session_id = new_session_id(); - let segment_id = new_segment_id(); - worker_metadata_store - .write(&WorkerMetadata::new( - "agent", - Some(WorkerActiveSegmentRef::active_segment( - session_id, segment_id, - )), - )) - .unwrap(); - - let records = read_stored_worker_infos(&store, &worker_metadata_store).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].worker_name, "agent"); - assert_eq!(records[0].metadata_state, StoredMetadataState::Present); - } - - fn single_entry(list: WorkerList) -> WorkerListEntry { - assert_eq!(list.entries.len(), 1); - list.entries.into_iter().next().unwrap() - } - - fn metadata_info( - store: &FsStore, - worker_name: &str, - session_id: SessionId, - segment_id: SegmentId, - ) -> StoredWorkerInfo { - stored_info_from_metadata( - store, - worker_name.to_string(), - WorkerMetadata::new( - worker_name, - Some(WorkerActiveSegmentRef::active_segment( - session_id, segment_id, - )), - ), - ) - } - - fn pending_metadata_info(worker_name: &str, session_id: SessionId) -> StoredWorkerInfo { - StoredWorkerInfo { - worker_name: worker_name.to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: Some(session_id), - active_segment_id: None, - updated_at: 0, - workspace_root: None, - preview: Some("[pending segment]".to_string()), - } - } - - fn stopped_info_with_updated_at(worker_name: &str, updated_at: u64) -> StoredWorkerInfo { - StoredWorkerInfo { - worker_name: worker_name.to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at, - workspace_root: None, - preview: None, - } - } - - fn live_info(worker_name: &str, status: WorkerStatus) -> LiveWorkerInfo { - live_info_with_updated_at(worker_name, status, 0) - } - - fn live_info_with_segment( - worker_name: &str, - status: WorkerStatus, - segment_id: SegmentId, - ) -> LiveWorkerInfo { - let mut info = live_info(worker_name, status); - info.segment_id = Some(segment_id); - info - } - - fn live_info_with_updated_at( - worker_name: &str, - status: WorkerStatus, - updated_at: u64, - ) -> LiveWorkerInfo { - LiveWorkerInfo { - worker_name: worker_name.to_string(), - socket_path: PathBuf::from(format!("/tmp/{worker_name}.sock")), - status: Some(status), - reachable: true, - segment_id: None, - summary: WorkerEntrySummary { - active_session_id: None, - active_segment_id: None, - updated_at, - preview: None, - }, - } - } - - fn live_probe_record(worker_name: &str, socket_path: PathBuf) -> LiveWorkerInfo { - LiveWorkerInfo { - worker_name: worker_name.to_string(), - socket_path, - status: None, - reachable: false, - segment_id: None, - summary: WorkerEntrySummary::default(), - } - } - - fn test_greeting() -> protocol::Greeting { - protocol::Greeting { - worker_name: "live".to_string(), - cwd: "/tmp".to_string(), - provider: "test".to_string(), - model: "test".to_string(), - scope_summary: "test".to_string(), - tools: vec![], - context_window: 0, - context_tokens: 0, - } - } - - fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) { - store - .append( - session_id, - segment_id, - &LogEntry::SegmentStart { - ts, - session_id, - system_prompt: None, - config: RequestConfig::default(), - history: vec![], - forked_from: None, - compacted_from: None, - }, - ) - .unwrap(); - } - - fn append_user( - store: &FsStore, - session_id: SessionId, - segment_id: SegmentId, - ts: u64, - text: &str, - ) { - store - .append( - session_id, - segment_id, - &LogEntry::UserInput { - ts, - segments: vec![protocol::Segment::text(text)], - extensions: vec![], - }, - ) - .unwrap(); - } - - fn stopped_info_for_workspace(worker_name: &str, workspace_root: &Path) -> StoredWorkerInfo { - let mut info = stopped_info_with_updated_at(worker_name, 10); - info.workspace_root = Some(workspace_root.to_path_buf()); - info - } - - #[test] - fn workspace_sources_include_current_and_hide_external_or_unknown_pods() { - let current = tempdir().unwrap(); - let external = tempdir().unwrap(); - - let list = WorkerList::from_workspace_sources( - SOURCE, - vec![ - stopped_info_for_workspace("current", current.path()), - stopped_info_for_workspace("current-orchestrator", current.path()), - stopped_info_for_workspace("other-workspace", external.path()), - stopped_info_with_updated_at("legacy-unknown", 10), - corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()), - ], - vec![ - live_info("current", WorkerStatus::Idle), - live_info("current-orchestrator", WorkerStatus::Running), - live_info("other-workspace", WorkerStatus::Idle), - live_info("legacy-unknown", WorkerStatus::Idle), - live_info("live-only", WorkerStatus::Idle), - ], - None, - 10, - current.path(), - ); - - let names = list - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect::>(); - assert_eq!(names, vec!["current", "current-orchestrator"]); - assert!(list.entries.iter().all(|entry| entry.actions.can_open)); - } - - #[test] - fn workspace_sources_use_workspace_metadata_not_cwd_or_live_presence() { - let current = tempdir().unwrap(); - let worktree_cwd = current.path().join(".worktree/impl"); - - let list = WorkerList::from_workspace_sources( - SOURCE, - vec![stopped_info_for_workspace("ticket-role", current.path())], - vec![live_info("ticket-role", WorkerStatus::Idle)], - None, - 10, - &worktree_cwd, - ); - assert!(list.entries.is_empty()); - - let list = WorkerList::from_workspace_sources( - SOURCE, - vec![stopped_info_for_workspace("ticket-role", current.path())], - vec![live_info("ticket-role", WorkerStatus::Idle)], - None, - 10, - current.path(), - ); - assert_eq!(list.entries[0].name, "ticket-role"); - assert!(list.entries[0].actions.can_open); - } -} diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs deleted file mode 100644 index 8b3223fa..00000000 --- a/crates/tui/src/workspace_panel.rs +++ /dev/null @@ -1,2750 +0,0 @@ -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::process::Command; -#[cfg(feature = "e2e-test")] -use std::time::Instant; - -use protocol::WorkerStatus; -use ticket::config::{ - TICKET_CONFIG_RELATIVE_PATH, TicketConfig, TicketOrchestrationConfig, - WORKSPACE_SETTINGS_RELATIVE_PATH, -}; -use ticket::{ - LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug, - TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary, - TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction, - TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item, -}; - -use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; -use crate::worker_list::{StoredMetadataState, WorkerList, WorkerListEntry}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WorkspacePanelViewModel { - pub(crate) header: WorkspacePanelHeader, - pub(crate) rows: Vec, - 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, - pub(crate) orchestrator: Option, - pub(crate) diagnostics: Vec, -} - -#[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, -} - -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) worker_name: String, - pub(crate) status: CompanionPanelStatus, - pub(crate) detail: Option, -} - -impl CompanionPanelState { - pub(crate) fn new( - worker_name: impl Into, - status: CompanionPanelStatus, - detail: Option, - ) -> Self { - Self { - worker_name: worker_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) worker_name: String, - pub(crate) status: OrchestratorPanelStatus, - pub(crate) detail: Option, -} - -impl OrchestratorPanelState { - pub(crate) fn new( - worker_name: impl Into, - status: OrchestratorPanelStatus, - detail: Option, - ) -> Self { - Self { - worker_name: worker_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), - InvalidTicket(String), - TicketIntakeWorker { - ticket_id: String, - worker_name: String, - }, - Worker(String), -} - -impl PanelRowKey { - pub(crate) fn worker_name(&self) -> Option<&str> { - match self { - Self::Worker(name) - | Self::TicketIntakeWorker { - worker_name: name, .. - } => Some(name), - Self::Ticket(_) | Self::InvalidTicket(_) => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PanelRowKind { - Planning, - Ticket, - Review, - ActiveWork, - TicketIntakeWorker, - Worker, - InvalidTicket, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) enum ActionPriority { - ReadyForQueue, - ActiveWork, - Background, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum NextUserAction { - Clarify, - Queue, - Close, - Wait, - OpenWorker, -} - -impl NextUserAction { - pub(crate) fn label(self) -> &'static str { - match self { - Self::Clarify => "Clarify", - Self::Queue => "Queue", - Self::Close => "Close", - Self::Wait => "Wait", - Self::OpenWorker => "Open", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TicketPanelEntry { - pub(crate) id: String, - pub(crate) resource_key: Option, - pub(crate) title: String, - pub(crate) priority: String, - pub(crate) workflow_state: TicketWorkflowState, - pub(crate) workflow_state_explicit: bool, - pub(crate) orchestration_overlay: Option, - pub(crate) next_action: Option, - pub(crate) updated_at: Option, - pub(crate) latest_event_kind: Option, - pub(crate) latest_event_excerpt: Option, - pub(crate) blocked_reason: Option, - pub(crate) related_workers: Vec, - pub(crate) local_claim: Option, - pub(crate) intake_workers: Vec, -} - -pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TicketAssociatedIntakeEntry { - pub(crate) ticket_id: String, - pub(crate) worker_name: String, - pub(crate) status: TicketLocalClaimStatus, - pub(crate) source: TicketAssociatedIntakeSource, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TicketAssociatedIntakeSource { - LocalClaim, - RelatedSession, -} - -impl TicketAssociatedIntakeSource { - pub(crate) fn label(self) -> &'static str { - match self { - Self::LocalClaim => "local claim", - Self::RelatedSession => "related session", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TicketLocalClaimEntry { - pub(crate) worker_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, - pub(crate) status: String, - pub(crate) priority: ActionPriority, - pub(crate) next_action: Option, - pub(crate) ticket: Option, - pub(crate) related_workers: Vec, - pub(crate) disabled_reason: Option, - pub(crate) key_hint: Option, -} - -impl PanelRow { - pub(crate) fn is_ticket_action(&self) -> bool { - matches!( - self.kind, - PanelRowKind::Planning - | PanelRowKind::Ticket - | PanelRowKind::Review - | PanelRowKind::ActiveWork - ) - } - - pub(crate) fn is_ticket_section_row(&self) -> bool { - self.is_ticket_action() - || matches!( - self.kind, - PanelRowKind::TicketIntakeWorker | PanelRowKind::InvalidTicket - ) - } -} - -const MAX_POD_NAME_CHARS: usize = 80; -const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3; -const MAX_INVALID_TICKET_PLACEHOLDER_ROWS: usize = 5; -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 CompanionWorkerPresence { - 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 OrchestratorWorkerPresence { - Live, - Restorable, - Missing, - Unavailable(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum OrchestratorLifecyclePlan { - SkipNoTicketConfig, - ReportLive, - Restore, - Spawn, - Unavailable(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestrationTicketOverlay { - states: BTreeMap, - diagnostics: Vec, -} - -impl OrchestrationTicketOverlay { - fn empty() -> Self { - Self { - states: BTreeMap::new(), - diagnostics: Vec::new(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrchestrationWorktreeLayout { - path: PathBuf, - branch: String, -} - -pub(crate) fn workspace_companion_worker_name(workspace_root: &Path) -> String { - let seed = workspace_root - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("workspace"); - sanitise_worker_name_component(seed, MAX_POD_NAME_CHARS) - .filter(|component| !component.is_empty()) - .unwrap_or_else(|| "workspace".to_string()) -} - -pub(crate) fn workspace_orchestrator_worker_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_worker_name_component(seed, max_component_chars) - .filter(|component| !component.is_empty()) - .unwrap_or_else(|| "workspace".to_string()); - format!("{component}{ORCHESTRATOR_SUFFIX}") -} - -fn sanitise_worker_name_component(value: &str, max_chars: usize) -> Option { - 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( - worker_name: &str, - workers: &WorkerList, -) -> CompanionWorkerPresence { - let Some(entry) = workers - .entries - .iter() - .find(|entry| entry.name == worker_name) - else { - return CompanionWorkerPresence::Missing; - }; - if entry.live.as_ref().is_some_and(|live| live.reachable) { - return CompanionWorkerPresence::Live; - } - if entry.actions.can_restore { - return CompanionWorkerPresence::Restorable; - } - let reason = entry - .actions - .disabled_reason - .clone() - .or_else(|| { - entry - .diagnostics - .first() - .map(|diagnostic| diagnostic.message.clone()) - }) - .unwrap_or_else(|| "worker state is not live, restorable, or spawn-safe".to_string()); - CompanionWorkerPresence::Unavailable(reason) -} - -pub(crate) fn decide_companion_lifecycle( - presence: &CompanionWorkerPresence, -) -> CompanionLifecyclePlan { - match presence { - CompanionWorkerPresence::Live => CompanionLifecyclePlan::ReportLive, - CompanionWorkerPresence::Restorable => CompanionLifecyclePlan::Restore, - CompanionWorkerPresence::Missing => CompanionLifecyclePlan::Spawn, - CompanionWorkerPresence::Unavailable(message) => CompanionLifecyclePlan::Unavailable( - format!("Workspace Companion Worker state is unusable: {message}"), - ), - } -} - -pub(crate) fn workspace_orchestrator_worker_presence( - worker_name: &str, - workers: &WorkerList, -) -> OrchestratorWorkerPresence { - let Some(entry) = workers - .entries - .iter() - .find(|entry| entry.name == worker_name) - else { - return OrchestratorWorkerPresence::Missing; - }; - if entry.live.as_ref().is_some_and(|live| live.reachable) { - return OrchestratorWorkerPresence::Live; - } - if entry.actions.can_restore { - return OrchestratorWorkerPresence::Restorable; - } - let reason = entry - .actions - .disabled_reason - .clone() - .or_else(|| { - entry - .diagnostics - .first() - .map(|diagnostic| diagnostic.message.clone()) - }) - .unwrap_or_else(|| "worker state is not live, restorable, or spawn-safe".to_string()); - OrchestratorWorkerPresence::Unavailable(reason) -} - -pub(crate) fn decide_orchestrator_lifecycle( - config: &TicketConfigAvailability, - presence: &OrchestratorWorkerPresence, -) -> 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 { - OrchestratorWorkerPresence::Live => OrchestratorLifecyclePlan::ReportLive, - OrchestratorWorkerPresence::Restorable => OrchestratorLifecyclePlan::Restore, - OrchestratorWorkerPresence::Missing => OrchestratorLifecyclePlan::Spawn, - OrchestratorWorkerPresence::Unavailable(message) => { - OrchestratorLifecyclePlan::Unavailable(format!( - "Workspace Orchestrator Worker state is unusable: {message}" - )) - } - }, - } -} - -pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability { - let settings_path = workspace_root.join(WORKSPACE_SETTINGS_RELATIVE_PATH); - match settings_path.symlink_metadata() { - Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!( - "{} exists but is not a regular file", - WORKSPACE_SETTINGS_RELATIVE_PATH - )), - Ok(_) => match std::fs::read_to_string(&settings_path) { - Ok(content) => { - match TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content) { - Ok(true) => match TicketConfig::load_workspace(workspace_root) { - Ok(_) => TicketConfigAvailability::Usable, - Err(error) => TicketConfigAvailability::Unusable(error.to_string()), - }, - Ok(false) => legacy_ticket_config_availability(workspace_root), - Err(error) => TicketConfigAvailability::Unusable(error.to_string()), - } - } - Err(error) => TicketConfigAvailability::Unusable(format!( - "could not read {}: {error}", - WORKSPACE_SETTINGS_RELATIVE_PATH - )), - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - legacy_ticket_config_availability(workspace_root) - } - Err(error) => TicketConfigAvailability::Unusable(format!( - "could not inspect {}: {error}", - WORKSPACE_SETTINGS_RELATIVE_PATH - )), - } -} - -fn legacy_ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability { - let legacy_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); - match legacy_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) -> String { - let collapsed = message - .as_ref() - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" "); - excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string()) -} - -fn load_orchestration_ticket_overlay( - workspace_root: &Path, - config: &TicketConfig, -) -> OrchestrationTicketOverlay { - let layout = orchestration_worktree_layout(workspace_root, &config.orchestration); - if !layout.path.exists() { - return OrchestrationTicketOverlay::empty(); - } - match validate_orchestration_overlay_source(workspace_root, &layout) { - Ok(()) => { - load_orchestration_ticket_overlay_states(&layout.path).unwrap_or_else(|message| { - OrchestrationTicketOverlay { - states: BTreeMap::new(), - diagnostics: vec![bounded_panel_diagnostic(format!( - "Orchestration Ticket overlay unavailable: {message}" - ))], - } - }) - } - Err(message) => OrchestrationTicketOverlay { - states: BTreeMap::new(), - diagnostics: vec![bounded_panel_diagnostic(format!( - "Orchestration Ticket overlay unavailable: {message}" - ))], - }, - } -} - -fn orchestration_worktree_layout( - workspace_root: &Path, - config: &TicketOrchestrationConfig, -) -> OrchestrationWorktreeLayout { - OrchestrationWorktreeLayout { - path: workspace_root - .join(config.worktree_dir()) - .join(config.worktree_name()), - branch: config.effective_branch_name().to_string(), - } -} - -fn load_orchestration_ticket_overlay_states( - worktree_root: &Path, -) -> Result { - let overlay_config = TicketConfig::load_workspace(worktree_root).map_err(|error| { - format!( - "orchestration worktree {} has unusable Ticket settings: {error}", - worktree_root.display() - ) - })?; - let ticket_root = overlay_config.backend_root(); - if !ticket_root.is_dir() { - return Ok(OrchestrationTicketOverlay { - states: BTreeMap::new(), - diagnostics: vec![bounded_panel_diagnostic(format!( - "Orchestration worktree configured Ticket backend root {} is not a directory", - ticket_root.display() - ))], - }); - } - let backend = LocalTicketBackend::new(ticket_root.to_path_buf()) - .with_record_language(overlay_config.ticket_record_language()); - let partial = backend - .list_partial(TicketListQuery::all()) - .map_err(|error| error.to_string())?; - let mut states = BTreeMap::new(); - for summary in partial.tickets { - states.insert( - summary.id, - TicketStateOverlay { - source: "orchestration".to_string(), - workflow_state: summary.workflow_state, - }, - ); - } - let diagnostics = invalid_ticket_diagnostics(partial.invalid_records.len()); - Ok(OrchestrationTicketOverlay { - states, - diagnostics, - }) -} - -fn validate_orchestration_overlay_source( - workspace_root: &Path, - layout: &OrchestrationWorktreeLayout, -) -> Result<(), String> { - if !layout.path.exists() { - return Err(format!( - "expected worktree {} does not exist", - layout.path.display() - )); - } - if !layout.path.is_dir() { - return Err(format!( - "expected worktree {} is not a directory", - layout.path.display() - )); - } - let expected_path = layout - .path - .canonicalize() - .map_err(|error| format!("could not canonicalize expected worktree: {error}"))?; - let overlay_top = git_output(&layout.path, &["rev-parse", "--show-toplevel"])?; - let overlay_top = PathBuf::from(overlay_top); - let overlay_top = overlay_top - .canonicalize() - .map_err(|error| format!("could not canonicalize overlay git top-level: {error}"))?; - if overlay_top != expected_path { - return Err(format!( - "overlay git top-level {} does not match expected path {}", - overlay_top.display(), - expected_path.display() - )); - } - - let current_common_dir = git_common_dir(workspace_root)?; - let overlay_common_dir = git_common_dir(&layout.path)?; - if current_common_dir != overlay_common_dir { - return Err("expected worktree is from a different git common-dir".to_string()); - } - - let overlay_branch = git_output(&layout.path, &["branch", "--show-current"])?; - if overlay_branch != layout.branch { - return Err(format!( - "expected branch {} but worktree is on {}", - layout.branch, overlay_branch - )); - } - - Ok(()) -} - -fn git_common_dir(worktree_root: &Path) -> Result { - let common_dir = git_output(worktree_root, &["rev-parse", "--git-common-dir"])?; - let common_dir_path = PathBuf::from(common_dir); - let absolute = if common_dir_path.is_absolute() { - common_dir_path - } else { - worktree_root.join(common_dir_path) - }; - absolute - .canonicalize() - .map_err(|error| format!("could not canonicalize git common-dir: {error}")) -} - -fn git_output(worktree_root: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .args(args) - .current_dir(worktree_root) - .output() - .map_err(|error| format!("could not run git {}: {error}", args.join(" ")))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let message = if stderr.is_empty() { - format!("git {} exited with {}", args.join(" "), output.status) - } else { - format!("git {} failed: {stderr}", args.join(" ")) - }; - return Err(message); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -#[cfg_attr(feature = "e2e-test", allow(dead_code))] -pub(crate) fn build_workspace_panel( - workspace_root: &Path, - workers: &WorkerList, -) -> 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, - workers, - &PanelRegistrySnapshot::empty(), - ); - } - }; - build_workspace_panel_with_registry(workspace_root, workers, ®istry) -} - -#[cfg(feature = "e2e-test")] -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WorkspacePanelE2eSourceTiming { - pub(crate) source: &'static str, - pub(crate) elapsed_ms: u128, -} - -#[cfg(feature = "e2e-test")] -pub(crate) fn build_workspace_panel_with_e2e_timings( - workspace_root: &Path, - workers: &WorkerList, -) -> (WorkspacePanelViewModel, Vec) { - let mut timings = Vec::new(); - let started = Instant::now(); - let registry = match PanelRegistryStore::default_for_workspace(workspace_root) - .and_then(|store| store.snapshot()) - { - Ok(snapshot) => snapshot, - Err(error) => { - timings.push(WorkspacePanelE2eSourceTiming { - source: "local_claim_scan", - elapsed_ms: started.elapsed().as_millis(), - }); - 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, - workers, - &PanelRegistrySnapshot::empty(), - ), - timings, - ); - } - }; - timings.push(WorkspacePanelE2eSourceTiming { - source: "local_claim_scan", - elapsed_ms: started.elapsed().as_millis(), - }); - - let mut model = WorkspacePanelViewModel::empty(workspace_root); - let started = Instant::now(); - let availability = ticket_config_availability(workspace_root); - timings.push(WorkspacePanelE2eSourceTiming { - source: "ticket_config_probe", - elapsed_ms: started.elapsed().as_millis(), - }); - match availability { - TicketConfigAvailability::Absent => {} - TicketConfigAvailability::Usable => { - model.header.ticket_configured = true; - model.composer = WorkspacePanelComposer::ticket_enabled(); - let started = Instant::now(); - match TicketConfig::load_workspace(workspace_root) { - Ok(config) => { - timings.push(WorkspacePanelE2eSourceTiming { - source: "ticket_config_parse", - elapsed_ms: started.elapsed().as_millis(), - }); - 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()); - let started = Instant::now(); - let orchestration_overlay = - load_orchestration_ticket_overlay(workspace_root, &config); - timings.push(WorkspacePanelE2eSourceTiming { - source: "orchestration_overlay_validation_read_git", - elapsed_ms: started.elapsed().as_millis(), - }); - let started = Instant::now(); - match build_ticket_rows( - &backend, - workers, - ®istry, - &orchestration_overlay.states, - ) { - Ok(ticket_rows) => { - timings.push(WorkspacePanelE2eSourceTiming { - source: "ticket_scan_parse", - elapsed_ms: started.elapsed().as_millis(), - }); - model.rows.extend(ticket_rows.rows); - model.header.diagnostics.extend(ticket_rows.diagnostics); - model - .header - .diagnostics - .extend(orchestration_overlay.diagnostics); - } - Err(error) => { - timings.push(WorkspacePanelE2eSourceTiming { - source: "ticket_scan_parse", - elapsed_ms: started.elapsed().as_millis(), - }); - model - .header - .diagnostics - .push(bounded_panel_diagnostic(format!( - "Ticket rows unavailable: {error}" - ))) - } - } - } - Err(error) => { - timings.push(WorkspacePanelE2eSourceTiming { - source: "ticket_config_parse", - elapsed_ms: started.elapsed().as_millis(), - }); - 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}" - ))); - } - } - - let started = Instant::now(); - model.rows.extend(worker_rows(workers)); - timings.push(WorkspacePanelE2eSourceTiming { - source: "pod_row_materialization", - elapsed_ms: started.elapsed().as_millis(), - }); - (model, timings) -} - -fn build_workspace_panel_with_registry( - workspace_root: &Path, - workers: &WorkerList, - registry: &PanelRegistrySnapshot, -) -> WorkspacePanelViewModel { - let model = WorkspacePanelViewModel::empty(workspace_root); - build_workspace_panel_with_registry_model(model, workspace_root, workers, registry) -} - -fn build_workspace_panel_with_registry_model( - mut model: WorkspacePanelViewModel, - workspace_root: &Path, - workers: &WorkerList, - registry: &PanelRegistrySnapshot, -) -> WorkspacePanelViewModel { - let workers = workers.filter_for_workspace(workspace_root); - let workers = &workers; - 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()); - let orchestration_overlay = - load_orchestration_ticket_overlay(workspace_root, &config); - match build_ticket_rows( - &backend, - workers, - registry, - &orchestration_overlay.states, - ) { - Ok(ticket_rows) => { - model.rows.extend(ticket_rows.rows); - model.header.diagnostics.extend(ticket_rows.diagnostics); - model - .header - .diagnostics - .extend(orchestration_overlay.diagnostics); - } - 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(worker_rows(workers)); - model -} - -pub(crate) fn build_current_ticket_row( - backend: &LocalTicketBackend, - ticket_id: &str, - workers: &WorkerList, -) -> ticket::Result { - 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, - workers, - ®istry, - None, - )) -} - -fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { - TicketSummary { - id: meta.id.clone(), - resource_key: meta.resource_key.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(), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct TicketRowsBuild { - rows: Vec, - diagnostics: Vec, -} - -fn build_ticket_rows( - backend: &LocalTicketBackend, - workers: &WorkerList, - registry: &PanelRegistrySnapshot, - orchestration_overlay: &BTreeMap, -) -> ticket::Result { - let partial = backend.list_partial(TicketListQuery::all())?; - let mut ticket_rows = Vec::new(); - let mut invalid_records = partial.invalid_records; - for summary in partial.tickets { - if summary.workflow_state == TicketWorkflowState::Closed { - continue; - } - match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) { - Ok(ticket) => { - let current_ticket_invalid = ticket - .invalid_records - .iter() - .any(|record| record.label == summary.id); - invalid_records.extend(ticket.invalid_records); - if current_ticket_invalid { - continue; - } - let overlay = orchestration_overlay.get(&summary.id); - ticket_rows.push(ticket_row( - summary, - &ticket.ticket.events, - &ticket.ticket.relations.blockers, - workers, - registry, - overlay, - )); - } - Err(_) => invalid_records.push(TicketInvalidRecord { - label: summary.id, - reason: "could not load ticket detail".to_string(), - }), - } - } - ticket_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)) - }); - - let mut rows = Vec::new(); - for row in ticket_rows { - let intake_rows = ticket_intake_worker_rows(&row); - rows.push(row); - rows.extend(intake_rows); - } - - let invalid_records = dedupe_invalid_ticket_records(invalid_records); - let diagnostics = invalid_ticket_diagnostics(invalid_records.len()); - rows.extend(invalid_ticket_rows(&invalid_records)); - - Ok(TicketRowsBuild { rows, diagnostics }) -} - -fn dedupe_invalid_ticket_records(records: Vec) -> Vec { - let mut deduped = Vec::new(); - for record in records { - if deduped - .iter() - .any(|existing: &TicketInvalidRecord| existing.label == record.label) - { - continue; - } - deduped.push(record); - } - deduped -} - -fn invalid_ticket_diagnostics(invalid_count: usize) -> Vec { - if invalid_count == 0 { - return Vec::new(); - } - let suffix = if invalid_count > MAX_INVALID_TICKET_PLACEHOLDER_ROWS { - format!( - "; showing first {} placeholder rows", - MAX_INVALID_TICKET_PLACEHOLDER_ROWS - ) - } else { - String::new() - }; - vec![bounded_panel_diagnostic(format!( - "Ticket records partially loaded: {invalid_count} invalid record(s) unavailable for actions{suffix}." - ))] -} - -fn invalid_ticket_rows(records: &[TicketInvalidRecord]) -> Vec { - records - .iter() - .take(MAX_INVALID_TICKET_PLACEHOLDER_ROWS) - .map(invalid_ticket_row) - .collect() -} - -fn invalid_ticket_row(record: &TicketInvalidRecord) -> PanelRow { - PanelRow { - key: PanelRowKey::InvalidTicket(record.label.clone()), - kind: PanelRowKind::InvalidTicket, - title: format!("Invalid Ticket record: {}", record.label), - subtitle: Some(record.reason.clone()), - status: "invalid".to_string(), - priority: ActionPriority::Background, - next_action: None, - ticket: None, - related_workers: Vec::new(), - disabled_reason: Some( - "Invalid Ticket record is diagnostics-only; lifecycle actions are disabled." - .to_string(), - ), - key_hint: Some( - "Actions unavailable until the Ticket record is repaired manually.".to_string(), - ), - } -} - -fn ticket_row( - summary: TicketSummary, - events: &[TicketEvent], - relation_blockers: &[TicketRelationBlocker], - workers: &WorkerList, - registry: &PanelRegistrySnapshot, - orchestration_overlay: Option<&TicketStateOverlay>, -) -> PanelRow { - let local_claim = local_claim_for_ticket(&summary, workers, registry); - let intake_workers = - associated_intake_entries_for_ticket(&summary, workers, registry, local_claim.as_ref()); - let mut related_workers = Vec::new(); - if let Some(claim) = local_claim.as_ref() { - related_workers.push(claim.worker_name.clone()); - } - for worker_name in intake_workers - .iter() - .map(|intake| intake.worker_name.clone()) - { - if !related_workers - .iter() - .any(|existing| existing == &worker_name) - { - related_workers.push(worker_name); - } - } - let projection = - project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay); - let latest_event = events.last(); - let kind = panel_row_kind_from_workspace(projection.kind); - let priority = action_priority_from_workspace(projection.priority); - let next_action = projection.next_action.map(next_user_action_from_workspace); - let entry = TicketPanelEntry { - id: summary.id.clone(), - resource_key: summary.resource_key.clone(), - title: summary.title.clone(), - priority: summary.priority.clone(), - workflow_state: summary.workflow_state, - workflow_state_explicit: summary.workflow_state_explicit, - orchestration_overlay: projection.visible_overlay.clone(), - next_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: projection.blocked_reason.clone(), - related_workers: related_workers.clone(), - local_claim, - intake_workers, - }; - let subtitle = ticket_subtitle(&entry); - PanelRow { - key: PanelRowKey::Ticket(summary.id), - kind, - title: summary.title, - subtitle, - status: projection.visible_state, - priority, - next_action, - ticket: Some(entry), - related_workers, - disabled_reason: projection.disabled_reason, - key_hint: projection.key_hint, - } -} - -fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind { - match kind { - TicketWorkspaceRowKind::Planning => PanelRowKind::Planning, - TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket, - TicketWorkspaceRowKind::Review => PanelRowKind::Review, - TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork, - } -} - -fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority { - match priority { - TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue, - TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork, - TicketWorkspaceActionPriority::Background => ActionPriority::Background, - } -} - -fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction { - match action { - TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify, - TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue, - TicketWorkspaceNextAction::Close => NextUserAction::Close, - TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait, - } -} - -fn associated_intake_entries_for_ticket( - summary: &TicketSummary, - workers: &WorkerList, - registry: &PanelRegistrySnapshot, - local_claim: Option<&TicketLocalClaimEntry>, -) -> Vec { - let mut entries = Vec::new(); - if let Some(claim) = local_claim.filter(|claim| is_intake_role(&claim.role)) { - entries.push(TicketAssociatedIntakeEntry { - ticket_id: summary.id.clone(), - worker_name: claim.worker_name.clone(), - status: claim.status, - source: TicketAssociatedIntakeSource::LocalClaim, - }); - } - - let mut related_sessions = registry - .sessions - .iter() - .filter(|session| { - is_intake_role(&session.role) - && session - .related_tickets - .iter() - .any(|related| related.id == summary.id.as_str()) - }) - .map(|session| session.worker_name.clone()) - .collect::>(); - related_sessions.sort(); - related_sessions.dedup(); - - for worker_name in related_sessions { - if entries.iter().any(|entry| entry.worker_name == worker_name) { - continue; - } - entries.push(TicketAssociatedIntakeEntry { - ticket_id: summary.id.clone(), - status: local_claim_status_for_pod(&worker_name, workers), - worker_name, - source: TicketAssociatedIntakeSource::RelatedSession, - }); - if entries.len() >= MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET { - break; - } - } - - entries.truncate(MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET); - entries -} - -fn is_intake_role(role: &str) -> bool { - role.eq_ignore_ascii_case("intake") -} - -fn ticket_intake_worker_rows(row: &PanelRow) -> Vec { - row.ticket - .as_ref() - .map(|ticket| { - ticket - .intake_workers - .iter() - .map(ticket_intake_pod_row) - .collect() - }) - .unwrap_or_default() -} - -fn ticket_intake_pod_row(intake: &TicketAssociatedIntakeEntry) -> PanelRow { - let stale = intake.status == TicketLocalClaimStatus::Stale; - PanelRow { - key: PanelRowKey::TicketIntakeWorker { - ticket_id: intake.ticket_id.clone(), - worker_name: intake.worker_name.clone(), - }, - kind: PanelRowKind::TicketIntakeWorker, - title: format!("Intake Worker: {}", intake.worker_name), - subtitle: Some(format!( - "Ticket {} · {} · {}", - intake.ticket_id, - intake.source.label(), - intake.status.label() - )), - status: intake.status.label().to_string(), - priority: ActionPriority::ActiveWork, - next_action: if stale { - None - } else { - Some(NextUserAction::OpenWorker) - }, - ticket: None, - related_workers: vec![intake.worker_name.clone()], - disabled_reason: if stale { - Some( - "Associated Intake Worker is stale; no live or restorable Worker entry is available." - .to_string(), - ) - } else { - None - }, - key_hint: Some(if stale { - "Stale Intake claim/session; restore is unavailable".to_string() - } else { - "Open/attach this Ticket's Intake Worker".to_string() - }), - } -} - -fn local_claim_for_ticket( - summary: &TicketSummary, - workers: &WorkerList, - registry: &PanelRegistrySnapshot, -) -> Option { - let claim = registry.claim_for_ticket(&summary.id)?; - let status = local_claim_status_for_pod(&claim.worker_name, workers); - Some(TicketLocalClaimEntry { - worker_name: claim.worker_name.clone(), - role: claim.role.clone(), - status, - }) -} - -pub(crate) fn local_claim_status_for_pod( - worker_name: &str, - workers: &WorkerList, -) -> TicketLocalClaimStatus { - let Some(entry) = workers - .entries - .iter() - .find(|entry| entry.name == worker_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_state_display( - local: TicketWorkflowState, - overlay: Option<&TicketStateOverlay>, -) -> String { - match overlay { - Some(overlay) => format!( - "{}→{}", - compact_ticket_state_label(local), - compact_ticket_state_label(overlay.workflow_state) - ), - None => local.as_str().to_string(), - } -} - -fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { - match state { - TicketWorkflowState::Planning => "plan", - TicketWorkflowState::Ready => "ready", - TicketWorkflowState::Queued => "q", - TicketWorkflowState::InProgress => "prog", - TicketWorkflowState::Done => "done", - TicketWorkflowState::Closed => "cls", - } -} - -fn ticket_subtitle(entry: &TicketPanelEntry) -> Option { - let mut parts = vec![format!( - "{} · {}", - entry.id, - ticket_state_display(entry.workflow_state, entry.orchestration_overlay.as_ref()) - )]; - if let Some(claim) = entry.local_claim.as_ref() { - parts.push(format!( - "claim: {} ({})", - claim.worker_name, - claim.status.label() - )); - } - if !entry.related_workers.is_empty() { - parts.push(format!("workers: {}", entry.related_workers.join(", "))); - } - if let Some(excerpt) = entry.latest_event_excerpt.as_ref() { - parts.push(format!("latest: {excerpt}")); - } - Some(parts.join(" ")) -} - -fn worker_rows(workers: &WorkerList) -> Vec { - workers.entries.iter().map(pod_row).collect() -} - -fn pod_row(entry: &WorkerListEntry) -> PanelRow { - let status = worker_status_label(entry).to_string(); - let next_action = if entry.actions.can_open { - Some(NextUserAction::OpenWorker) - } 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::Worker(entry.name.clone()), - kind: PanelRowKind::Worker, - title: entry.name.clone(), - subtitle, - status, - priority: ActionPriority::Background, - next_action, - ticket: None, - related_workers: Vec::new(), - disabled_reason: entry.actions.disabled_reason.clone(), - key_hint: Some("Enter opens/attaches for inspection".to_string()), - } -} - -fn worker_status_label(entry: &WorkerListEntry) -> &'static str { - if let Some(live) = entry.live.as_ref() { - if !live.reachable { - return "unreachable"; - } - return match live.status { - Some(WorkerStatus::Idle) => "live idle", - Some(WorkerStatus::Running) => "live running", - Some(WorkerStatus::Paused) => "live paused", - Some(WorkerStatus::Stopped) => "live stopped", - 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 { - let collapsed = markdown - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .collect::>() - .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::(); - value.push('…'); - Some(value) - } -} - -#[allow(dead_code)] -#[cfg(test)] -mod tests { - use super::*; - use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin}; - use crate::worker_list::{LiveWorkerInfo, StoredWorkerInfo, WorkerEntrySummary}; - use std::fs; - use std::path::{Path, PathBuf}; - use tempfile::TempDir; - use ticket::{ - NewTicket, NewTicketRelation, TicketIdOrSlug, TicketRelationKind, TicketStateChange, - TicketWorkflowState, - }; - - fn empty_pods() -> WorkerList { - WorkerList::from_sources( - crate::worker_list::WorkerVisibilitySource::ResumePicker, - vec![], - vec![], - None, - 10, - ) - } - - fn create_ticket( - backend: &LocalTicketBackend, - title: &str, - configure: impl FnOnce(&mut NewTicket), - ) { - create_ticket_with_id(backend, title, configure); - } - - fn create_ticket_with_id( - backend: &LocalTicketBackend, - title: &str, - configure: impl FnOnce(&mut NewTicket), - ) -> String { - let mut input = NewTicket::new(title); - configure(&mut input); - backend.create(input).unwrap().id - } - - fn write_ticket_config(workspace_root: &Path) { - write_ticket_config_with_root(workspace_root, ".yoi/tickets"); - } - - fn write_ticket_config_with_root(workspace_root: &Path, root: &str) { - let config_dir = workspace_root.join(".yoi"); - fs::create_dir_all(&config_dir).unwrap(); - fs::write( - config_dir.join("workspace.toml"), - format!( - "[ticket]\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"{root}\"\n" - ), - ) - .unwrap(); - } - - fn run_git(workspace_root: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(workspace_root) - .output() - .unwrap_or_else(|error| panic!("failed to run git {args:?}: {error}")); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - fn init_git_repo(workspace_root: &Path) { - run_git(workspace_root, &["init", "--initial-branch", "main"]); - run_git(workspace_root, &["config", "user.name", "Panel Test"]); - run_git( - workspace_root, - &["config", "user.email", "panel-test@example.invalid"], - ); - fs::write(workspace_root.join("README.md"), "panel test\n").unwrap(); - run_git(workspace_root, &["add", "README.md"]); - run_git(workspace_root, &["commit", "-m", "init"]); - } - - fn add_orchestration_worktree(workspace_root: &Path, branch: &str) -> PathBuf { - let orchestration_root = workspace_root.join(".worktree/orchestration"); - fs::create_dir_all(orchestration_root.parent().unwrap()).unwrap(); - run_git( - workspace_root, - &[ - "worktree", - "add", - "-b", - branch, - orchestration_root.to_str().unwrap(), - "HEAD", - ], - ); - orchestration_root - } - - fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) { - copy_ticket_to_overlay_root(workspace_root, &orchestration_root.join(".yoi/tickets"), id); - } - - fn copy_ticket_to_overlay_root(workspace_root: &Path, overlay_root: &Path, id: &str) { - let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id); - let overlay_ticket_dir = overlay_root.join(id); - fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap(); - fs::create_dir_all(&overlay_ticket_dir).unwrap(); - fs::copy( - local_ticket_dir.join("item.md"), - overlay_ticket_dir.join("item.md"), - ) - .unwrap(); - let local_thread = local_ticket_dir.join("thread.md"); - if local_thread.exists() { - fs::copy(local_thread, overlay_ticket_dir.join("thread.md")).unwrap(); - } - } - - fn set_ticket_state(backend: &LocalTicketBackend, id: &str, state: TicketWorkflowState) { - loop { - let ticket = backend.show(TicketIdOrSlug::Id(id.to_string())).unwrap(); - if ticket.meta.workflow_state == state { - break; - } - let next = match (ticket.meta.workflow_state, state) { - (TicketWorkflowState::Queued, TicketWorkflowState::InProgress) - | (TicketWorkflowState::Queued, TicketWorkflowState::Done) => { - TicketWorkflowState::InProgress - } - (TicketWorkflowState::InProgress, TicketWorkflowState::Done) => { - TicketWorkflowState::Done - } - (from, to) => panic!("unsupported test transition {from} -> {to}"), - }; - backend - .set_workflow_state( - TicketIdOrSlug::Id(id.to_string()), - TicketStateChange::new( - ticket.meta.workflow_state.as_str(), - next.as_str(), - "test", - format!("test state -> {}", next.as_str()), - ), - ) - .unwrap(); - } - } - - fn ticket_row_by_title<'a>(model: &'a WorkspacePanelViewModel, title: &str) -> &'a PanelRow { - model - .rows - .iter() - .find(|row| row.title == title) - .unwrap_or_else(|| panic!("missing row for {title}")) - } - - fn live_workers(workspace_root: &Path, names: &[&str]) -> WorkerList { - let stored = names - .iter() - .map(|name| StoredWorkerInfo { - worker_name: (*name).to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at: 1, - workspace_root: Some(workspace_root.to_path_buf()), - preview: None, - }) - .collect(); - WorkerList::from_sources( - crate::worker_list::WorkerVisibilitySource::ResumePicker, - stored, - names - .iter() - .map(|name| LiveWorkerInfo { - worker_name: (*name).to_string(), - socket_path: PathBuf::from(format!("/tmp/{name}.sock")), - status: Some(WorkerStatus::Idle), - reachable: true, - segment_id: None, - summary: WorkerEntrySummary::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_workers(temp.path(), &["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::Worker("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_joins_orchestration_overlay_by_ticket_id() { - let temp = TempDir::new().unwrap(); - init_git_repo(temp.path()); - write_ticket_config(temp.path()); - let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let id = create_ticket_with_id(&backend, "Overlay Match", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - create_ticket(&backend, "Local Only", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); - let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); - set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); - create_ticket(&overlay_backend, "Overlay Only", |input| { - input.workflow_state = Some(TicketWorkflowState::Done); - }); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - let matched = ticket_row_by_title(&model, "Overlay Match"); - assert_eq!(matched.status, "q→prog"); - assert_eq!( - matched.ticket.as_ref().unwrap().workflow_state, - TicketWorkflowState::Queued - ); - assert_eq!( - matched - .ticket - .as_ref() - .unwrap() - .orchestration_overlay - .as_ref() - .unwrap() - .workflow_state, - TicketWorkflowState::InProgress - ); - assert_eq!(ticket_row_by_title(&model, "Local Only").status, "queued"); - assert!(model.rows.iter().all(|row| row.title != "Overlay Only")); - } - - #[test] - fn workspace_panel_orchestration_overlay_uses_configured_backend_root() { - let temp = TempDir::new().unwrap(); - init_git_repo(temp.path()); - write_ticket_config(temp.path()); - let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); - write_ticket_config_with_root(&orchestration_root, "configured-overlay-tickets"); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let id = create_ticket_with_id(&backend, "Overlay Custom Root", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - let configured_overlay_root = orchestration_root.join("configured-overlay-tickets"); - copy_ticket_to_overlay_root(temp.path(), &configured_overlay_root, &id); - let overlay_backend = LocalTicketBackend::new(&configured_overlay_root); - set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - assert!(model.header.diagnostics.is_empty()); - let row = ticket_row_by_title(&model, "Overlay Custom Root"); - assert_eq!(row.status, "q→prog"); - assert_eq!( - row.ticket - .as_ref() - .unwrap() - .orchestration_overlay - .as_ref() - .unwrap() - .workflow_state, - TicketWorkflowState::InProgress - ); - assert!(!orchestration_root.join(".yoi/tickets").exists()); - } - - #[test] - fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket() - { - let temp = TempDir::new().unwrap(); - init_git_repo(temp.path()); - write_ticket_config(temp.path()); - let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let id = create_ticket_with_id(&backend, "Overlay In Progress", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); - let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); - set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); - let local_item = temp.path().join(".yoi/tickets").join(&id).join("item.md"); - let before = fs::read_to_string(&local_item).unwrap(); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - let row = ticket_row_by_title(&model, "Overlay In Progress"); - assert_eq!(row.status, "q→prog"); - assert_eq!(row.next_action, Some(NextUserAction::Wait)); - assert_eq!(row.kind, PanelRowKind::ActiveWork); - assert_eq!(fs::read_to_string(&local_item).unwrap(), before); - let local_ticket = backend.show(TicketIdOrSlug::Id(id)).unwrap(); - assert_eq!( - local_ticket.meta.workflow_state, - TicketWorkflowState::Queued - ); - } - - #[test] - fn workspace_panel_displays_queued_plus_orchestration_done_as_merge_pending_without_queue() { - let temp = TempDir::new().unwrap(); - init_git_repo(temp.path()); - write_ticket_config(temp.path()); - let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let id = create_ticket_with_id(&backend, "Overlay Done", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); - let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); - set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - let row = ticket_row_by_title(&model, "Overlay Done"); - assert_eq!(row.status, "q→done"); - assert_eq!(row.kind, PanelRowKind::Review); - assert_eq!(row.next_action, Some(NextUserAction::Wait)); - assert_ne!(row.next_action, Some(NextUserAction::Queue)); - assert!( - row.disabled_reason - .as_deref() - .unwrap() - .contains("merge/review/close authority") - ); - } - - #[test] - fn workspace_panel_ignores_orchestration_overlay_on_branch_mismatch() { - let temp = TempDir::new().unwrap(); - init_git_repo(temp.path()); - write_ticket_config(temp.path()); - let orchestration_root = add_orchestration_worktree(temp.path(), "other-branch"); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let id = create_ticket_with_id(&backend, "Branch Mismatch", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); - let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); - set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - let row = ticket_row_by_title(&model, "Branch Mismatch"); - assert_eq!(row.status, "queued"); - assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none()); - let diagnostics = model.header.diagnostics.join("\n"); - assert!(diagnostics.contains("expected branch orchestration")); - } - - #[test] - fn workspace_panel_falls_back_when_orchestration_worktree_is_missing() { - let temp = TempDir::new().unwrap(); - write_ticket_config(temp.path()); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - create_ticket(&backend, "Missing Overlay", |input| { - input.workflow_state = Some(TicketWorkflowState::Queued); - }); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - let row = ticket_row_by_title(&model, "Missing Overlay"); - assert_eq!(row.status, "queued"); - assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none()); - } - - #[test] - fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() { - 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 Still Queueable"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - let ready = backend.create(ready_input).unwrap(); - backend - .create(NewTicket::new("Planning Still Clarifies")) - .unwrap(); - - for index in 0..6 { - let ticket = backend - .create(NewTicket::new(format!("Leaked Secret Invalid {index}"))) - .unwrap(); - fs::write( - temp.path() - .join(".yoi/tickets") - .join(&ticket.id) - .join("item.md"), - format!( - "---\ntitle: Leaked Secret Invalid {index}\nstate: super-secret-invalid-{index}\n---\nbody\n" - ), - ) - .unwrap(); - } - - let registry = PanelRegistryStore::from_root(temp.path().join("registry")); - registry - .claim_ticket(&ready.id, None, "ready-intake", "intake") - .unwrap(); - let model = build_workspace_panel_with_registry( - temp.path(), - &live_workers(temp.path(), &["ready-intake"]), - ®istry.snapshot().unwrap(), - ); - - let ready_index = model - .rows - .iter() - .position(|row| row.title == "Ready Still Queueable") - .unwrap(); - let ready_row = &model.rows[ready_index]; - assert_eq!(ready_row.next_action, Some(NextUserAction::Queue)); - assert!(ready_row.is_ticket_action()); - assert_eq!( - model.rows[ready_index + 1].key, - PanelRowKey::TicketIntakeWorker { - ticket_id: ready.id.clone(), - worker_name: "ready-intake".to_string(), - } - ); - - let planning = model - .rows - .iter() - .find(|row| row.title == "Planning Still Clarifies") - .unwrap(); - assert_eq!(planning.next_action, Some(NextUserAction::Clarify)); - assert!(planning.is_ticket_action()); - - let invalid_rows = model - .rows - .iter() - .filter(|row| row.kind == PanelRowKind::InvalidTicket) - .collect::>(); - assert_eq!(invalid_rows.len(), MAX_INVALID_TICKET_PLACEHOLDER_ROWS); - for row in invalid_rows { - assert_eq!(row.status, "invalid"); - assert!(row.ticket.is_none()); - assert_eq!(row.next_action, None); - assert!(!row.is_ticket_action()); - assert!(row.disabled_reason.as_deref().unwrap().contains("disabled")); - } - - let diagnostics = model.header.diagnostics.join("\n"); - assert!(diagnostics.contains("Ticket records partially loaded: 6 invalid record")); - assert!(diagnostics.contains("showing first 5")); - assert!(!diagnostics.contains("super-secret-invalid")); - assert!( - !model - .rows - .iter() - .any(|row| row.title.contains("Leaked Secret Invalid")) - ); - } - - #[test] - fn workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid() { - let temp = TempDir::new().unwrap(); - write_ticket_config(temp.path()); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let mut corrupt_input = NewTicket::new("Ready With Corrupt Relations"); - corrupt_input.workflow_state = Some(TicketWorkflowState::Ready); - let corrupt = backend.create(corrupt_input).unwrap(); - let mut other_input = NewTicket::new("Other Ready Still Queueable"); - other_input.workflow_state = Some(TicketWorkflowState::Ready); - let other = backend.create(other_input).unwrap(); - - let artifacts = temp - .path() - .join(".yoi/tickets") - .join(&corrupt.id) - .join("artifacts"); - fs::create_dir_all(&artifacts).unwrap(); - fs::write(artifacts.join("relations.json"), "{").unwrap(); - - let model = build_workspace_panel(temp.path(), &empty_pods()); - - let corrupt_placeholders = model - .rows - .iter() - .filter(|row| row.key == PanelRowKey::InvalidTicket(corrupt.id.clone())) - .collect::>(); - assert_eq!(corrupt_placeholders.len(), 1); - let corrupt_placeholder = corrupt_placeholders[0]; - assert_eq!(corrupt_placeholder.kind, PanelRowKind::InvalidTicket); - assert_eq!(corrupt_placeholder.next_action, None); - assert!(corrupt_placeholder.ticket.is_none()); - assert!(!corrupt_placeholder.is_ticket_action()); - - assert!( - !model - .rows - .iter() - .any(|row| row.key == PanelRowKey::Ticket(corrupt.id.clone())) - ); - - let other_row = model - .rows - .iter() - .find(|row| row.key == PanelRowKey::Ticket(other.id.clone())) - .unwrap(); - assert_eq!(other_row.next_action, Some(NextUserAction::Queue)); - assert!(other_row.is_ticket_action()); - - let diagnostics = model.header.diagnostics.join("\n"); - assert!(diagnostics.contains("Ticket records partially loaded: 1 invalid record")); - } - - #[test] - fn workspace_panel_keeps_backend_config_unusable_as_whole_ticket_degradation() { - let temp = TempDir::new().unwrap(); - let config_dir = temp.path().join(".yoi"); - fs::create_dir_all(&config_dir).unwrap(); - fs::write( - config_dir.join("workspace.toml"), - "[ticket]\n\n[ticket.backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n", - ) - .unwrap(); - - let model = build_workspace_panel(temp.path(), &live_workers(temp.path(), &["idle"])); - - let diagnostics = model.header.diagnostics.join("\n"); - assert!(diagnostics.contains("Ticket config is unusable")); - assert!( - model - .rows - .iter() - .all(|row| row.kind != PanelRowKind::InvalidTicket) - ); - assert_eq!( - model.composer.available_targets, - vec![ComposerTarget::Companion] - ); - assert!( - model - .rows - .iter() - .any(|row| row.key == PanelRowKey::Worker("idle".to_string())) - ); - } - #[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_blocks_ready_ticket_with_planning_relation() { - 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::Ticket); - assert_eq!(row.next_action, Some(NextUserAction::Wait)); - assert_eq!(row.priority, ActionPriority::Background); - assert!(row.disabled_reason.is_some()); - assert!( - row.ticket - .as_ref() - .unwrap() - .blocked_reason - .as_deref() - .unwrap() - .contains(&dependency.id) - ); - } - - #[test] - fn workspace_panel_queues_ready_ticket_when_relation_prerequisite_is_queued() { - 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 After Queued Relation"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - let ready = backend.create(ready_input).unwrap(); - let mut dependency_input = NewTicket::new("Queued Relation Dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Queued); - let dependency = backend.create(dependency_input).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 After Queued Relation") - .unwrap(); - - assert_eq!(row.kind, PanelRowKind::Ticket); - assert_eq!(row.next_action, Some(NextUserAction::Queue)); - assert_eq!(row.priority, ActionPriority::ReadyForQueue); - assert!(row.disabled_reason.is_none()); - assert!( - row.ticket - .as_ref() - .unwrap() - .blocked_reason - .as_deref() - .unwrap_or_default() - .contains(&dependency.id) - ); - assert!(row.key_hint.as_deref().unwrap().contains("Queue targets:")); - assert!(row.key_hint.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_shows_ticket_associated_intake_workers_adjacent_to_ticket() { - let temp = TempDir::new().unwrap(); - write_ticket_config(temp.path()); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - create_ticket(&backend, "Ticket With Intake", |input| { - input.workflow_state = Some(TicketWorkflowState::Ready); - }); - let ticket_id = backend - .list(TicketListQuery::all()) - .unwrap() - .into_iter() - .find(|ticket| ticket.title == "Ticket With Intake") - .unwrap() - .id; - let preticket_pod = format!("pre-{ticket_id}-intake"); - let registry = PanelRegistryStore::from_root(temp.path().join("registry")); - registry - .claim_ticket(&ticket_id, None, "claimed-intake", "intake") - .unwrap(); - registry - .record_session( - "shared-intake", - "intake", - RoleSessionOrigin::RoleLaunch, - None, - [RelatedTicketRef { - id: ticket_id.clone(), - slug: None, - }], - ) - .unwrap(); - registry - .record_session( - &preticket_pod, - "intake", - RoleSessionOrigin::PreTicketIntake, - None, - [], - ) - .unwrap(); - - let workers = live_workers( - temp.path(), - &["claimed-intake", "shared-intake", &preticket_pod], - ); - let model = build_workspace_panel_with_registry( - temp.path(), - &workers, - ®istry.snapshot().unwrap(), - ); - - let ticket_index = model - .rows - .iter() - .position(|row| row.key == PanelRowKey::Ticket(ticket_id.clone())) - .unwrap(); - let ticket_row = &model.rows[ticket_index]; - let ticket = ticket_row.ticket.as_ref().unwrap(); - assert_eq!( - ticket - .intake_workers - .iter() - .map(|entry| entry.worker_name.as_str()) - .collect::>(), - vec!["claimed-intake", "shared-intake"] - ); - assert_eq!( - ticket.related_workers, - vec!["claimed-intake", "shared-intake"] - ); - assert_eq!( - model.rows[ticket_index + 1].key, - PanelRowKey::TicketIntakeWorker { - ticket_id: ticket_id.clone(), - worker_name: "claimed-intake".to_string(), - } - ); - assert_eq!( - model.rows[ticket_index + 1].kind, - PanelRowKind::TicketIntakeWorker - ); - assert_eq!(model.rows[ticket_index + 1].status, "live"); - assert_eq!( - model.rows[ticket_index + 1].next_action, - Some(NextUserAction::OpenWorker) - ); - assert_eq!( - model.rows[ticket_index + 2].key, - PanelRowKey::TicketIntakeWorker { - ticket_id: ticket_id.clone(), - worker_name: "shared-intake".to_string(), - } - ); - assert!(model.rows.iter().all(|row| { - row.kind != PanelRowKind::TicketIntakeWorker - || row.key.worker_name() != Some(preticket_pod.as_str()) - })); - } - - #[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(TicketListQuery::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_workers(temp.path(), &["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.worker_name, "ticket-claimed-intake"); - assert_eq!(claim.status, TicketLocalClaimStatus::Live); - assert_eq!(row.related_workers, vec!["ticket-claimed-intake"]); - assert!( - row.subtitle - .as_deref() - .unwrap() - .contains("claim: ticket-claimed-intake (live)") - ); - } - - #[test] - fn workspace_companion_worker_name_is_workspace_basename_without_suffix() { - assert_eq!( - workspace_companion_worker_name(Path::new("/home/hare/Projects/yoi")), - "yoi" - ); - assert_eq!( - workspace_companion_worker_name(Path::new("/tmp/Yoi Workspace")), - "yoi-workspace" - ); - assert_eq!( - workspace_companion_worker_name(Path::new("/tmp/.strange_日本語!!")), - "strange" - ); - assert_eq!( - workspace_companion_worker_name(Path::new("/tmp/___")), - "workspace" - ); - let long = "a".repeat(120); - let name = workspace_companion_worker_name(&PathBuf::from(format!("/tmp/{long}"))); - assert_eq!(name.chars().count(), 80); - assert!(!name.ends_with("-companion")); - } - - #[test] - fn companion_lifecycle_decisions_follow_worker_state_without_ticket_gate() { - assert_eq!( - decide_companion_lifecycle(&CompanionWorkerPresence::Live), - CompanionLifecyclePlan::ReportLive - ); - assert_eq!( - decide_companion_lifecycle(&CompanionWorkerPresence::Restorable), - CompanionLifecyclePlan::Restore - ); - assert_eq!( - decide_companion_lifecycle(&CompanionWorkerPresence::Missing), - CompanionLifecyclePlan::Spawn - ); - assert!(matches!( - decide_companion_lifecycle(&CompanionWorkerPresence::Unavailable( - "corrupt metadata".to_string() - )), - CompanionLifecyclePlan::Unavailable(message) if message.contains("corrupt metadata") - )); - } - - #[test] - fn workspace_orchestrator_worker_name_is_stable_and_safe() { - assert_eq!( - workspace_orchestrator_worker_name(Path::new("/tmp/Yoi Workspace")), - "yoi-workspace-orchestrator" - ); - assert_eq!( - workspace_orchestrator_worker_name(Path::new("/tmp/.strange_日本語!!")), - "strange-orchestrator" - ); - assert_eq!( - workspace_orchestrator_worker_name(Path::new("/tmp/___")), - "workspace-orchestrator" - ); - let long = "a".repeat(120); - let name = workspace_orchestrator_worker_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_worker_state() { - assert_eq!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Absent, - &OrchestratorWorkerPresence::Missing, - ), - OrchestratorLifecyclePlan::SkipNoTicketConfig - ); - assert!(matches!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Unusable("bad config".to_string()), - &OrchestratorWorkerPresence::Missing, - ), - OrchestratorLifecyclePlan::Unavailable(message) if message.contains("bad config") - )); - assert_eq!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Usable, - &OrchestratorWorkerPresence::Live, - ), - OrchestratorLifecyclePlan::ReportLive - ); - assert_eq!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Usable, - &OrchestratorWorkerPresence::Restorable, - ), - OrchestratorLifecyclePlan::Restore - ); - assert_eq!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Usable, - &OrchestratorWorkerPresence::Missing, - ), - OrchestratorLifecyclePlan::Spawn - ); - assert!(matches!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Usable, - &OrchestratorWorkerPresence::Unavailable("corrupt metadata".to_string()), - ), - OrchestratorLifecyclePlan::Unavailable(message) if message.contains("corrupt metadata") - )); - } - - #[test] - fn existing_non_file_workspace_settings_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("workspace.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| LiveWorkerInfo { - worker_name: format!("worker-{index:02}"), - socket_path: PathBuf::from(format!("/tmp/worker-{index:02}.sock")), - status: Some(WorkerStatus::Idle), - reachable: true, - segment_id: None, - summary: WorkerEntrySummary::default(), - }) - .chain(std::iter::once(LiveWorkerInfo { - worker_name: "zz-workspace-orchestrator".to_string(), - socket_path: PathBuf::from("/tmp/zz-workspace-orchestrator.sock"), - status: Some(WorkerStatus::Idle), - reachable: true, - segment_id: None, - summary: WorkerEntrySummary::default(), - })) - .collect::>(); - let visible = WorkerList::from_sources( - crate::worker_list::WorkerVisibilitySource::ResumePicker, - vec![], - live.clone(), - None, - 50, - ); - let authority = WorkerList::from_sources( - crate::worker_list::WorkerVisibilitySource::ResumePicker, - vec![], - live, - None, - usize::MAX, - ); - - assert_eq!( - workspace_orchestrator_worker_presence("zz-workspace-orchestrator", &visible), - OrchestratorWorkerPresence::Missing - ); - assert_eq!( - workspace_orchestrator_worker_presence("zz-workspace-orchestrator", &authority), - OrchestratorWorkerPresence::Live - ); - assert_eq!( - decide_orchestrator_lifecycle( - &TicketConfigAvailability::Usable, - &workspace_orchestrator_worker_presence("zz-workspace-orchestrator", &authority), - ), - OrchestratorLifecyclePlan::ReportLive - ); - } - - fn mixed_workspace_pods(current: &Path, external: &Path) -> WorkerList { - let stored = vec![ - StoredWorkerInfo { - worker_name: "current".to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at: 10, - workspace_root: Some(current.to_path_buf()), - preview: None, - }, - StoredWorkerInfo { - worker_name: "current-coder".to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at: 20, - workspace_root: Some(current.to_path_buf()), - preview: None, - }, - StoredWorkerInfo { - worker_name: "external".to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at: 30, - workspace_root: Some(external.to_path_buf()), - preview: None, - }, - StoredWorkerInfo { - worker_name: "legacy".to_string(), - metadata_state: StoredMetadataState::Present, - active_session_id: None, - active_segment_id: None, - updated_at: 40, - workspace_root: None, - preview: None, - }, - StoredWorkerInfo { - worker_name: "corrupt".to_string(), - metadata_state: StoredMetadataState::Corrupt("bad metadata".to_string()), - active_session_id: None, - active_segment_id: None, - updated_at: 50, - workspace_root: None, - preview: Some("metadata: bad metadata".to_string()), - }, - ]; - let live = [ - "current", - "current-coder", - "external", - "legacy", - "live-only", - ] - .iter() - .map(|name| LiveWorkerInfo { - worker_name: (*name).to_string(), - socket_path: PathBuf::from(format!("/tmp/{name}.sock")), - status: Some(WorkerStatus::Idle), - reachable: true, - segment_id: None, - summary: WorkerEntrySummary::default(), - }) - .collect(); - WorkerList::from_sources( - crate::worker_list::WorkerVisibilitySource::ResumePicker, - stored, - live, - None, - 10, - ) - } - - #[test] - fn workspace_panel_filters_worker_rows_to_current_workspace_metadata() { - let current = TempDir::new().unwrap(); - let external = TempDir::new().unwrap(); - let workers = mixed_workspace_pods(current.path(), external.path()); - - let model = build_workspace_panel(current.path(), &workers); - let worker_names = model - .rows - .iter() - .filter_map(|row| match &row.key { - PanelRowKey::Worker(name) => Some(name.as_str()), - _ => None, - }) - .collect::>(); - - assert_eq!(worker_names, vec!["current-coder", "current"]); - assert!( - model - .rows - .iter() - .filter(|row| matches!(row.key, PanelRowKey::Worker(_))) - .all(|row| row.next_action == Some(NextUserAction::OpenWorker)) - ); - } -} diff --git a/crates/worker/src/discovery.rs b/crates/worker/src/discovery.rs index 6e31005b..43762678 100644 --- a/crates/worker/src/discovery.rs +++ b/crates/worker/src/discovery.rs @@ -14,9 +14,9 @@ use std::process::Stdio; use std::sync::Arc; use std::time::Duration; +use crate::runtime_command::WorkerRuntimeCommand; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use async_trait::async_trait; -use client::WorkerRuntimeCommand; use manifest::{Permission, ScopeRule}; use protocol::stream::JsonLineReader; use protocol::{Event, Method, WorkerStatus}; diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 2ebb30d0..fac6ff8c 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -11,6 +11,7 @@ pub mod ipc; pub mod model_client; pub mod prompt; pub mod runtime; +pub mod runtime_command; pub mod segment_log_sink; mod session_capture; mod session_history; diff --git a/crates/client/src/runtime_command.rs b/crates/worker/src/runtime_command.rs similarity index 100% rename from crates/client/src/runtime_command.rs rename to crates/worker/src/runtime_command.rs diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index a2748b35..a9670187 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -1,4 +1,4 @@ -use client::{BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind}; +use client::{BackendTarget, StandaloneTarget, Target, TargetKind}; use serde::Deserialize; use super::{ParseError, read_client_default_connection, resolve_backend_url}; @@ -74,13 +74,14 @@ impl CliCommand { #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub(crate) enum ClientDefaultConnection { - Local, + #[serde(rename = "local")] + Standalone, Backend, } impl Default for ClientDefaultConnection { fn default() -> Self { - Self::Local + Self::Standalone } } @@ -89,7 +90,7 @@ pub(crate) enum CliConnectionInput<'a> { DefaultTarget { workspace_id: Option<&'a str>, }, - LocalTarget, + StandaloneTarget, BackendTarget { explicit_backend_url: Option, workspace_id: Option<&'a str>, @@ -130,15 +131,15 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver { match (command.connection_requirement(), input) { ( CliConnectionRequirement::LocalOnly, - CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget, - ) => Ok(Box::new(LocalTarget::new())), + CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::StandaloneTarget, + ) => standalone_target(), (CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => { Err(ParseError(format!( - "{} uses a local connection target and cannot accept Backend target options", + "{} uses a host-only connection target and cannot accept Backend target options", command.display_name() ))) } - (CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalTarget) => { + (CliConnectionRequirement::BackendOnly, CliConnectionInput::StandaloneTarget) => { Err(ParseError(format!( "{} requires a Backend connection target", command.display_name() @@ -161,19 +162,14 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver { resolve_backend_url(explicit_backend_url, workspace_id)?, workspace_id.map(str::to_string), ))), - (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) - if command == CliCommand::DefaultTui => - { + (CliConnectionRequirement::ConnectionAware, CliConnectionInput::StandaloneTarget) => { standalone_target() } - (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => { - Ok(Box::new(LocalTarget::new())) - } ( CliConnectionRequirement::ConnectionAware, CliConnectionInput::DefaultTarget { workspace_id }, ) => match read_client_default_connection()? { - ClientDefaultConnection::Local => Ok(Box::new(LocalTarget::new())), + ClientDefaultConnection::Standalone => standalone_target(), ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new( resolve_backend_url(None, workspace_id)?, workspace_id.map(str::to_string), @@ -187,15 +183,11 @@ pub(crate) fn resolve_local_cli_connection( resolver: &R, command: CliCommand, ) -> Result, ParseError> { - let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?; + let target = resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget)?; match target.kind() { - TargetKind::Local => Ok(target), - TargetKind::Standalone => Err(ParseError(format!( - "{} resolved Standalone where a legacy local target was required", - command.display_name() - ))), + TargetKind::Standalone => Ok(target), TargetKind::Backend => Err(ParseError(format!( - "{} resolved a Backend target where a local target was required", + "{} resolved a Backend target where a Standalone target was required", command.display_name() ))), } @@ -216,7 +208,7 @@ pub(crate) fn resolve_backend_cli_connection( )?; match target.kind() { TargetKind::Backend => Ok(target), - TargetKind::Local | TargetKind::Standalone => Err(ParseError(format!( + TargetKind::Standalone => Err(ParseError(format!( "{} resolved a non-Backend target where a Backend target was required", command.display_name() ))), @@ -236,7 +228,7 @@ pub(crate) fn resolve_connection_aware_cli_connection ParseError { ParseError(format!( - "{} uses a local connection target and cannot accept Backend target option `{option}`", + "{} uses a host-only connection target and cannot accept Backend target option `{option}`", command.display_name() )) } @@ -317,7 +309,7 @@ mod tests { } #[test] - fn cli_connection_resolver_rejects_local_only_backend_target() { + fn cli_connection_resolver_rejects_host_only_backend_target() { let resolver = ClientConfigCliConnectionResolver; let err = resolver .resolve_connection( @@ -331,15 +323,15 @@ mod tests { assert_eq!( err.to_string(), - "yoi keys uses a local connection target and cannot accept Backend target options" + "yoi keys uses a host-only connection target and cannot accept Backend target options" ); } #[test] - fn cli_connection_resolver_rejects_backend_only_local_target() { + fn cli_connection_resolver_rejects_backend_only_standalone_target() { let resolver = ClientConfigCliConnectionResolver; let err = resolver - .resolve_connection(CliCommand::Login, CliConnectionInput::LocalTarget) + .resolve_connection(CliCommand::Login, CliConnectionInput::StandaloneTarget) .unwrap_err(); assert_eq!( @@ -363,7 +355,7 @@ mod tests { let workers = target .list_workers(client::WorkerListRequest::new(None)) .unwrap(); - let backend_target = workers.backend_target.as_ref().unwrap(); + let backend_target = workers.backend_target; assert_eq!(backend_target.base_url, "http://127.0.0.1:8787"); assert_eq!(backend_target.workspace_id, None); } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 50bf8669..48d19706 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -23,7 +23,6 @@ use cli_connection::{ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login}; use memory_lint::{LintCliOptions, LintStatus}; use serde::{Deserialize, Serialize}; -use session_store::SegmentId; use tui::{LaunchMode, LaunchOptions}; #[derive(Debug)] @@ -129,10 +128,8 @@ async fn main() -> ExitCode { match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await { Ok(Ok(output)) => { print!("{}", output.stdout); - match output.status { - objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS, - objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE, - } + let objective_cli::ObjectiveCliStatus::Success = output.status; + ExitCode::SUCCESS } Ok(Err(e)) => { eprintln!("yoi objective: {e}"); @@ -336,7 +333,6 @@ fn parse_args_slice_with_connection_resolver( LaunchMode::Workers { runtime_id: None, include_stopped: false, - all: false, } } else { LaunchMode::Spawn { @@ -422,17 +418,20 @@ fn parse_args_slice_with_connection_resolver( &target_selection, &panel_options.workspace_root, )?; - if panel_options.include_stopped && target.kind() == TargetKind::Backend { + if panel_options.include_stopped { return Err(ParseError( - "yoi panel -r is only supported for local targets; Backend panel restore UI is not implemented" + "yoi panel -r was a removed host-local Worker path; Backend panel restore UI is not implemented" .to_string(), )); } + if target.kind() != TargetKind::Backend { + return Err(ParseError( + "yoi panel requires a Backend connection target".to_string(), + )); + } return Ok(Mode::Tui { target, - mode: LaunchMode::Panel { - include_stopped: panel_options.include_stopped, - }, + mode: LaunchMode::Panel, workspace_root: panel_options.workspace_root, }); } @@ -752,40 +751,41 @@ fn parse_console_options( mode: LaunchMode::Workers { runtime_id, include_stopped: false, - all: false, }, workspace_root, }); } + if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) { + return Err(ParseError( + "Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone session store" + .to_string(), + )); + } + let mode = if standalone_resume { LaunchMode::StandaloneResume { include_all: standalone_all, } - } else if let Some(profile) = profile { - LaunchMode::Spawn { - worker_name, - profile: Some(profile), - } } else if target.kind() == TargetKind::Standalone { LaunchMode::Spawn { worker_name, - profile: None, - } - } else if let Some(session) = session { - LaunchMode::ResumeWithSession { - id: parse_session_id(&session.to_string_lossy())?, - worker_name, - } - } else if let Some(worker_name) = worker_name { - LaunchMode::WorkerName { - worker_name, - socket_override, + profile, } } else { - LaunchMode::Spawn { - worker_name: None, - profile: None, + if worker_name.is_some() + || profile.is_some() + || session.is_some() + || socket_override.is_some() + { + return Err(ParseError( + "Backend target does not accept host-local Worker, profile, session, or socket selectors" + .to_string(), + )); + } + LaunchMode::Workers { + runtime_id: None, + include_stopped: false, } }; @@ -877,12 +877,17 @@ fn parse_workers_args( target_selection, &workspace_root, )?; + if target.kind() != TargetKind::Backend { + return Err(ParseError( + "yoi workers requires a Backend connection target; use yoi --local --resume for Standalone sessions" + .to_string(), + )); + } Ok(Mode::Tui { target, mode: LaunchMode::Workers { runtime_id, include_stopped, - all: false, }, workspace_root, }) @@ -984,13 +989,29 @@ fn parse_resume_args( &workspace_root, )?; - Ok(Mode::Tui { - target, - mode: LaunchMode::Workers { + let mode = if target.kind() == TargetKind::Standalone { + if runtime_id.is_some() { + return Err(ParseError( + "Standalone resume does not accept --runtime-id".to_string(), + )); + } + LaunchMode::StandaloneResume { include_all: all } + } else { + if all { + return Err(ParseError( + "Backend resume does not accept --all; select a Runtime explicitly when needed" + .to_string(), + )); + } + LaunchMode::Workers { runtime_id, include_stopped: true, - all, - }, + } + }; + + Ok(Mode::Tui { + target, + mode, workspace_root, }) } @@ -1688,78 +1709,54 @@ fn parse_panel_args(args: &[String]) -> Result { }) } -fn parse_session_id(value: &str) -> Result { - value - .parse() - .map_err(|_| ParseError(format!("invalid --session UUID: {value}"))) -} - const TOP_LEVEL_HELP: &str = r#"yoi Usage: - yoi [TARGET] [CONSOLE_OPTIONS] + yoi [TARGET] yoi --local --resume [--all] - yoi [TARGET] workers [-r|--stopped] [--workspace ] [--runtime-id ] - yoi [TARGET] resume [--workspace |--all] [--runtime-id ] - yoi [TARGET] panel [-r|--stopped] [--workspace ] + yoi [TARGET] workers [-r|--stopped] [--runtime-id ] + yoi [TARGET] resume [--all] [--runtime-id ] + yoi --backend [--workspace-id ] panel yoi [--backend ] login [--no-wait] - yoi [OPTIONS] + yoi [OPTIONS] Target selection: - Target options are top-level options and must appear before the command. - - --local Start or restore a client-owned one-process Standalone Worker - --resume With --local, open the Standalone session picker for the current cwd - --all With --local --resume, include sessions from every cwd identity + --local Use the client-owned one-process Standalone host + --resume With --local, restore from the Standalone session store + --all With Standalone restore, include sessions from every cwd identity --backend Use a Workspace Backend explicitly --workspace-id Scope Backend routes to a Workspace id - If no target is explicit, connection-aware commands use the merged client config: - /client/config.toml - /.yoi/client.config.toml - - Supported client config keys: - default_connection = "local" | "backend" - default_backend = "" - [backends.] url = "https://backend.example" - [workspaces.] backend = "" + If no target is explicit, connection-aware commands use the merged client config. + `default_connection = "local"` selects Standalone; it does not enable a filesystem + Ticket, Objective, Worker catalog, PID, socket, or subprocess authority. Connection-aware commands: - yoi Local: open a new Console. Backend: open Backend Workers. - yoi workers List/select Workers for the selected target. - yoi workers -r Include stopped Workers. --restoreable is accepted as a legacy alias. - yoi resume Open the Worker picker with stopped Workers included. - yoi panel Open the dashboard/panel TUI for the selected target. - yoi panel -r Local only: include stopped/restorable Worker rows. + yoi Standalone: new Console. Backend: Worker picker. + yoi resume Standalone session picker or stopped Backend Worker picker. + yoi workers Backend Workspace Worker picker. + yoi panel Backend Workspace dashboard. Console options: - --workspace Local workspace root for local Console/Worker lists (defaults to cwd) - --worker Open/create a named local Worker Console - --socket Attach to a local Worker socket; requires --worker - --session Resume a local session segment - --profile Select a reusable Profile recipe for a fresh local Worker - --runtime-id Backend Runtime id for Backend Worker list/attach - --worker-id Backend Worker id to attach; requires --runtime-id + --workspace Standalone cwd or client display scope (defaults to cwd) + --profile Select the Standalone Profile recipe + --runtime-id Backend Runtime id + --worker-id Backend Worker id; requires --runtime-id -Local commands: +Host commands: keys Manage local model/API keys setup-model Configure a local model provider - worker [WORKER_OPTIONS] Run the local Worker runtime CLI - worker delete Delete local Worker records - worker prune Prune old local Worker records - ticket Manage Tickets through the selected target - objective Manage Objectives through the selected target + worker [WORKER_OPTIONS] Run the direct Worker process entrypoint + ticket Manage Tickets through a Backend target + objective Manage Objectives through a Backend target plugin Build/check/list/show plugins mcp Inspect configured MCP servers memory lint Lint local memory files - session Inspect/prune local session logs - -Backend-only commands: - login Run Backend device login and save the API token + session Inspect/prune Standalone session logs Standalone binaries: yoi-server Workspace Backend server/admin CLI - yoi-runtime Worker Runtime REST server + yoi-runtime Worker Runtime server Options: -h, --help Print help @@ -1785,9 +1782,7 @@ fn print_memory_lint_help() { mod tests { use super::*; use crate::cli_connection::CliConnectionInput; - use client::{ - BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest, - }; + use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest}; struct FixedCliConnectionResolver { backend_url: &'static str, @@ -1800,8 +1795,10 @@ mod tests { input: CliConnectionInput<'_>, ) -> Result, ParseError> { match input { - CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget => { - Ok(Box::new(LocalTarget::new())) + CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::StandaloneTarget => { + Ok(Box::new(StandaloneTarget::new( + "/tmp/yoi-test-standalone-state", + ))) } CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new( BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)), @@ -1823,7 +1820,7 @@ mod tests { let workspace_id = match input { CliConnectionInput::DefaultTarget { workspace_id } | CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id, - CliConnectionInput::LocalTarget => { + CliConnectionInput::StandaloneTarget => { return Ok(Box::new(StandaloneTarget::new( "/tmp/yoi-test-standalone-state", ))); @@ -1837,33 +1834,27 @@ mod tests { } #[test] - fn parser_uses_local_target_for_workers_without_backend_option() { + fn parser_never_uses_host_local_worker_catalog_without_backend_option() { let resolver = FixedCliConnectionResolver { backend_url: "http://fake-backend.example", }; let args = vec!["workers".to_string()]; - let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(); + let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err(); - match mode { - Mode::Tui { - target, - mode: LaunchMode::Workers { runtime_id, .. }, - .. - } => { - assert_eq!(runtime_id, None); - assert_eq!(target.kind(), TargetKind::Local); - let workers = target.list_workers(WorkerListRequest::new(None)).unwrap(); - assert!(workers.local_runtime_command.is_some()); - assert!(workers.backend_target.is_none()); - } - other => panic!("expected Workers mode, got {other:?}"), - } + assert!( + error + .to_string() + .contains("requires a Backend connection target") + ); } #[test] - fn client_config_default_connection_defaults_to_local() { + fn client_config_default_connection_defaults_to_standalone() { let config = ClientConfigFile::default(); - assert_eq!(config.default_connection, ClientDefaultConnection::Local); + assert_eq!( + config.default_connection, + ClientDefaultConnection::Standalone + ); } #[test] @@ -1936,11 +1927,9 @@ backend = "shared" #[test] fn top_level_help_matches_current_target_surface() { - assert!(TOP_LEVEL_HELP.contains("Target options are top-level options")); - assert!(TOP_LEVEL_HELP.contains("--local")); - assert!(TOP_LEVEL_HELP.contains("--backend ")); - assert!(TOP_LEVEL_HELP.contains("/client/config.toml")); - assert!(TOP_LEVEL_HELP.contains("/.yoi/client.config.toml")); + assert!(TOP_LEVEL_HELP.contains("Target selection:")); + assert!(TOP_LEVEL_HELP.contains("client config")); + assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\"")); assert!(TOP_LEVEL_HELP.contains("yoi-server")); assert!(TOP_LEVEL_HELP.contains("yoi-runtime")); assert!(!TOP_LEVEL_HELP.contains("yoi workspace")); @@ -1953,28 +1942,10 @@ backend = "shared" let err = parse_args_from(["keys", "--workspace-id=workspace-a"]).unwrap_err(); assert_eq!( err.to_string(), - "yoi keys uses a local connection target and cannot accept Backend target option `--workspace-id=workspace-a`" + "yoi keys uses a host-only connection target and cannot accept Backend target option `--workspace-id=workspace-a`" ); } - #[test] - fn parse_worker_name_mode() { - match parse_args_from(["--worker", "agent", "--socket", "/tmp/agent.sock"]).unwrap() { - Mode::Tui { - mode: - LaunchMode::WorkerName { - worker_name, - socket_override, - }, - .. - } => { - assert_eq!(worker_name, "agent"); - assert_eq!(socket_override, Some(PathBuf::from("/tmp/agent.sock"))); - } - _ => panic!("expected WorkerName mode"), - } - } - #[test] fn parse_backend_runtime_target_mode() { match parse_args_from([ @@ -2034,19 +2005,8 @@ backend = "shared" let workers = target .list_workers(WorkerListRequest::new(runtime_id.clone())) .unwrap(); - assert_eq!( - workers.backend_target.as_ref().unwrap().base_url, - "http://127.0.0.1:8787" - ); - assert_eq!( - workers - .backend_target - .as_ref() - .unwrap() - .runtime_id - .as_deref(), - Some("r") - ); + assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787"); + assert_eq!(workers.backend_target.runtime_id.as_deref(), Some("r")); assert_eq!(runtime_id.as_deref(), Some("r")); } _ => panic!("expected Workers mode"), @@ -2073,17 +2033,9 @@ backend = "shared" let workers = target .list_workers(WorkerListRequest::new(runtime_id.clone())) .unwrap(); + assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787"); assert_eq!( - workers.backend_target.as_ref().unwrap().base_url, - "http://127.0.0.1:8787" - ); - assert_eq!( - workers - .backend_target - .as_ref() - .unwrap() - .workspace_id - .as_deref(), + workers.backend_target.workspace_id.as_deref(), Some("workspace-a") ); assert_eq!(runtime_id, None); @@ -2114,10 +2066,7 @@ backend = "shared" profile: None, } if name == "my-local-worker" )); - assert!(matches!( - target.spawn_worker().unwrap(), - client::WorkerSpawn::Standalone { .. } - )); + assert!(target.spawn_worker().unwrap().state_dir.is_absolute()); } #[test] @@ -2144,10 +2093,6 @@ backend = "shared" LaunchMode::StandaloneResume { include_all: true } )); - assert_eq!( - parse_args_from(["--resume"]).unwrap_err().to_string(), - "--resume is a Standalone option and requires --local" - ); assert_eq!( parse_args_from(["--local", "--all"]) .unwrap_err() @@ -2229,61 +2174,43 @@ backend = "shared" } #[test] - fn parse_resume_subcommand_defaults_to_workspace_scope() { + fn parse_resume_subcommand_uses_standalone_store_by_default() { match parse_args_from(["resume"]).unwrap() { Mode::Tui { - mode: - LaunchMode::Workers { - include_stopped, - all, - .. - }, + target, + mode: LaunchMode::StandaloneResume { include_all }, .. } => { - assert!(include_stopped); - assert!(!all); + assert_eq!(target.kind(), TargetKind::Standalone); + assert!(!include_all); } - _ => panic!("expected Workers mode"), + other => panic!("expected StandaloneResume mode, got {other:?}"), } } #[test] - fn parse_resume_workspace_scope() { + fn parse_resume_preserves_standalone_cwd_scope() { match parse_args_from(["resume", "--workspace", "/tmp/resume-workspace"]).unwrap() { Mode::Tui { - mode: - LaunchMode::Workers { - include_stopped, - all, - .. - }, + mode: LaunchMode::StandaloneResume { include_all }, workspace_root, .. } => { - assert!(include_stopped); - assert!(!all); + assert!(!include_all); assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace")); } - _ => panic!("expected Workers mode"), + other => panic!("expected StandaloneResume mode, got {other:?}"), } } #[test] - fn parse_resume_all_scope() { + fn parse_resume_all_expands_only_standalone_store_scope() { match parse_args_from(["resume", "--all"]).unwrap() { Mode::Tui { - mode: - LaunchMode::Workers { - include_stopped, - all, - .. - }, + mode: LaunchMode::StandaloneResume { include_all }, .. - } => { - assert!(include_stopped); - assert!(all); - } - _ => panic!("expected Workers mode"), + } => assert!(include_all), + other => panic!("expected StandaloneResume mode, got {other:?}"), } } @@ -2357,7 +2284,7 @@ backend = "shared" match parse_args_from(["ticket", "doctor"]).unwrap() { Mode::Ticket { cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor), - target: client::ResolvedTarget::Local, + target: client::ResolvedTarget::Standalone, } => {} _ => panic!("expected Ticket doctor mode"), } @@ -2432,7 +2359,7 @@ backend = "shared" match parse_args_from(["ticket", "--help"]).unwrap() { Mode::Ticket { cli: ticket_cli::TicketCli::Help, - target: client::ResolvedTarget::Local, + target: client::ResolvedTarget::Standalone, } => {} _ => panic!("expected Ticket help mode"), } @@ -2472,24 +2399,6 @@ backend = "shared" assert_eq!(err.to_string(), "yoi setup-model does not accept arguments"); } - #[test] - fn parse_literal_worker_name_still_available_with_flag() { - match parse_args_from(["--worker", "worker"]).unwrap() { - Mode::Tui { - mode: - LaunchMode::WorkerName { - worker_name, - socket_override, - }, - .. - } => { - assert_eq!(worker_name, "worker"); - assert_eq!(socket_override, None); - } - _ => panic!("expected WorkerName mode"), - } - } - #[test] fn parse_memory_lint_mode() { match parse_args_from([ @@ -2613,32 +2522,6 @@ backend = "shared" assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand"); } - #[test] - fn parse_session_accepts_explicit_runtime_pod_identity() { - let segment_id = session_store::new_segment_id(); - match parse_args_from([ - "--session", - &segment_id.to_string(), - "--worker", - "explicit-name", - ]) - .unwrap() - { - Mode::Tui { - mode: - LaunchMode::ResumeWithSession { - id, - worker_name: Some(worker_name), - }, - .. - } => { - assert_eq!(id, segment_id); - assert_eq!(worker_name, "explicit-name"); - } - _ => panic!("expected ResumeWithSession mode with explicit worker name"), - } - } - #[test] fn parse_rejects_legacy_resume_flags() { let cases = [ @@ -2727,48 +2610,6 @@ backend = "shared" } } - #[test] - fn parse_panel_mode() { - match parse_args_from(["panel", "--workspace", "/tmp/other-workspace"]).unwrap() { - Mode::Tui { - mode: - LaunchMode::Panel { - include_stopped: false, - }, - workspace_root, - .. - } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")), - _ => panic!("expected Panel mode"), - } - } - - #[test] - fn parse_panel_stopped_mode() { - for flag in ["-r", "--stopped", "--restoreable"] { - match parse_args_from(["panel", flag, "--workspace", "/tmp/other-workspace"]).unwrap() { - Mode::Tui { - mode: - LaunchMode::Panel { - include_stopped: true, - }, - workspace_root, - .. - } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")), - _ => panic!("expected Panel stopped mode for {flag}"), - } - } - } - - #[test] - fn parse_backend_panel_stopped_is_not_supported() { - let err = - parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err(); - assert_eq!( - err.to_string(), - "yoi panel -r is only supported for local targets; Backend panel restore UI is not implemented" - ); - } - #[test] fn parse_dashboard_word_is_not_an_alias_or_worker_name() { let err = parse_args_from(["dashboard"]).unwrap_err(); @@ -2804,4 +2645,30 @@ backend = "shared" _ => panic!("expected MemoryLintHelp mode"), } } + #[test] + fn parse_backend_panel_uses_backend_dashboard_only() { + match parse_args_from([ + "--backend", + "http://127.0.0.1:8787", + "--workspace-id", + "workspace-a", + "panel", + ]) + .unwrap() + { + Mode::Tui { + target, + mode: LaunchMode::Panel, + .. + } => assert_eq!(target.kind(), TargetKind::Backend), + other => panic!("expected Backend Panel mode, got {other:?}"), + } + } + + #[test] + fn parse_panel_rejects_removed_host_local_restore_path() { + let err = + parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err(); + assert!(err.to_string().contains("removed host-local Worker path")); + } } diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs index 3ce9c7eb..7603312d 100644 --- a/crates/yoi/src/objective_cli.rs +++ b/crates/yoi/src/objective_cli.rs @@ -1,21 +1,6 @@ use std::fmt; -use std::fs; -use std::path::{Component, Path, PathBuf}; -use chrono::Utc; use client::{BackendWorkspaceProductClient, ResolvedTarget}; -use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id}; -use serde::Deserialize; -use ticket::config::TicketConfig; - -const OBJECTIVE_ROOT_RELATIVE_PATH: &str = ".yoi/objectives"; -const REQUIRED_SECTION_HEADINGS: [&str; 5] = [ - "## Goal", - "## Motivation / background", - "## Strategy / design direction", - "## Success criteria / exit conditions", - "## Decision context", -]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ObjectiveCli { @@ -33,12 +18,12 @@ pub enum ObjectiveCommand { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateOptions { - pub title: String, - pub linked_tickets: Vec, + title: String, + linked_tickets: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ObjectiveListState { +enum ObjectiveListState { Active, Paused, Done, @@ -46,15 +31,14 @@ pub enum ObjectiveListState { All, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ListOptions { - pub state: ObjectiveListState, + state: ObjectiveListState, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ObjectiveCliStatus { Success, - Failure, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -80,18 +64,6 @@ impl fmt::Display for ObjectiveCliError { impl std::error::Error for ObjectiveCliError {} -impl From for ObjectiveCliError { - fn from(error: std::io::Error) -> Self { - Self::new(error.to_string()) - } -} - -impl From for ObjectiveCliError { - fn from(error: ticket::config::TicketConfigError) -> Self { - Self::new(error.to_string()) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ObjectiveState { Active, @@ -101,15 +73,6 @@ enum ObjectiveState { } impl ObjectiveState { - fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Paused => "paused", - Self::Done => "done", - Self::Archived => "archived", - } - } - fn parse(value: &str) -> Option { match value { "active" => Some(Self::Active), @@ -121,23 +84,6 @@ impl ObjectiveState { } } -#[derive(Debug, Clone, Deserialize)] -struct ObjectiveFrontmatter { - title: String, - state: String, - created_at: String, - updated_at: String, - #[serde(default)] - linked_tickets: Vec, -} - -#[derive(Debug, Clone)] -struct ObjectiveRecord { - id: String, - meta: ObjectiveFrontmatter, - body: String, -} - pub fn parse_objective_args(args: &[String]) -> Result { if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { return Ok(ObjectiveCli::Help); @@ -173,14 +119,8 @@ pub fn run( target: ResolvedTarget, ) -> Result { match target { - ResolvedTarget::Local => { - let workspace = std::env::current_dir().map_err(|error| { - ObjectiveCliError::new(format!("failed to resolve current directory: {error}")) - })?; - run_in_workspace(cli, &workspace) - } ResolvedTarget::Standalone => Err(ObjectiveCliError::new( - "Standalone is a one-shot Worker host, not Objective storage authority", + "Standalone is a one-shot Worker host, not Objective storage authority; select a Backend target", )), ResolvedTarget::Backend { base_url, @@ -267,319 +207,6 @@ fn run_with_backend( } } -pub fn run_in_workspace( - cli: ObjectiveCli, - workspace: &Path, -) -> Result { - match cli { - ObjectiveCli::Help => Ok(success(help_text().to_string())), - ObjectiveCli::Command(ObjectiveCommand::Create(options)) => create(workspace, options), - ObjectiveCli::Command(ObjectiveCommand::List(options)) => list(workspace, options), - ObjectiveCli::Command(ObjectiveCommand::Show { id }) => show(workspace, id), - ObjectiveCli::Command(ObjectiveCommand::Doctor) => doctor(workspace), - } -} - -fn create( - workspace: &Path, - options: CreateOptions, -) -> Result { - let title = options.title.trim(); - if title.is_empty() { - return Err(ObjectiveCliError::new("create --title must not be empty")); - } - validate_ticket_links(workspace, &options.linked_tickets)?; - - let root = objective_root(workspace); - fs::create_dir_all(&root)?; - let base_millis = unix_epoch_millis_now().map_err(|error| { - ObjectiveCliError::new(format!("failed to read objective id timestamp: {error}")) - })?; - let id = allocate_record_id(base_millis, |candidate| root.join(candidate).exists()).map_err( - |error| ObjectiveCliError::new(format!("failed to allocate unique objective id: {error}")), - )?; - let dir = root.join(&id); - - fs::create_dir_all(&dir)?; - fs::write( - dir.join("item.md"), - render_objective_item(title, &options.linked_tickets), - )?; - Ok(success(format!("created\t{id}\n"))) -} - -fn list(workspace: &Path, options: ListOptions) -> Result { - let mut records = load_objectives(workspace)?; - records.sort_by(|a, b| { - b.meta - .updated_at - .cmp(&a.meta.updated_at) - .then(a.id.cmp(&b.id)) - }); - let mut stdout = String::from("state\tid\ttitle\tupdated_at\tlinked_tickets\n"); - for record in records { - let state = ObjectiveState::parse(&record.meta.state); - if !list_state_matches(options.state, state) { - continue; - } - stdout.push_str(&format!( - "{}\t{}\t{}\t{}\t{}\n", - record.meta.state, - record.id, - record.meta.title, - record.meta.updated_at, - record.meta.linked_tickets.join(",") - )); - } - Ok(success(stdout)) -} - -fn show(workspace: &Path, id: String) -> Result { - validate_record_component(&id)?; - let record = load_objective(&objective_root(workspace).join(&id), &id)?; - let mut stdout = String::new(); - stdout.push_str(&format!("# {}\n\n", record.meta.title)); - stdout.push_str(&format!("State: {}\n", record.meta.state)); - stdout.push_str(&format!("ID: {}\n", record.id)); - stdout.push_str(&format!("Updated: {}\n", record.meta.updated_at)); - stdout.push_str("\n## item.md\n\n---\n"); - stdout.push_str(&format!("title: {}\n", yaml_string(&record.meta.title))); - stdout.push_str(&format!("state: {}\n", yaml_string(&record.meta.state))); - stdout.push_str(&format!( - "created_at: {}\n", - yaml_string(&record.meta.created_at) - )); - stdout.push_str(&format!( - "updated_at: {}\n", - yaml_string(&record.meta.updated_at) - )); - stdout.push_str(&format!( - "linked_tickets: {}\n", - yaml_string_array(&record.meta.linked_tickets) - )); - stdout.push_str("---\n\n"); - stdout.push_str(&record.body); - if !stdout.ends_with('\n') { - stdout.push('\n'); - } - Ok(success(stdout)) -} - -fn doctor(workspace: &Path) -> Result { - let root = objective_root(workspace); - if !root.exists() { - return Ok(success("doctor: ok\n".to_string())); - } - let mut diagnostics = Vec::new(); - for entry in sorted_dirs(&root)? { - let id = entry.file_name().to_string_lossy().to_string(); - if let Err(error) = validate_record_component(&id) { - diagnostics.push(format!("error\t{id}\t{error}")); - continue; - } - match load_objective(&entry.path(), &id) { - Ok(record) => validate_record(workspace, &record, &mut diagnostics)?, - Err(error) => diagnostics.push(format!("error\t{id}\t{error}")), - } - } - if diagnostics.is_empty() { - Ok(success("doctor: ok\n".to_string())) - } else { - let mut stdout = String::new(); - for diagnostic in diagnostics { - stdout.push_str(&diagnostic); - stdout.push('\n'); - } - Ok(ObjectiveCliOutput { - status: ObjectiveCliStatus::Failure, - stdout, - }) - } -} - -fn validate_record( - workspace: &Path, - record: &ObjectiveRecord, - diagnostics: &mut Vec, -) -> Result<(), ObjectiveCliError> { - if record.meta.title.trim().is_empty() { - diagnostics.push(format!("error\t{}\ttitle must not be empty", record.id)); - } - if ObjectiveState::parse(&record.meta.state).is_none() { - diagnostics.push(format!( - "error\t{}\tinvalid state {}; expected active|paused|done|archived", - record.id, record.meta.state - )); - } - for field in [ - record.meta.created_at.as_str(), - record.meta.updated_at.as_str(), - ] { - if field.trim().is_empty() { - diagnostics.push(format!( - "error\t{}\ttimestamps must not be empty", - record.id - )); - } - } - for heading in REQUIRED_SECTION_HEADINGS { - if !record.body.contains(heading) { - diagnostics.push(format!("error\t{}\tmissing section {heading}", record.id)); - } - } - let mut seen = std::collections::BTreeSet::new(); - for ticket_id in &record.meta.linked_tickets { - if !seen.insert(ticket_id) { - diagnostics.push(format!( - "warning\t{}\tduplicate linked ticket {}", - record.id, ticket_id - )); - } - } - if let Err(error) = validate_ticket_links(workspace, &record.meta.linked_tickets) { - diagnostics.push(format!("error\t{}\t{error}", record.id)); - } - Ok(()) -} - -fn load_objectives(workspace: &Path) -> Result, ObjectiveCliError> { - let root = objective_root(workspace); - if !root.exists() { - return Ok(Vec::new()); - } - let mut records = Vec::new(); - for entry in sorted_dirs(&root)? { - let id = entry.file_name().to_string_lossy().to_string(); - records.push(load_objective(&entry.path(), &id)?); - } - Ok(records) -} - -fn load_objective(dir: &Path, id: &str) -> Result { - validate_record_component(id)?; - let path = dir.join("item.md"); - let raw = fs::read_to_string(&path) - .map_err(|error| ObjectiveCliError::new(format!("{}: {error}", path.display())))?; - let (frontmatter, body) = split_frontmatter(&raw).ok_or_else(|| { - ObjectiveCliError::new(format!("{}: missing YAML frontmatter", path.display())) - })?; - let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter).map_err(|error| { - ObjectiveCliError::new(format!( - "{}: invalid YAML frontmatter: {error}", - path.display() - )) - })?; - Ok(ObjectiveRecord { - id: id.to_string(), - meta, - body: body.to_string(), - }) -} - -fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { - let rest = raw.strip_prefix("---\n")?; - let (frontmatter, body) = rest.split_once("\n---\n")?; - Some((frontmatter, body)) -} - -fn validate_ticket_links(workspace: &Path, ticket_ids: &[String]) -> Result<(), ObjectiveCliError> { - let config = TicketConfig::load_workspace(workspace)?; - let ticket_root = config.backend_root().to_path_buf(); - for ticket_id in ticket_ids { - validate_record_component(ticket_id)?; - let item = ticket_root.join(ticket_id).join("item.md"); - if !item.is_file() { - return Err(ObjectiveCliError::new(format!( - "linked ticket {ticket_id} does not exist as canonical Ticket id under {}", - ticket_root.display() - ))); - } - } - Ok(()) -} - -fn validate_record_component(value: &str) -> Result<(), ObjectiveCliError> { - if value.is_empty() || value == "." || value == ".." { - return Err(ObjectiveCliError::new(format!( - "invalid path-derived id component: {value}" - ))); - } - let path = Path::new(value); - if path.components().count() != 1 { - return Err(ObjectiveCliError::new(format!( - "invalid path-derived id component: {value}" - ))); - } - match path.components().next() { - Some(Component::Normal(_)) => validate_record_id(value).map_err(|error| { - ObjectiveCliError::new(format!("{value} is not a canonical record id: {error}")) - }), - _ => Err(ObjectiveCliError::new(format!( - "invalid path-derived id component: {value}" - ))), - } -} - -fn sorted_dirs(root: &Path) -> Result, ObjectiveCliError> { - let mut entries = Vec::new(); - for entry in fs::read_dir(root)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - entries.push(entry); - } - } - entries.sort_by_key(|entry| entry.file_name()); - Ok(entries) -} - -fn objective_root(workspace: &Path) -> PathBuf { - workspace.join(OBJECTIVE_ROOT_RELATIVE_PATH) -} - -fn list_state_matches(filter: ObjectiveListState, state: Option) -> bool { - match filter { - ObjectiveListState::All => true, - ObjectiveListState::Active => state == Some(ObjectiveState::Active), - ObjectiveListState::Paused => state == Some(ObjectiveState::Paused), - ObjectiveListState::Done => state == Some(ObjectiveState::Done), - ObjectiveListState::Archived => state == Some(ObjectiveState::Archived), - } -} - -fn objective_body_template() -> String { - "## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n" - .to_string() -} - -fn render_objective_item(title: &str, linked_tickets: &[String]) -> String { - let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - format!( - "---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n{}\n", - yaml_string(title), - yaml_string(ObjectiveState::Active.as_str()), - yaml_string(&now), - yaml_string(&now), - yaml_string_array(linked_tickets), - objective_body_template() - ) -} - -fn yaml_string(value: &str) -> String { - format!("{:?}", value) -} - -fn yaml_string_array(values: &[String]) -> String { - if values.is_empty() { - return "[]".to_string(); - } - let items = values - .iter() - .map(|value| yaml_string(value)) - .collect::>() - .join(", "); - format!("[{items}]") -} - fn parse_create(args: &[String]) -> Result { let mut title = None; let mut linked_tickets = Vec::new(); @@ -678,6 +305,21 @@ fn parse_list_state(value: &str) -> Result) -> bool { + match filter { + ObjectiveListState::All => true, + ObjectiveListState::Active => state == Some(ObjectiveState::Active), + ObjectiveListState::Paused => state == Some(ObjectiveState::Paused), + ObjectiveListState::Done => state == Some(ObjectiveState::Done), + ObjectiveListState::Archived => state == Some(ObjectiveState::Archived), + } +} + +fn objective_body_template() -> String { + "## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n" + .to_string() +} + fn success(stdout: String) -> ObjectiveCliOutput { ObjectiveCliOutput { status: ObjectiveCliStatus::Success, @@ -686,137 +328,39 @@ fn success(stdout: String) -> ObjectiveCliOutput { } fn help_text() -> &'static str { - "yoi objective\n\nUsage:\n yoi objective create --title [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nBackend targets use the Workspace-scoped Objective API selected by the shared client Target. Explicit local targets preserve the repository-file Objective backend. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n" + "yoi objective\n\nUsage:\n yoi objective create --title <TITLE> [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nObjective commands require the Workspace-scoped Backend selected by the shared client Target. Standalone does not provide Objective authority.\n" } #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; fn args(values: &[&str]) -> Vec<String> { values.iter().map(|value| value.to_string()).collect() } - fn run(temp: &TempDir, values: &[&str]) -> ObjectiveCliOutput { - let cli = parse_objective_args(&args(values)).unwrap(); - run_in_workspace(cli, temp.path()).unwrap() - } - - fn create_ticket_dir(temp: &TempDir, ticket_id: &str) { - let dir = temp.path().join(".yoi/tickets").join(ticket_id); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("item.md"), - "---\ntitle: \"Ticket\"\nstate: \"planning\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\n---\n\nBody\n", - ) - .unwrap(); - } - - fn created_id(output: &ObjectiveCliOutput) -> String { - output - .stdout - .strip_prefix("created\t") - .unwrap() - .trim() - .to_string() + #[test] + fn standalone_rejects_objective_storage_operations() { + let cli = parse_objective_args(&args(&["list"])).unwrap(); + let error = run(cli, ResolvedTarget::Standalone).unwrap_err(); + assert!(error.to_string().contains("select a Backend target")); } #[test] - fn objective_cli_creates_lists_and_shows_records() { - let temp = TempDir::new().unwrap(); - create_ticket_dir(&temp, "00001KTKMS0VG"); - - let created = run( - &temp, - &[ - "create", - "--title", - "Medium-term goal", - "--ticket", - "00001KTKMS0VG", - ], - ); - let objective_id = created_id(&created); - validate_record_id(&objective_id).unwrap(); - assert_eq!(objective_id.len(), project_record::RECORD_ID_WIDTH); - assert!( - temp.path() - .join(".yoi/objectives") - .join(&objective_id) - .join("item.md") - .exists() - ); - - let listed = run(&temp, &["list", "--state", "active"]); - assert!(listed.stdout.contains(&objective_id)); - assert!(listed.stdout.contains("00001KTKMS0VG")); - - let shown = run(&temp, &["show", &objective_id]); - assert!(shown.stdout.contains("# Medium-term goal")); - assert!( - shown - .stdout - .contains("## Success criteria / exit conditions") - ); - assert!(shown.stdout.contains("00001KTKMS0VG")); + fn objective_parser_keeps_backend_command_contract() { + assert!(matches!( + parse_objective_args(&args(&["create", "--title", "Goal"])).unwrap(), + ObjectiveCli::Command(ObjectiveCommand::Create(_)) + )); + assert!(matches!( + parse_objective_args(&args(&["list", "--state", "active"])).unwrap(), + ObjectiveCli::Command(ObjectiveCommand::List(_)) + )); } #[test] - fn objective_cli_validates_ticket_links() { - let temp = TempDir::new().unwrap(); - let cli = parse_objective_args(&args(&[ - "create", - "--title", - "Broken link", - "--ticket", - "0000000000ABC", - ])) - .unwrap(); - let err = run_in_workspace(cli, temp.path()).unwrap_err(); - assert!( - err.to_string() - .contains("linked ticket 0000000000ABC does not exist") - ); - } - - #[test] - fn objective_doctor_reports_invalid_linked_ticket() { - let temp = TempDir::new().unwrap(); - let dir = temp.path().join(".yoi/objectives/0000000000001"); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("item.md"), - "---\ntitle: \"Goal\"\nstate: \"active\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\nlinked_tickets: [\"0000000000ABD\"]\n---\n\n## Goal\n\nText\n\n## Motivation / background\n\nText\n\n## Strategy / design direction\n\nText\n\n## Success criteria / exit conditions\n\n- Text\n\n## Decision context\n\n- Text\n", - ) - .unwrap(); - - let output = run(&temp, &["doctor"]); - assert_eq!(output.status, ObjectiveCliStatus::Failure); - assert!( - output - .stdout - .contains("linked ticket 0000000000ABD does not exist") - ); - } - - #[test] - fn objective_doctor_accepts_well_formed_records() { - let temp = TempDir::new().unwrap(); - create_ticket_dir(&temp, "00001KTKMS0VG"); - run( - &temp, - &[ - "create", - "--title", - "Good objective", - "--ticket", - "00001KTKMS0VG", - ], - ); - - let output = run(&temp, &["doctor"]); - assert_eq!(output.status, ObjectiveCliStatus::Success); - assert_eq!(output.stdout, "doctor: ok\n"); + fn help_states_backend_authority() { + assert!(help_text().contains("require the Workspace-scoped Backend")); + assert!(!help_text().contains("repository-file")); } } diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index fd965e62..db1f5399 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -1,19 +1,12 @@ use std::fmt; use std::fs; -use std::hash::{Hash, Hasher}; -use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use chrono::{SecondsFormat, Utc}; use client::{BackendWorkspaceProductClient, ResolvedTarget}; -use ticket::config::{ - TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH, - ticket_config_scaffold, -}; use ticket::{ - LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, - SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, - TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState, + MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, + TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketListQuery, TicketListState, + TicketRelationKind, TicketSummary, TicketWorkflowState, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -29,8 +22,6 @@ pub enum TicketCli { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TicketCommand { - Init, - ImportLocal, Create(CreateOptions), List(ListOptions), Show { query: String }, @@ -148,12 +139,6 @@ impl From<ticket::TicketError> for TicketCliError { } } -impl From<ticket::config::TicketConfigError> for TicketCliError { - fn from(error: ticket::config::TicketConfigError) -> Self { - Self::new(error.to_string()) - } -} - impl From<std::io::Error> for TicketCliError { fn from(error: std::io::Error) -> Self { Self::new(error.to_string()) @@ -166,20 +151,6 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> { } let command = match args[0].as_str() { - "init" => { - if args.len() != 1 { - return Err(TicketCliError::new("ticket init takes no arguments")); - } - TicketCommand::Init - } - "import-local" => { - if args.len() != 1 { - return Err(TicketCliError::new( - "ticket import-local takes no arguments", - )); - } - TicketCommand::ImportLocal - } "create" => TicketCommand::Create(parse_create(&args[1..])?), "list" => TicketCommand::List(parse_list(&args[1..])?), "show" => TicketCommand::Show { @@ -208,14 +179,8 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> { pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> { match target { - ResolvedTarget::Local => { - let workspace = std::env::current_dir().map_err(|error| { - TicketCliError::new(format!("failed to resolve current directory: {error}")) - })?; - run_in_workspace(cli, &workspace) - } ResolvedTarget::Standalone => Err(TicketCliError::new( - "Standalone is a one-shot Worker host, not Ticket storage authority", + "Standalone is a one-shot Worker host, not Ticket storage authority; select a Backend target; select a Backend target", )), ResolvedTarget::Backend { base_url, @@ -225,9 +190,6 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, Ti status: TicketCliStatus::Success, stdout: help_text().to_string(), }), - TicketCli::Command(TicketCommand::Init | TicketCommand::ImportLocal) => Err( - TicketCliError::new("ticket init/import-local require an explicit local target"), - ), TicketCli::Command(command) => { let backend = BackendWorkspaceProductClient::new(base_url, workspace_id) .map_err(|error| TicketCliError::new(error.to_string()))?; @@ -237,33 +199,6 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, Ti } } -pub fn run_in_workspace( - cli: TicketCli, - workspace: &Path, -) -> Result<TicketCliOutput, TicketCliError> { - match cli { - TicketCli::Help => Ok(TicketCliOutput { - status: TicketCliStatus::Success, - stdout: help_text().to_string(), - }), - TicketCli::Command(command) => run_command(command, workspace), - } -} - -fn run_command( - command: TicketCommand, - workspace: &Path, -) -> Result<TicketCliOutput, TicketCliError> { - match command { - TicketCommand::Init => init(workspace), - TicketCommand::ImportLocal => import_local(workspace), - command => { - let backend = backend_for_workspace(workspace)?; - run_backend_command(command, backend.as_ref()) - } - } -} - fn run_backend_command( command: TicketCommand, backend: &dyn TicketBackend, @@ -277,190 +212,9 @@ fn run_backend_command( TicketCommand::Close(options) => close(backend, options), TicketCommand::Relation(options) => relation(backend, options), TicketCommand::Doctor => doctor(backend), - TicketCommand::Init | TicketCommand::ImportLocal => Err(TicketCliError::new( - "ticket init/import-local require an explicit local target", - )), } } -fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> { - let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); - if legacy_config_path.exists() { - return Err(TicketCliError::new(format!( - "legacy ticket config exists at {}; `.yoi/ticket.config.toml` is obsolete and read-only. Move its policy into {} before running `yoi ticket init`.", - legacy_config_path.display(), - WORKSPACE_SETTINGS_RELATIVE_PATH - ))); - } - - let yoi_dir = workspace.join(".yoi"); - fs::create_dir_all(&yoi_dir)?; - - let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH); - let scaffold = ticket_config_scaffold(); - let created_settings = if settings_path.exists() { - let content = fs::read_to_string(&settings_path)?; - if TicketConfig::workspace_settings_has_ticket_config(&settings_path, &content)? { - return Err(TicketCliError::new(format!( - "workspace Ticket settings already exist at {}; refusing to overwrite. Edit the [ticket] table manually before running `yoi ticket init`.", - settings_path.display() - ))); - } - let mut file = fs::OpenOptions::new().append(true).open(&settings_path)?; - if !content.ends_with('\n') { - file.write_all(b"\n")?; - } - file.write_all(b"\n")?; - file.write_all(scaffold.as_bytes())?; - false - } else { - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&settings_path) - .map_err(|error| { - if error.kind() == std::io::ErrorKind::AlreadyExists { - TicketCliError::new(format!( - "workspace settings already exists at {}; retry `yoi ticket init` to append Ticket settings safely.", - settings_path.display() - )) - } else { - TicketCliError::from(error) - } - })?; - let identity = workspace_settings_identity_header(workspace)?; - file.write_all(identity.as_bytes())?; - file.write_all(b"\n")?; - file.write_all(scaffold.as_bytes())?; - true - }; - - let verb = if created_settings { - "created" - } else { - "updated" - }; - Ok(success(format!( - "{verb}\t{}\nbackend\tworkspace-sqlite\n", - WORKSPACE_SETTINGS_RELATIVE_PATH - ))) -} - -fn workspace_settings_identity_header(workspace: &Path) -> Result<String, TicketCliError> { - let display_name = workspace - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.trim().is_empty()) - .unwrap_or("workspace"); - if display_name.contains('\0') || display_name.chars().any(|ch| ch.is_control()) { - return Err(TicketCliError::new( - "workspace display name derived from path must not contain control characters", - )); - } - Ok(format!( - "workspace_id = \"{}\"\ncreated_at = \"{}\"\ndisplay_name = \"{}\"\n", - workspace_settings_uuid_v7(workspace), - Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - display_name.replace('\\', "\\\\").replace('"', "\\\"") - )) -} - -fn workspace_settings_uuid_v7(workspace: &Path) -> String { - let now = Utc::now(); - let timestamp_ms = (now.timestamp_millis().max(0) as u64) & 0xffff_ffff_ffff; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - workspace.hash(&mut hasher); - std::process::id().hash(&mut hasher); - now.timestamp_nanos_opt() - .unwrap_or_default() - .hash(&mut hasher); - let random = hasher.finish(); - - let time_low = (timestamp_ms >> 16) as u32; - let time_mid = (timestamp_ms & 0xffff) as u16; - let version_and_rand = 0x7000 | (((random >> 52) as u16) & 0x0fff); - let variant_and_rand = 0x8000 | (((random >> 38) as u16) & 0x3fff); - let node = random & 0xffff_ffff_ffff; - format!( - "{time_low:08x}-{time_mid:04x}-{version_and_rand:04x}-{variant_and_rand:04x}-{node:012x}" - ) -} - -fn backend_for_workspace(workspace: &Path) -> Result<Box<dyn TicketBackend>, TicketCliError> { - let config = TicketConfig::load_workspace(workspace)?; - let workspace_id = workspace_id_for_workspace(workspace)?; - let db_path = server_database_path(workspace)?; - Ok(Box::new( - SqliteTicketBackend::open(db_path, workspace_id)? - .with_record_language(config.ticket_record_language()), - )) -} - -fn import_local(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> { - let config = TicketConfig::load_workspace(workspace)?; - let local = LocalTicketBackend::new(config.backend_root().to_path_buf()) - .with_record_language(config.ticket_record_language()); - let workspace_id = workspace_id_for_workspace(workspace)?; - let db_path = server_database_path(workspace)?; - let sqlite = SqliteTicketBackend::open(db_path.clone(), workspace_id)? - .with_record_language(config.ticket_record_language()); - sqlite.import_from_local_backend(&local)?; - Ok(success(format!( - "imported\t{}\nbackend\t{}\n", - config.backend_root().display(), - db_path.display() - ))) -} - -fn server_database_path(workspace: &Path) -> Result<PathBuf, TicketCliError> { - #[cfg(test)] - { - return Ok(workspace - .join(".test-yoi-data") - .join("server") - .join("server.db")); - } - - #[cfg(not(test))] - { - let _ = workspace; - let data_dir = manifest::paths::data_dir().ok_or_else(|| { - TicketCliError::new( - "could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)", - ) - })?; - Ok(data_dir.join("server").join("server.db")) - } -} - -fn workspace_id_for_workspace(workspace: &Path) -> Result<String, TicketCliError> { - let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH); - let raw = fs::read_to_string(&settings_path).map_err(|error| { - TicketCliError::new(format!( - "failed to read workspace settings {}: {error}", - settings_path.display() - )) - })?; - let value: toml::Value = toml::from_str(&raw).map_err(|error| { - TicketCliError::new(format!( - "failed to parse workspace settings {}: {error}", - settings_path.display() - )) - })?; - value - .get("workspace_id") - .and_then(toml::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .ok_or_else(|| { - TicketCliError::new(format!( - "workspace settings {} must contain workspace_id for SQLite Ticket backend", - settings_path.display() - )) - }) -} - fn create( backend: &dyn TicketBackend, options: CreateOptions, @@ -1185,369 +939,35 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Backend targets use the Workspace-scoped Ticket API selected by the shared client Target.\n Explicit local targets use the workspace SQLite backend. `init` and `import-local` are local-only.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Ticket commands require the Workspace-scoped Backend selected by the shared client Target.\n Standalone never falls back to repository-local Ticket storage.\n" } #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; - use ticket::config::TicketRole; - fn args(items: &[&str]) -> Vec<String> { - items.iter().map(|item| item.to_string()).collect() + fn args(values: &[&str]) -> Vec<String> { + values.iter().map(|value| value.to_string()).collect() } - fn run(temp: &TempDir, items: &[&str]) -> TicketCliOutput { - if items.first().copied() != Some("init") - && !temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH).exists() - { - let init_cli = parse_ticket_args(&args(&["init"])).unwrap(); - let init = run_in_workspace(init_cli, temp.path()).unwrap(); - assert_eq!(init.status, TicketCliStatus::Success); + #[test] + fn standalone_rejects_ticket_storage_operations() { + let cli = parse_ticket_args(&args(&["list"])).unwrap(); + let error = run(cli, ResolvedTarget::Standalone).unwrap_err(); + assert!(error.to_string().contains("select a Backend target")); + } + + #[test] + fn repository_local_ticket_commands_are_not_normal_target_commands() { + for command in ["init", "import-local"] { + let error = parse_ticket_args(&args(&[command])).unwrap_err(); + assert!(error.to_string().contains("unknown ticket command")); } - let cli = parse_ticket_args(&args(items)).unwrap(); - run_in_workspace(cli, temp.path()).unwrap() - } - - fn created_id(output: &TicketCliOutput) -> String { - output - .stdout - .strip_prefix("created\t") - .and_then(|rest| rest.lines().next()) - .expect("create output contains created id") - .to_string() } #[test] - fn ticket_cli_init_writes_explicit_workspace_ticket_settings() { - let temp = TempDir::new().unwrap(); - - let initialized = run(&temp, &["init"]); - assert_eq!(initialized.status, TicketCliStatus::Success); - assert!(initialized.stdout.contains("created\t.yoi/workspace.toml")); - assert!(initialized.stdout.contains("backend\tworkspace-sqlite")); - assert!(!temp.path().join(".yoi/tickets").exists()); - - let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap(); - assert!(config.contains("workspace_id = \"")); - assert!(config.contains("[ticket]\n")); - assert!(config.contains("[ticket.backend]\n")); - assert!(config.contains("provider = \"builtin:yoi_local\"")); - assert!(config.contains("root = \".yoi/tickets\"")); - assert!(config.contains("# language = \"Japanese\"")); - for role in TicketRole::ALL { - assert!(config.contains(&format!( - "[ticket.roles.{role}]\nprofile = \"{}\"", - role.default_profile() - ))); - } - assert!(!config.contains("[ticket.roles.investigator]")); - } - - #[test] - fn ticket_cli_init_does_not_overwrite_existing_config() { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join(".yoi")).unwrap(); - let config_path = temp.path().join(".yoi/ticket.config.toml"); - fs::write( - &config_path, - "[backend]\nprovider = \"builtin:yoi_local\"\n", - ) - .unwrap(); - - let cli = parse_ticket_args(&args(&["init"])).unwrap(); - let err = run_in_workspace(cli, temp.path()).unwrap_err(); - assert!(err.to_string().contains("legacy ticket config exists")); - assert!(err.to_string().contains("obsolete and read-only")); - assert!(err.to_string().contains(WORKSPACE_SETTINGS_RELATIVE_PATH)); - assert_eq!( - fs::read_to_string(config_path).unwrap(), - "[backend]\nprovider = \"builtin:yoi_local\"\n" - ); - } - - #[test] - fn ticket_cli_list_is_bounded_and_truncates_titles() { - let temp = TempDir::new().unwrap(); - let long_title = format!("Long {}", "x".repeat(LIST_TITLE_MAX_CHARS + 40)); - let first = run(&temp, &["create", "--title", long_title.as_str()]); - assert_eq!(first.status, TicketCliStatus::Success); - for index in 0..DEFAULT_LIST_LIMIT { - let title = format!("Ticket {index:03}"); - let created = run(&temp, &["create", "--title", title.as_str()]); - assert_eq!(created.status, TicketCliStatus::Success); - } - - let listed = run(&temp, &["list", "--state", "all"]); - assert_eq!(listed.status, TicketCliStatus::Success); - assert!(listed.stdout.contains("# truncated: returned")); - let non_note_lines = listed - .stdout - .lines() - .filter(|line| !line.starts_with('#')) - .count(); - assert_eq!(non_note_lines, DEFAULT_LIST_LIMIT + 1); - let first_ticket_line = listed.stdout.lines().nth(1).unwrap(); - let listed_title = first_ticket_line.split('\t').nth(2).unwrap(); - assert!(listed_title.starts_with("Long ")); - assert!(listed_title.chars().count() <= LIST_TITLE_MAX_CHARS); - assert!(listed_title.ends_with("...")); - } - - #[test] - fn ticket_cli_list_limit_is_capped() { - let temp = TempDir::new().unwrap(); - for index in 0..(MAX_LIST_LIMIT + 5) { - let title = format!("Ticket {index:03}"); - let created = run(&temp, &["create", "--title", title.as_str()]); - assert_eq!(created.status, TicketCliStatus::Success); - } - - let listed = run(&temp, &["list", "--state", "all", "--limit", "1000"]); - assert_eq!(listed.status, TicketCliStatus::Success); - assert!(listed.stdout.contains("# truncated: returned")); - let non_note_lines = listed - .stdout - .lines() - .filter(|line| !line.starts_with('#')) - .count(); - assert_eq!(non_note_lines, MAX_LIST_LIMIT + 1); - } - - #[test] - fn ticket_cli_create_list_show_comment_state_close_and_doctor() { - let temp = TempDir::new().unwrap(); - - let created = run(&temp, &["create", "--title", "CLI Created"]); - assert_eq!(created.status, TicketCliStatus::Success); - assert!(created.stdout.contains("created\t")); - let ticket_id = created_id(&created); - assert!(!temp.path().join(".yoi/tickets").exists()); - assert!(!temp.path().join("work-items").exists()); - - let listed = run(&temp, &["list", "--state", "planning"]); - assert!(listed.stdout.contains("state\tid\ttitle")); - assert!(listed.stdout.contains(&ticket_id)); - assert!(listed.stdout.contains("CLI Created")); - assert!(!listed.stdout.contains("legacy_ticket")); - assert!(!listed.stdout.contains("needs_preflight")); - - let shown = run(&temp, &["show", &ticket_id]); - assert!(shown.stdout.contains("# CLI Created")); - assert!(shown.stdout.contains(&format!("ID: {ticket_id}"))); - assert!(shown.stdout.contains("State: planning")); - assert!(!shown.stdout.contains("legacy_ticket")); - assert!(!shown.stdout.contains("needs_preflight")); - - let commented = run( - &temp, - &[ - "comment", - &ticket_id, - "--role", - "implementation_report", - "--message", - "Implemented.", - ], - ); - assert!( - commented - .stdout - .contains(&format!("appended\t{}\timplementation_report", ticket_id)) - ); - - let ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"])) - .and_then(|cli| run_in_workspace(cli, temp.path())) - .unwrap_err(); - assert!(ready_error.to_string().contains("TicketMarkReady")); - let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"])) - .and_then(|cli| run_in_workspace(cli, temp.path())) - .unwrap_err(); - assert!(queue_error.to_string().contains("TicketQueue")); - - let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"])) - .and_then(|cli| run_in_workspace(cli, temp.path())) - .unwrap_err(); - assert!(done_error.to_string().contains("CompleteMergeRequest")); - - let closed = run( - &temp, - &["close", &ticket_id, "--resolution", "Done via yoi ticket."], - ); - assert!(closed.stdout.contains(&format!("closed\t{}", ticket_id))); - - let doctor = run(&temp, &["doctor"]); - assert_eq!(doctor.status, TicketCliStatus::Success); - assert_eq!(doctor.stdout, "doctor: ok\n"); - - let final_show = run(&temp, &["show", &ticket_id]); - assert!(final_show.stdout.contains("State: closed")); - assert!(final_show.stdout.contains("Done via yoi ticket.")); - assert!(final_show.stdout.contains("implementation_report")); - } - - #[test] - fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() { - let temp = TempDir::new().unwrap(); - let initialized = run(&temp, &["init"]); - assert_eq!(initialized.status, TicketCliStatus::Success); - let local = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let created = local.create(NewTicket::new("Legacy Overlay")).unwrap(); - let ticket_id = created.id; - let item_path = temp - .path() - .join(".yoi/tickets") - .join(&ticket_id) - .join("item.md"); - let item = fs::read_to_string(&item_path).unwrap(); - fs::write( - &item_path, - item.replacen( - "---\n", - "---\naction_required: legacy action\nattention_required: legacy attention\n", - 1, - ), - ) - .unwrap(); - - let imported = run(&temp, &["import-local"]); - assert_eq!(imported.status, TicketCliStatus::Success); - - let shown = run(&temp, &["show", &ticket_id]); - assert_eq!(shown.status, TicketCliStatus::Success); - assert!(shown.stdout.contains("# Legacy Overlay")); - assert!(!shown.stdout.contains("action_required")); - assert!(!shown.stdout.contains("attention_required")); - assert!(!shown.stdout.contains("legacy action")); - assert!(!shown.stdout.contains("legacy attention")); - } - - #[test] - fn ticket_cli_records_lists_and_shows_relations() { - let temp = TempDir::new().unwrap(); - let source = created_id(&run(&temp, &["create", "--title", "Relation Source"])); - let target = created_id(&run(&temp, &["create", "--title", "Relation Target"])); - - let added = run( - &temp, - &[ - "relation", - "add", - "--ticket", - &source, - "--kind", - "depends_on", - "--target", - &target, - "--note", - "target first", - ], - ); - assert_eq!( - added.stdout, - format!("relation\t{source}\tdepends_on\t{target}\n") - ); - - let listed = run(&temp, &["relation", "list", "--ticket", &target]); - assert!(listed.stdout.contains("ticket\tkind\ttarget")); - assert!( - listed - .stdout - .contains(&format!("{source}\tdepends_on\t{target}")) - ); - - let shown_source = run(&temp, &["show", &source]); - assert!(shown_source.stdout.contains("## relations")); - assert!( - shown_source - .stdout - .contains(&format!("- depends_on {target}")) - ); - assert!(shown_source.stdout.contains("unresolved queue blockers")); - - let shown_target = run(&temp, &["show", &target]); - assert!(shown_target.stdout.contains("incoming / derived inverse")); - assert!( - shown_target - .stdout - .contains(&format!("dependency_of {source}")) - ); - } - - #[test] - fn ticket_cli_import_local_uses_configured_backend_root() { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join(".yoi")).unwrap(); - fs::write( - temp.path().join(".yoi/workspace.toml"), - "workspace_id = \"workspace-test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", - ) - .unwrap(); - let local = LocalTicketBackend::new(temp.path().join("custom-tickets")); - let created = local.create(NewTicket::new("Configured Root")).unwrap(); - - let imported = run(&temp, &["import-local"]); - assert_eq!(imported.status, TicketCliStatus::Success); - let shown = run(&temp, &["show", &created.id]); - assert_eq!(shown.status, TicketCliStatus::Success); - assert!(shown.stdout.contains("# Configured Root")); - assert!(!temp.path().join("work-items").exists()); - } - - #[test] - fn ticket_cli_rejects_ambiguous_body_sources() { - let err = parse_ticket_args(&args(&[ - "comment", - "ticket", - "--file", - "body.md", - "--message", - "body", - ])) - .unwrap_err(); - assert!(err.to_string().contains("exactly one")); - } - - #[test] - fn ticket_cli_state_closed_requires_close_command() { - let temp = TempDir::new().unwrap(); - let created = run(&temp, &["create", "--title", "Close Me"]); - let ticket_id = created_id(&created); - let cli = parse_ticket_args(&args(&["state", &ticket_id, "closed"])).unwrap(); - let err = run_in_workspace(cli, temp.path()).unwrap_err(); - assert!(err.to_string().contains("use `yoi ticket close")); - } - - #[test] - fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() { - let default = parse_ticket_args(&args(&["list"])).unwrap(); - match default { - TicketCli::Command(TicketCommand::List(options)) => { - assert_eq!(options.state, ListState::Active) - } - other => panic!("unexpected command: {other:?}"), - } - - let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap(); - match explicit { - TicketCli::Command(TicketCommand::List(options)) => assert_eq!( - options.state, - ListState::States(vec![TicketListState::Planning, TicketListState::Closed]) - ), - other => panic!("unexpected command: {other:?}"), - } - - let mixed = parse_ticket_args(&args(&["list", "--state", "active,planning"])).unwrap_err(); - assert!(mixed.to_string().contains("cannot be mixed")); - } - - #[test] - fn ticket_cli_help_lists_required_commands() { - let help = parse_ticket_args(&args(&["--help"])).unwrap(); - let output = run_in_workspace(help, Path::new(".")).unwrap(); - assert!(output.stdout.contains("yoi ticket init")); - assert!(output.stdout.contains("yoi ticket create")); - assert!(output.stdout.contains("yoi ticket doctor")); + fn help_states_backend_authority() { + assert!(help_text().contains("Workspace-scoped Backend")); + assert!(!help_text().contains("repository-file Ticket backend")); } } diff --git a/docs/design/overview.md b/docs/design/overview.md index 691b58d8..e51b2f16 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -12,12 +12,13 @@ That rule shapes the crate split. The runtime can restart, attach, compact, or d - `session-store` owns replayable append-only conversation/session logs. - `pod-store` owns current Worker metadata keyed by Worker name. - `protocol` defines the socket message boundary between clients and Workers. -- `client` contains reusable one-shot socket/runtime-command mechanics so lower crates do not depend on the product CLI. +- `standalone` owns the client-side, one-process Standalone host and its dedicated session store. It does not create Runtime Workers, PID/socket catalogs, or Workspace product authority. +- `client` contains reusable Backend Workspace/Runtime clients plus the shared `Target` boundary. Its normal targets are exactly Standalone and Backend; it is not a subprocess launcher or repository-local product backend. - `manifest` resolves Profiles, Manifests, model/provider references, scopes, prompts, and tool permission policy into a runtime contract. - `tools` implements built-in tools with bounded output and policy-aware execution. - `memory` owns generated memory summary/decision/request records, linting, staging, and audit observations. - `workspace-server` is the local Workspace control-plane seam. It can project Tickets, Workers, lifecycle, usage, and orchestration events, but browser/API operations must stay on opaque backend identities instead of raw local paths, sockets, Worker names, or session files. -- `tui` is a UI over Worker authority; it should not invent durable state. +- `tui` is a UI over either one in-process Standalone session or Backend Workspace/Runtime Worker authority; it should not invent a local Worker catalog or durable product state. ## Why these boundaries exist diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md index 1e70eb44..96fd646a 100644 --- a/docs/design/standalone-agent-host.md +++ b/docs/design/standalone-agent-host.md @@ -29,6 +29,13 @@ worker - `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。 - startup error は category のみを公開し、credential、prompt 本文、session metadata、内部 path を error text に含めない。 +## CLI / TUI routing + +- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` と client config の `default_connection = "local"` は Standalone を選ぶ入力であり、旧 LocalBackend を有効化しない。 +- Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。 +- `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。 +- `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。 + ## 非目標 - TUI/CLI routing や画面実装 From 928ff0eabe9e78c16febd06da02091d801bb5395 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 20:45:40 +0900 Subject: [PATCH 11/18] fix: isolate standalone target resolution --- crates/yoi/src/main.rs | 139 ++++++++++++++++++++++----- docs/design/standalone-agent-host.md | 2 +- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 48d19706..d40a082e 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -217,6 +217,24 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>( selection: &TargetSelection, workspace_root: &Path, ) -> Result<Box<dyn Target>, ParseError> { + if selection.explicit_local { + return resolve_connection_aware_cli_connection( + connection_resolver, + command, + true, + None, + None, + ); + } + + if selection.backend_url.is_none() + && let Ok(target) = + resolve_connection_aware_cli_connection(connection_resolver, command, false, None, None) + && target.kind() == TargetKind::Standalone + { + return Ok(target); + } + let workspace_id = match selection.workspace_id.clone() { Some(workspace_id) => Some(workspace_id), None => resolve_workspace_id_from_root(workspace_root)?, @@ -1160,23 +1178,22 @@ fn read_client_default_connection() -> Result<ClientDefaultConnection, ParseErro } fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> { + let path = client_global_config_path(); + read_client_config_from_global_path(path.as_deref()) +} + +fn read_client_config_from_global_path( + path: Option<&Path>, +) -> Result<Option<ClientConfigFile>, ParseError> { + let Some(path) = path else { + return Ok(None); + }; + let Some(overlay) = read_client_config_overlay(path)? else { + return Ok(None); + }; let mut config = ClientConfigFile::default(); - let mut found = false; - - if let Some(path) = client_global_config_path() { - if let Some(overlay) = read_client_config_overlay(&path)? { - config.apply_overlay(overlay); - found = true; - } - } - - let cwd_path = client_cwd_config_path()?; - if let Some(overlay) = read_client_config_overlay(&cwd_path)? { - config.apply_overlay(overlay); - found = true; - } - - Ok(found.then_some(config)) + config.apply_overlay(overlay); + Ok(Some(config)) } fn read_client_config_overlay(path: &Path) -> Result<Option<ClientConfigOverlay>, ParseError> { @@ -1194,14 +1211,10 @@ fn client_global_config_path() -> Option<PathBuf> { manifest::paths::data_dir().map(|dir| dir.join("client").join("config.toml")) } -fn client_cwd_config_path() -> Result<PathBuf, ParseError> { - Ok(current_dir()?.join(".yoi").join("client.config.toml")) -} - fn client_config_location_message() -> String { match client_global_config_path() { - Some(path) => format!("{} or <cwd>/.yoi/client.config.toml", path.display()), - None => "<data_dir>/client/config.toml or <cwd>/.yoi/client.config.toml".to_string(), + Some(path) => path.display().to_string(), + None => "<data_dir>/client/config.toml".to_string(), } } @@ -2249,6 +2262,88 @@ backend = "shared" } } + #[test] + fn explicit_standalone_ignores_malformed_repository_workspace_identity() { + let repository = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(repository.path().join(".yoi")).unwrap(); + std::fs::write( + repository.path().join(".yoi/workspace.toml"), + "this is not valid toml = [", + ) + .unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://fake-backend.example", + }; + + let target = resolve_tui_target( + &resolver, + CliCommand::DefaultTui, + &TargetSelection { + explicit_local: true, + ..TargetSelection::default() + }, + repository.path(), + ) + .unwrap(); + + assert_eq!(target.kind(), TargetKind::Standalone); + } + + #[test] + fn default_standalone_ignores_malformed_repository_workspace_identity() { + let repository = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(repository.path().join(".yoi")).unwrap(); + std::fs::write( + repository.path().join(".yoi/workspace.toml"), + "this is not valid toml = [", + ) + .unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://fake-backend.example", + }; + + let target = resolve_tui_target( + &resolver, + CliCommand::DefaultTui, + &TargetSelection::default(), + repository.path(), + ) + .unwrap(); + + assert_eq!(target.kind(), TargetKind::Standalone); + } + + #[test] + fn client_config_reader_uses_only_the_supplied_global_path() { + let root = tempfile::tempdir().unwrap(); + let global = root.path().join("client/config.toml"); + std::fs::create_dir_all(global.parent().unwrap()).unwrap(); + std::fs::write( + &global, + "default_connection = \"local\"\n[backends.main]\nurl = \"http://backend.example\"\n", + ) + .unwrap(); + std::fs::create_dir_all(root.path().join("repository/.yoi")).unwrap(); + std::fs::write( + root.path().join("repository/.yoi/client.config.toml"), + "this is not valid toml = [", + ) + .unwrap(); + + let config = read_client_config_from_global_path(Some(&global)) + .unwrap() + .unwrap(); + + assert_eq!( + config.default_connection, + ClientDefaultConnection::Standalone + ); + assert_eq!( + config.backends["main"].url.as_deref(), + Some("http://backend.example") + ); + } + #[test] fn default_backend_target_inherits_workspace_identity_from_workspace_root() { let workspace = tempfile::tempdir().unwrap(); diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md index 96fd646a..3eff3604 100644 --- a/docs/design/standalone-agent-host.md +++ b/docs/design/standalone-agent-host.md @@ -31,7 +31,7 @@ worker ## CLI / TUI routing -- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` と client config の `default_connection = "local"` は Standalone を選ぶ入力であり、旧 LocalBackend を有効化しない。 +- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` と client config の `default_connection = "local"` は Standalone を選ぶ入力であり、旧 LocalBackend を有効化しない。 Client config は repository `.yoi/client.config.toml` を読まず、repository `.yoi/workspace.toml` は Backend Workspace identity が必要な場合だけ参照する。 - Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。 - `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。 - `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。 From 733632509a9349b15ad60094b8502f01c88adb18 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 20:57:16 +0900 Subject: [PATCH 12/18] fix: keep product help target independent --- crates/yoi/src/objective_cli.rs | 14 +++++++++++--- crates/yoi/src/ticket_cli.rs | 12 ++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs index 7603312d..b0fa3bb3 100644 --- a/crates/yoi/src/objective_cli.rs +++ b/crates/yoi/src/objective_cli.rs @@ -118,6 +118,9 @@ pub fn run( cli: ObjectiveCli, target: ResolvedTarget, ) -> Result<ObjectiveCliOutput, ObjectiveCliError> { + if cli == ObjectiveCli::Help { + return Ok(success(help_text().to_string())); + } match target { ResolvedTarget::Standalone => Err(ObjectiveCliError::new( "Standalone is a one-shot Worker host, not Objective storage authority; select a Backend target", @@ -359,8 +362,13 @@ mod tests { } #[test] - fn help_states_backend_authority() { - assert!(help_text().contains("require the Workspace-scoped Backend")); - assert!(!help_text().contains("repository-file")); + fn help_is_available_without_objective_storage_authority() { + let output = run(ObjectiveCli::Help, ResolvedTarget::Standalone).unwrap(); + assert!( + output + .stdout + .contains("require the Workspace-scoped Backend") + ); + assert!(!output.stdout.contains("repository-file")); } } diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index db1f5399..3f108629 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -178,9 +178,12 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> { } pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> { + if cli == TicketCli::Help { + return Ok(success(help_text().to_string())); + } match target { ResolvedTarget::Standalone => Err(TicketCliError::new( - "Standalone is a one-shot Worker host, not Ticket storage authority; select a Backend target; select a Backend target", + "Standalone is a one-shot Worker host, not Ticket storage authority; select a Backend target", )), ResolvedTarget::Backend { base_url, @@ -966,8 +969,9 @@ mod tests { } #[test] - fn help_states_backend_authority() { - assert!(help_text().contains("Workspace-scoped Backend")); - assert!(!help_text().contains("repository-file Ticket backend")); + fn help_is_available_without_ticket_storage_authority() { + let output = run(TicketCli::Help, ResolvedTarget::Standalone).unwrap(); + assert!(output.stdout.contains("Workspace-scoped Backend")); + assert!(!output.stdout.contains("repository-file Ticket backend")); } } From 62372a48cc82cb8d72d8fb78c8e57a14ac806f39 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 21:08:08 +0900 Subject: [PATCH 13/18] fix: parse product help before target resolution --- crates/yoi/src/main.rs | 56 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index d40a082e..fe402837 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -382,6 +382,14 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>( return Ok(Mode::WorkerRuntime(args[1..].to_vec())); } "objective" => { + let cli = objective_cli::parse_objective_args(&args[1..]) + .map_err(|e| ParseError(e.to_string()))?; + if cli == objective_cli::ObjectiveCli::Help { + return Ok(Mode::Objective { + cli, + target: client::ResolvedTarget::Standalone, + }); + } let workspace_root = current_dir()?; let target = resolve_tui_target( connection_resolver, @@ -391,8 +399,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>( )? .resolve() .map_err(|error| ParseError(error.to_string()))?; - let cli = objective_cli::parse_objective_args(&args[1..]) - .map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Objective { cli, target }); } "session" => { @@ -402,6 +408,14 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>( return Ok(Mode::Session(session_cli)); } "ticket" => { + let cli = + ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; + if cli == ticket_cli::TicketCli::Help { + return Ok(Mode::Ticket { + cli, + target: client::ResolvedTarget::Standalone, + }); + } let workspace_root = current_dir()?; let target = resolve_tui_target( connection_resolver, @@ -411,8 +425,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>( )? .resolve() .map_err(|error| ParseError(error.to_string()))?; - let cli = - ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Ticket { cli, target }); } "plugin" => { @@ -2449,6 +2461,42 @@ backend = "shared" } } + #[test] + fn parser_resolves_ticket_help_before_backend_target_authority() { + match parse_args_from([ + "--backend", + "http://unconfigured-backend.example", + "ticket", + "--help", + ]) + .unwrap() + { + Mode::Ticket { + cli: ticket_cli::TicketCli::Help, + target: client::ResolvedTarget::Standalone, + } => {} + other => panic!("expected target-independent Ticket help, got {other:?}"), + } + } + + #[test] + fn parser_resolves_objective_help_before_backend_target_authority() { + match parse_args_from([ + "--backend", + "http://unconfigured-backend.example", + "objective", + "--help", + ]) + .unwrap() + { + Mode::Objective { + cli: objective_cli::ObjectiveCli::Help, + target: client::ResolvedTarget::Standalone, + } => {} + other => panic!("expected target-independent Objective help, got {other:?}"), + } + } + #[test] fn parse_ticket_help_uses_ticket_mode() { match parse_args_from(["ticket", "--help"]).unwrap() { From 6398ca089347a28975271a1e0e2d143c6399207f Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 21:16:45 +0900 Subject: [PATCH 14/18] docs: clarify standalone resume help --- crates/yoi/src/main.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index fe402837..b37d7a38 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1791,10 +1791,25 @@ fn print_help() { println!("{TOP_LEVEL_HELP}"); } +const RESUME_HELP: &str = r#"yoi resume + +Usage: + yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>] + +Target options: + --local Restore from the client-owned Standalone session store + --backend <URL> Restore a stopped Backend Workspace Worker + --workspace-id <ID> Scope Backend routes to a Workspace id + +Options: + --workspace <PATH> Scope Standalone sessions to this cwd identity (defaults to cwd) + --all Include Standalone sessions from every cwd identity + --runtime-id <ID> Restrict the Backend stopped-Worker picker to a Runtime id + -h, --help Print help +"#; + fn print_resume_help() { - println!( - "yoi resume\n\nUsage:\n yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]\n\nTarget options:\n --local Use local Worker records explicitly\n --backend <URL> Use Backend Worker records explicitly\n --workspace-id <ID> Scope Backend routes to a Workspace id\n\nOptions:\n --workspace <PATH> Open the Worker picker scoped to this local workspace (defaults to cwd)\n --all Open the Worker picker across this host/data dir\n --runtime-id <ID> Restrict Backend picker to a Runtime id\n -h, --help Print help\n" - ); + println!("{RESUME_HELP}"); } fn print_memory_lint_help() { @@ -2774,11 +2789,15 @@ backend = "shared" } #[test] - fn parse_resume_help() { + fn parse_resume_help_uses_standalone_session_store_terminology() { match parse_args_from(["resume", "--help"]).unwrap() { Mode::ResumeHelp => {} _ => panic!("expected ResumeHelp mode"), } + assert!(RESUME_HELP.contains("Standalone session store")); + assert!(RESUME_HELP.contains("Backend stopped-Worker picker")); + assert!(!RESUME_HELP.contains("local Worker records")); + assert!(!RESUME_HELP.contains("local workspace")); } #[test] From 6df95bf9817de7667ef075baaffb6f14e38045af Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 21:26:27 +0900 Subject: [PATCH 15/18] fix: make workers help backend specific --- crates/yoi/src/main.rs | 45 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index b37d7a38..01cd92ca 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -29,6 +29,7 @@ use tui::{LaunchMode, LaunchOptions}; enum Mode { Help, ResumeHelp, + WorkersHelp, MemoryLintHelp, MemoryLint(LintCliOptions), Mcp(mcp_cli::McpCliCommand), @@ -88,6 +89,10 @@ async fn main() -> ExitCode { print_resume_help(); ExitCode::SUCCESS } + Mode::WorkersHelp => { + print_workers_help(); + ExitCode::SUCCESS + } Mode::MemoryLintHelp => { print_memory_lint_help(); ExitCode::SUCCESS @@ -838,11 +843,7 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>( while i < args.len() { let arg = &args[i]; match arg.as_str() { - "--help" | "-h" => { - return Err(ParseError( - "usage: yoi [--local|--backend <URL>] [--workspace-id <ID>] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]".to_string(), - )); - } + "--help" | "-h" => return Ok(Mode::WorkersHelp), "-r" | "--restoreable" | "--stopped" => { include_stopped = true; i += 1; @@ -1791,6 +1792,28 @@ fn print_help() { println!("{TOP_LEVEL_HELP}"); } +const WORKERS_HELP: &str = r#"yoi workers + +Usage: + yoi --backend <URL> [--workspace-id <ID>] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>] + +Authority: + Lists Workers from the selected Backend Workspace. Standalone sessions are restored with + `yoi --local --resume` and are not part of the Workspace Worker catalog. + +Options: + --backend <URL> Use this Workspace Backend + --workspace-id <ID> Scope Backend routes to a Workspace id + --workspace <PATH> Resolve Backend Workspace identity from this repository root + -r, --stopped List stopped Backend Workers + --runtime-id <ID> Restrict the Backend Worker picker to a Runtime id + -h, --help Print help +"#; + +fn print_workers_help() { + println!("{WORKERS_HELP}"); +} + const RESUME_HELP: &str = r#"yoi resume Usage: @@ -2788,6 +2811,18 @@ backend = "shared" } } + #[test] + fn parse_workers_help_is_backend_only_and_target_independent() { + match parse_args_from(["workers", "--help"]).unwrap() { + Mode::WorkersHelp => {} + other => panic!("expected WorkersHelp mode, got {other:?}"), + } + assert!(WORKERS_HELP.contains("selected Backend Workspace")); + assert!(WORKERS_HELP.contains("--local --resume")); + assert!(!WORKERS_HELP.contains("[--local|--backend")); + assert!(!WORKERS_HELP.contains("local Worker records")); + } + #[test] fn parse_resume_help_uses_standalone_session_store_terminology() { match parse_args_from(["resume", "--help"]).unwrap() { From cd9d8545950f9886785193141bba7df152159f8e Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 21:36:33 +0900 Subject: [PATCH 16/18] fix: reject standalone runtime selectors --- crates/yoi/src/main.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 01cd92ca..262b9af7 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -755,6 +755,12 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>( } if target.kind() == TargetKind::Standalone { + if runtime_id.is_some() || worker_id.is_some() { + return Err(ParseError( + "Standalone does not accept Backend Runtime selectors; use --backend with --runtime-id/--worker-id" + .to_string(), + )); + } if session.is_some() { return Err(ParseError( "--local does not accept legacy --session; use --local --resume for Standalone session restore" @@ -2045,6 +2051,25 @@ backend = "shared" } } + #[test] + fn parse_standalone_rejects_backend_runtime_selectors() { + let runtime_error = parse_args_from(["--local", "--runtime-id", "runtime-a"]) + .unwrap_err() + .to_string(); + assert!(runtime_error.contains("Standalone does not accept Backend Runtime selectors")); + + let worker_error = parse_args_from([ + "--local", + "--runtime-id", + "runtime-a", + "--worker-id", + "worker-b", + ]) + .unwrap_err() + .to_string(); + assert!(worker_error.contains("Standalone does not accept Backend Runtime selectors")); + } + #[test] fn parse_backend_runtime_target_requires_runtime_for_worker_identity() { let err = parse_args_from(["--backend", "http://127.0.0.1:8787", "--worker-id", "w"]) From dfc48f7a0553203a01b8eb9d431de4169d04a5a8 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 21:48:07 +0900 Subject: [PATCH 17/18] fix: preserve login backend selection --- crates/yoi/src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 262b9af7..05dfe3f4 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -438,7 +438,32 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>( return Ok(Mode::Plugin(plugin_cli)); } "login" => { - return parse_login_args(&args[1..], connection_resolver); + if target_selection.explicit_local { + return Err(ParseError( + "yoi login requires a Backend target and cannot use --local".to_string(), + )); + } + if target_selection.workspace_id.is_some() { + return Err(ParseError( + "yoi login authenticates a Backend and does not accept --workspace-id" + .to_string(), + )); + } + let mut login_args = args[1..].to_vec(); + if let Some(backend_url) = target_selection.backend_url { + if login_args + .iter() + .any(|arg| arg == "--backend" || arg.starts_with("--backend=")) + { + return Err(ParseError( + "--backend may be provided either before or after login, not both" + .to_string(), + )); + } + login_args.insert(0, backend_url); + login_args.insert(0, "--backend".to_string()); + } + return parse_login_args(&login_args, connection_resolver); } "mcp" => { let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Mcp)?; @@ -2006,6 +2031,43 @@ backend = "shared" assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS")); } + #[test] + fn parse_login_preserves_top_level_backend_selector() { + match parse_args_from(["--backend", "http://127.0.0.1:8787", "login", "--no-wait"]).unwrap() + { + Mode::Login { + backend_url, + no_wait, + } => { + assert_eq!(backend_url, "http://127.0.0.1:8787"); + assert!(no_wait); + } + other => panic!("expected Login mode, got {other:?}"), + } + } + + #[test] + fn parse_login_rejects_non_backend_or_duplicate_target_selectors() { + assert!( + parse_args_from(["--local", "login"]) + .unwrap_err() + .to_string() + .contains("cannot use --local") + ); + assert!( + parse_args_from([ + "--backend", + "http://127.0.0.1:8787", + "login", + "--backend", + "http://127.0.0.1:8788", + ]) + .unwrap_err() + .to_string() + .contains("not both") + ); + } + #[test] fn parse_local_only_commands_reject_backend_target_options() { let err = parse_args_from(["keys", "--workspace-id=workspace-a"]).unwrap_err(); From 583c343d0824e9f4e1694e86bf19d9cabc00e57c Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 30 Aug 2026 21:57:50 +0900 Subject: [PATCH 18/18] fix: reject duplicate backend selectors --- crates/yoi/src/main.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 05dfe3f4..903486e4 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -270,6 +270,11 @@ fn parse_top_level_target_selection( i += 1; } "--backend" => { + if selection.backend_url.is_some() { + return Err(ParseError( + "--backend must not be provided more than once".to_string(), + )); + } if selection.explicit_local { return Err(ParseError( "--local and --backend are mutually exclusive".to_string(), @@ -295,6 +300,11 @@ fn parse_top_level_target_selection( i += 2; } arg if arg.starts_with("--backend=") => { + if selection.backend_url.is_some() { + return Err(ParseError( + "--backend must not be provided more than once".to_string(), + )); + } if selection.explicit_local { return Err(ParseError( "--local and --backend are mutually exclusive".to_string(), @@ -1289,6 +1299,11 @@ fn parse_login_args<R: CliConnectionResolver + ?Sized>( while i < args.len() { match args[i].as_str() { "--backend" => { + if backend_url.is_some() { + return Err(ParseError( + "--backend must not be provided more than once".to_string(), + )); + } let value = args .get(i + 1) .ok_or_else(|| ParseError("--backend requires a URL".to_string()))?; @@ -1299,6 +1314,11 @@ fn parse_login_args<R: CliConnectionResolver + ?Sized>( i += 2; } arg if arg.starts_with("--backend=") => { + if backend_url.is_some() { + return Err(ParseError( + "--backend must not be provided more than once".to_string(), + )); + } let value = arg.trim_start_matches("--backend="); if value.is_empty() { return Err(ParseError("--backend requires a URL".to_string())); @@ -2046,6 +2066,27 @@ backend = "shared" } } + #[test] + fn parse_login_rejects_repeated_backend_selectors_in_each_scope() { + for args in [ + vec![ + "--backend", + "http://127.0.0.1:8787", + "--backend=http://127.0.0.1:8788", + "login", + ], + vec![ + "login", + "--backend", + "http://127.0.0.1:8787", + "--backend=http://127.0.0.1:8788", + ], + ] { + let error = parse_args_from(args).unwrap_err().to_string(); + assert!(error.contains("must not be provided more than once")); + } + } + #[test] fn parse_login_rejects_non_backend_or_duplicate_target_selectors() { assert!(