feat: add workspace server launcher
This commit is contained in:
parent
be517417ff
commit
e6f68496b4
|
|
@ -15,7 +15,7 @@ serde_json.workspace = true
|
|||
serde_yaml.workspace = true
|
||||
thiserror.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
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
185
crates/workspace-server/src/main.rs
Normal file
185
crates/workspace-server/src/main.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -5,9 +5,10 @@ mod plugin_cli;
|
|||
mod session_cli;
|
||||
mod ticket_cli;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
use client::PodRuntimeCommand;
|
||||
use memory_lint::{LintCliOptions, LintStatus};
|
||||
|
|
@ -25,6 +26,8 @@ enum Mode {
|
|||
Objective(objective_cli::ObjectiveCli),
|
||||
Session(session_cli::SessionCli),
|
||||
Ticket(ticket_cli::TicketCli),
|
||||
WorkspaceHelp,
|
||||
WorkspaceServe(Vec<String>),
|
||||
PodRuntime(Vec<String>),
|
||||
Keys,
|
||||
SetupModel,
|
||||
|
|
@ -69,6 +72,11 @@ async fn main() -> ExitCode {
|
|||
print_memory_lint_help();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Mode::WorkspaceHelp => {
|
||||
print_workspace_help();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Mode::WorkspaceServe(args) => run_workspace_server(args),
|
||||
Mode::MemoryLint(options) => match memory_lint::run(&options) {
|
||||
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
|
||||
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..])?;
|
||||
return Ok(Mode::Plugin(plugin_cli));
|
||||
}
|
||||
"workspace" => {
|
||||
return parse_workspace_args(&args[1..]);
|
||||
}
|
||||
"mcp" => {
|
||||
let mcp_cli = parse_mcp_args(&args[1..])?;
|
||||
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}")))
|
||||
}
|
||||
|
||||
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> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Err(ParseError(
|
||||
|
|
@ -810,7 +878,13 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
fn print_help() {
|
||||
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]
|
||||
fn parse_keys_subcommand() {
|
||||
match parse_args_from(["keys"]).unwrap() {
|
||||
|
|
|
|||
22
package.nix
22
package.nix
|
|
@ -91,17 +91,39 @@ rustPlatform.buildRustPackage rec {
|
|||
"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
|
||||
# workspace test suite is intentionally left to cargo-based CI because this
|
||||
# derivation is scoped to packaging the user-facing binaries.
|
||||
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;
|
||||
installCheckPhase = ''
|
||||
runHook preInstallCheck
|
||||
|
||||
"$out/bin/yoi" pod --help >/dev/null
|
||||
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/share/yoi/resources"
|
||||
if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user