feat: add workspace server launcher

This commit is contained in:
Keisuke Hirata 2026-06-21 19:55:55 +09:00
parent be517417ff
commit e6f68496b4
No known key found for this signature in database
4 changed files with 300 additions and 3 deletions

View File

@ -15,7 +15,7 @@ serde_json.workspace = true
serde_yaml.workspace = true serde_yaml.workspace = true
thiserror.workspace = true thiserror.workspace = true
ticket.workspace = true ticket.workspace = true
tokio = { workspace = true, features = ["fs", "net", "rt", "sync"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tracing.workspace = true tracing.workspace = true
[dev-dependencies] [dev-dependencies]

View File

@ -0,0 +1,185 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use tokio::net::TcpListener;
use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve};
#[derive(Debug)]
struct ServeOptions {
workspace: PathBuf,
db: Option<PathBuf>,
frontend: Option<PathBuf>,
listen: SocketAddr,
}
#[derive(Debug)]
struct CliError(String);
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for CliError {}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("yoi-workspace-server: {error}");
ExitCode::FAILURE
}
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let Some((command, rest)) = args.split_first() else {
print_help();
return Ok(());
};
match command.as_str() {
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_serve_help();
return Ok(());
}
let options = parse_serve_options(rest)?;
run_serve(options).await?;
Ok(())
}
"--help" | "-h" => {
print_help();
Ok(())
}
other => Err(Box::new(CliError(format!(
"unknown command `{other}`; expected `serve`"
)))),
}
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let db = options
.db
.unwrap_or_else(|| options.workspace.join(".yoi/workspace.db"));
if let Some(parent) = db.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&db)?);
let mut config = ServerConfig::local_dev(&options.workspace);
config.static_assets_dir = options.frontend;
let listener = TcpListener::bind(options.listen).await?;
eprintln!(
"yoi-workspace-server: serving workspace `{}` on http://{}",
options.workspace.display(),
listener.local_addr()?
);
serve(config, store, listener).await?;
Ok(())
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?;
let mut db = None;
let mut frontend = None;
let mut listen = "127.0.0.1:8787".parse::<SocketAddr>().unwrap();
let mut index = 0;
while index < args.len() {
let arg = &args[index];
match arg.as_str() {
"--workspace" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--workspace requires a value".to_string()))?;
workspace = PathBuf::from(value);
}
"--db" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--db requires a value".to_string()))?;
db = Some(PathBuf::from(value));
}
"--frontend" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--frontend requires a value".to_string()))?;
frontend = Some(PathBuf::from(value));
}
"--listen" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--listen requires a value".to_string()))?;
listen = parse_listen(value)?;
}
_ if arg.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
_ if arg.starts_with("--db=") => {
db = Some(PathBuf::from(value_after_equals(arg, "--db")?));
}
_ if arg.starts_with("--frontend=") => {
frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?));
}
_ if arg.starts_with("--listen=") => {
listen = parse_listen(value_after_equals(arg, "--listen")?)?;
}
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`")));
}
_ => {
return Err(CliError(format!(
"unexpected positional argument `{arg}`; use --workspace <PATH>"
)));
}
}
index += 1;
}
Ok(ServeOptions {
workspace,
db,
frontend,
listen,
})
}
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
let value = arg
.strip_prefix(flag)
.and_then(|rest| rest.strip_prefix('='))
.unwrap_or_default();
if value.is_empty() {
return Err(CliError(format!("{flag} requires a value")));
}
Ok(value)
}
fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
value
.parse()
.map_err(|_| CliError(format!("invalid --listen address `{value}`")))
}
fn print_help() {
println!(
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_serve_help() {
println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (defaults to <workspace>/.yoi/workspace.db)\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help"
);
}

View File

@ -5,9 +5,10 @@ mod plugin_cli;
mod session_cli; mod session_cli;
mod ticket_cli; mod ticket_cli;
use std::ffi::OsString;
use std::fmt; use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::{Command, ExitCode};
use client::PodRuntimeCommand; use client::PodRuntimeCommand;
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
@ -25,6 +26,8 @@ enum Mode {
Objective(objective_cli::ObjectiveCli), Objective(objective_cli::ObjectiveCli),
Session(session_cli::SessionCli), Session(session_cli::SessionCli),
Ticket(ticket_cli::TicketCli), Ticket(ticket_cli::TicketCli),
WorkspaceHelp,
WorkspaceServe(Vec<String>),
PodRuntime(Vec<String>), PodRuntime(Vec<String>),
Keys, Keys,
SetupModel, SetupModel,
@ -69,6 +72,11 @@ async fn main() -> ExitCode {
print_memory_lint_help(); print_memory_lint_help();
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Mode::WorkspaceHelp => {
print_workspace_help();
ExitCode::SUCCESS
}
Mode::WorkspaceServe(args) => run_workspace_server(args),
Mode::MemoryLint(options) => match memory_lint::run(&options) { Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE, Ok(LintStatus::Failed) => ExitCode::FAILURE,
@ -200,6 +208,9 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
let plugin_cli = parse_plugin_args(&args[1..])?; let plugin_cli = parse_plugin_args(&args[1..])?;
return Ok(Mode::Plugin(plugin_cli)); return Ok(Mode::Plugin(plugin_cli));
} }
"workspace" => {
return parse_workspace_args(&args[1..]);
}
"mcp" => { "mcp" => {
let mcp_cli = parse_mcp_args(&args[1..])?; let mcp_cli = parse_mcp_args(&args[1..])?;
return Ok(Mode::Mcp(mcp_cli)); return Ok(Mode::Mcp(mcp_cli));
@ -472,6 +483,63 @@ fn current_dir() -> Result<PathBuf, ParseError> {
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
} }
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
"yoi workspace requires `serve` (try `yoi workspace --help`)".to_string(),
));
};
match subcommand.as_str() {
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::WorkspaceHelp);
}
Ok(Mode::WorkspaceServe(rest.to_vec()))
}
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
other => Err(ParseError(format!(
"unknown yoi workspace subcommand `{other}`"
))),
}
}
fn run_workspace_server(args: Vec<String>) -> ExitCode {
let command = match resolve_workspace_server_command() {
Ok(command) => command,
Err(error) => {
eprintln!("yoi workspace: {error}");
return ExitCode::FAILURE;
}
};
let mut child = Command::new(&command);
child.arg("serve");
child.args(args);
match child.status() {
Ok(status) if status.success() => ExitCode::SUCCESS,
Ok(status) => ExitCode::from(status.code().unwrap_or(1).min(255) as u8),
Err(error) => {
eprintln!(
"yoi workspace: failed to launch `{}`: {error}",
command.to_string_lossy()
);
ExitCode::FAILURE
}
}
}
fn resolve_workspace_server_command() -> Result<OsString, ParseError> {
if let Some(value) = std::env::var_os("YOI_WORKSPACE_SERVER_COMMAND") {
if !value.is_empty() {
return Ok(value);
}
}
let current = std::env::current_exe()
.map_err(|error| ParseError(format!("failed to resolve current executable: {error}")))?;
let sibling = current.with_file_name("yoi-workspace-server");
Ok(sibling.into_os_string())
}
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> { fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(
@ -810,7 +878,13 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n" "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}
fn print_workspace_help() {
println!(
"yoi workspace\n\nUsage:\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nOptions forwarded to yoi-workspace-server serve:\n --workspace <PATH> Workspace root containing .yoi project records (defaults to cwd)\n --db <PATH> SQLite database path (defaults to <workspace>/.yoi/workspace.db)\n --frontend <PATH> Static SPA build directory to serve\n --listen <ADDR> Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
); );
} }
@ -931,6 +1005,22 @@ mod tests {
} }
} }
#[test]
fn parse_workspace_serve_passthrough() {
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
Mode::WorkspaceServe(args) => assert_eq!(args, vec!["--listen", "127.0.0.1:0"]),
other => panic!("unexpected mode: {other:?}"),
}
}
#[test]
fn parse_workspace_help() {
assert!(matches!(
parse_args_from(["workspace", "--help"]).unwrap(),
Mode::WorkspaceHelp
));
}
#[test] #[test]
fn parse_keys_subcommand() { fn parse_keys_subcommand() {
match parse_args_from(["keys"]).unwrap() { match parse_args_from(["keys"]).unwrap() {

View File

@ -91,17 +91,39 @@ rustPlatform.buildRustPackage rec {
"yoi" "yoi"
]; ];
postBuild = ''
cargo build --offline --profile release -p yoi-workspace-server --bin yoi-workspace-server
'';
# The package check is a credential-free install smoke check below. Running the # The package check is a credential-free install smoke check below. Running the
# workspace test suite is intentionally left to cargo-based CI because this # workspace test suite is intentionally left to cargo-based CI because this
# derivation is scoped to packaging the user-facing binaries. # derivation is scoped to packaging the user-facing binaries.
doCheck = false; doCheck = false;
installPhase = ''
runHook preInstall
yoi_bin=$(find . -type f -name yoi | head -n 1)
workspace_server_bin=$(find . -type f -name yoi-workspace-server | head -n 1)
if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ]; then
echo "built binaries not found" >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server \) -print >&2
exit 1
fi
install -Dm755 "$yoi_bin" "$out/bin/yoi"
install -Dm755 "$workspace_server_bin" "$out/bin/yoi-workspace-server"
runHook postInstall
'';
doInstallCheck = true; doInstallCheck = true;
installCheckPhase = '' installCheckPhase = ''
runHook preInstallCheck runHook preInstallCheck
"$out/bin/yoi" pod --help >/dev/null "$out/bin/yoi" pod --help >/dev/null
test -x "$out/bin/yoi" test -x "$out/bin/yoi"
test -x "$out/bin/yoi-workspace-server"
"$out/bin/yoi-workspace-server" --help >/dev/null
test ! -e "$out/bin/yoi-pod" test ! -e "$out/bin/yoi-pod"
test ! -e "$out/share/yoi/resources" test ! -e "$out/share/yoi/resources"
if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then