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
+1 -2
View File
@@ -6,14 +6,13 @@ license.workspace = true
[dependencies]
protocol = { workspace = true }
manifest = { workspace = true }
ticket = { workspace = true }
futures = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] }
tokio-tungstenite = { workspace = true }
uuid = { workspace = true }
workspace-api.workspace = true
+6 -27
View File
@@ -1,20 +1,12 @@
//! Worker プロトコルを喋るクライアント。
//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
//!
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
//! `Event` を受け取る低レベル接続。
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
//! Standalone execution is owned by the `standalone` crate and does not spawn
//! a Worker subprocess through this crate.
pub mod backend_auth;
pub mod backend_runtime;
pub mod backend_workspace;
pub mod runtime_command;
pub mod spawn;
pub mod target;
pub mod ticket_role;
mod worker_client;
mod workspace_product;
@@ -35,23 +27,10 @@ pub use backend_workspace::{
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
};
pub use runtime_command::WorkerRuntimeCommand;
pub use target::{
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent,
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
WorkerSpawn,
};
pub use spawn::{
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
spawn_worker, spawn_worker_with_options,
};
pub use ticket_role::{
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
plan_ticket_role_launch_with_config,
BackendTarget, Dashboard, ResolvedTarget, StandaloneSessionListIntent,
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
};
pub use worker_client::WorkerClient;
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
-165
View File
@@ -1,165 +0,0 @@
use std::ffi::OsString;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerRuntimeCommand {
pub program: PathBuf,
pub prefix_args: Vec<OsString>,
}
impl WorkerRuntimeCommand {
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
Self {
program: program.into(),
prefix_args,
}
}
pub fn for_current_exe() -> io::Result<Self> {
Ok(Self::for_executable(std::env::current_exe()?))
}
pub fn for_executable(program: impl Into<PathBuf>) -> Self {
Self::new(program, vec![OsString::from("worker")])
}
/// Resolve the Worker runtime command used for subprocess launches.
///
/// The default launch path is always the current `yoi` executable plus
/// the unified `worker` prefix argument. During development, a non-empty
/// `YOI_POD_RUNTIME_COMMAND` value replaces only the executable path;
/// the `worker` prefix is still added here and the env value is not parsed as a
/// shell command.
pub fn resolve() -> io::Result<Self> {
Self::resolve_from_env_value(
std::env::var_os(POD_RUNTIME_COMMAND_ENV),
std::env::current_exe,
)
}
fn resolve_from_env_value<F>(
override_program: Option<OsString>,
current_exe: F,
) -> io::Result<Self>
where
F: FnOnce() -> io::Result<PathBuf>,
{
if let Some(program) = override_program.filter(|program| !program.as_os_str().is_empty()) {
return Ok(Self::for_executable(program));
}
Ok(Self::for_executable(current_exe()?))
}
pub fn program(&self) -> &Path {
&self.program
}
pub fn prefix_args(&self) -> &[OsString] {
&self.prefix_args
}
pub fn argv_with<I, S>(&self, args: I) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut argv = self.prefix_args.clone();
argv.extend(args.into_iter().map(Into::into));
argv
}
}
impl fmt::Display for WorkerRuntimeCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.program.display())?;
for arg in &self.prefix_args {
write!(f, " {}", arg.to_string_lossy())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn yoi_binary_defaults_to_worker_prefix() {
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
assert_eq!(command.program(), Path::new("/opt/yoi/bin/yoi"));
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn any_runtime_executable_gets_worker_prefix() {
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
assert_eq!(command.program(), Path::new("/opt/yoi/bin/custom-runtime"));
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn resolve_uses_current_exe_when_override_is_unset() {
let command = WorkerRuntimeCommand::resolve_from_env_value(None, || {
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
})
.unwrap();
assert_eq!(
command,
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
);
}
#[test]
fn resolve_uses_current_exe_when_override_is_empty() {
let command = WorkerRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
})
.unwrap();
assert_eq!(
command,
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
);
}
#[test]
fn resolve_override_replaces_only_program_and_keeps_worker_prefix() {
let command = WorkerRuntimeCommand::resolve_from_env_value(
Some(OsString::from("/tmp/rebuilt yoi")),
|| panic!("override must not inspect current_exe"),
)
.unwrap();
assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi"));
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
}
-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",
]
);
}
}
+40 -266
View File
@@ -1,11 +1,9 @@
use std::{fmt, path::PathBuf};
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
/// Legacy local Runtime process/socket authority.
Local,
/// One-process Standalone authority with no Runtime or Workspace backend.
Standalone,
Backend,
@@ -13,7 +11,6 @@ pub enum TargetKind {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedTarget {
Local,
Standalone,
Backend {
base_url: String,
@@ -24,7 +21,6 @@ pub enum ResolvedTarget {
impl ResolvedTarget {
pub fn kind(&self) -> TargetKind {
match self {
Self::Local => TargetKind::Local,
Self::Standalone => TargetKind::Standalone,
Self::Backend { .. } => TargetKind::Backend,
}
@@ -34,32 +30,12 @@ impl ResolvedTarget {
impl fmt::Display for TargetKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local => f.write_str("local"),
Self::Standalone => f.write_str("Standalone"),
Self::Backend => f.write_str("Backend"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalTarget;
impl LocalTarget {
pub fn new() -> Self {
Self
}
fn runtime_command(&self) -> Result<WorkerRuntimeCommand, TargetError> {
WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command)
}
}
impl Default for LocalTarget {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendTarget {
pub base_url: String,
@@ -113,23 +89,8 @@ impl WorkerConnectionSelector {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkerSpawn {
LegacyLocal {
runtime_command: WorkerRuntimeCommand,
},
Standalone {
state_dir: PathBuf,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerByName {
pub runtime_command: WorkerRuntimeCommand,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerResume {
pub runtime_command: WorkerRuntimeCommand,
pub struct WorkerSpawn {
pub state_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -146,20 +107,14 @@ pub struct StandaloneSessionResumeIntent {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Dashboard {
Local {
runtime_command: WorkerRuntimeCommand,
},
Backend {
base_url: String,
workspace_id: String,
},
pub struct Dashboard {
pub base_url: String,
pub workspace_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList {
pub local_runtime_command: Option<WorkerRuntimeCommand>,
pub backend_target: Option<BackendRuntimeListTarget>,
pub backend_target: BackendRuntimeListTarget,
pub include_stopped: bool,
}
@@ -185,12 +140,6 @@ impl TargetError {
message: format!("invalid {target} target: {}", message.into()),
}
}
fn local_runtime_command(error: std::io::Error) -> Self {
Self {
message: format!("failed to resolve local Worker runtime command: {error}"),
}
}
}
impl fmt::Display for TargetError {
@@ -207,15 +156,13 @@ pub trait Target: fmt::Debug + Send + Sync {
/// Resolve the target once for Workspace product-state operations.
///
/// Backend targets must carry an explicit Workspace identity. Callers use
/// this value instead of rediscovering Backend/local authority from cwd or
/// process configuration after command dispatch.
/// this value instead of rediscovering authority from cwd or process
/// configuration after command dispatch.
fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Err(TargetError::unsupported("Worker spawn", self.kind()))
}
fn standalone_session_list(
&self,
@@ -237,61 +184,12 @@ pub trait Target: fmt::Debug + Send + Sync {
))
}
fn dashboard(&self) -> Result<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> {
Ok(Dashboard::Local {
runtime_command: self.runtime_command()?,
})
Err(TargetError::unsupported("Worker dashboard", self.kind()))
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
if request.runtime_id.is_some() {
return Err(TargetError::unsupported(
"Explicit runtime id for local worker listing",
self.kind(),
));
}
Ok(WorkerList {
local_runtime_command: Some(self.runtime_command()?),
backend_target: None,
include_stopped: request.include_stopped,
})
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind()))
}
fn connect_worker(
@@ -329,22 +227,11 @@ impl Target for StandaloneTarget {
}
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Ok(WorkerSpawn::Standalone {
Ok(WorkerSpawn {
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(
&self,
include_all: bool,
@@ -367,24 +254,6 @@ impl Target for StandaloneTarget {
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 {
@@ -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> {
match self.resolve()? {
ResolvedTarget::Backend {
base_url,
workspace_id,
} => Ok(Dashboard::Backend {
base_url,
workspace_id,
}),
ResolvedTarget::Local | ResolvedTarget::Standalone => {
unreachable!("BackendTarget cannot resolve as a local target")
}
}
let ResolvedTarget::Backend {
base_url,
workspace_id,
} = self.resolve()?
else {
unreachable!("BackendTarget resolves only Backend authority")
};
Ok(Dashboard {
base_url,
workspace_id,
})
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList {
local_runtime_command: None,
backend_target: Some(BackendRuntimeListTarget::new(
backend_target: BackendRuntimeListTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
request.runtime_id,
)),
),
include_stopped: request.include_stopped,
})
}
@@ -499,32 +351,23 @@ mod tests {
}
#[test]
fn local_target_resolves_local_product_state_authority() {
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local);
}
#[test]
fn standalone_target_carries_in_process_state_without_resolving_runtime_command() {
fn standalone_target_carries_in_process_state_without_runtime_command() {
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
assert_eq!(target.kind(), TargetKind::Standalone);
assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone);
assert_eq!(
target.spawn_worker().unwrap(),
WorkerSpawn::Standalone {
WorkerSpawn {
state_dir: PathBuf::from("/tmp/yoi-standalone-state"),
}
);
}
#[test]
fn standalone_target_never_falls_back_to_legacy_local_operations() {
fn standalone_target_never_exposes_workspace_worker_operations() {
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
assert_eq!(
target.worker_by_name().unwrap_err().to_string(),
"Worker name attachment is not supported by Standalone target"
);
assert_eq!(
target
.list_workers(WorkerListRequest::new(None))
@@ -532,6 +375,10 @@ mod tests {
.to_string(),
"Worker listing is not supported by Standalone target"
);
assert_eq!(
target.dashboard().unwrap_err().to_string(),
"Worker dashboard is not supported by Standalone target"
);
}
#[test]
@@ -540,26 +387,13 @@ mod tests {
assert_eq!(
target.dashboard().unwrap(),
Dashboard::Backend {
Dashboard {
base_url: "http://127.0.0.1:8787".to_string(),
workspace_id: "workspace-a".to_string(),
}
);
}
#[test]
fn backend_target_rejects_dashboard_without_workspace_selection() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
assert!(
target
.dashboard()
.unwrap_err()
.to_string()
.contains("workspace selection is required")
);
}
#[test]
fn backend_target_builds_worker_list() {
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
@@ -567,26 +401,13 @@ mod tests {
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
.unwrap();
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
assert_eq!(
workers.backend_target.as_ref().unwrap().base_url,
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
workers.backend_target.workspace_id.as_deref(),
Some("workspace-a")
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
workers.backend_target.runtime_id.as_deref(),
Some("runtime-a")
);
}
@@ -604,33 +425,6 @@ mod tests {
assert_eq!(connection.target.worker_id, "worker-b");
}
#[test]
fn backend_target_rejects_worker_connection_before_workspace_selection() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<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]
fn standalone_target_builds_explicit_session_intents() {
let target = StandaloneTarget::new("/tmp/yoi-client-sessions");
@@ -644,25 +438,5 @@ mod tests {
.unwrap();
assert_eq!(resume.state_dir, list.state_dir);
assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000");
assert!(
LocalTarget::new()
.standalone_session_list(false)
.unwrap_err()
.to_string()
.contains("not supported")
);
}
#[test]
fn local_target_builds_local_worker_list() {
let target = LocalTarget::new();
let workers = target
.list_workers(WorkerListRequest::with_stopped(None))
.unwrap();
assert!(workers.local_runtime_command.is_some());
assert!(workers.backend_target.is_none());
assert!(workers.include_stopped);
}
}
File diff suppressed because it is too large Load Diff