refactor: remove legacy local client authority

This commit is contained in:
2026-08-30 20:35:26 +09:00
parent 6945986dd1
commit 406559b13d
26 changed files with 322 additions and 19417 deletions
Generated
-3
View File
@@ -638,7 +638,6 @@ name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"futures", "futures",
"manifest",
"protocol", "protocol",
"reqwest", "reqwest",
"serde", "serde",
@@ -5325,7 +5324,6 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"client", "client",
"crossterm 0.28.1", "crossterm 0.28.1",
"fs4",
"manifest", "manifest",
"protocol", "protocol",
"pulldown-cmark", "pulldown-cmark",
@@ -5342,7 +5340,6 @@ dependencies = [
"toml", "toml",
"unicode-width", "unicode-width",
"uuid", "uuid",
"worker",
] ]
[[package]] [[package]]
+1 -2
View File
@@ -6,14 +6,13 @@ license.workspace = true
[dependencies] [dependencies]
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { 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 } tokio-tungstenite = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
workspace-api.workspace = true workspace-api.workspace = true
+6 -27
View File
@@ -1,20 +1,12 @@
//! Worker プロトコルを喋るクライアント。 //! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
//! //!
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、 //! Standalone execution is owned by the `standalone` crate and does not spawn
//! `Event` を受け取る低レベル接続。 //! a Worker subprocess through this crate.
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_auth; pub mod backend_auth;
pub mod backend_runtime; pub mod backend_runtime;
pub mod backend_workspace; pub mod backend_workspace;
pub mod runtime_command;
pub mod spawn;
pub mod target; pub mod target;
pub mod ticket_role;
mod worker_client; mod worker_client;
mod workspace_product; mod workspace_product;
@@ -35,23 +27,10 @@ pub use backend_workspace::{
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
}; };
pub use runtime_command::WorkerRuntimeCommand;
pub use target::{ pub use target::{
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent, BackendTarget, Dashboard, ResolvedTarget, StandaloneSessionListIntent,
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName, StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
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,
}; };
pub use worker_client::WorkerClient; pub use worker_client::WorkerClient;
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary}; pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
-435
View File
@@ -1,435 +0,0 @@
//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
//! ハンドシェイク。
//!
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
//! `YOI-READY\t<name>\t<socket>` を吐く。
//! - 待機中の 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<String>,
/// 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<PathBuf>,
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
/// resume させる。
pub resume_from: Option<Uuid>,
}
#[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<String>,
}
impl WorkerProcessLaunchOptions {
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> 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<io::Error> for SpawnError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
fn runtime_args(
config: &WorkerProcessLaunchConfig,
options: &WorkerProcessLaunchOptions,
) -> Vec<String> {
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<F>(
config: WorkerProcessLaunchConfig,
progress: F,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await
}
pub async fn spawn_worker_with_options<F>(
config: WorkerProcessLaunchConfig,
options: WorkerProcessLaunchOptions,
mut progress: F,
) -> Result<SpawnReady, SpawnError>
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<F>(
progress: &mut F,
stderr_path: &Path,
child: &mut tokio::process::Child,
) -> Result<SpawnReady, SpawnError>
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<String>,
}
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::<Vec<_>>().join(" | ")
}
fn into_string(self) -> String {
self.lines.into_iter().collect::<Vec<_>>().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",
]
);
}
}
+36 -262
View File
@@ -1,11 +1,9 @@
use std::{fmt, path::PathBuf}; use std::{fmt, path::PathBuf};
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; use crate::{BackendRuntimeListTarget, BackendRuntimeTarget};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind { pub enum TargetKind {
/// Legacy local Runtime process/socket authority.
Local,
/// One-process Standalone authority with no Runtime or Workspace backend. /// One-process Standalone authority with no Runtime or Workspace backend.
Standalone, Standalone,
Backend, Backend,
@@ -13,7 +11,6 @@ pub enum TargetKind {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedTarget { pub enum ResolvedTarget {
Local,
Standalone, Standalone,
Backend { Backend {
base_url: String, base_url: String,
@@ -24,7 +21,6 @@ pub enum ResolvedTarget {
impl ResolvedTarget { impl ResolvedTarget {
pub fn kind(&self) -> TargetKind { pub fn kind(&self) -> TargetKind {
match self { match self {
Self::Local => TargetKind::Local,
Self::Standalone => TargetKind::Standalone, Self::Standalone => TargetKind::Standalone,
Self::Backend { .. } => TargetKind::Backend, Self::Backend { .. } => TargetKind::Backend,
} }
@@ -34,32 +30,12 @@ impl ResolvedTarget {
impl fmt::Display for TargetKind { impl fmt::Display for TargetKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Local => f.write_str("local"),
Self::Standalone => f.write_str("Standalone"), Self::Standalone => f.write_str("Standalone"),
Self::Backend => f.write_str("Backend"), 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, TargetError> {
WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command)
}
}
impl Default for LocalTarget {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendTarget { pub struct BackendTarget {
pub base_url: String, pub base_url: String,
@@ -113,23 +89,8 @@ impl WorkerConnectionSelector {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkerSpawn { pub struct WorkerSpawn {
LegacyLocal { pub state_dir: PathBuf,
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,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -146,20 +107,14 @@ pub struct StandaloneSessionResumeIntent {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Dashboard { pub struct Dashboard {
Local { pub base_url: String,
runtime_command: WorkerRuntimeCommand, pub workspace_id: String,
},
Backend {
base_url: String,
workspace_id: String,
},
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList { pub struct WorkerList {
pub local_runtime_command: Option<WorkerRuntimeCommand>, pub backend_target: BackendRuntimeListTarget,
pub backend_target: Option<BackendRuntimeListTarget>,
pub include_stopped: bool, pub include_stopped: bool,
} }
@@ -185,12 +140,6 @@ impl TargetError {
message: format!("invalid {target} target: {}", message.into()), 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 { 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. /// Resolve the target once for Workspace product-state operations.
/// ///
/// Backend targets must carry an explicit Workspace identity. Callers use /// Backend targets must carry an explicit Workspace identity. Callers use
/// this value instead of rediscovering Backend/local authority from cwd or /// this value instead of rediscovering authority from cwd or process
/// process configuration after command dispatch. /// configuration after command dispatch.
fn resolve(&self) -> Result<ResolvedTarget, TargetError>; fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>; fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Err(TargetError::unsupported("Worker spawn", self.kind()))
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>; }
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
fn standalone_session_list( fn standalone_session_list(
&self, &self,
@@ -237,61 +184,12 @@ pub trait Target: fmt::Debug + Send + Sync {
)) ))
} }
fn dashboard(&self) -> Result<Dashboard, TargetError>;
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
fn connect_worker(
&self,
selector: WorkerConnectionSelector,
) -> Result<WorkerConnection, TargetError>;
}
impl Target for LocalTarget {
fn kind(&self) -> TargetKind {
TargetKind::Local
}
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
Ok(ResolvedTarget::Local)
}
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Ok(WorkerSpawn::LegacyLocal {
runtime_command: self.runtime_command()?,
})
}
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
Ok(WorkerByName {
runtime_command: self.runtime_command()?,
})
}
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
Ok(WorkerResume {
runtime_command: self.runtime_command()?,
})
}
fn dashboard(&self) -> Result<Dashboard, TargetError> { fn dashboard(&self) -> Result<Dashboard, TargetError> {
Ok(Dashboard::Local { Err(TargetError::unsupported("Worker dashboard", self.kind()))
runtime_command: self.runtime_command()?,
})
} }
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
if request.runtime_id.is_some() { Err(TargetError::unsupported("Worker listing", self.kind()))
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 connect_worker( fn connect_worker(
@@ -329,22 +227,11 @@ impl Target for StandaloneTarget {
} }
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> { fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Ok(WorkerSpawn::Standalone { Ok(WorkerSpawn {
state_dir: self.state_dir.clone(), state_dir: self.state_dir.clone(),
}) })
} }
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
Err(TargetError::unsupported(
"Worker name attachment",
self.kind(),
))
}
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
Err(TargetError::unsupported("Worker restore", self.kind()))
}
fn standalone_session_list( fn standalone_session_list(
&self, &self,
include_all: bool, include_all: bool,
@@ -367,24 +254,6 @@ impl Target for StandaloneTarget {
session_id, session_id,
}) })
} }
fn dashboard(&self) -> Result<Dashboard, TargetError> {
Err(TargetError::unsupported("Worker dashboard", self.kind()))
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind()))
}
fn connect_worker(
&self,
_selector: WorkerConnectionSelector,
) -> Result<WorkerConnection, TargetError> {
Err(TargetError::unsupported(
"Backend runtime worker connection",
self.kind(),
))
}
} }
impl Target for BackendTarget { impl Target for BackendTarget {
@@ -405,44 +274,27 @@ impl Target for BackendTarget {
}) })
} }
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Err(TargetError::unsupported("Worker spawn", self.kind()))
}
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
Err(TargetError::unsupported(
"Worker name attachment",
self.kind(),
))
}
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
Err(TargetError::unsupported("Worker resume", self.kind()))
}
fn dashboard(&self) -> Result<Dashboard, TargetError> { fn dashboard(&self) -> Result<Dashboard, TargetError> {
match self.resolve()? { let ResolvedTarget::Backend {
ResolvedTarget::Backend {
base_url, base_url,
workspace_id, workspace_id,
} => Ok(Dashboard::Backend { } = self.resolve()?
else {
unreachable!("BackendTarget resolves only Backend authority")
};
Ok(Dashboard {
base_url, base_url,
workspace_id, workspace_id,
}), })
ResolvedTarget::Local | ResolvedTarget::Standalone => {
unreachable!("BackendTarget cannot resolve as a local target")
}
}
} }
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList { Ok(WorkerList {
local_runtime_command: None, backend_target: BackendRuntimeListTarget::new(
backend_target: Some(BackendRuntimeListTarget::new(
self.base_url.clone(), self.base_url.clone(),
self.workspace_id.clone(), self.workspace_id.clone(),
request.runtime_id, request.runtime_id,
)), ),
include_stopped: request.include_stopped, include_stopped: request.include_stopped,
}) })
} }
@@ -499,32 +351,23 @@ mod tests {
} }
#[test] #[test]
fn local_target_resolves_local_product_state_authority() { fn standalone_target_carries_in_process_state_without_runtime_command() {
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"); let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
assert_eq!(target.kind(), TargetKind::Standalone); assert_eq!(target.kind(), TargetKind::Standalone);
assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone); assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone);
assert_eq!( assert_eq!(
target.spawn_worker().unwrap(), target.spawn_worker().unwrap(),
WorkerSpawn::Standalone { WorkerSpawn {
state_dir: PathBuf::from("/tmp/yoi-standalone-state"), state_dir: PathBuf::from("/tmp/yoi-standalone-state"),
} }
); );
} }
#[test] #[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"); 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!( assert_eq!(
target target
.list_workers(WorkerListRequest::new(None)) .list_workers(WorkerListRequest::new(None))
@@ -532,6 +375,10 @@ mod tests {
.to_string(), .to_string(),
"Worker listing is not supported by Standalone target" "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] #[test]
@@ -540,26 +387,13 @@ mod tests {
assert_eq!( assert_eq!(
target.dashboard().unwrap(), target.dashboard().unwrap(),
Dashboard::Backend { Dashboard {
base_url: "http://127.0.0.1:8787".to_string(), base_url: "http://127.0.0.1:8787".to_string(),
workspace_id: "workspace-a".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::<String>);
assert!(
target
.dashboard()
.unwrap_err()
.to_string()
.contains("workspace selection is required")
);
}
#[test] #[test]
fn backend_target_builds_worker_list() { fn backend_target_builds_worker_list() {
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); 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()))) .list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
.unwrap(); .unwrap();
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
assert_eq!( assert_eq!(
workers.backend_target.as_ref().unwrap().base_url, workers.backend_target.workspace_id.as_deref(),
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
Some("workspace-a") Some("workspace-a")
); );
assert_eq!( assert_eq!(
workers workers.backend_target.runtime_id.as_deref(),
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
Some("runtime-a") Some("runtime-a")
); );
} }
@@ -604,33 +425,6 @@ mod tests {
assert_eq!(connection.target.worker_id, "worker-b"); 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::<String>);
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::<String>);
let err = target.spawn_worker().unwrap_err();
assert_eq!(
err.to_string(),
"Worker spawn is not supported by Backend target"
);
}
#[test] #[test]
fn standalone_target_builds_explicit_session_intents() { fn standalone_target_builds_explicit_session_intents() {
let target = StandaloneTarget::new("/tmp/yoi-client-sessions"); let target = StandaloneTarget::new("/tmp/yoi-client-sessions");
@@ -644,25 +438,5 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(resume.state_dir, list.state_dir); assert_eq!(resume.state_dir, list.state_dir);
assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000"); 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);
} }
} }
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -16,7 +16,7 @@ protocol = { workspace = true }
ratatui = { version = "0.30.0", features = ["scrolling-regions"] } ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
base64 = "0.22.1" base64 = "0.22.1"
crossterm = "0.28" 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 } serde_json = { workspace = true }
unicode-width = "0.2.2" unicode-width = "0.2.2"
uuid = { workspace = true } uuid = { workspace = true }
@@ -24,10 +24,8 @@ toml = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
secrets = { workspace = true } secrets = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
fs4 = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
worker = { path = "../worker" }
pulldown-cmark = { version = "0.13.3", default-features = false } pulldown-cmark = { version = "0.13.3", default-features = false }
agen.workspace = true agen.workspace = true
+7 -305
View File
@@ -1,4 +1,3 @@
use std::error::Error;
use std::fmt; use std::fmt;
use std::future::Future; use std::future::Future;
use std::io; use std::io;
@@ -21,30 +20,18 @@ use protocol::{Event, Method, WorkerStatus};
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SegmentId;
use standalone::{StandaloneHost, StandaloneLaunchConfig}; use standalone::{StandaloneHost, StandaloneLaunchConfig};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use client::{ use client::{BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent};
BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent, WorkerClient,
WorkerRuntimeCommand,
};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::composer_keys::{ComposerEditAction, composer_edit_action};
use crate::picker::PickerOutcome; use crate::ui;
use crate::spawn::{SpawnOutcome, SpawnReady};
use crate::{picker, spawn, ui};
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>; pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// 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<PathBuf>,
}
/// Enable SGR coordinates plus normal mouse tracking. This captures clicks, /// Enable SGR coordinates plus normal mouse tracking. This captures clicks,
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`) /// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
/// so terminal-native drag selection remains available during startup. /// 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) copy_selection_to_writer(app, &mut stdout)
} }
fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> 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<PathBuf>,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
#[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 { enum ConsoleConnection {
LegacySocket(WorkerClient),
BackendRuntime(BackendRuntimeClient), BackendRuntime(BackendRuntimeClient),
Standalone { Standalone {
host: Option<StandaloneHost>, host: Option<StandaloneHost>,
@@ -198,7 +141,6 @@ impl ConsoleConnection {
fn try_next_event(&mut self) -> Option<Event> { fn try_next_event(&mut self) -> Option<Event> {
match self { match self {
Self::LegacySocket(client) => client.try_next_event(),
Self::BackendRuntime(client) => client.try_next_event(), Self::BackendRuntime(client) => client.try_next_event(),
Self::Standalone { Self::Standalone {
events, events,
@@ -210,7 +152,6 @@ impl ConsoleConnection {
async fn next_event(&mut self) -> Option<Event> { async fn next_event(&mut self) -> Option<Event> {
match self { match self {
Self::LegacySocket(client) => client.next_event().await,
Self::BackendRuntime(client) => client.next_event().await, Self::BackendRuntime(client) => client.next_event().await,
Self::Standalone { host, events, .. } => loop { Self::Standalone { host, events, .. } => loop {
match events.recv().await { match events.recv().await {
@@ -229,7 +170,6 @@ impl ConsoleConnection {
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> { async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
match self { match self {
Self::LegacySocket(client) => Ok(client.send(method).await?),
Self::BackendRuntime(client) => Ok(client.send(method).await?), Self::BackendRuntime(client) => Ok(client.send(method).await?),
Self::Standalone { host, .. } => { Self::Standalone { host, .. } => {
let host = host.as_ref().ok_or_else(|| { 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 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 let shutdown_result = connection
.shutdown() .shutdown()
.await .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); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
app.connected = true; app.connected = true;
let mut connection = ConsoleConnection::BackendRuntime(client); 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); let _ = leave_fullscreen(&mut terminal);
result result
} }
async fn run_connected_pod(
terminal: &mut ConsoleTerminal,
worker_name: String,
client: WorkerClient,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<SpawnReady, Box<dyn std::error::Error>> {
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<PathBuf>,
) -> Option<WorkerClient> {
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<dyn std::error::Error>> {
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(&registry_socket)
.await
.ok()
.map(|client| (registry_socket, client))
}
pub(crate) async fn run_resume(
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}
pub(crate) async fn run_spawn(
resume_from: Option<SegmentId>,
worker_name: Option<String>,
profile: Option<String>,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
#[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<ConsoleTerminal, Box<dyn std::error::Error>> { fn enter_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
// Enable button-event tracking so the transcript can own drag selection; // Enable button-event tracking so the transcript can own drag selection;
@@ -542,19 +303,6 @@ pub(crate) fn enter_dashboard_fullscreen() -> Result<ConsoleTerminal, Box<dyn st
Ok(Terminal::new(backend)?) Ok(Terminal::new(backend)?)
} }
fn enter_fullscreen_existing(
terminal: &mut ConsoleTerminal,
) -> Result<(), Box<dyn std::error::Error>> {
// 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<()> { fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> {
execute!( execute!(
terminal.backend_mut(), terminal.backend_mut(),
@@ -567,35 +315,6 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
leave_fullscreen(terminal) leave_fullscreen(terminal)
} }
async fn run(
terminal: &mut ConsoleTerminal,
worker_name: String,
socket_path: &std::path::Path,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
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<TermEvent>; type TerminalEventResult = io::Result<TermEvent>;
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50); const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
@@ -861,14 +580,13 @@ async fn drain_terminal_events(
app: &mut App, app: &mut App,
client: &mut ConsoleConnection, client: &mut ConsoleConnection,
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>, term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
runtime_command: Option<&WorkerRuntimeCommand>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
match term_rx.try_recv() { match term_rx.try_recv() {
Ok(event) => { Ok(event) => {
handled = true; handled = true;
handle_terminal_event(app, client, event?, runtime_command).await?; handle_terminal_event(app, client, event?).await?;
if app.quit { if app.quit {
break; break;
} }
@@ -908,7 +626,6 @@ async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App, app: &mut App,
client: &mut ConsoleConnection, client: &mut ConsoleConnection,
runtime_command: Option<WorkerRuntimeCommand>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
@@ -919,8 +636,7 @@ async fn run_loop(
break; break;
} }
let handled_term_event = let handled_term_event = drain_terminal_events(app, client, &mut term_rx).await?;
drain_terminal_events(app, client, &mut term_rx, runtime_command.as_ref()).await?;
if app.quit { if app.quit {
break; break;
} }
@@ -932,7 +648,7 @@ async fn run_loop(
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
LoopInput::Terminal(term_event) => { 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 { LoopInput::Worker(event) => match event {
Some(ev) => { Some(ev) => {
@@ -958,7 +674,6 @@ async fn handle_terminal_event(
app: &mut App, app: &mut App,
client: &mut ConsoleConnection, client: &mut ConsoleConnection,
event: TermEvent, event: TermEvent,
_runtime_command: Option<&WorkerRuntimeCommand>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
match event { match event {
TermEvent::Key(key) => { TermEvent::Key(key) => {
@@ -980,19 +695,6 @@ async fn handle_terminal_event(
Ok(()) Ok(())
} }
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
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 /// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so
/// hand-rolling through long histories isn't tedious, but slow enough /// hand-rolling through long histories isn't tedious, but slow enough
/// that a single notch doesn't blow past the section the user is /// that a single notch doesn't blow past the section the user is
File diff suppressed because it is too large Load Diff
-784
View File
@@ -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::<Vec<_>>();
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<PanelRowKey>,
}
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<Line<'static>> {
list_rows(app, width, height)
.into_iter()
.map(|row| row.line)
.collect()
}
pub(super) fn list_rows(app: &DashboardApp, width: u16, height: u16) -> Vec<PanelListRow> {
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::<Vec<_>>();
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::<Vec<_>>();
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<PanelRowHitBox> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let mut hit_boxes: Vec<PanelRowHitBox> = 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<Line<'static>> {
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<PanelListRow> {
let rows = panel
.rows
.iter()
.filter(|row| row.is_ticket_section_row())
.collect::<Vec<_>>();
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<Line<'static>> {
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<Span<'static>>,
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<Span<'static>>,
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<Span<'static>>,
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<String> {
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<Span<'static>>,
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<Span<'static>>,
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<PanelListRow> {
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<Line<'static>> = 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
}
File diff suppressed because it is too large Load Diff
+17 -96
View File
@@ -8,25 +8,19 @@ mod command;
mod composer_history; mod composer_history;
mod composer_keys; mod composer_keys;
mod console; mod console;
mod dashboard;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
mod e2e_observer; mod e2e_observer;
mod input; mod input;
pub mod keys; pub mod keys;
mod markdown; mod markdown;
mod picker;
mod role_session_registry;
mod scroll; mod scroll;
pub mod setup_model; pub mod setup_model;
mod spawn;
mod standalone_picker; mod standalone_picker;
mod task; mod task;
mod text_selection; mod text_selection;
mod tool; mod tool;
mod ui; mod ui;
mod view_mode; mod view_mode;
mod worker_list;
mod workspace_panel;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
@@ -35,9 +29,8 @@ use std::process::ExitCode;
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste}; use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste};
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; 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)] #[derive(Debug)]
pub struct LaunchOptions { pub struct LaunchOptions {
@@ -48,6 +41,7 @@ pub struct LaunchOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LaunchMode { pub enum LaunchMode {
/// Start one client-owned in-process Standalone Worker.
Spawn { Spawn {
worker_name: Option<String>, worker_name: Option<String>,
profile: Option<String>, profile: Option<String>,
@@ -55,38 +49,18 @@ pub enum LaunchMode {
/// Restore one client-owned standalone session. The current cwd is the default scope; /// 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. /// `include_all` opts into all standalone sessions under the same client data root.
StandaloneResume { include_all: bool }, StandaloneResume { include_all: bool },
/// `yoi --worker <name>`: attach to a live Worker by name if possible; /// List Backend Workers and attach to the selected Worker.
/// otherwise launch the Worker runtime command with `--worker <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Worker.
WorkerName {
worker_name: String,
socket_override: Option<PathBuf>,
},
/// `yoi workers` / `yoi --backend <url>`: list workers through the selected
/// connection target, then attach to the selected Worker.
Workers { Workers {
runtime_id: Option<String>, runtime_id: Option<String>,
include_stopped: bool, include_stopped: bool,
all: bool,
}, },
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker /// Open one Backend Worker through the selected connection target.
/// through the selected connection target.
OpenWorker { OpenWorker {
runtime_id: String, runtime_id: String,
worker_id: String, worker_id: String,
}, },
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker /// Open the Backend Workspace dashboard.
/// or restore the selected stopped Worker by name. Without `--all`, the picker Panel,
/// is scoped to the current runtime workspace.
Resume { all: bool },
/// `yoi --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession {
id: SegmentId,
worker_name: Option<String>,
},
/// `yoi panel`: open the workspace Dashboard from the current workspace.
Panel { include_stopped: bool },
} }
struct TerminalModeGuard { struct TerminalModeGuard {
@@ -163,11 +137,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
worker_name, worker_name,
profile, profile,
} => match target.spawn_worker() { } => match target.spawn_worker() {
Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { Ok(spawn) => {
console::run_spawn(None, worker_name, profile, runtime_command).await console::run_standalone(
} workspace_root.clone(),
Ok(WorkerSpawn::Standalone { state_dir }) => { spawn.state_dir,
console::run_standalone(workspace_root.clone(), state_dir, worker_name, profile) worker_name,
profile,
)
.await .await
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
@@ -179,45 +155,17 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>), Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
} }
} }
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<dyn std::error::Error>),
},
LaunchMode::Workers { LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped, include_stopped,
all,
} => match target.list_workers(if include_stopped { } => match target.list_workers(if include_stopped {
WorkerListRequest::with_stopped(runtime_id) WorkerListRequest::with_stopped(runtime_id)
} else { } else {
WorkerListRequest::new(runtime_id) WorkerListRequest::new(runtime_id)
}) { }) {
Ok(worker_list) => { Ok(worker_list) => {
if let Some(target) = worker_list.backend_target { backend_worker_picker::run(worker_list.backend_target, worker_list.include_stopped)
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,
)
.await .await
} else {
Err(Box::new(io::Error::other(
"worker list target did not include a local or backend source",
)) as Box<dyn std::error::Error>)
}
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
@@ -228,32 +176,12 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Ok(connection) => console::run_backend_runtime(connection.target).await, Ok(connection) => console::run_backend_runtime(connection.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
LaunchMode::Resume { all } => match target.resume_worker() { LaunchMode::Panel => match target.dashboard() {
Ok(resume) => { Ok(dashboard) => {
console::run_resume(resume.runtime_command, workspace_root.clone(), all).await backend_dashboard::launch(dashboard.base_url, dashboard.workspace_id).await
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
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<dyn std::error::Error>),
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
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<dyn std::error::Error>),
},
}; };
// Always restore the terminal first so any pending eprintln below // Always restore the terminal first so any pending eprintln below
@@ -272,14 +200,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Err(e) => { 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::<spawn::SpawnError>().is_none() {
eprintln!("yoi: {e}"); eprintln!("yoi: {e}");
}
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" }));
ExitCode::FAILURE ExitCode::FAILURE
-525
View File
@@ -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<io::Error> for PickerError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<session_store::StoreError> 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<PathBuf>,
},
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<StoredWorkerInfo>,
live_workers: Vec<LiveWorkerInfo>,
) -> 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<PickerOutcome, PickerError> {
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<CrosstermBackend<io::Stdout>>) -> 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<PathBuf, PickerError> {
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<PathBuf, PickerError> {
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<PathBuf> {
worker_list_live_socket_for_worker(worker_name)
}
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
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<Option<Action>> {
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<Constraint> = 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<T: ToString>(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::<String>();
assert!(text.contains("[live]"));
assert!(text.contains("[live, pending segment]"));
assert!(text.contains(&format!("g:{}", short_id(segment_id))));
}
}
-556
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRegistry {
pub version: u32,
pub workspace_root: String,
pub sessions: BTreeMap<String, RoleSessionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRecord {
pub role: String,
pub 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<String>,
#[serde(default)]
pub related_tickets: Vec<RelatedTicketRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RoleSessionOrigin {
PreTicketIntake,
TicketClaim,
RoleLaunch,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct RelatedTicketRef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct TicketClaim {
pub ticket_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_slug: Option<String>,
pub worker_name: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PanelRegistrySnapshot {
pub sessions: Vec<RoleSessionRecord>,
pub claims: Vec<TicketClaim>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TicketClaimResult {
Claimed,
AlreadyOwned(TicketClaim),
}
#[derive(Debug)]
pub(crate) enum PanelRegistryError {
Io(io::Error),
Json(serde_json::Error),
TicketAlreadyClaimed(TicketClaim),
}
impl std::fmt::Display for PanelRegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "local role session registry I/O error: {error}"),
Self::Json(error) => write!(f, "local role session registry JSON error: {error}"),
Self::TicketAlreadyClaimed(claim) => write!(
f,
"Ticket {} is already claimed locally by {} ({})",
claim.ticket_id, claim.worker_name, claim.role
),
}
}
}
impl std::error::Error for PanelRegistryError {}
impl From<io::Error> for PanelRegistryError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for PanelRegistryError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl PanelRegistryStore {
pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result<Self, PanelRegistryError> {
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
PanelRegistryError::Io(io::Error::other("failed to resolve yoi data directory"))
})?;
Ok(Self::for_data_dir(data_dir, workspace_root))
}
pub(crate) fn for_data_dir(data_dir: impl AsRef<Path>, workspace_root: &Path) -> Self {
let workspace_root = normalized_workspace_key(workspace_root);
let leaf = workspace_leaf(&workspace_root);
let digest = fnv1a64_hex(workspace_root.as_bytes());
Self {
root: data_dir
.as_ref()
.join("panel")
.join("workspaces")
.join(format!("{leaf}-{digest}")),
workspace_root: Some(workspace_root),
}
}
pub(crate) fn from_root(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
workspace_root: None,
}
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn snapshot(&self) -> Result<PanelRegistrySnapshot, PanelRegistryError> {
let registry = self.load_registry()?;
let claims = self.load_claims()?;
Ok(PanelRegistrySnapshot {
sessions: registry.sessions.into_values().collect(),
claims,
})
}
pub(crate) fn load_registry(&self) -> Result<RoleSessionRegistry, PanelRegistryError> {
match fs::read(self.registry_path()) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(RoleSessionRegistry {
version: REGISTRY_VERSION,
workspace_root: self.workspace_root.clone().unwrap_or_default(),
sessions: BTreeMap::new(),
}),
Err(error) => Err(error.into()),
}
}
pub(crate) fn record_session(
&self,
worker_name: impl Into<String>,
role: impl Into<String>,
origin: RoleSessionOrigin,
session_id: Option<String>,
related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
) -> Result<(), PanelRegistryError> {
let worker_name = worker_name.into();
let role = role.into();
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
self.update_registry(|registry| {
let now = now_timestamp_string();
let mut tickets: BTreeSet<RelatedTicketRef> = registry
.sessions
.get(&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<TicketClaimResult, PanelRegistryError> {
fs::create_dir_all(self.claims_dir())?;
let claim_path = self.claim_path(ticket_id);
let claim = TicketClaim {
ticket_id: ticket_id.to_string(),
ticket_slug: ticket_slug.map(ToOwned::to_owned),
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<TicketClaim, PanelRegistryError> {
let bytes = fs::read(self.claim_path(ticket_id))?;
Ok(serde_json::from_slice(&bytes)?)
}
pub(crate) fn claim_for_ticket(
&self,
ticket_id: &str,
) -> Result<Option<TicketClaim>, PanelRegistryError> {
match self.load_claim(ticket_id) {
Ok(claim) => Ok(Some(claim)),
Err(PanelRegistryError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
Ok(None)
}
Err(error) => Err(error),
}
}
fn update_registry(
&self,
update: impl FnOnce(&mut RoleSessionRegistry) -> Result<(), PanelRegistryError>,
) -> Result<(), PanelRegistryError> {
fs::create_dir_all(&self.root)?;
let _lock = self.acquire_registry_lock()?;
let mut registry = self.load_registry()?;
registry.version = REGISTRY_VERSION;
if let Some(workspace_root) = self.workspace_root.as_ref() {
registry.workspace_root = workspace_root.clone();
}
update(&mut registry)?;
self.save_registry(&registry)
}
fn acquire_registry_lock(&self) -> Result<RegistryLockGuard, PanelRegistryError> {
let lock_path = self.root.join(REGISTRY_LOCK_FILE);
for _ in 0..50 {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => return Ok(RegistryLockGuard { path: lock_path }),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
}
Err(PanelRegistryError::Io(io::Error::new(
io::ErrorKind::WouldBlock,
"timed out acquiring panel role session registry lock",
)))
}
fn save_registry(&self, registry: &RoleSessionRegistry) -> Result<(), PanelRegistryError> {
let path = self.registry_path();
let temp_path = path.with_extension(format!("json.{}.tmp", now_timestamp_string()));
let bytes = serde_json::to_vec_pretty(registry)?;
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
fs::rename(temp_path, path)?;
Ok(())
}
fn create_claim_file(&self, claim_path: &Path, claim: &TicketClaim) -> io::Result<()> {
let temp_path = self
.claims_dir()
.join(format!(".{}.tmp", now_timestamp_string()));
let bytes = serde_json::to_vec_pretty(claim).map_err(io::Error::other)?;
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
let link_result = fs::hard_link(&temp_path, claim_path);
let remove_result = fs::remove_file(&temp_path);
match (link_result, remove_result) {
(Ok(()), Ok(())) | (Ok(()), Err(_)) => Ok(()),
(Err(error), _) => Err(error),
}
}
fn load_claims(&self) -> Result<Vec<TicketClaim>, PanelRegistryError> {
let mut claims: Vec<TicketClaim> = Vec::new();
match fs::read_dir(self.claims_dir()) {
Ok(entries) => {
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_file()
&& entry
.path()
.extension()
.is_some_and(|extension| extension == "json")
{
let bytes = fs::read(entry.path())?;
claims.push(serde_json::from_slice(&bytes)?);
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
claims.sort_by(|left, right| left.ticket_id.cmp(&right.ticket_id));
Ok(claims)
}
fn registry_path(&self) -> PathBuf {
self.root.join(REGISTRY_FILE)
}
fn claims_dir(&self) -> PathBuf {
self.root.join(CLAIMS_DIR)
}
fn claim_path(&self, ticket_id: &str) -> PathBuf {
self.claims_dir()
.join(format!("{}.json", encode_path_component(ticket_id)))
}
}
struct RegistryLockGuard {
path: PathBuf,
}
impl Drop for RegistryLockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl PanelRegistrySnapshot {
pub(crate) fn empty() -> Self {
Self {
sessions: Vec::new(),
claims: Vec::new(),
}
}
pub(crate) fn claim_for_ticket(&self, ticket_id: &str) -> Option<&TicketClaim> {
self.claims
.iter()
.find(|claim| claim.ticket_id == ticket_id)
}
}
fn normalized_workspace_key(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn workspace_leaf(workspace_root: &str) -> String {
let leaf = workspace_root
.rsplit('/')
.find(|part| !part.is_empty())
.unwrap_or("workspace");
let sanitized = leaf
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
"workspace".to_string()
} else {
sanitized
}
}
fn fnv1a64_hex(bytes: &[u8]) -> String {
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn encode_path_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => encoded.push(byte as char),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn now_timestamp_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos().to_string())
.unwrap_or_else(|_| "0".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn registry_path_is_workspace_scoped_under_data_dir() {
let data_dir = TempDir::new().unwrap();
let store = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
let other = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
assert!(store.root().starts_with(data_dir.path()));
let root = store.root().to_string_lossy();
assert!(root.contains("panel/workspaces/yoi-"));
assert_ne!(store.root(), other.root());
store
.record_session(
"ticket-intake-preticket",
"intake",
RoleSessionOrigin::PreTicketIntake,
None,
[],
)
.unwrap();
assert_eq!(store.load_registry().unwrap().workspace_root, "/repo/yoi");
}
#[test]
fn claim_ticket_rejects_second_active_local_pod() {
let temp = TempDir::new().unwrap();
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
assert!(matches!(
store.claim_ticket("T-1", Some("ticket-one"), "ticket-one-intake", "intake"),
Ok(TicketClaimResult::Claimed)
));
let error = store
.claim_ticket("T-1", Some("ticket-one"), "ticket-two-intake", "intake")
.unwrap_err();
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
assert_eq!(claim.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()),
},
]
);
}
}
-756
View File
@@ -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<io::Error> for SpawnError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<client::SpawnError> for SpawnError {
fn from(e: client::SpawnError) -> Self {
Self::Spawn(e)
}
}
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Worker" mode and
/// passes `--session <id>` to the spawned Worker runtime child.
pub async fn run(
resume_from: Option<SegmentId>,
worker_name: Option<String>,
profile: Option<String>,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
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 <name>` 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<SpawnOutcome, SpawnError> {
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<ProfileChoice>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileChoice {
selector: Option<String>,
label: String,
is_default: bool,
}
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
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<ProfileChoice>, 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<ProfileChoice>,
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<InlineTerminal> {
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<Option<Action>> {
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<SpawnReady, SpawnError> {
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 <id>` so it restores
/// from `id` and appends to the same session log.
resume_from: Option<SegmentId>,
/// 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<ProfileChoice>,
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<String> {
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");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,9 +14,9 @@ use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::runtime_command::WorkerRuntimeCommand;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
use client::WorkerRuntimeCommand;
use manifest::{Permission, ScopeRule}; use manifest::{Permission, ScopeRule};
use protocol::stream::JsonLineReader; use protocol::stream::JsonLineReader;
use protocol::{Event, Method, WorkerStatus}; use protocol::{Event, Method, WorkerStatus};
+1
View File
@@ -11,6 +11,7 @@ pub mod ipc;
pub mod model_client; pub mod model_client;
pub mod prompt; pub mod prompt;
pub mod runtime; pub mod runtime;
pub mod runtime_command;
pub mod segment_log_sink; pub mod segment_log_sink;
mod session_capture; mod session_capture;
mod session_history; mod session_history;
+22 -30
View File
@@ -1,4 +1,4 @@
use client::{BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind}; use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
use serde::Deserialize; use serde::Deserialize;
use super::{ParseError, read_client_default_connection, resolve_backend_url}; use super::{ParseError, read_client_default_connection, resolve_backend_url};
@@ -74,13 +74,14 @@ impl CliCommand {
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub(crate) enum ClientDefaultConnection { pub(crate) enum ClientDefaultConnection {
Local, #[serde(rename = "local")]
Standalone,
Backend, Backend,
} }
impl Default for ClientDefaultConnection { impl Default for ClientDefaultConnection {
fn default() -> Self { fn default() -> Self {
Self::Local Self::Standalone
} }
} }
@@ -89,7 +90,7 @@ pub(crate) enum CliConnectionInput<'a> {
DefaultTarget { DefaultTarget {
workspace_id: Option<&'a str>, workspace_id: Option<&'a str>,
}, },
LocalTarget, StandaloneTarget,
BackendTarget { BackendTarget {
explicit_backend_url: Option<String>, explicit_backend_url: Option<String>,
workspace_id: Option<&'a str>, workspace_id: Option<&'a str>,
@@ -130,15 +131,15 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
match (command.connection_requirement(), input) { match (command.connection_requirement(), input) {
( (
CliConnectionRequirement::LocalOnly, CliConnectionRequirement::LocalOnly,
CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget, CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::StandaloneTarget,
) => Ok(Box::new(LocalTarget::new())), ) => standalone_target(),
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => { (CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
Err(ParseError(format!( 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() command.display_name()
))) )))
} }
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalTarget) => { (CliConnectionRequirement::BackendOnly, CliConnectionInput::StandaloneTarget) => {
Err(ParseError(format!( Err(ParseError(format!(
"{} requires a Backend connection target", "{} requires a Backend connection target",
command.display_name() command.display_name()
@@ -161,19 +162,14 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
resolve_backend_url(explicit_backend_url, workspace_id)?, resolve_backend_url(explicit_backend_url, workspace_id)?,
workspace_id.map(str::to_string), workspace_id.map(str::to_string),
))), ))),
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) (CliConnectionRequirement::ConnectionAware, CliConnectionInput::StandaloneTarget) => {
if command == CliCommand::DefaultTui =>
{
standalone_target() standalone_target()
} }
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
Ok(Box::new(LocalTarget::new()))
}
( (
CliConnectionRequirement::ConnectionAware, CliConnectionRequirement::ConnectionAware,
CliConnectionInput::DefaultTarget { workspace_id }, CliConnectionInput::DefaultTarget { workspace_id },
) => match read_client_default_connection()? { ) => match read_client_default_connection()? {
ClientDefaultConnection::Local => Ok(Box::new(LocalTarget::new())), ClientDefaultConnection::Standalone => standalone_target(),
ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new( ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new(
resolve_backend_url(None, workspace_id)?, resolve_backend_url(None, workspace_id)?,
workspace_id.map(str::to_string), workspace_id.map(str::to_string),
@@ -187,15 +183,11 @@ pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R, resolver: &R,
command: CliCommand, command: CliCommand,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?; let target = resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget)?;
match target.kind() { match target.kind() {
TargetKind::Local => Ok(target), TargetKind::Standalone => Ok(target),
TargetKind::Standalone => Err(ParseError(format!(
"{} resolved Standalone where a legacy local target was required",
command.display_name()
))),
TargetKind::Backend => Err(ParseError(format!( 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() command.display_name()
))), ))),
} }
@@ -216,7 +208,7 @@ pub(crate) fn resolve_backend_cli_connection<R: CliConnectionResolver + ?Sized>(
)?; )?;
match target.kind() { match target.kind() {
TargetKind::Backend => Ok(target), 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", "{} resolved a non-Backend target where a Backend target was required",
command.display_name() command.display_name()
))), ))),
@@ -236,7 +228,7 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
)); ));
} }
if explicit_local { if explicit_local {
return resolver.resolve_connection(command, CliConnectionInput::LocalTarget); return resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget);
} }
if explicit_backend_url.is_some() { if explicit_backend_url.is_some() {
return resolver.resolve_connection( return resolver.resolve_connection(
@@ -255,7 +247,7 @@ pub(crate) fn backend_target_option_error_for_local_command(
option: &str, option: &str,
) -> ParseError { ) -> ParseError {
ParseError(format!( 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() command.display_name()
)) ))
} }
@@ -317,7 +309,7 @@ mod tests {
} }
#[test] #[test]
fn cli_connection_resolver_rejects_local_only_backend_target() { fn cli_connection_resolver_rejects_host_only_backend_target() {
let resolver = ClientConfigCliConnectionResolver; let resolver = ClientConfigCliConnectionResolver;
let err = resolver let err = resolver
.resolve_connection( .resolve_connection(
@@ -331,15 +323,15 @@ mod tests {
assert_eq!( assert_eq!(
err.to_string(), 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] #[test]
fn cli_connection_resolver_rejects_backend_only_local_target() { fn cli_connection_resolver_rejects_backend_only_standalone_target() {
let resolver = ClientConfigCliConnectionResolver; let resolver = ClientConfigCliConnectionResolver;
let err = resolver let err = resolver
.resolve_connection(CliCommand::Login, CliConnectionInput::LocalTarget) .resolve_connection(CliCommand::Login, CliConnectionInput::StandaloneTarget)
.unwrap_err(); .unwrap_err();
assert_eq!( assert_eq!(
@@ -363,7 +355,7 @@ mod tests {
let workers = target let workers = target
.list_workers(client::WorkerListRequest::new(None)) .list_workers(client::WorkerListRequest::new(None))
.unwrap(); .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.base_url, "http://127.0.0.1:8787");
assert_eq!(backend_target.workspace_id, None); assert_eq!(backend_target.workspace_id, None);
} }
+152 -285
View File
@@ -23,7 +23,6 @@ use cli_connection::{
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login}; use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions}; use tui::{LaunchMode, LaunchOptions};
#[derive(Debug)] #[derive(Debug)]
@@ -129,10 +128,8 @@ async fn main() -> ExitCode {
match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await { match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await {
Ok(Ok(output)) => { Ok(Ok(output)) => {
print!("{}", output.stdout); print!("{}", output.stdout);
match output.status { let objective_cli::ObjectiveCliStatus::Success = output.status;
objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS, ExitCode::SUCCESS
objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE,
}
} }
Ok(Err(e)) => { Ok(Err(e)) => {
eprintln!("yoi objective: {e}"); eprintln!("yoi objective: {e}");
@@ -336,7 +333,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
LaunchMode::Workers { LaunchMode::Workers {
runtime_id: None, runtime_id: None,
include_stopped: false, include_stopped: false,
all: false,
} }
} else { } else {
LaunchMode::Spawn { LaunchMode::Spawn {
@@ -422,17 +418,20 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
&target_selection, &target_selection,
&panel_options.workspace_root, &panel_options.workspace_root,
)?; )?;
if panel_options.include_stopped && target.kind() == TargetKind::Backend { if panel_options.include_stopped {
return Err(ParseError( 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(), .to_string(),
)); ));
} }
if target.kind() != TargetKind::Backend {
return Err(ParseError(
"yoi panel requires a Backend connection target".to_string(),
));
}
return Ok(Mode::Tui { return Ok(Mode::Tui {
target, target,
mode: LaunchMode::Panel { mode: LaunchMode::Panel,
include_stopped: panel_options.include_stopped,
},
workspace_root: panel_options.workspace_root, workspace_root: panel_options.workspace_root,
}); });
} }
@@ -752,40 +751,41 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
mode: LaunchMode::Workers { mode: LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped: false, include_stopped: false,
all: false,
}, },
workspace_root, 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 { let mode = if standalone_resume {
LaunchMode::StandaloneResume { LaunchMode::StandaloneResume {
include_all: standalone_all, include_all: standalone_all,
} }
} else if let Some(profile) = profile {
LaunchMode::Spawn {
worker_name,
profile: Some(profile),
}
} else if target.kind() == TargetKind::Standalone { } else if target.kind() == TargetKind::Standalone {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name, worker_name,
profile: None, profile,
}
} 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,
} }
} else { } else {
LaunchMode::Spawn { if worker_name.is_some()
worker_name: None, || profile.is_some()
profile: None, || 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<R: CliConnectionResolver + ?Sized>(
target_selection, target_selection,
&workspace_root, &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 { Ok(Mode::Tui {
target, target,
mode: LaunchMode::Workers { mode: LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped, include_stopped,
all: false,
}, },
workspace_root, workspace_root,
}) })
@@ -984,13 +989,29 @@ fn parse_resume_args<R: CliConnectionResolver + ?Sized>(
&workspace_root, &workspace_root,
)?; )?;
Ok(Mode::Tui { let mode = if target.kind() == TargetKind::Standalone {
target, if runtime_id.is_some() {
mode: LaunchMode::Workers { 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, runtime_id,
include_stopped: true, include_stopped: true,
all, }
}, };
Ok(Mode::Tui {
target,
mode,
workspace_root, workspace_root,
}) })
} }
@@ -1688,78 +1709,54 @@ fn parse_panel_args(args: &[String]) -> Result<PanelCliOptions, ParseError> {
}) })
} }
fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
value
.parse()
.map_err(|_| ParseError(format!("invalid --session UUID: {value}")))
}
const TOP_LEVEL_HELP: &str = r#"yoi const TOP_LEVEL_HELP: &str = r#"yoi
Usage: Usage:
yoi [TARGET] [CONSOLE_OPTIONS] yoi [TARGET]
yoi --local --resume [--all] yoi --local --resume [--all]
yoi [TARGET] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>] yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>] yoi [TARGET] resume [--all] [--runtime-id <ID>]
yoi [TARGET] panel [-r|--stopped] [--workspace <PATH>] yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] login [--no-wait] yoi [--backend <URL>] login [--no-wait]
yoi <LOCAL_COMMAND> [OPTIONS] yoi <HOST_COMMAND> [OPTIONS]
Target selection: Target selection:
Target options are top-level options and must appear before the command. --local Use the client-owned one-process Standalone host
--resume With --local, restore from the Standalone session store
--local Start or restore a client-owned one-process Standalone Worker --all With Standalone restore, include sessions from every cwd identity
--resume With --local, open the Standalone session picker for the current cwd
--all With --local --resume, include sessions from every cwd identity
--backend <URL> Use a Workspace Backend explicitly --backend <URL> Use a Workspace Backend explicitly
--workspace-id <ID> Scope Backend routes to a Workspace id --workspace-id <ID> Scope Backend routes to a Workspace id
If no target is explicit, connection-aware commands use the merged client config: If no target is explicit, connection-aware commands use the merged client config.
<data_dir>/client/config.toml `default_connection = "local"` selects Standalone; it does not enable a filesystem
<cwd>/.yoi/client.config.toml Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
Supported client config keys:
default_connection = "local" | "backend"
default_backend = "<name>"
[backends.<name>] url = "https://backend.example"
[workspaces.<workspace_id>] backend = "<name>"
Connection-aware commands: Connection-aware commands:
yoi Local: open a new Console. Backend: open Backend Workers. yoi Standalone: new Console. Backend: Worker picker.
yoi workers List/select Workers for the selected target. yoi resume Standalone session picker or stopped Backend Worker picker.
yoi workers -r Include stopped Workers. --restoreable is accepted as a legacy alias. yoi workers Backend Workspace Worker picker.
yoi resume Open the Worker picker with stopped Workers included. yoi panel Backend Workspace dashboard.
yoi panel Open the dashboard/panel TUI for the selected target.
yoi panel -r Local only: include stopped/restorable Worker rows.
Console options: Console options:
--workspace <PATH> Local workspace root for local Console/Worker lists (defaults to cwd) --workspace <PATH> Standalone cwd or client display scope (defaults to cwd)
--worker <NAME> Open/create a named local Worker Console --profile <REF> Select the Standalone Profile recipe
--socket <PATH> Attach to a local Worker socket; requires --worker --runtime-id <ID> Backend Runtime id
--session <UUID> Resume a local session segment --worker-id <ID> Backend Worker id; requires --runtime-id
--profile <REF> Select a reusable Profile recipe for a fresh local Worker
--runtime-id <ID> Backend Runtime id for Backend Worker list/attach
--worker-id <ID> Backend Worker id to attach; requires --runtime-id
Local commands: Host commands:
keys Manage local model/API keys keys Manage local model/API keys
setup-model Configure a local model provider setup-model Configure a local model provider
worker [WORKER_OPTIONS] Run the local Worker runtime CLI worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
worker delete <NAME> Delete local Worker records ticket <COMMAND> Manage Tickets through a Backend target
worker prune Prune old local Worker records objective <COMMAND> Manage Objectives through a Backend target
ticket <COMMAND> Manage Tickets through the selected target
objective <COMMAND> Manage Objectives through the selected target
plugin <COMMAND> Build/check/list/show plugins plugin <COMMAND> Build/check/list/show plugins
mcp <COMMAND> Inspect configured MCP servers mcp <COMMAND> Inspect configured MCP servers
memory lint Lint local memory files memory lint Lint local memory files
session <COMMAND> Inspect/prune local session logs session <COMMAND> Inspect/prune Standalone session logs
Backend-only commands:
login Run Backend device login and save the API token
Standalone binaries: Standalone binaries:
yoi-server Workspace Backend server/admin CLI yoi-server Workspace Backend server/admin CLI
yoi-runtime Worker Runtime REST server yoi-runtime Worker Runtime server
Options: Options:
-h, --help Print help -h, --help Print help
@@ -1785,9 +1782,7 @@ fn print_memory_lint_help() {
mod tests { mod tests {
use super::*; use super::*;
use crate::cli_connection::CliConnectionInput; use crate::cli_connection::CliConnectionInput;
use client::{ use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest};
BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest,
};
struct FixedCliConnectionResolver { struct FixedCliConnectionResolver {
backend_url: &'static str, backend_url: &'static str,
@@ -1800,8 +1795,10 @@ mod tests {
input: CliConnectionInput<'_>, input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
match input { match input {
CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget => { CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::StandaloneTarget => {
Ok(Box::new(LocalTarget::new())) Ok(Box::new(StandaloneTarget::new(
"/tmp/yoi-test-standalone-state",
)))
} }
CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new( CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new(
BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)), BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)),
@@ -1823,7 +1820,7 @@ mod tests {
let workspace_id = match input { let workspace_id = match input {
CliConnectionInput::DefaultTarget { workspace_id } CliConnectionInput::DefaultTarget { workspace_id }
| CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id, | CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id,
CliConnectionInput::LocalTarget => { CliConnectionInput::StandaloneTarget => {
return Ok(Box::new(StandaloneTarget::new( return Ok(Box::new(StandaloneTarget::new(
"/tmp/yoi-test-standalone-state", "/tmp/yoi-test-standalone-state",
))); )));
@@ -1837,33 +1834,27 @@ mod tests {
} }
#[test] #[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 { let resolver = FixedCliConnectionResolver {
backend_url: "http://fake-backend.example", backend_url: "http://fake-backend.example",
}; };
let args = vec!["workers".to_string()]; 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 { assert!(
Mode::Tui { error
target, .to_string()
mode: LaunchMode::Workers { runtime_id, .. }, .contains("requires a Backend connection target")
.. );
} => {
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:?}"),
}
} }
#[test] #[test]
fn client_config_default_connection_defaults_to_local() { fn client_config_default_connection_defaults_to_standalone() {
let config = ClientConfigFile::default(); let config = ClientConfigFile::default();
assert_eq!(config.default_connection, ClientDefaultConnection::Local); assert_eq!(
config.default_connection,
ClientDefaultConnection::Standalone
);
} }
#[test] #[test]
@@ -1936,11 +1927,9 @@ backend = "shared"
#[test] #[test]
fn top_level_help_matches_current_target_surface() { fn top_level_help_matches_current_target_surface() {
assert!(TOP_LEVEL_HELP.contains("Target options are top-level options")); assert!(TOP_LEVEL_HELP.contains("Target selection:"));
assert!(TOP_LEVEL_HELP.contains("--local")); assert!(TOP_LEVEL_HELP.contains("client config"));
assert!(TOP_LEVEL_HELP.contains("--backend <URL>")); assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\""));
assert!(TOP_LEVEL_HELP.contains("<data_dir>/client/config.toml"));
assert!(TOP_LEVEL_HELP.contains("<cwd>/.yoi/client.config.toml"));
assert!(TOP_LEVEL_HELP.contains("yoi-server")); assert!(TOP_LEVEL_HELP.contains("yoi-server"));
assert!(TOP_LEVEL_HELP.contains("yoi-runtime")); assert!(TOP_LEVEL_HELP.contains("yoi-runtime"));
assert!(!TOP_LEVEL_HELP.contains("yoi workspace")); 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(); let err = parse_args_from(["keys", "--workspace-id=workspace-a"]).unwrap_err();
assert_eq!( assert_eq!(
err.to_string(), 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] #[test]
fn parse_backend_runtime_target_mode() { fn parse_backend_runtime_target_mode() {
match parse_args_from([ match parse_args_from([
@@ -2034,19 +2005,8 @@ backend = "shared"
let workers = target let workers = target
.list_workers(WorkerListRequest::new(runtime_id.clone())) .list_workers(WorkerListRequest::new(runtime_id.clone()))
.unwrap(); .unwrap();
assert_eq!( assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
workers.backend_target.as_ref().unwrap().base_url, assert_eq!(workers.backend_target.runtime_id.as_deref(), Some("r"));
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
Some("r")
);
assert_eq!(runtime_id.as_deref(), Some("r")); assert_eq!(runtime_id.as_deref(), Some("r"));
} }
_ => panic!("expected Workers mode"), _ => panic!("expected Workers mode"),
@@ -2073,17 +2033,9 @@ backend = "shared"
let workers = target let workers = target
.list_workers(WorkerListRequest::new(runtime_id.clone())) .list_workers(WorkerListRequest::new(runtime_id.clone()))
.unwrap(); .unwrap();
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
assert_eq!( assert_eq!(
workers.backend_target.as_ref().unwrap().base_url, workers.backend_target.workspace_id.as_deref(),
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
Some("workspace-a") Some("workspace-a")
); );
assert_eq!(runtime_id, None); assert_eq!(runtime_id, None);
@@ -2114,10 +2066,7 @@ backend = "shared"
profile: None, profile: None,
} if name == "my-local-worker" } if name == "my-local-worker"
)); ));
assert!(matches!( assert!(target.spawn_worker().unwrap().state_dir.is_absolute());
target.spawn_worker().unwrap(),
client::WorkerSpawn::Standalone { .. }
));
} }
#[test] #[test]
@@ -2144,10 +2093,6 @@ backend = "shared"
LaunchMode::StandaloneResume { include_all: true } 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!( assert_eq!(
parse_args_from(["--local", "--all"]) parse_args_from(["--local", "--all"])
.unwrap_err() .unwrap_err()
@@ -2229,61 +2174,43 @@ backend = "shared"
} }
#[test] #[test]
fn parse_resume_subcommand_defaults_to_workspace_scope() { fn parse_resume_subcommand_uses_standalone_store_by_default() {
match parse_args_from(["resume"]).unwrap() { match parse_args_from(["resume"]).unwrap() {
Mode::Tui { Mode::Tui {
mode: target,
LaunchMode::Workers { mode: LaunchMode::StandaloneResume { include_all },
include_stopped,
all,
..
},
.. ..
} => { } => {
assert!(include_stopped); assert_eq!(target.kind(), TargetKind::Standalone);
assert!(!all); assert!(!include_all);
} }
_ => panic!("expected Workers mode"), other => panic!("expected StandaloneResume mode, got {other:?}"),
} }
} }
#[test] #[test]
fn parse_resume_workspace_scope() { fn parse_resume_preserves_standalone_cwd_scope() {
match parse_args_from(["resume", "--workspace", "/tmp/resume-workspace"]).unwrap() { match parse_args_from(["resume", "--workspace", "/tmp/resume-workspace"]).unwrap() {
Mode::Tui { Mode::Tui {
mode: mode: LaunchMode::StandaloneResume { include_all },
LaunchMode::Workers {
include_stopped,
all,
..
},
workspace_root, workspace_root,
.. ..
} => { } => {
assert!(include_stopped); assert!(!include_all);
assert!(!all);
assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace")); assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace"));
} }
_ => panic!("expected Workers mode"), other => panic!("expected StandaloneResume mode, got {other:?}"),
} }
} }
#[test] #[test]
fn parse_resume_all_scope() { fn parse_resume_all_expands_only_standalone_store_scope() {
match parse_args_from(["resume", "--all"]).unwrap() { match parse_args_from(["resume", "--all"]).unwrap() {
Mode::Tui { Mode::Tui {
mode: mode: LaunchMode::StandaloneResume { include_all },
LaunchMode::Workers {
include_stopped,
all,
.. ..
}, } => assert!(include_all),
.. other => panic!("expected StandaloneResume mode, got {other:?}"),
} => {
assert!(include_stopped);
assert!(all);
}
_ => panic!("expected Workers mode"),
} }
} }
@@ -2357,7 +2284,7 @@ backend = "shared"
match parse_args_from(["ticket", "doctor"]).unwrap() { match parse_args_from(["ticket", "doctor"]).unwrap() {
Mode::Ticket { Mode::Ticket {
cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor), cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor),
target: client::ResolvedTarget::Local, target: client::ResolvedTarget::Standalone,
} => {} } => {}
_ => panic!("expected Ticket doctor mode"), _ => panic!("expected Ticket doctor mode"),
} }
@@ -2432,7 +2359,7 @@ backend = "shared"
match parse_args_from(["ticket", "--help"]).unwrap() { match parse_args_from(["ticket", "--help"]).unwrap() {
Mode::Ticket { Mode::Ticket {
cli: ticket_cli::TicketCli::Help, cli: ticket_cli::TicketCli::Help,
target: client::ResolvedTarget::Local, target: client::ResolvedTarget::Standalone,
} => {} } => {}
_ => panic!("expected Ticket help mode"), _ => panic!("expected Ticket help mode"),
} }
@@ -2472,24 +2399,6 @@ backend = "shared"
assert_eq!(err.to_string(), "yoi setup-model does not accept arguments"); 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] #[test]
fn parse_memory_lint_mode() { fn parse_memory_lint_mode() {
match parse_args_from([ match parse_args_from([
@@ -2613,32 +2522,6 @@ backend = "shared"
assert_eq!(err.to_string(), "yoi memory requires the `lint` subcommand"); 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] #[test]
fn parse_rejects_legacy_resume_flags() { fn parse_rejects_legacy_resume_flags() {
let cases = [ 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] #[test]
fn parse_dashboard_word_is_not_an_alias_or_worker_name() { fn parse_dashboard_word_is_not_an_alias_or_worker_name() {
let err = parse_args_from(["dashboard"]).unwrap_err(); let err = parse_args_from(["dashboard"]).unwrap_err();
@@ -2804,4 +2645,30 @@ backend = "shared"
_ => panic!("expected MemoryLintHelp mode"), _ => 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"));
}
} }
+39 -495
View File
@@ -1,21 +1,6 @@
use std::fmt; use std::fmt;
use std::fs;
use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use client::{BackendWorkspaceProductClient, ResolvedTarget}; 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObjectiveCli { pub enum ObjectiveCli {
@@ -33,12 +18,12 @@ pub enum ObjectiveCommand {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateOptions { pub struct CreateOptions {
pub title: String, title: String,
pub linked_tickets: Vec<String>, linked_tickets: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectiveListState { enum ObjectiveListState {
Active, Active,
Paused, Paused,
Done, Done,
@@ -46,15 +31,14 @@ pub enum ObjectiveListState {
All, All,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListOptions { pub struct ListOptions {
pub state: ObjectiveListState, state: ObjectiveListState,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectiveCliStatus { pub enum ObjectiveCliStatus {
Success, Success,
Failure,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -80,18 +64,6 @@ impl fmt::Display for ObjectiveCliError {
impl std::error::Error for ObjectiveCliError {} impl std::error::Error for ObjectiveCliError {}
impl From<std::io::Error> for ObjectiveCliError {
fn from(error: std::io::Error) -> Self {
Self::new(error.to_string())
}
}
impl From<ticket::config::TicketConfigError> for ObjectiveCliError {
fn from(error: ticket::config::TicketConfigError) -> Self {
Self::new(error.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ObjectiveState { enum ObjectiveState {
Active, Active,
@@ -101,15 +73,6 @@ enum ObjectiveState {
} }
impl 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<Self> { fn parse(value: &str) -> Option<Self> {
match value { match value {
"active" => Some(Self::Active), "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<String>,
}
#[derive(Debug, Clone)]
struct ObjectiveRecord {
id: String,
meta: ObjectiveFrontmatter,
body: String,
}
pub fn parse_objective_args(args: &[String]) -> Result<ObjectiveCli, ObjectiveCliError> { pub fn parse_objective_args(args: &[String]) -> Result<ObjectiveCli, ObjectiveCliError> {
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(ObjectiveCli::Help); return Ok(ObjectiveCli::Help);
@@ -173,14 +119,8 @@ pub fn run(
target: ResolvedTarget, target: ResolvedTarget,
) -> Result<ObjectiveCliOutput, ObjectiveCliError> { ) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
match target { 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( 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 { ResolvedTarget::Backend {
base_url, base_url,
@@ -267,319 +207,6 @@ fn run_with_backend(
} }
} }
pub fn run_in_workspace(
cli: ObjectiveCli,
workspace: &Path,
) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<ObjectiveCliOutput, ObjectiveCliError> {
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<String>,
) -> 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<Vec<ObjectiveRecord>, 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<ObjectiveRecord, ObjectiveCliError> {
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<Vec<fs::DirEntry>, 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<ObjectiveState>) -> 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::<Vec<_>>()
.join(", ");
format!("[{items}]")
}
fn parse_create(args: &[String]) -> Result<CreateOptions, ObjectiveCliError> { fn parse_create(args: &[String]) -> Result<CreateOptions, ObjectiveCliError> {
let mut title = None; let mut title = None;
let mut linked_tickets = Vec::new(); let mut linked_tickets = Vec::new();
@@ -678,6 +305,21 @@ fn parse_list_state(value: &str) -> Result<ObjectiveListState, ObjectiveCliError
} }
} }
fn list_state_matches(filter: ObjectiveListState, state: Option<ObjectiveState>) -> 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 { fn success(stdout: String) -> ObjectiveCliOutput {
ObjectiveCliOutput { ObjectiveCliOutput {
status: ObjectiveCliStatus::Success, status: ObjectiveCliStatus::Success,
@@ -686,137 +328,39 @@ fn success(stdout: String) -> ObjectiveCliOutput {
} }
fn help_text() -> &'static str { fn help_text() -> &'static str {
"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\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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::TempDir;
fn args(values: &[&str]) -> Vec<String> { fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect() values.iter().map(|value| value.to_string()).collect()
} }
fn run(temp: &TempDir, values: &[&str]) -> ObjectiveCliOutput { #[test]
let cli = parse_objective_args(&args(values)).unwrap(); fn standalone_rejects_objective_storage_operations() {
run_in_workspace(cli, temp.path()).unwrap() 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"));
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] #[test]
fn objective_cli_creates_lists_and_shows_records() { fn objective_parser_keeps_backend_command_contract() {
let temp = TempDir::new().unwrap(); assert!(matches!(
create_ticket_dir(&temp, "00001KTKMS0VG"); parse_objective_args(&args(&["create", "--title", "Goal"])).unwrap(),
ObjectiveCli::Command(ObjectiveCommand::Create(_))
let created = run( ));
&temp, assert!(matches!(
&[ parse_objective_args(&args(&["list", "--state", "active"])).unwrap(),
"create", ObjectiveCli::Command(ObjectiveCommand::List(_))
"--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"));
} }
#[test] #[test]
fn objective_cli_validates_ticket_links() { fn help_states_backend_authority() {
let temp = TempDir::new().unwrap(); assert!(help_text().contains("require the Workspace-scoped Backend"));
let cli = parse_objective_args(&args(&[ assert!(!help_text().contains("repository-file"));
"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");
} }
} }
+20 -600
View File
@@ -1,19 +1,12 @@
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::hash::{Hash, Hasher}; use std::path::PathBuf;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use client::{BackendWorkspaceProductClient, ResolvedTarget}; use client::{BackendWorkspaceProductClient, ResolvedTarget};
use ticket::config::{
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
ticket_config_scaffold,
};
use ticket::{ use ticket::{
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketListQuery, TicketListState,
TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState, TicketRelationKind, TicketSummary, TicketWorkflowState,
}; };
const DEFAULT_LIST_LIMIT: usize = 50; const DEFAULT_LIST_LIMIT: usize = 50;
@@ -29,8 +22,6 @@ pub enum TicketCli {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketCommand { pub enum TicketCommand {
Init,
ImportLocal,
Create(CreateOptions), Create(CreateOptions),
List(ListOptions), List(ListOptions),
Show { query: String }, 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 { impl From<std::io::Error> for TicketCliError {
fn from(error: std::io::Error) -> Self { fn from(error: std::io::Error) -> Self {
Self::new(error.to_string()) 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() { 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..])?), "create" => TicketCommand::Create(parse_create(&args[1..])?),
"list" => TicketCommand::List(parse_list(&args[1..])?), "list" => TicketCommand::List(parse_list(&args[1..])?),
"show" => TicketCommand::Show { "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> { pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> {
match target { 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( 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 { ResolvedTarget::Backend {
base_url, base_url,
@@ -225,9 +190,6 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, Ti
status: TicketCliStatus::Success, status: TicketCliStatus::Success,
stdout: help_text().to_string(), 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) => { TicketCli::Command(command) => {
let backend = BackendWorkspaceProductClient::new(base_url, workspace_id) let backend = BackendWorkspaceProductClient::new(base_url, workspace_id)
.map_err(|error| TicketCliError::new(error.to_string()))?; .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( fn run_backend_command(
command: TicketCommand, command: TicketCommand,
backend: &dyn TicketBackend, backend: &dyn TicketBackend,
@@ -277,190 +212,9 @@ fn run_backend_command(
TicketCommand::Close(options) => close(backend, options), TicketCommand::Close(options) => close(backend, options),
TicketCommand::Relation(options) => relation(backend, options), TicketCommand::Relation(options) => relation(backend, options),
TicketCommand::Doctor => doctor(backend), 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( fn create(
backend: &dyn TicketBackend, backend: &dyn TicketBackend,
options: CreateOptions, options: CreateOptions,
@@ -1185,369 +939,35 @@ fn default_author() -> String {
} }
fn help_text() -> &'static str { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::TempDir;
use ticket::config::TicketRole;
fn args(items: &[&str]) -> Vec<String> { fn args(values: &[&str]) -> Vec<String> {
items.iter().map(|item| item.to_string()).collect() 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);
}
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] #[test]
fn ticket_cli_init_writes_explicit_workspace_ticket_settings() { fn standalone_rejects_ticket_storage_operations() {
let temp = TempDir::new().unwrap(); let cli = parse_ticket_args(&args(&["list"])).unwrap();
let error = run(cli, ResolvedTarget::Standalone).unwrap_err();
let initialized = run(&temp, &["init"]); assert!(error.to_string().contains("select a Backend target"));
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] #[test]
fn ticket_cli_init_does_not_overwrite_existing_config() { fn repository_local_ticket_commands_are_not_normal_target_commands() {
let temp = TempDir::new().unwrap(); for command in ["init", "import-local"] {
fs::create_dir_all(temp.path().join(".yoi")).unwrap(); let error = parse_ticket_args(&args(&[command])).unwrap_err();
let config_path = temp.path().join(".yoi/ticket.config.toml"); assert!(error.to_string().contains("unknown ticket command"));
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] #[test]
fn ticket_cli_list_is_bounded_and_truncates_titles() { fn help_states_backend_authority() {
let temp = TempDir::new().unwrap(); assert!(help_text().contains("Workspace-scoped Backend"));
let long_title = format!("Long {}", "x".repeat(LIST_TITLE_MAX_CHARS + 40)); assert!(!help_text().contains("repository-file Ticket backend"));
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"));
} }
} }
+3 -2
View File
@@ -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. - `session-store` owns replayable append-only conversation/session logs.
- `pod-store` owns current Worker metadata keyed by Worker name. - `pod-store` owns current Worker metadata keyed by Worker name.
- `protocol` defines the socket message boundary between clients and Workers. - `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. - `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. - `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. - `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. - `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 ## Why these boundaries exist
+7
View File
@@ -29,6 +29,13 @@ worker
- `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。 - `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。
- startup error は category のみを公開し、credential、prompt 本文、session metadata、内部 path を error text に含めない。 - 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 や画面実装 - TUI/CLI routing や画面実装