feat: live reload runtime connections

This commit is contained in:
2026-07-04 04:47:23 +09:00
parent 296da3a440
commit b3dac8ea68
19 changed files with 1040 additions and 336 deletions
+221 -104
View File
@@ -1,8 +1,8 @@
//! Minimal Runtime REST process wrapper.
//! Worker-backed Runtime REST process wrapper.
//!
//! This binary is available only when the `http-server` feature is enabled. It
//! starts a Runtime-local command API intended for a trusted backend/proxy;
//! browsers must not connect to this Runtime process directly.
//! This binary starts a Runtime command API with a real worker execution backend.
//! A REST Runtime process that cannot spawn Workers is not a valid Runtime for the
//! Workspace Browser.
use std::collections::VecDeque;
use std::env;
@@ -11,11 +11,16 @@ use std::fmt;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use worker_runtime::error::RuntimeError;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RuntimeHttpServer, RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
use worker_runtime::identity::RuntimeId;
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
match run() {
@@ -40,25 +45,77 @@ fn run() -> Result<(), ProcessError> {
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_all()
.build()?;
runtime.block_on(async move {
let server = RuntimeHttpServer::bind(config).await?;
let local_addr = server.local_addr()?;
let listener = tokio::net::TcpListener::bind(config.http.bind_addr).await?;
let local_addr = listener.local_addr()?;
let worker_runtime = build_runtime(&config)?;
eprintln!(
"worker-runtime REST server listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
);
server.serve().await
worker_runtime::http_server::serve_runtime_http(
worker_runtime,
listener,
config.http.local_token,
)
.await
.map_err(ProcessError::from)
})?;
Ok(())
}
fn parse_args<I, S>(args: I) -> Result<Option<RuntimeHttpServerConfig>, ProcessError>
fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
let mut factory = ProfileRuntimeWorkerFactory::new(config.workspace_root.clone())
.with_cwd(config.cwd.clone());
if let Some(store_dir) = config.worker_store_dir.clone() {
factory = factory.with_store_dir(store_dir);
}
if let Some(metadata_dir) = config.worker_metadata_dir.clone() {
factory = factory.with_worker_metadata_dir(metadata_dir);
}
if let Some(runtime_base_dir) = config.worker_runtime_base_dir.clone() {
factory = factory.with_runtime_base_dir(runtime_base_dir);
}
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let backend =
Arc::new(WorkerRuntimeExecutionBackend::new(factory).map_err(ProcessError::WorkerAdapter)?);
match &config.http.store {
RuntimeHttpStoreSelection::Memory => {
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
.map_err(ProcessError::Runtime)
}
RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone());
options.runtime_id = config.http.runtime_id.clone();
options.display_name = config.http.display_name.clone();
options.limits = config.http.limits.clone();
Runtime::with_fs_store_and_execution_backend(options, backend)
.map_err(ProcessError::Runtime)
}
_ => Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(),
)),
}
}
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
RuntimeOptions {
runtime_id: config.runtime_id.clone(),
display_name: config.display_name.clone(),
limits: config.limits.clone(),
}
}
fn parse_args<I, S>(args: I) -> Result<Option<ProcessConfig>, ProcessError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut config = RuntimeHttpServerConfig::default();
let mut config = ProcessConfig::default()?;
let mut store = StoreArg::Memory;
let mut args = args.into_iter().map(Into::into).collect::<VecDeque<_>>();
@@ -68,18 +125,39 @@ where
"--help" | "-h" => return Ok(None),
"--bind" => {
let value = take_value(&flag, inline_value, &mut args)?;
config.bind_addr = value.parse::<SocketAddr>().map_err(|error| {
config.http.bind_addr = value.parse::<SocketAddr>().map_err(|error| {
ProcessError::usage(format!("invalid --bind socket address `{value}`: {error}"))
})?;
}
"--runtime-id" => {
let value = take_value(&flag, inline_value, &mut args)?;
config.runtime_id = Some(RuntimeId::new(value).ok_or_else(|| {
config.http.runtime_id = Some(RuntimeId::new(value).ok_or_else(|| {
ProcessError::usage("--runtime-id must not be empty".to_string())
})?);
}
"--display-name" => {
config.display_name = Some(take_value(&flag, inline_value, &mut args)?);
config.http.display_name = Some(take_value(&flag, inline_value, &mut args)?);
}
"--workspace" => {
config.workspace_root = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
}
"--cwd" => {
config.cwd = PathBuf::from(take_value(&flag, inline_value, &mut args)?);
}
"--worker-store-dir" => {
config.worker_store_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--worker-metadata-dir" => {
config.worker_metadata_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--worker-runtime-base-dir" => {
config.worker_runtime_base_dir =
Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?));
}
"--profile" => {
config.profile = Some(take_value(&flag, inline_value, &mut args)?);
}
"--store" => {
let value = take_value(&flag, inline_value, &mut args)?;
@@ -106,7 +184,7 @@ where
"--local-token must not be empty when provided".to_string(),
));
}
config.local_token = Some(value);
config.http.local_token = Some(value);
}
"--local-token-env" => {
let name = take_value(&flag, inline_value, &mut args)?;
@@ -120,14 +198,14 @@ where
"--local-token-env `{name}` resolved to an empty value"
)));
}
config.local_token = Some(value);
config.http.local_token = Some(value);
}
"--max-transcript-projection-items" => {
config.limits.max_transcript_projection_items =
config.http.limits.max_transcript_projection_items =
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
}
"--max-event-batch-items" => {
config.limits.max_event_batch_items =
config.http.limits.max_event_batch_items =
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
}
_ => {
@@ -136,7 +214,10 @@ where
}
}
apply_store_selection(&mut config, store)?;
apply_store_selection(&mut config.http, store)?;
if config.cwd.as_os_str().is_empty() {
config.cwd = config.workspace_root.clone();
}
Ok(Some(config))
}
@@ -180,31 +261,40 @@ fn apply_store_selection(
config.store = RuntimeHttpStoreSelection::Memory;
Ok(())
}
StoreArg::Fs { root } => apply_fs_store_selection(config, root),
StoreArg::Fs { root } => {
let root = root.ok_or_else(|| {
ProcessError::usage("--store fs requires --fs-root <PATH>".to_string())
})?;
config.store = RuntimeHttpStoreSelection::Fs { root };
Ok(())
}
}
}
#[cfg(feature = "fs-store")]
fn apply_fs_store_selection(
config: &mut RuntimeHttpServerConfig,
root: Option<PathBuf>,
) -> Result<(), ProcessError> {
let root = root
.ok_or_else(|| ProcessError::usage("--store fs requires --fs-root <PATH>".to_string()))?;
config.store = RuntimeHttpStoreSelection::Fs { root };
Ok(())
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProcessConfig {
http: RuntimeHttpServerConfig,
workspace_root: PathBuf,
cwd: PathBuf,
worker_store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>,
worker_runtime_base_dir: Option<PathBuf>,
profile: Option<String>,
}
#[cfg(not(feature = "fs-store"))]
fn apply_fs_store_selection(
_config: &mut RuntimeHttpServerConfig,
root: Option<PathBuf>,
) -> Result<(), ProcessError> {
let _ = root;
Err(ProcessError::usage(
"fs store selection requires building worker-runtime with features `http-server,fs-store`"
.to_string(),
))
impl ProcessConfig {
fn default() -> Result<Self, ProcessError> {
let workspace_root = env::current_dir()?;
Ok(Self {
http: RuntimeHttpServerConfig::default(),
cwd: workspace_root.clone(),
workspace_root,
worker_store_dir: None,
worker_metadata_dir: None,
worker_runtime_base_dir: None,
profile: None,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -217,6 +307,8 @@ enum StoreArg {
enum ProcessError {
Usage(String),
Server(RuntimeHttpServerError),
Runtime(RuntimeError),
WorkerAdapter(String),
Io(std::io::Error),
}
@@ -231,6 +323,8 @@ impl fmt::Display for ProcessError {
match self {
Self::Usage(message) => message.fmt(f),
Self::Server(error) => error.fmt(f),
Self::Runtime(error) => error.fmt(f),
Self::WorkerAdapter(error) => error.fmt(f),
Self::Io(error) => error.fmt(f),
}
}
@@ -239,8 +333,9 @@ impl fmt::Display for ProcessError {
impl Error for ProcessError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Usage(_) => None,
Self::Usage(_) | Self::WorkerAdapter(_) => None,
Self::Server(error) => Some(error),
Self::Runtime(error) => Some(error),
Self::Io(error) => Some(error),
}
}
@@ -260,14 +355,20 @@ impl From<std::io::Error> for ProcessError {
fn usage() -> &'static str {
"Usage: worker-runtime-rest-server [OPTIONS]\n\n\
Starts the worker-runtime REST command API for a trusted backend/proxy.\n\
Starts a worker-backed Runtime REST command API for a trusted backend/proxy.\n\
Browsers must not connect to this Runtime process directly.\n\n\
Options:\n\
--bind <ADDR> Bind socket address (default: 127.0.0.1:0)\n\
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800)\n\
--runtime-id <ID> Runtime authority id (default: generated)\n\
--display-name <NAME> Runtime display name\n\
--store <memory|fs> Store selection (default: memory)\n\
--fs-root <PATH> Filesystem store root; requires fs-store feature\n\
--workspace <PATH> Workspace root used for spawned Workers (default: cwd)\n\
--cwd <PATH> Process cwd used for spawned Workers (default: workspace)\n\
--profile <SELECTOR> Force spawned Workers to use a Profile selector\n\
--worker-store-dir <PATH> Worker session store directory\n\
--worker-metadata-dir <PATH> Worker metadata directory\n\
--worker-runtime-base-dir <PATH> Worker controller runtime directory\n\
--store <memory|fs> Runtime catalog store selection (default: memory)\n\
--fs-root <PATH> Runtime catalog filesystem store root\n\
--local-token <TOKEN> Minimal local bearer token placeholder\n\
--local-token-env <ENV> Read local bearer token placeholder from env\n\
--max-transcript-projection-items <N> Override transcript projection limit\n\
@@ -279,68 +380,84 @@ Options:\n\
mod tests {
use super::*;
#[test]
fn parses_memory_runtime_process_config() {
let config = parse_args([
"--bind",
"127.0.0.1:48181",
"--runtime-id=runtime-review",
"--display-name",
"review runtime",
"--store",
"memory",
"--local-token",
"local-placeholder",
"--max-transcript-projection-items",
"32",
"--max-event-batch-items=16",
])
.unwrap()
.unwrap();
assert_eq!(
config.bind_addr,
"127.0.0.1:48181".parse::<SocketAddr>().unwrap()
);
assert_eq!(
config.runtime_id.as_ref().map(RuntimeId::as_str),
Some("runtime-review")
);
assert_eq!(config.display_name.as_deref(), Some("review runtime"));
assert!(matches!(config.store, RuntimeHttpStoreSelection::Memory));
assert_eq!(config.local_token.as_deref(), Some("local-placeholder"));
assert_eq!(config.limits.max_transcript_projection_items, 32);
assert_eq!(config.limits.max_event_batch_items, 16);
}
#[cfg(feature = "fs-store")]
#[test]
fn parses_fs_store_runtime_process_config_when_feature_enabled() {
let config = parse_args(["--fs-root", "/tmp/yoi-worker-runtime-store"])
.unwrap()
.unwrap();
assert!(matches!(
config.store,
RuntimeHttpStoreSelection::Fs { ref root }
if root == &PathBuf::from("/tmp/yoi-worker-runtime-store")
));
}
#[cfg(not(feature = "fs-store"))]
#[test]
fn rejects_fs_store_runtime_process_config_without_feature() {
let error = parse_args(["--store", "fs", "--fs-root", "/tmp/store"]).unwrap_err();
assert!(
error
.to_string()
.contains("requires building worker-runtime with features")
);
}
#[test]
fn help_does_not_start_server() {
assert!(parse_args(["--help"]).unwrap().is_none());
}
#[test]
fn default_bind_addr_is_stable_for_local_dev() {
let config = parse_args([] as [&str; 0]).unwrap().unwrap();
assert_eq!(
config.http.bind_addr,
"127.0.0.1:38800".parse::<SocketAddr>().unwrap()
);
}
#[test]
fn parses_memory_runtime_process_config() {
let config = parse_args([
"--bind",
"127.0.0.1:0",
"--runtime-id=runtime-alpha",
"--display-name",
"Runtime Alpha",
"--workspace",
"/tmp/workspace",
"--cwd",
"/tmp/workspace/subdir",
"--profile",
"builtin:coder",
"--local-token-env",
"TEST_RUNTIME_TOKEN",
]);
assert!(config.is_err());
unsafe {
env::set_var("TEST_RUNTIME_TOKEN", "secret-token");
}
let config = parse_args([
"--bind",
"127.0.0.1:0",
"--runtime-id=runtime-alpha",
"--display-name",
"Runtime Alpha",
"--workspace",
"/tmp/workspace",
"--cwd",
"/tmp/workspace/subdir",
"--profile",
"builtin:coder",
"--local-token-env",
"TEST_RUNTIME_TOKEN",
])
.unwrap()
.unwrap();
unsafe {
env::remove_var("TEST_RUNTIME_TOKEN");
}
assert_eq!(config.http.bind_addr, "127.0.0.1:0".parse().unwrap());
assert_eq!(
config.http.runtime_id.as_ref().map(ToString::to_string),
Some("runtime-alpha".to_string())
);
assert_eq!(config.http.display_name.as_deref(), Some("Runtime Alpha"));
assert_eq!(config.workspace_root, PathBuf::from("/tmp/workspace"));
assert_eq!(config.cwd, PathBuf::from("/tmp/workspace/subdir"));
assert_eq!(config.profile.as_deref(), Some("builtin:coder"));
assert_eq!(config.http.local_token.as_deref(), Some("secret-token"));
}
#[test]
fn parses_fs_store_runtime_process_config() {
let config = parse_args(["--store", "fs", "--fs-root", "/tmp/runtime-store"])
.unwrap()
.unwrap();
assert!(matches!(
config.http.store,
RuntimeHttpStoreSelection::Fs { ref root } if root == &PathBuf::from("/tmp/runtime-store")
));
}
}