From 51f3fb6c60232fa96cb3931c2c2a9a39bf0a8d21 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Tue, 9 Jun 2026 18:02:33 +0800 Subject: [PATCH 1/6] sidecar: stabilize cell endpoint lifecycle --- apps/client/cell/src/main.rs | 238 ++++++++++++++++++++++--- apps/server/cell/src/main.rs | 115 ++++++++++-- proto/crates/client/src/lib.rs | 10 +- proto/crates/server/src/lib.rs | 12 +- proto/crates/transport/src/lib.rs | 16 ++ proto/crates/transport/src/tcp_port.rs | 63 +++++++ 6 files changed, 419 insertions(+), 35 deletions(-) create mode 100644 proto/crates/transport/src/tcp_port.rs diff --git a/apps/client/cell/src/main.rs b/apps/client/cell/src/main.rs index 81bc1f3..0faf342 100644 --- a/apps/client/cell/src/main.rs +++ b/apps/client/cell/src/main.rs @@ -1,7 +1,19 @@ -use std::{env, fs::OpenOptions, net::TcpListener, path::Path, process::Stdio, time::Duration}; +use std::{ + env, + fs::OpenOptions, + path::Path, + process::Stdio, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; -use mini_stim_proto_client::{CellMode, TransportError, bootstrap}; +use async_trait::async_trait; +use mini_stim_proto_client::{ + CellMode, CellRuntime, CellState, ClientCell, ClientCellStatus, ServerCellClient, + TransportError, bootstrap, +}; use mini_stim_proto_transport::CellContext; +use tokio::sync::RwLock; use tokio::{process::Command, time::sleep}; #[tokio::main] @@ -12,49 +24,143 @@ async fn main() -> Result<(), TransportError> { let log_path = store.join("logs").join("soma.log"); tokio::fs::create_dir_all(log_path.parent().expect("log path has parent")).await?; - let server_url = wait_for_server_url(&context, Duration::from_secs(45)).await?; - let port = allocate_port()?; + let state = Arc::new(RwLock::new(ClientRuntimeState::new(context.mode.clone()))); + let inspect_runtime = runtime.clone(); + let inspect_state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(error) = inspect_runtime + .register(ClientStatus { + state: inspect_state, + }) + .await + { + eprintln!("client inspect failed: {error}"); + } + }); + + let server_client = runtime.server_client(); + let server_url = wait_for_server_url(&server_client, Duration::from_secs(45)).await?; + let port = runtime.acquire_tcp_port("web.port", "client web")?; let url = format!("http://127.0.0.1:{port}"); let mut child = spawn_client_soma(&context, port, &server_url, &log_path)?; let pid = child.id(); + state.write().await.pid = pid; match wait_for_health(&url, Duration::from_secs(45)).await { - Ok(()) => print_ready("client", &url, &context.endpoint, pid), + Ok(()) => { + state.write().await.mark_running(url.clone(), pid); + print_ready("client", &url, &context.endpoint, pid); + } Err(error) => { + state.write().await.mark_failed(error.to_string()); let _ = child.start_kill(); let _ = child.wait().await; return Err(error); } } + let mut active_server_url = server_url; + let soma = ClientSoma { + context: context.clone(), + log_path, + port, + state: Arc::clone(&state), + url, + }; tokio::select! { - result = child.wait() => { - match result { - Ok(status) if status.success() => {} - Ok(status) => { - return Err(TransportError::Config(format!("client soma exited with {status}"))); + result = maintain_client_soma( + &soma, + &server_client, + &mut active_server_url, + &mut child, + ) => result?, + _ = shutdown_signal() => {} + } + + let _ = child.start_kill(); + let _ = child.wait().await; + state.write().await.mark_stopped(); + + Ok(()) +} + +struct ClientSoma { + context: CellContext, + log_path: std::path::PathBuf, + port: u16, + state: Arc>, + url: String, +} + +async fn maintain_client_soma( + soma: &ClientSoma, + server_client: &ServerCellClient, + active_server_url: &mut String, + child: &mut tokio::process::Child, +) -> Result<(), TransportError> { + loop { + tokio::select! { + result = child.wait() => { + match result { + Ok(status) if status.success() => { + soma.state.write().await.mark_stopped(); + return Ok(()); + } + Ok(status) => { + let message = format!("client soma exited with {status}"); + soma.state.write().await.mark_failed(message.clone()); + return Err(TransportError::Config(message)); + } + Err(error) => { + soma.state.write().await.mark_failed(error.to_string()); + return Err(TransportError::Io(error)); + } } - Err(error) => return Err(TransportError::Io(error)), } - } - _ = shutdown_signal() => { - let _ = child.start_kill(); - let _ = child.wait().await; + _ = sleep(Duration::from_secs(2)) => { + let Some(server_url) = current_server_url(server_client).await? else { + continue; + }; + if server_url == *active_server_url { + continue; + } + restart_client_soma(soma, &server_url, child).await?; + *active_server_url = server_url; + } } } +} + +async fn restart_client_soma( + soma: &ClientSoma, + server_url: &str, + child: &mut tokio::process::Child, +) -> Result<(), TransportError> { + let _ = child.start_kill(); + let _ = child.wait().await; + soma.state.write().await.mark_starting(); + let mut replacement = spawn_client_soma(&soma.context, soma.port, server_url, &soma.log_path)?; + let pid = replacement.id(); + soma.state.write().await.pid = pid; + if let Err(error) = wait_for_health(&soma.url, Duration::from_secs(45)).await { + soma.state.write().await.mark_failed(error.to_string()); + let _ = replacement.start_kill(); + let _ = replacement.wait().await; + return Err(error); + } + soma.state.write().await.mark_running(soma.url.clone(), pid); + *child = replacement; Ok(()) } async fn wait_for_server_url( - _context: &CellContext, + server_client: &ServerCellClient, timeout: Duration, ) -> Result { let started = std::time::Instant::now(); while started.elapsed() < timeout { - if let Ok(url) = env::var("MINI_STIM_SERVER_URL") - && !url.trim().is_empty() - { + if let Some(url) = current_server_url(server_client).await? { return Ok(url); } sleep(Duration::from_millis(150)).await; @@ -64,6 +170,24 @@ async fn wait_for_server_url( )) } +async fn current_server_url( + server_client: &ServerCellClient, +) -> Result, TransportError> { + if let Ok(status) = server_client.status().await + && status.state == CellState::Running + && let Some(url) = status.url + && !url.trim().is_empty() + { + return Ok(Some(url)); + } + if let Ok(url) = env::var("MINI_STIM_SERVER_URL") + && !url.trim().is_empty() + { + return Ok(Some(url)); + } + Ok(None) +} + fn print_ready(role: &str, endpoint: &str, runtime_endpoint: &str, pid: Option) { let instance_id = pid.map(|value| value.to_string()).unwrap_or_default(); println!( @@ -71,6 +195,74 @@ fn print_ready(role: &str, endpoint: &str, runtime_endpoint: &str, pid: Option, + mode: CellMode, + pid: Option, + state: CellState, + updated_at: String, + url: Option, +} + +impl ClientRuntimeState { + fn new(mode: CellMode) -> Self { + Self { + error: None, + mode, + pid: None, + state: CellState::Starting, + updated_at: timestamp(), + url: None, + } + } + + fn mark_starting(&mut self) { + self.error = None; + self.pid = None; + self.state = CellState::Starting; + self.updated_at = timestamp(); + } + + fn mark_running(&mut self, url: String, pid: Option) { + self.error = None; + self.pid = pid; + self.state = CellState::Running; + self.updated_at = timestamp(); + self.url = Some(url); + } + + fn mark_failed(&mut self, error: String) { + self.error = Some(error); + self.state = CellState::Failed; + self.updated_at = timestamp(); + } + + fn mark_stopped(&mut self) { + self.state = CellState::Stopped; + self.updated_at = timestamp(); + } +} + +struct ClientStatus { + state: Arc>, +} + +#[async_trait] +impl ClientCell for ClientStatus { + async fn status(&self) -> Result { + let state = self.state.read().await.clone(); + Ok(ClientCellStatus { + error: state.error, + mode: state.mode, + pid: state.pid, + state: state.state, + updated_at: state.updated_at, + url: state.url, + }) + } +} + fn spawn_client_soma( context: &CellContext, port: u16, @@ -101,9 +293,11 @@ fn spawn_client_soma( command.spawn().map_err(TransportError::Io) } -fn allocate_port() -> Result { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - Ok(listener.local_addr()?.port()) +fn timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()) } async fn wait_for_health(url: &str, timeout: Duration) -> Result<(), TransportError> { diff --git a/apps/server/cell/src/main.rs b/apps/server/cell/src/main.rs index bb22034..caff787 100644 --- a/apps/server/cell/src/main.rs +++ b/apps/server/cell/src/main.rs @@ -1,7 +1,15 @@ -use std::{fs::OpenOptions, net::TcpListener, path::Path, process::Stdio, time::Duration}; +use std::{ + fs::OpenOptions, + path::Path, + process::Stdio, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; -use mini_stim_proto_server::bootstrap; +use async_trait::async_trait; +use mini_stim_proto_server::{CellRuntime, CellState, ServerCell, ServerCellStatus, bootstrap}; use mini_stim_proto_transport::{CellContext, CellMode, TransportError}; +use tokio::sync::RwLock; use tokio::{process::Command, time::sleep}; #[tokio::main] @@ -12,18 +20,37 @@ async fn main() -> Result<(), TransportError> { let log_path = store.join("logs").join("soma.log"); tokio::fs::create_dir_all(log_path.parent().expect("log path has parent")).await?; - let port = allocate_port()?; + let port = runtime.acquire_tcp_port("api.port", "server API")?; let url = format!("http://127.0.0.1:{port}"); let db_path = store.join("santi.sqlite"); if let Some(parent) = db_path.parent() { tokio::fs::create_dir_all(parent).await?; } + let state = Arc::new(RwLock::new(ServerRuntimeState::new(context.mode.clone()))); + let inspect_runtime = runtime.clone(); + let inspect_state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(error) = inspect_runtime + .register(ServerStatus { + state: inspect_state, + }) + .await + { + eprintln!("server inspect failed: {error}"); + } + }); + let mut child = spawn_server_soma(&context, &port, &db_path, &log_path)?; let pid = child.id(); + state.write().await.pid = pid; match wait_for_health(&format!("{url}/api/v1/health"), Duration::from_secs(45)).await { - Ok(()) => print_ready("server", &url, &context.endpoint, pid), + Ok(()) => { + state.write().await.mark_running(url.clone(), pid); + print_ready("server", &url, &context.endpoint, pid); + } Err(error) => { + state.write().await.mark_failed(error.to_string()); let _ = child.start_kill(); let _ = child.wait().await; return Err(error); @@ -35,20 +62,86 @@ async fn main() -> Result<(), TransportError> { match result { Ok(status) if status.success() => {} Ok(status) => { + state.write().await.mark_failed(format!("server soma exited with {status}")); return Err(TransportError::Config(format!("server soma exited with {status}"))); } - Err(error) => return Err(TransportError::Io(error)), + Err(error) => { + state.write().await.mark_failed(error.to_string()); + return Err(TransportError::Io(error)); + } } } _ = shutdown_signal() => { let _ = child.start_kill(); let _ = child.wait().await; + state.write().await.mark_stopped(); } } Ok(()) } +#[derive(Clone)] +struct ServerRuntimeState { + error: Option, + mode: CellMode, + pid: Option, + state: CellState, + updated_at: String, + url: Option, +} + +impl ServerRuntimeState { + fn new(mode: CellMode) -> Self { + Self { + error: None, + mode, + pid: None, + state: CellState::Starting, + updated_at: timestamp(), + url: None, + } + } + + fn mark_running(&mut self, url: String, pid: Option) { + self.error = None; + self.pid = pid; + self.state = CellState::Running; + self.updated_at = timestamp(); + self.url = Some(url); + } + + fn mark_failed(&mut self, error: String) { + self.error = Some(error); + self.state = CellState::Failed; + self.updated_at = timestamp(); + } + + fn mark_stopped(&mut self) { + self.state = CellState::Stopped; + self.updated_at = timestamp(); + } +} + +struct ServerStatus { + state: Arc>, +} + +#[async_trait] +impl ServerCell for ServerStatus { + async fn status(&self) -> Result { + let state = self.state.read().await.clone(); + Ok(ServerCellStatus { + error: state.error, + mode: state.mode, + pid: state.pid, + state: state.state, + updated_at: state.updated_at, + url: state.url, + }) + } +} + fn spawn_server_soma( context: &CellContext, port: &u16, @@ -80,11 +173,6 @@ fn spawn_server_soma( command.spawn().map_err(TransportError::Io) } -fn allocate_port() -> Result { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - Ok(listener.local_addr()?.port()) -} - fn print_ready(role: &str, endpoint: &str, runtime_endpoint: &str, pid: Option) { let instance_id = pid.map(|value| value.to_string()).unwrap_or_default(); println!( @@ -115,6 +203,13 @@ fn format_mode(mode: &CellMode) -> &'static str { } } +fn timestamp() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + async fn shutdown_signal() { #[cfg(unix)] { diff --git a/proto/crates/client/src/lib.rs b/proto/crates/client/src/lib.rs index 6d5a7c7..19cded1 100644 --- a/proto/crates/client/src/lib.rs +++ b/proto/crates/client/src/lib.rs @@ -2,7 +2,9 @@ use std::{ffi::OsString, path::PathBuf, sync::Arc}; use async_trait::async_trait; pub use mini_stim_proto_server::{CellState, ServerCellClient}; -pub use mini_stim_proto_transport::{CellContext, CellMode, TransportError, TransportResult}; +pub use mini_stim_proto_transport::{ + CellContext, CellMode, CellRuntime, TransportError, TransportResult, +}; use mini_stim_proto_transport::{InspectRegistry, bootstrap as bootstrap_transport, invoke}; use serde::{Deserialize, Serialize}; @@ -59,6 +61,12 @@ impl ClientCellRuntime { } } +impl CellRuntime for ClientCellRuntime { + fn context(&self) -> &CellContext { + &self.context + } +} + #[derive(Clone)] pub struct ClientCellClient { socket_path: PathBuf, diff --git a/proto/crates/server/src/lib.rs b/proto/crates/server/src/lib.rs index aa02e61..9c2a7ef 100644 --- a/proto/crates/server/src/lib.rs +++ b/proto/crates/server/src/lib.rs @@ -1,14 +1,16 @@ use std::{ffi::OsString, path::PathBuf, sync::Arc}; use async_trait::async_trait; -pub use mini_stim_proto_transport::{CellContext, CellMode, TransportError, TransportResult}; +pub use mini_stim_proto_transport::{ + CellContext, CellMode, CellRuntime, TransportError, TransportResult, +}; use mini_stim_proto_transport::{InspectRegistry, bootstrap as bootstrap_transport, invoke}; use serde::{Deserialize, Serialize}; pub const SERVER_CELL: &str = "server"; const STATUS_EVENT: &str = "server.status"; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum CellState { Starting, @@ -69,6 +71,12 @@ impl ServerCellRuntime { } } +impl CellRuntime for ServerCellRuntime { + fn context(&self) -> &CellContext { + &self.context + } +} + #[derive(Clone)] pub struct ServerCellClient { socket_path: PathBuf, diff --git a/proto/crates/transport/src/lib.rs b/proto/crates/transport/src/lib.rs index d060c3f..c30ee81 100644 --- a/proto/crates/transport/src/lib.rs +++ b/proto/crates/transport/src/lib.rs @@ -1,3 +1,5 @@ +mod tcp_port; + use std::{ collections::HashMap, ffi::OsString, @@ -14,6 +16,8 @@ use tokio::{ net::{UnixListener, UnixStream}, }; +pub use tcp_port::acquire_stored_tcp_port; + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CellMode { @@ -59,6 +63,18 @@ impl CellContext { } } +pub trait CellRuntime { + fn context(&self) -> &CellContext; + + fn cell_store(&self) -> PathBuf { + self.context().store.join(&self.context().app) + } + + fn acquire_tcp_port(&self, name: &str, label: &str) -> TransportResult { + acquire_stored_tcp_port(&self.cell_store().join(name), label) + } +} + #[derive(Debug)] pub enum TransportError { Config(String), diff --git a/proto/crates/transport/src/tcp_port.rs b/proto/crates/transport/src/tcp_port.rs new file mode 100644 index 0000000..ab7fb24 --- /dev/null +++ b/proto/crates/transport/src/tcp_port.rs @@ -0,0 +1,63 @@ +use std::{io::ErrorKind, net::TcpListener, path::Path}; + +use crate::{TransportError, TransportResult}; + +pub fn acquire_stored_tcp_port(port_path: &Path, label: &str) -> TransportResult { + if let Some(port) = read_stored_tcp_port(port_path, label)? { + match reserve_tcp_port(port) { + Ok(listener) => { + drop(listener); + return Ok(port); + } + Err(error) => { + eprintln!( + "{label} could not reuse stored TCP port {port}: {error}; allocating a new port" + ); + } + } + } + let port = allocate_tcp_port()?; + write_stored_tcp_port(port_path, port)?; + Ok(port) +} + +fn read_stored_tcp_port(port_path: &Path, label: &str) -> TransportResult> { + let raw = match std::fs::read_to_string(port_path) { + Ok(raw) => raw, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(TransportError::Io(error)), + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + match trimmed.parse::() { + Ok(port) => Ok(Some(port)), + Err(error) => { + eprintln!( + "{label} ignored invalid stored TCP port at {}: {error}", + port_path.display() + ); + Ok(None) + } + } +} + +fn write_stored_tcp_port(port_path: &Path, port: u16) -> TransportResult<()> { + if let Some(parent) = port_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(port_path, format!("{port}\n"))?; + Ok(()) +} + +fn reserve_tcp_port(port: u16) -> Result { + TcpListener::bind(("127.0.0.1", port)) +} + +fn allocate_tcp_port() -> TransportResult { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} From 978116413f488da4c462dbae30d151052a74e9ef Mon Sep 17 00:00:00 2001 From: PerishCode Date: Tue, 9 Jun 2026 18:20:04 +0800 Subject: [PATCH 2/6] dev: staticize contracts hook workflow --- .runseal/hooks/pre-commit | 10 +++------- README.md | 12 ++---------- package.json | 1 + scripts/contracts-codegen.sh | 10 ++++++++++ 4 files changed, 16 insertions(+), 17 deletions(-) create mode 100755 scripts/contracts-codegen.sh diff --git a/.runseal/hooks/pre-commit b/.runseal/hooks/pre-commit index acfc519..bc7e07f 100644 --- a/.runseal/hooks/pre-commit +++ b/.runseal/hooks/pre-commit @@ -26,15 +26,11 @@ echo "==> cargo test" cargo test --locked --workspace echo "==> contracts" -if [ -z "${SANTI_API_URL:-}" ]; then - echo "SANTI_API_URL must point at a running server soma for contract generation" >&2 +./scripts/contracts-codegen.sh +if ! git diff --exit-code -- packages/contracts/openapi.json packages/contracts/src/openapi.ts >/dev/null; then + echo "contracts are out of date; run ./scripts/contracts-codegen.sh and stage the result" >&2 exit 1 fi -api_url=$SANTI_API_URL -curl -fsS "$api_url/api/openapi.json" -o packages/contracts/openapi.json -python3 -m json.tool --indent 2 packages/contracts/openapi.json packages/contracts/openapi.json.tmp -mv packages/contracts/openapi.json.tmp packages/contracts/openapi.json -pnpm -C packages/contracts codegen echo "==> typecheck" pnpm typecheck diff --git a/README.md b/README.md index 88325a0..0ee1a6d 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,7 @@ runseal :init Generate contracts from the Rust OpenAPI source of truth: ```sh -SANTI_HOST=127.0.0.1 SANTI_PORT=43307 SANTI_DB=.tmp/manual.sqlite cargo run -p mini-stim-server-soma -- serve -``` - -Then, in another shell: - -```sh -curl -fsS "${SANTI_API_URL:-http://127.0.0.1:43307}/api/openapi.json" -o packages/contracts/openapi.json -python3 -m json.tool --indent 2 packages/contracts/openapi.json packages/contracts/openapi.json.tmp -mv packages/contracts/openapi.json.tmp packages/contracts/openapi.json -pnpm -C packages/contracts codegen +./scripts/contracts-codegen.sh ``` ## Run @@ -138,6 +129,7 @@ cargo fmt --all --check flavor check --root . --config flavor.toml cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace +./scripts/contracts-codegen.sh pnpm typecheck pnpm build sidecar status --config sidecar.toml diff --git a/package.json b/package.json index 560fc9c..b75b066 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "packageManager": "pnpm@10.33.2", "scripts": { + "contracts:codegen": "./scripts/contracts-codegen.sh", "typecheck": "pnpm -r --if-present typecheck", "build": "pnpm -r --if-present build" }, diff --git a/scripts/contracts-codegen.sh b/scripts/contracts-codegen.sh new file mode 100755 index 0000000..b5336f1 --- /dev/null +++ b/scripts/contracts-codegen.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu + +root=$(git rev-parse --show-toplevel) +cd "$root" + +cargo run --quiet -p mini-stim-server-soma -- export-openapi > packages/contracts/openapi.json +python3 -m json.tool --indent 2 packages/contracts/openapi.json packages/contracts/openapi.json.tmp +mv packages/contracts/openapi.json.tmp packages/contracts/openapi.json +pnpm -C packages/contracts codegen From 33db140e3da4e06353665bca30b8996696ae12c7 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 17:09:54 +0800 Subject: [PATCH 3/6] dev: absorb contracts codegen into runseal --- .runseal/hooks/pre-commit | 4 +-- .runseal/wrappers/codegen.seal | 46 ++++++++++++++++++++++++++++++++++ .runseal/wrappers/init.seal | 6 ++--- README.md | 4 +-- package.json | 1 - scripts/contracts-codegen.sh | 10 -------- 6 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 .runseal/wrappers/codegen.seal delete mode 100755 scripts/contracts-codegen.sh diff --git a/.runseal/hooks/pre-commit b/.runseal/hooks/pre-commit index bc7e07f..10e267c 100644 --- a/.runseal/hooks/pre-commit +++ b/.runseal/hooks/pre-commit @@ -26,9 +26,9 @@ echo "==> cargo test" cargo test --locked --workspace echo "==> contracts" -./scripts/contracts-codegen.sh +runseal :codegen if ! git diff --exit-code -- packages/contracts/openapi.json packages/contracts/src/openapi.ts >/dev/null; then - echo "contracts are out of date; run ./scripts/contracts-codegen.sh and stage the result" >&2 + echo "contracts are out of date; run runseal :codegen and stage the result" >&2 exit 1 fi diff --git a/.runseal/wrappers/codegen.seal b/.runseal/wrappers/codegen.seal new file mode 100644 index 0000000..582f509 --- /dev/null +++ b/.runseal/wrappers/codegen.seal @@ -0,0 +1,46 @@ +print() { + printf '%s\n' "$1" +} + +error() { + printf '%s\n' "$1" >&2 +} + +fail() { + error "$1" + exit 1 +} + +usage() { + print "Usage: runseal :codegen" + print "" + print "Regenerate contracts from the Rust OpenAPI source of truth." +} + +__seal_help=false +while [ "$#" -gt 0 ]; do + case "$1" in + --) + shift + break + ;; + -h|--help|help) + __seal_help=true + shift + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +if [ "$__seal_help" = true ]; then + usage + exit 0 +fi + +root=$(git rev-parse --show-toplevel) +cd "$root" + +cargo run --quiet -p mini-stim-server-soma -- export-openapi > packages/contracts/openapi.json +pnpm -C packages/contracts codegen diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal index bb22a39..7f7e36d 100644 --- a/.runseal/wrappers/init.seal +++ b/.runseal/wrappers/init.seal @@ -114,7 +114,6 @@ print "repository: $root" print "==> checking required tools" require_tool git -require_tool python3 require_tool cargo require_tool pnpm require_tool sidecar @@ -123,10 +122,8 @@ require_tool runseal require_tool sh require_tool sed require_tool grep -require_tool curl -require_tool mv require_tool cp -print "ok: git, python3, cargo, pnpm, sidecar, flavor, runseal, sh, sed, grep, curl, mv, cp" +print "ok: git, cargo, pnpm, sidecar, flavor, runseal, sh, sed, grep, cp" print "==> checking repository entrypoints" require_path AGENTS.md @@ -152,6 +149,7 @@ require_path apps/client/soma/Cargo.toml require_path apps/client/soma/web/package.json require_path packages/components/package.json require_path packages/contracts/package.json +require_path .runseal/wrappers/codegen.seal require_path .runseal/wrappers/init.seal require_path .runseal/wrappers/pr.seal require_path .runseal/hooks/pre-commit diff --git a/README.md b/README.md index 0ee1a6d..31edaea 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ runseal :init Generate contracts from the Rust OpenAPI source of truth: ```sh -./scripts/contracts-codegen.sh +runseal :codegen ``` ## Run @@ -129,7 +129,7 @@ cargo fmt --all --check flavor check --root . --config flavor.toml cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace -./scripts/contracts-codegen.sh +runseal :codegen pnpm typecheck pnpm build sidecar status --config sidecar.toml diff --git a/package.json b/package.json index b75b066..560fc9c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "type": "module", "packageManager": "pnpm@10.33.2", "scripts": { - "contracts:codegen": "./scripts/contracts-codegen.sh", "typecheck": "pnpm -r --if-present typecheck", "build": "pnpm -r --if-present build" }, diff --git a/scripts/contracts-codegen.sh b/scripts/contracts-codegen.sh deleted file mode 100755 index b5336f1..0000000 --- a/scripts/contracts-codegen.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env sh -set -eu - -root=$(git rev-parse --show-toplevel) -cd "$root" - -cargo run --quiet -p mini-stim-server-soma -- export-openapi > packages/contracts/openapi.json -python3 -m json.tool --indent 2 packages/contracts/openapi.json packages/contracts/openapi.json.tmp -mv packages/contracts/openapi.json.tmp packages/contracts/openapi.json -pnpm -C packages/contracts codegen From 03589c17a0492f34c4e5093d1c91847b059c112e Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 17:16:06 +0800 Subject: [PATCH 4/6] ci: align web guard with santi api --- .github/workflows/guard.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index 5f94878..2d264d5 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -99,6 +99,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Install runseal + run: | + curl -fsSL https://runseal.perish.uk/manage.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/runseal" --version + - name: Start API env: OPENAI_API_KEY: ci-placeholder @@ -111,7 +117,7 @@ jobs: mkdir -p .tmp cargo run -p mini-stim-server-soma -- serve & for attempt in $(seq 1 180); do - if curl -fsS http://127.0.0.1:43307/api/health >/dev/null; then + if curl -fsS http://127.0.0.1:43307/api/v1/health >/dev/null; then exit 0 fi sleep 1 @@ -119,13 +125,7 @@ jobs: exit 1 - name: Generate contracts - env: - SANTI_API_URL: http://127.0.0.1:43307 - run: | - curl -fsS "$SANTI_API_URL/api/openapi.json" -o packages/contracts/openapi.json - python3 -m json.tool --indent 2 packages/contracts/openapi.json packages/contracts/openapi.json.tmp - mv packages/contracts/openapi.json.tmp packages/contracts/openapi.json - pnpm -C packages/contracts codegen + run: runseal :codegen - name: Typecheck run: pnpm typecheck From 93cfd99c68fa3ee2638035b7aa338c5c0dd0f52f Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 17:22:46 +0800 Subject: [PATCH 5/6] ci: pin runseal beta for guard --- .github/workflows/guard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index 2d264d5..db0522e 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -45,7 +45,7 @@ jobs: - name: Install runseal run: | - curl -fsSL https://runseal.perish.uk/manage.sh | sh + curl -fsSL https://runseal.perish.uk/manage.sh | sh -s -- install --channel beta echo "$HOME/.local/bin" >> "$GITHUB_PATH" "$HOME/.local/bin/runseal" --version @@ -101,7 +101,7 @@ jobs: - name: Install runseal run: | - curl -fsSL https://runseal.perish.uk/manage.sh | sh + curl -fsSL https://runseal.perish.uk/manage.sh | sh -s -- install --channel beta echo "$HOME/.local/bin" >> "$GITHUB_PATH" "$HOME/.local/bin/runseal" --version From 5412d8bc1fd35d0a5b5a6abde9d5977697213ca8 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 17:29:45 +0800 Subject: [PATCH 6/6] dev: make codegen wrapper path-stable --- .runseal/wrappers/codegen.seal | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.runseal/wrappers/codegen.seal b/.runseal/wrappers/codegen.seal index 582f509..5824b88 100644 --- a/.runseal/wrappers/codegen.seal +++ b/.runseal/wrappers/codegen.seal @@ -40,7 +40,6 @@ if [ "$__seal_help" = true ]; then fi root=$(git rev-parse --show-toplevel) -cd "$root" -cargo run --quiet -p mini-stim-server-soma -- export-openapi > packages/contracts/openapi.json -pnpm -C packages/contracts codegen +cargo run --manifest-path "$root/Cargo.toml" --quiet -p mini-stim-server-soma -- export-openapi > "$root/packages/contracts/openapi.json" +pnpm -C "$root/packages/contracts" codegen