feat: less crates, swactor distribution node

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-02-19 12:24:17 +07:00
parent eb4bf65c20
commit c954e95d4f
87 changed files with 3172 additions and 2523 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
*
!target/x86_64-unknown-linux-musl/release/swactor

9
.gitignore vendored
View file

@ -16,4 +16,11 @@ docs/architecture.html
crates/simulation/traces
# xtask personal config
xtask/config.toml
xtask/config.toml
# Deploy config (machine-specific)
deploy.toml
.deploy/
# Local dev node state
.dev-node/

288
Cargo.lock generated
View file

@ -287,6 +287,58 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backon"
version = "1.6.0"
@ -337,12 +389,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@ -489,21 +535,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "ci-relay"
version = "0.1.0"
dependencies = [
"clap",
"hex",
"hmac",
"iroh",
"serde_json",
"sha2 0.10.9",
"swactor-ci",
"tiny_http",
"tokio",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@ -619,15 +650,6 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -916,7 +938,7 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
@ -1085,6 +1107,28 @@ dependencies = [
"syn",
]
[[package]]
name = "dashboard"
version = "0.1.0"
dependencies = [
"axum",
"clap",
"crossbeam-queue",
"crossterm",
"ctrlc",
"distribution",
"iroh",
"ratatui",
"serde",
"serde_json",
"swactor",
"swactor-std",
"tokio",
"tokio-stream",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@ -1267,7 +1311,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"block2",
"libc",
"objc2",
@ -1512,17 +1556,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin 0.9.8",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -1700,7 +1733,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"debugid",
"fxhash",
"serde",
@ -2241,16 +2274,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "if-addrs"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "igd-next"
version = "0.16.2"
@ -2709,7 +2732,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"libc",
]
@ -2737,20 +2760,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "local-runner"
version = "0.1.0"
dependencies = [
"clap",
"ctrlc",
"iroh",
"runtime-dashboard",
"serde_json",
"swactor",
"swactor-ci",
"tokio",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@ -2828,17 +2837,10 @@ dependencies = [
]
[[package]]
name = "mdns-sd"
version = "0.11.5"
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e"
dependencies = [
"flume",
"if-addrs",
"log",
"polling",
"socket2 0.5.10",
]
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
@ -3022,7 +3024,7 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ec2f5b6839be2a19d7fa5aab5bc444380f6311c2b693551cb80f45caaa7b5ef"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"libc",
"log",
"netlink-packet-core",
@ -3034,7 +3036,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"libc",
"log",
"netlink-packet-core",
@ -3109,7 +3111,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
@ -3229,7 +3231,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"block2",
"dispatch2",
"libc",
@ -3248,7 +3250,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"objc2",
"objc2-core-foundation",
]
@ -3259,7 +3261,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"dispatch2",
"libc",
"objc2",
@ -3325,7 +3327,7 @@ version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"cfg-if",
"foreign-types",
"libc",
@ -3573,22 +3575,6 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "polling"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"concurrent-queue",
"libc",
"log",
"pin-project-lite",
"windows-sys 0.48.0",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@ -3710,7 +3696,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.11.0",
"bitflags",
"num-traits",
"rand",
"rand_chacha",
@ -3962,7 +3948,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"cassowary",
"compact_str",
"crossterm",
@ -4017,7 +4003,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.0",
"bitflags",
]
[[package]]
@ -4194,26 +4180,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "runtime-dashboard"
version = "0.1.0"
dependencies = [
"clap",
"crossbeam-queue",
"crossterm",
"ctrlc",
"distribution",
"iroh",
"ratatui",
"serde",
"serde_json",
"swactor",
"swactor-ci",
"tiny_http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
@ -4250,7 +4216,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@ -4263,7 +4229,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.11.0",
@ -4441,7 +4407,7 @@ version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@ -4543,6 +4509,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
@ -4712,7 +4689,7 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a"
dependencies = [
"bitflags 2.11.0",
"bitflags",
]
[[package]]
@ -4928,35 +4905,32 @@ dependencies = [
name = "swactor-ci"
version = "0.1.0"
dependencies = [
"clap",
"ctrlc",
"dashboard",
"hex",
"hmac",
"iroh",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.10.9",
"swactor",
"tiny_http",
"tokio",
"ureq",
]
[[package]]
name = "swactor-crypto-wasm"
version = "0.1.0"
dependencies = [
"ed25519-dalek 2.2.0",
]
[[package]]
name = "swactor-datastore"
version = "0.1.0"
dependencies = [
"blake3",
"clap",
"ctrlc",
"dashboard",
"distribution",
"getrandom 0.2.17",
"proptest",
"runtime-dashboard",
"serde",
"serde_json",
"shared-types",
@ -4964,7 +4938,6 @@ dependencies = [
"swactor-std",
"tempfile",
"tiny_http",
"toml 0.8.23",
"ureq",
]
@ -4974,14 +4947,14 @@ version = "0.1.0"
dependencies = [
"clap",
"ctrlc",
"dashboard",
"distribution",
"iroh",
"libc",
"mdns-sd",
"runtime-dashboard",
"serde",
"swactor",
"swactor-datastore",
"swactor-std",
"toml 0.8.23",
]
@ -5030,7 +5003,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@ -5443,6 +5416,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -5451,7 +5425,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"bytes",
"futures-util",
"http",
@ -5797,15 +5771,6 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm"
version = "0.1.0"
dependencies = [
"swactor",
"swactor-std",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@ -5907,6 +5872,15 @@ dependencies = [
"wasmparser 0.244.0",
]
[[package]]
name = "wasm-runtime"
version = "0.1.0"
dependencies = [
"swactor",
"swactor-std",
"wasm-bindgen",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
@ -5926,7 +5900,7 @@ version = "0.221.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
@ -5939,7 +5913,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
@ -5951,7 +5925,7 @@ version = "0.245.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e"
dependencies = [
"bitflags 2.11.0",
"bitflags",
"indexmap",
"semver",
]
@ -5976,7 +5950,7 @@ dependencies = [
"addr2line",
"anyhow",
"async-trait",
"bitflags 2.11.0",
"bitflags",
"bumpalo",
"cc",
"cfg-if",
@ -6849,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
"bitflags",
"indexmap",
"log",
"serde",

View file

@ -2,23 +2,20 @@
members = [
".",
"crates/python",
"crates/wasm",
"crates/wasm/runtime",
"crates/bin-runner",
"crates/simulation",
"crates/runtime-dashboard",
"crates/dashboard",
"crates/distribution",
"crates/std",
"crates/datastore",
"crates/shared-types",
"crates/crypto-wasm",
"crates/swactor-node",
"tests/docker",
"crates/ci",
"crates/local-runner",
"crates/ci-relay",
"xtask",
]
exclude = ["tools/depgraph"]
exclude = ["tools/depgraph", "crates/wasm/crypto"]
[package]
name = "swactor"

View file

@ -1,8 +1,3 @@
FROM rust:1.93-slim AS builder
WORKDIR /build
COPY . .
RUN cargo build --release -p swactor-node
FROM debian:bookworm-slim
COPY --from=builder /build/target/release/swactor /usr/local/bin/
ENTRYPOINT ["swactor"]
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/swactor /swactor
ENTRYPOINT ["/swactor"]

View file

@ -1,19 +0,0 @@
[package]
name = "ci-relay"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "ci-relay"
path = "src/main.rs"
[dependencies]
swactor-ci = { path = "../ci", features = ["local"] }
iroh = "0.96"
tokio = { version = "1", features = ["rt-multi-thread"] }
tiny_http = "0.12"
serde_json = "1"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
clap = { version = "4", features = ["derive"] }

View file

@ -9,13 +9,20 @@ serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1"
# Local runner library dependencies
# Shared optional deps (used by lib local feature and binaries)
tiny_http = { version = "0.12", optional = true }
ureq = { version = "2", features = ["json"], optional = true }
hmac = { version = "0.12", optional = true }
sha2 = { version = "0.10", optional = true }
hex = { version = "0.4", optional = true }
# Binary-only optional deps
iroh = { version = "0.96", optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
clap = { version = "4", features = ["derive"], optional = true }
ctrlc = { version = "3", optional = true }
dashboard = { path = "../dashboard", optional = true }
[features]
default = []
local = [
@ -25,5 +32,29 @@ local = [
"dep:sha2",
"dep:hex",
]
relay = [
"local",
"dep:iroh",
"dep:tokio",
"dep:clap",
]
runner = [
"local",
"dep:iroh",
"dep:tokio",
"dep:clap",
"dep:ctrlc",
"dep:dashboard",
]
[dev-dependencies]
[[bin]]
name = "ci-relay"
path = "src/bin/ci_relay.rs"
required-features = ["relay"]
[[bin]]
name = "local-runner"
path = "src/bin/local_runner.rs"
required-features = ["runner"]

View file

@ -14,7 +14,7 @@ use swactor::actor::ActorAddress;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use runtime_dashboard::ci_collector::{
use dashboard::ci_collector::{
CiSnapshot, CiStatsProvider, JobSnapshot, PipelineSnapshot, ProvisionerStatus,
};
@ -92,22 +92,34 @@ impl CiStatsProvider for LocalCiSnapshotProvider {
}
fn pipeline_to_dashboard(p: &PipelineExecution) -> PipelineSnapshot {
// Convert between swactor_ci types and dashboard::ci_collector types
// (structurally identical but separate to avoid a dependency cycle).
let status: dashboard::ci_collector::PipelineStatus =
serde_json::from_value(serde_json::to_value(&p.status).unwrap()).unwrap();
PipelineSnapshot {
pipeline_id: p.pipeline_id,
pipeline_id: dashboard::ci_collector::PipelineId(p.pipeline_id.0),
pipeline_name: p.pipeline_name.clone(),
repo_owner: p.repo_owner.clone(),
repo_name: p.repo_name.clone(),
commit_sha: p.commit_sha.clone(),
branch: p.branch.clone(),
status: p.status.clone(),
status,
jobs: p
.jobs
.values()
.map(|j| JobSnapshot {
job_id: j.job_id.clone(),
job_name: j.definition.name.clone(),
status: j.status.clone(),
output_line_count: j.output_lines.len(),
.map(|j| {
let job_status: dashboard::ci_collector::JobStatus =
serde_json::from_value(serde_json::to_value(&j.status).unwrap()).unwrap();
JobSnapshot {
job_id: dashboard::ci_collector::JobId {
pipeline_id: dashboard::ci_collector::PipelineId(j.job_id.pipeline_id.0),
job_name: j.job_id.job_name.clone(),
},
job_name: j.definition.name.clone(),
status: job_status,
output_line_count: j.output_lines.len(),
}
})
.collect(),
}
@ -128,7 +140,7 @@ fn main() {
// Optionally start dashboard.
let dash = args.dashboard_port.map(|port| {
let d = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig {
let d = dashboard::start_dashboard(dashboard::DashboardConfig {
port,
..Default::default()
});
@ -138,7 +150,7 @@ fn main() {
// Create 2-thread runtime.
let num_threads = 2;
let collector = runtime_dashboard::collector::StatsCollector::new(num_threads);
let collector = dashboard::collector::StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 256,

View file

@ -1,15 +1,18 @@
[package]
name = "runtime-dashboard"
name = "dashboard"
version = "0.1.0"
edition = "2024"
[dependencies]
swactor = { path = "../..", features = ["serde", "tracing"] }
swactor-std = { path = "../std", default-features = false }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tiny_http = "0.12"
axum = "0.8"
tokio = { version = "1", features = ["net", "rt-multi-thread", "sync", "time"] }
tokio-stream = "0.1"
crossbeam-queue = "0.3.12"
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", optional = true }
@ -17,7 +20,6 @@ distribution = { path = "../distribution", optional = true }
clap = { version = "4", features = ["derive"], optional = true }
ctrlc = "3"
iroh = { version = "0.96", optional = true }
swactor-ci = { path = "../ci", optional = true }
[features]
default = ["distribution"]
@ -26,7 +28,6 @@ distribution = ["dep:distribution"]
node = ["distribution", "dep:clap", "swactor/transport", "tcp"]
tcp = ["distribution/tcp"]
iroh = ["distribution/iroh", "dep:iroh"]
ci = ["dep:swactor-ci"]
[[bin]]
name = "swactor-tui"

View file

@ -5,8 +5,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::{BackoffPolicy, RuntimeConfig};
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::{start_dashboard, DashboardConfig};
use dashboard::collector::StatsCollector;
use dashboard::{start_dashboard, DashboardConfig};
// ---------------------------------------------------------------------------
// Actors
@ -102,7 +102,7 @@ fn run_for(duration: Duration, mut tick: impl FnMut()) {
// Scenarios
// ---------------------------------------------------------------------------
fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_single_actor(dash: &dashboard::DashboardHandle) {
eprintln!(" [1/4] Single-actor bombardment (5s)");
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, 64, 100_000));
@ -122,7 +122,7 @@ fn scenario_single_actor(dash: &runtime_dashboard::DashboardHandle) {
handle.join();
}
fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_multi_actor(dash: &dashboard::DashboardHandle) {
eprintln!(" [2/4] Multi-actor fan-out (5s)");
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, 128, 10_000));
@ -146,7 +146,7 @@ fn scenario_multi_actor(dash: &runtime_dashboard::DashboardHandle) {
handle.join();
}
fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_ring(dash: &dashboard::DashboardHandle) {
eprintln!(" [3/4] Ring topology (5s)");
let ring_size = 100;
let collector = StatsCollector::new(4);
@ -182,7 +182,7 @@ fn scenario_ring(dash: &runtime_dashboard::DashboardHandle) {
handle.join();
}
fn scenario_spawn_storm(dash: &runtime_dashboard::DashboardHandle) {
fn scenario_spawn_storm(dash: &dashboard::DashboardHandle) {
eprintln!(" [4/4] Spawn storm (5s)");
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, 50_000, 1_024));

View file

@ -13,9 +13,9 @@ use distribution::swim::node::NodeAction;
use distribution::swim::probe::SwimConfig;
use distribution::types::NodeId;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::distribution_collector::DistributionStatsProvider;
use runtime_dashboard::{start_dashboard, DashboardConfig};
use dashboard::collector::StatsCollector;
use dashboard::distribution_collector::DistributionStatsProvider;
use dashboard::{start_dashboard, DashboardConfig};
// ── Demo actors ─────────────────────────────────────────────────────────

View file

@ -7,8 +7,8 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig};
use dashboard::collector::StatsCollector;
use dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig};
// ── Demo actors ─────────────────────────────────────────────────────────

View file

@ -135,8 +135,11 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
</div>
<div class="info-card">
<div class="info-row" id="nameRow" style="display:none;">
<div><div class="info-label">Name</div><div class="info-value" id="addrName" style="color:#00bcd4;">—</div></div>
</div>
<div class="info-row">
<div><div class="info-label">Address</div><div class="info-value" id="addrFull">—</div></div>
<div><div class="info-label">Address</div><div class="info-value" id="addrFull" style="font-size:12px;color:#aaa;font-weight:400;">—</div></div>
<div><div class="info-label">Worker</div><div class="info-value" id="addrWorker">—</div></div>
<div><div class="info-label">Status</div><div class="info-value" id="addrStatus">—</div></div>
</div>
@ -237,7 +240,14 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
if (!actor) return;
var hex = formatAddr(actor.address);
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
if (actor.name) {
document.getElementById('addrBreadcrumb').textContent = actor.name;
document.getElementById('nameRow').style.display = '';
document.getElementById('addrName').textContent = actor.name;
} else {
document.getElementById('addrBreadcrumb').textContent = shortAddr(hex);
document.getElementById('nameRow').style.display = 'none';
}
document.getElementById('addrFull').textContent = hex;
document.getElementById('addrWorker').textContent = 'W' + actor.worker_id;

View file

@ -478,7 +478,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
if (filter) {
var hex = addrToHex(a.address).toLowerCase();
var msgType = (a.last_msg_type || '').toLowerCase();
if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0) {
var actorName = (a.name || '').toLowerCase();
if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0 && actorName.indexOf(filter) < 0) {
return false;
}
}
@ -542,8 +543,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
if (a.poisoned) tr.style.opacity = '0.6';
tr.setAttribute('data-addr', fullHex);
var addrCell = a.name
? '<td><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(a.name) + '</span> <span style="color:#555;font-size:10px;">' + escapeHtml(hex) + '</span>' + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>'
: '<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>';
tr.innerHTML =
'<td style="color:#aaa;font-size:11px;">' + escapeHtml(hex) + (a.poisoned ? ' <span style="color:#f44336;font-size:9px;">DEAD</span>' : '') + '</td>' +
addrCell +
'<td>W' + a.worker_id + '</td>' +
'<td>' + a.mailbox_depth + '</td>' +
'<td>' + (a.messages_processed || 0).toLocaleString() + '</td>' +
@ -593,8 +597,14 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
return;
}
document.getElementById('detailAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
document.getElementById('detailFullAddr').innerHTML = '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
var detailAddrText = actor.name
? '<a href="/actor/' + focusedAddrHex + '" style="color:#00bcd4;text-decoration:none;font-weight:600;">' + escapeHtml(actor.name) + '</a>'
: '<a href="/actor/' + focusedAddrHex + '" style="color:#aaa;text-decoration:none;">' + escapeHtml(addrToHex(actor.address)) + '</a>';
document.getElementById('detailAddr').innerHTML = detailAddrText;
var detailFullText = actor.name
? '<span style="color:#00bcd4;font-weight:600;">' + escapeHtml(actor.name) + '</span> <span style="color:#555;font-size:9px;">' + escapeHtml(focusedAddrHex) + '</span>'
: '<a href="/actor/' + focusedAddrHex + '" style="color:#fff;text-decoration:none;">' + escapeHtml(focusedAddrHex) + '</a>';
document.getElementById('detailFullAddr').innerHTML = detailFullText;
document.getElementById('detailWorker').textContent = 'W' + actor.worker_id;
document.getElementById('detailMailbox').textContent = actor.mailbox_depth;
document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString();

View file

@ -14,9 +14,9 @@ use distribution::registry::RegistryConfig;
use distribution::snapshot::DistributionNodeSnapshot;
use distribution::swim::probe::SwimConfig;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::distribution_collector::DistributionStatsProvider;
use runtime_dashboard::{start_dashboard, DashboardConfig};
use dashboard::collector::StatsCollector;
use dashboard::distribution_collector::DistributionStatsProvider;
use dashboard::{start_dashboard, DashboardConfig};
// ── CLI ──────────────────────────────────────────────────────────────────
@ -95,6 +95,7 @@ fn main() {
..Default::default()
});
dash.install_tracing();
dash.start_http_standalone();
// Create actor runtime
let num_threads = 2;
@ -154,7 +155,7 @@ fn run_tcp(
args: Args,
node_config: DistributedNodeConfig,
handle: &swactor::runtime::RuntimeHandle,
dash: &runtime_dashboard::DashboardHandle,
dash: &dashboard::DashboardHandle,
stop: &Arc<AtomicBool>,
) {
use distribution::driver::NodeDriver;
@ -209,7 +210,7 @@ fn run_iroh(
args: Args,
node_config: DistributedNodeConfig,
handle: &swactor::runtime::RuntimeHandle,
dash: &runtime_dashboard::DashboardHandle,
dash: &dashboard::DashboardHandle,
stop: &Arc<AtomicBool>,
) {
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};

View file

@ -1,5 +1,5 @@
use runtime_dashboard::tui::types::RuntimeEndpoint;
use runtime_dashboard::tui::{start_tui_remote, TuiConfig};
use dashboard::tui::types::RuntimeEndpoint;
use dashboard::tui::{start_tui_remote, TuiConfig};
fn main() -> std::io::Result<()> {
let url = std::env::args()

View file

@ -2,7 +2,39 @@
use serde::{Deserialize, Serialize};
use swactor_ci::{JobId, JobStatus, PipelineId, PipelineStatus};
// CI types defined locally to avoid a cyclic dependency with swactor-ci.
/// Unique identifier for a pipeline execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PipelineId(pub u64);
/// Unique identifier for a job within a pipeline.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JobId {
pub pipeline_id: PipelineId,
pub job_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum JobStatus {
Pending,
WaitingForProvisioner,
Provisioning,
Running,
Passed,
Failed { reason: String },
Skipped,
Interrupted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PipelineStatus {
Pending,
Running,
Passed,
Failed,
Error { reason: String },
}
// ─── Stats Provider ─────────────────────────────────────────────────────────

View file

@ -6,6 +6,7 @@
use std::sync::{Arc, RwLock};
use swactor::runtime::Runtime;
use swactor::stats::{ActorInfo, ActorSnapshot, RuntimeStats, StatsHook};
/// Collects per-actor snapshots pushed by worker threads.
@ -48,6 +49,21 @@ impl crate::command::StatsEnricher for StatsCollector {
}
}
/// Enrich actor names from the runtime's StdExtension name registry.
pub fn enrich_names(stats: &mut RuntimeStats, runtime: &Runtime) {
let ext = match runtime.extension() {
Some(ext) => ext,
None => return,
};
let std_ext = match ext.as_any().downcast_ref::<swactor_std::StdExtension>() {
Some(ext) => ext,
None => return,
};
for actor in &mut stats.actor_details {
actor.name = std_ext.resolve_name(&actor.address);
}
}
impl StatsHook for StatsCollector {
fn on_tick(&self, worker_id: usize, snapshots: &[ActorSnapshot]) {
if let Some(slot) = self.slots.get(worker_id) {
@ -60,6 +76,7 @@ impl StatsHook for StatsCollector {
last_msg_type: s.last_msg_type.map(|t| t.to_string()),
messages_processed: s.messages_processed,
poisoned: s.poisoned,
name: None,
message_type_counts: s.message_type_counts.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
}));
}

View file

@ -356,6 +356,14 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
renderWorkerCards(data);
var nameMap = {};
if (data.actor_details) {
data.actor_details.forEach(function(a) {
var h = formatAddr(a.address);
if (a.name) nameMap[h] = a.name;
});
}
var tbody = document.getElementById('actorTableBody');
tbody.innerHTML = '';
if (data.actors) {
@ -372,7 +380,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
hex += '\u2026';
}
var tr = document.createElement('tr');
tr.innerHTML = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td><td>W' + wid + '</td>';
var addrCell;
if (nameMap[hex]) {
addrCell = '<td><a href="/actor/' + hex + '" style="text-decoration:none;"><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(nameMap[hex]) + '</span> <span style="color:#555;font-size:10px;">' + hex + '</span></a></td>';
} else {
addrCell = '<td style="color:#aaa;font-size:11px;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>';
}
tr.innerHTML = addrCell + '<td>W' + wid + '</td>';
tbody.appendChild(tr);
});
if (data.actors.length > 200) {
@ -493,7 +507,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
rows += '<tr><td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>' +
var addrHtml;
if (a.name) {
addrHtml = '<td><a href="/actor/' + hex + '" style="text-decoration:none;"><span style="color:#00bcd4;font-weight:600;">' + escapeHtml(a.name) + '</span> <span style="color:#555;font-size:10px;">' + hex + '</span></a></td>';
} else {
addrHtml = '<td style="color:#aaa;"><a href="/actor/' + hex + '" style="color:#aaa;text-decoration:none;">' + hex + '</a></td>';
}
rows += '<tr>' + addrHtml +
'<td>' + a.mailbox_depth + '</td>' +
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
});

View file

@ -94,6 +94,19 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
flex: 1; overflow-y: auto; font-size: 11px;
}
.relay-badge {
display: inline-block;
background: #14b8a6;
color: #fff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
vertical-align: middle;
letter-spacing: 0.5px;
}
.ego-hint {
position: absolute; bottom: 12px; left: 12px; font-size: 10px;
color: #555; pointer-events: none;
@ -600,6 +613,18 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
ctx.stroke();
}
// Teal ring around relay nodes
var isRelay = (i === 0 && data && data.relay_url)
|| (i > 0 && data && data.members[i-1] && data.members[i-1].relay_url);
if (isRelay) {
ctx.beginPath();
ctx.arc(posX[i], posY[i], nr + 3/vs, 0, Math.PI * 2);
ctx.lineWidth = 2/vs;
ctx.strokeStyle = '#14b8a6';
ctx.globalAlpha = opacity * 0.9;
ctx.stroke();
}
ctx.globalAlpha = 1;
// Label (name for self, short ID for others)
@ -699,9 +724,12 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
var tr = document.createElement('tr');
var idCell = m.node_id.substring(0, 16) + '\u2026';
if (m.label) idCell = m.label + ' <span style="color:#555;">(' + m.node_id.substring(0, 8) + ')</span>';
var relayBadge = m.relay_url
? ' <span class="relay-badge" title="' + m.relay_url + '">RELAY</span>'
: '';
tr.innerHTML =
'<td class="' + cls + '">' + m.state + '</td>' +
'<td style="color:#aaa;font-size:10px;">' + idCell + '</td>' +
'<td style="color:#aaa;font-size:10px;">' + idCell + relayBadge + '</td>' +
'<td>' + m.addr + '</td>' +
'<td>' + m.incarnation + '</td>';
body.appendChild(tr);

View file

@ -253,6 +253,7 @@ mod tests {
fn make_actor(id: u8, worker: usize, depth: usize, msgs: u64) -> ActorInfo {
ActorInfo {
address: ActorAddress([id; 32]),
name: None,
worker_id: worker,
mailbox_depth: depth,
last_msg_type: None,

View file

@ -23,7 +23,6 @@ pub mod distribution_collector;
mod datastore_html;
pub mod datastore_collector;
#[cfg(feature = "ci")]
pub mod ci_collector;
use std::io;
@ -43,6 +42,9 @@ use crate::history::{DashboardHistory, HistoryConfig};
use crate::layer::{now_ms, DashboardLayer, EventStore};
use crate::trace::{RuntimeTrace, TimestampedStats};
/// Peer info sent through the join channel: (public_key, optional_relay_url).
pub type JoinPeerInfo = ([u8; 32], Option<String>);
/// Configuration for the runtime dashboard.
#[derive(Debug, Clone)]
pub struct DashboardConfig {
@ -92,18 +94,19 @@ pub struct DashboardHandle {
runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>,
shutdown_notify: Arc<tokio::sync::Notify>,
stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
history: Arc<DashboardHistory>,
recording: bool,
port: u16,
#[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>>,
datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>>,
#[cfg(feature = "ci")]
ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>>,
dashboard_token: Arc<Mutex<Option<String>>>,
peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<[u8; 32]>>>>,
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>>,
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
}
impl DashboardHandle {
@ -152,23 +155,17 @@ impl DashboardHandle {
}
/// Attach a CI stats provider, enabling the `/api/ci/*` endpoints.
#[cfg(feature = "ci")]
pub fn set_ci(&self, provider: Arc<dyn ci_collector::CiStatsProvider>) {
*self.ci.lock().unwrap() = Some(provider);
}
/// Set a bearer token for protecting mutating dashboard API endpoints.
pub fn set_dashboard_token(&self, token: String) {
*self.dashboard_token.lock().unwrap() = Some(token);
}
/// Attach a peer allow-list for the peer management API.
pub fn set_peer_auth(&self, auth: Arc<Mutex<distribution::peer_auth::PeerAllowList>>) {
*self.peer_auth.lock().unwrap() = Some(auth);
}
/// Set a sender that triggers `driver.join()` when a peer is added via the dashboard.
pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender<[u8; 32]>) {
pub fn set_join_sender(&self, tx: std::sync::mpsc::Sender<JoinPeerInfo>) {
*self.join_sender.lock().unwrap() = Some(tx);
}
@ -180,6 +177,49 @@ impl DashboardHandle {
/// Signal the dashboard to shut down (SSE clients receive "done").
pub fn shutdown(&self) {
self.shutdown.store(true, Ordering::Release);
self.shutdown_notify.notify_waiters();
}
/// Start the HTTP server on the provided tokio handle.
/// Use this when a tokio runtime already exists (e.g. IrohDriver's runtime).
pub fn start_http(&self, handle: tokio::runtime::Handle) {
let state = self.build_app_state();
let port = self.port;
handle.spawn(async move {
server::run_server(state, port).await;
});
}
/// Start the HTTP server on a standalone tokio runtime (1 worker thread).
/// Use this when no external tokio runtime is available (e.g. TCP transport).
pub fn start_http_standalone(&self) {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("failed to create tokio runtime for dashboard HTTP");
let handle = rt.handle().clone();
*self.standalone_rt.lock().unwrap() = Some(rt);
self.start_http(handle);
}
fn build_app_state(&self) -> server::AppState {
server::AppState {
store: Arc::clone(&self.store),
runtime: Arc::clone(&self.runtime),
collector: Arc::clone(&self.collector),
shutdown: Arc::clone(&self.shutdown),
shutdown_notify: Arc::clone(&self.shutdown_notify),
history: Arc::clone(&self.history),
cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()),
#[cfg(feature = "distribution")]
distribution: Arc::clone(&self.distribution),
datastore: Arc::clone(&self.datastore),
datastore_factory: Arc::clone(&self.datastore_factory),
ci: Arc::clone(&self.ci),
peer_auth: Arc::clone(&self.peer_auth),
join_sender: Arc::clone(&self.join_sender),
}
}
/// Save the recorded trace to a JSON file.
@ -207,10 +247,12 @@ impl DashboardHandle {
}
}
/// Start a dashboard server and return a handle.
/// Start a dashboard and return a handle.
///
/// The dashboard starts serving immediately. Call `install_tracing()` to set up
/// the global subscriber, and `set_runtime()` to enable stats polling.
/// The dashboard state is created immediately but the HTTP server is NOT started.
/// Call `start_http()` or `start_http_standalone()` to begin serving.
/// Call `install_tracing()` to set up the global subscriber, and `set_runtime()`
/// to enable stats polling.
pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let store = Arc::new(EventStore::new(
config.event_capacity,
@ -220,6 +262,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let runtime: Arc<Mutex<Option<Arc<Runtime>>>> = Arc::new(Mutex::new(None));
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
@ -233,35 +276,15 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>> =
Arc::new(Mutex::new(None));
#[cfg(feature = "ci")]
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
Arc::new(Mutex::new(None));
let dashboard_token: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>> =
Arc::new(Mutex::new(None));
let join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<[u8; 32]>>>> =
let join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>> =
Arc::new(Mutex::new(None));
server::spawn_http_server(
Arc::clone(&store),
Arc::clone(&runtime),
Arc::clone(&collector),
Arc::clone(&shutdown),
Arc::clone(&history),
config.port,
#[cfg(feature = "distribution")]
Arc::clone(&distribution),
Arc::clone(&datastore),
Arc::clone(&datastore_factory),
#[cfg(feature = "ci")]
Arc::clone(&ci),
Arc::clone(&dashboard_token),
Arc::clone(&peer_auth),
Arc::clone(&join_sender),
);
// Start stats recorder thread when recording is enabled
if config.record {
let rt_ref = Arc::clone(&runtime);
@ -290,25 +313,26 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
});
}
eprintln!("Runtime dashboard at http://localhost:{}", config.port);
let port = config.port;
DashboardHandle {
store,
runtime,
collector,
shutdown,
shutdown_notify,
stats_timeline,
history,
recording: config.record,
port,
#[cfg(feature = "distribution")]
distribution,
datastore,
datastore_factory,
#[cfg(feature = "ci")]
ci,
dashboard_token,
ci,
peer_auth,
join_sender,
standalone_rt: Mutex::new(None),
}
}
@ -321,6 +345,7 @@ pub fn run_with_dashboard(
) -> (RuntimeHandle, DashboardHandle) {
let dash = start_dashboard(dash_config);
dash.install_tracing();
dash.start_http_standalone();
let num_workers = if rt_config.num_threads < 2 { 1 } else { rt_config.num_threads };
let collector = StatsCollector::new(num_workers);
@ -339,7 +364,21 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> {
let trace: RuntimeTrace = serde_json::from_str(&data)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
server::spawn_replay_server(Arc::new(trace), config.port, config.speed);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let state = server::ReplayState {
trace: Arc::new(trace),
speed: config.speed,
};
let port = config.port;
rt.spawn(async move {
server::run_replay_server(state, port).await;
});
eprintln!("Replay dashboard at http://localhost:{}", config.port);
eprintln!("Press Ctrl+C to stop");

View file

@ -0,0 +1,858 @@
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::StreamExt;
use swactor::runtime::Runtime;
use crate::actor_detail_html::ACTOR_DETAIL_HTML;
use crate::actors_html::ACTORS_HTML;
use crate::collector::StatsCollector;
use crate::command::CommandRouter;
use crate::dashboard_html::DASHBOARD_HTML;
use crate::datastore_collector::{DatastoreFactory, DatastoreStatsProvider, ListScope};
use crate::datastore_html::DATASTORE_HTML;
use crate::history::DashboardHistory;
use crate::layer::EventStore;
use crate::topology;
use crate::topology_html::TOPOLOGY_HTML;
use crate::warnings::{WarningConfig, WarningDetector};
#[cfg(feature = "distribution")]
use crate::distribution_collector::DistributionStatsProvider;
#[cfg(feature = "distribution")]
use crate::distribution_html::DISTRIBUTION_HTML;
use crate::ci_collector::CiStatsProvider;
use crate::trace::RuntimeTrace;
/// Format a server-sent event.
fn format_sse(event: &str, data: &str) -> Event {
Event::default().event(event).data(data)
}
// ── Shared application state ────────────────────────────────────────────
#[derive(Clone)]
pub(crate) struct AppState {
pub store: Arc<EventStore>,
pub runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
pub collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
pub shutdown: Arc<AtomicBool>,
pub shutdown_notify: Arc<tokio::sync::Notify>,
pub history: Arc<DashboardHistory>,
pub cmd_router: Arc<CommandRouter>,
#[cfg(feature = "distribution")]
pub distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
pub datastore: Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
pub datastore_factory: Arc<Mutex<Option<Arc<dyn DatastoreFactory>>>>,
pub ci: Arc<Mutex<Option<Arc<dyn CiStatsProvider>>>>,
pub peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
pub join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<crate::JoinPeerInfo>>>>,
}
// ── Router builders ─────────────────────────────────────────────────────
pub(crate) fn build_live_router(state: AppState) -> Router {
let router = Router::new()
.route("/", get(page_dashboard))
.route("/actors", get(page_actors))
.route("/topology", get(page_topology))
.route("/datastore", get(page_datastore))
.route("/events", get(handle_live_sse))
.route("/api/stats", get(handle_stats_api))
.route("/api/history", get(handle_history_api))
.route("/api/topology", get(handle_topology_api))
.route("/api/investigate", get(handle_investigate_api))
.route("/api/datastore", get(handle_datastore_api))
.route("/api/logs", get(handle_logs_api))
.route("/api/datastore/list", get(handle_ds_list))
.route("/api/datastore/get", get(handle_ds_get))
.route("/api/datastore/data", get(handle_ds_data))
.route("/api/datastore/status", get(handle_ds_status))
.route("/api/datastore/put", post(handle_ds_put))
.route("/api/datastore/delete", post(handle_ds_delete))
.route("/api/datastore/start", post(handle_ds_start))
.route("/api/datastore/shutdown", post(handle_ds_shutdown))
.route("/api/peers", get(handle_peers_list))
.route("/api/peers/add", post(handle_peers_add))
.route("/api/peers/remove", post(handle_peers_remove))
.route("/actor/{hex}", get(handle_actor_detail));
#[cfg(feature = "distribution")]
let router = router
.route("/distribution", get(page_distribution))
.route("/api/distribution", get(handle_distribution_api));
let router = router.route("/api/ci/{*rest}", get(handle_ci_api));
router.with_state(state)
}
pub(crate) fn build_replay_router(state: ReplayState) -> Router {
Router::new()
.route("/", get(replay_page_dashboard))
.route("/actors", get(replay_page_actors))
.route("/events", get(handle_replay_sse))
.with_state(state)
}
// ── Server startup ──────────────────────────────────────────────────────
pub(crate) async fn run_server(state: AppState, port: u16) {
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
.await
.expect("failed to bind HTTP server");
let shutdown = state.shutdown_notify.clone();
axum::serve(listener, build_live_router(state))
.with_graceful_shutdown(async move { shutdown.notified().await })
.await
.expect("HTTP server error");
}
pub(crate) async fn run_replay_server(state: ReplayState, port: u16) {
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}"))
.await
.expect("failed to bind HTTP server");
axum::serve(listener, build_replay_router(state))
.await
.expect("HTTP server error");
}
// ── HTML page handlers ──────────────────────────────────────────────────
fn html_response(template: &str, mode: &str) -> Response {
let html = template.replace("__DASHBOARD_MODE__", mode);
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}
async fn page_dashboard() -> Response {
html_response(DASHBOARD_HTML, "live")
}
async fn page_actors() -> Response {
html_response(ACTORS_HTML, "live")
}
async fn page_topology() -> Response {
html_response(TOPOLOGY_HTML, "live")
}
async fn page_datastore() -> Response {
html_response(DATASTORE_HTML, "live")
}
#[cfg(feature = "distribution")]
async fn page_distribution() -> Response {
html_response(DISTRIBUTION_HTML, "live")
}
async fn handle_actor_detail(Path(hex_addr): Path<String>) -> Response {
let html = ACTOR_DETAIL_HTML
.replace("__DASHBOARD_MODE__", "live")
.replace("__ACTOR_ADDR__", &hex_addr);
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}
// ── Live SSE handler ────────────────────────────────────────────────────
async fn handle_live_sse(
State(state): State<AppState>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(32);
tokio::spawn(async move {
let mut cursor: u64 = 0;
let mut warning_detector = WarningDetector::new(WarningConfig::default());
let mut tick_count: u64 = 0;
// Send initial history snapshot so sparklines render immediately
if state.history.sample_count() > 0 {
let json = state.history.worker_history_json();
if tx.send(format_sse("history", &json)).await.is_err() {
return;
}
}
loop {
// Send stats if runtime is available
{
let maybe_rt = state.runtime.lock().unwrap().clone();
if let Some(rt) = maybe_rt {
let mut stats = rt.stats();
if let Some(col) = state.collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
crate::collector::enrich_names(&mut stats, &rt);
state.history.record(&stats);
// Run warning detection
let warnings = warning_detector.check(&stats);
if !warnings.is_empty() {
if let Ok(wjson) = serde_json::to_string(&warnings) {
if tx.send(format_sse("warnings", &wjson)).await.is_err() {
return;
}
}
}
let json = serde_json::to_string(&stats).unwrap();
if tx.send(format_sse("stats", &json)).await.is_err() {
return;
}
// Send topology every 5th tick (~1/sec)
tick_count += 1;
if tick_count % 5 == 0 {
let topo = topology::worker_topology(&stats);
if let Ok(tjson) = serde_json::to_string(&topo) {
if tx.send(format_sse("topology", &tjson)).await.is_err() {
return;
}
}
}
}
}
// Send distribution snapshot if provider is attached
#[cfg(feature = "distribution")]
{
let maybe_dist = state.distribution.lock().unwrap().clone();
if let Some(provider) = maybe_dist {
if let Some(snapshot) = provider.snapshot() {
if let Ok(json) = serde_json::to_string(&snapshot) {
if tx.send(format_sse("distribution", &json)).await.is_err() {
return;
}
}
}
}
}
// Send datastore snapshot if provider is attached
{
let maybe_ds = state.datastore.lock().unwrap().clone();
match maybe_ds {
Some(provider) => {
let is_running = provider.is_running();
let snap_json = provider.snapshot_json().unwrap_or_else(|| "null".into());
let envelope = format!(
r#"{{"is_running":{},"snapshot":{}}}"#,
is_running, snap_json
);
if tx.send(format_sse("datastore", &envelope)).await.is_err() {
return;
}
}
None => {
let envelope = r#"{"is_running":false,"snapshot":null}"#;
if tx.send(format_sse("datastore", envelope)).await.is_err() {
return;
}
}
}
}
// Send CI snapshot if provider is attached
{
let maybe_ci = state.ci.lock().unwrap().clone();
if let Some(provider) = maybe_ci {
let snapshot = provider.snapshot();
if let Ok(json) = serde_json::to_string(&snapshot) {
if tx.send(format_sse("ci", &json)).await.is_err() {
return;
}
}
}
}
// Send new activity events
let (batch, new_cursor) = state.store.read_from(cursor);
if !batch.is_empty() {
let json = serde_json::to_string(&batch).unwrap();
if tx.send(format_sse("activity", &json)).await.is_err() {
return;
}
cursor = new_cursor;
}
if state.shutdown.load(Ordering::Relaxed) {
let _ = tx.send(format_sse("done", "{}")).await;
return;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
});
Sse::new(ReceiverStream::new(rx).map(Ok))
.keep_alive(KeepAlive::default())
}
// ── JSON API handlers ───────────────────────────────────────────────────
fn json_response(json: String) -> Response {
([(header::CONTENT_TYPE, "application/json")], json).into_response()
}
fn json_error(status: StatusCode, msg: &str) -> Response {
let json = serde_json::json!({ "error": msg }).to_string();
(status, [(header::CONTENT_TYPE, "application/json")], json).into_response()
}
async fn handle_stats_api(State(state): State<AppState>) -> Response {
let maybe_rt = state.runtime.lock().unwrap().clone();
let json = match maybe_rt {
Some(rt) => {
let mut stats = rt.stats();
if let Some(col) = state.collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
crate::collector::enrich_names(&mut stats, &rt);
serde_json::to_string(&stats).unwrap()
}
None => "{}".to_string(),
};
json_response(json)
}
async fn handle_investigate_api(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let maybe_rt = state.runtime.lock().unwrap().clone();
let maybe_col = state.collector.lock().unwrap().clone();
let json = match (maybe_rt, maybe_col) {
(Some(rt), Some(col)) => {
let ctx = crate::command::CommandContext::with_enricher(rt, col);
let req = crate::command::from_query_params(&params);
state.cmd_router.dispatch(&req, &ctx).to_json_line()
}
(Some(rt), None) => {
let ctx = crate::command::CommandContext::new(rt);
let req = crate::command::from_query_params(&params);
state.cmd_router.dispatch(&req, &ctx).to_json_line()
}
_ => {
let cmd = params.get("cmd").map(|s| s.as_str()).unwrap_or("help");
serde_json::json!({
"ok": false,
"command": cmd,
"error": "runtime not attached yet"
})
.to_string()
}
};
json_response(json)
}
#[cfg(feature = "distribution")]
async fn handle_distribution_api(State(state): State<AppState>) -> Response {
let json = match state.distribution.lock().unwrap().as_ref() {
Some(provider) => match provider.snapshot() {
Some(snapshot) => serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()),
None => "{}".to_string(),
},
None => serde_json::json!({
"error": "distribution provider not attached"
})
.to_string(),
};
json_response(json)
}
async fn handle_datastore_api(State(state): State<AppState>) -> Response {
let json = match state.datastore.lock().unwrap().as_ref() {
Some(provider) => provider.snapshot_json().unwrap_or_else(|| "{}".into()),
None => serde_json::json!({
"error": "datastore provider not attached"
})
.to_string(),
};
json_response(json)
}
async fn handle_ci_api(
State(state): State<AppState>,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
) -> Response {
use crate::ci_collector;
let path = uri.path();
let route = ci_collector::parse_route(path);
let json = match state.ci.lock().unwrap().as_ref() {
Some(provider) => {
let snapshot = provider.snapshot();
ci_collector::handle_route(&route, &snapshot)
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
}
None => serde_json::json!({
"error": "CI provider not attached"
})
.to_string(),
};
json_response(json)
}
async fn handle_topology_api(State(state): State<AppState>) -> Response {
let maybe_rt = state.runtime.lock().unwrap().clone();
let json = match maybe_rt {
Some(rt) => {
let mut stats = rt.stats();
if let Some(col) = state.collector.lock().unwrap().as_ref() {
col.enrich(&mut stats);
}
let topo = topology::worker_topology(&stats);
serde_json::to_string(&topo).unwrap_or_else(|_| "{}".into())
}
None => "{}".to_string(),
};
json_response(json)
}
async fn handle_logs_api(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let actor = params.get("actor").cloned().unwrap_or_default();
let limit: usize = params
.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(200);
let level = params.get("level").cloned();
let mut events = state.store.read_for_actor(&actor, limit);
// Filter by level if specified
if let Some(ref lvl) = level {
let lvl_upper = lvl.to_uppercase();
events.retain(|e| e.level == lvl_upper);
}
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into());
json_response(json)
}
async fn handle_history_api(State(state): State<AppState>) -> Response {
let json = state.history.worker_history_json();
json_response(json)
}
// ── Datastore CRUD API handlers ─────────────────────────────────────────
fn get_ds_provider(
datastore: &Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
) -> Option<Arc<dyn DatastoreStatsProvider>> {
datastore.lock().unwrap().clone()
}
async fn handle_ds_list(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
let scope = match params.get("scope").map(|s| s.as_str()) {
Some("local") => ListScope::Local,
_ => ListScope::Swarm,
};
let name_filter = params.get("name").map(|s| s.as_str());
match provider.list_objects(name_filter, scope) {
Ok(json) => json_response(json),
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_get(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
let hash = match params.get("hash") {
Some(h) => h.as_str(),
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
};
match provider.get_object(hash) {
Ok(json) => json_response(json),
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_data(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
let hash = match params.get("hash") {
Some(h) => h.as_str(),
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
};
match provider.get_data(hash) {
Ok(data) => {
([(header::CONTENT_TYPE, "application/octet-stream")], data).into_response()
}
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_status(State(state): State<AppState>) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
match provider.node_status() {
Ok(json) => json_response(json),
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_put(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
body: Bytes,
) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
let name = params.get("name").cloned();
match provider.put_data(body.to_vec(), name) {
Ok(json) => json_response(json),
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_delete(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
let hash = match params.get("hash") {
Some(h) => h.as_str(),
None => return json_error(StatusCode::BAD_REQUEST, "missing ?hash= parameter"),
};
match provider.delete_object(hash) {
Ok(json) => json_response(json),
Err(e) if e.contains("not found") => json_error(StatusCode::NOT_FOUND, &e),
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_start(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
// Check if already running
{
let ds = state.datastore.lock().unwrap();
if ds.is_some() {
return json_error(StatusCode::CONFLICT, "datastore already running");
}
}
let fac = match state.datastore_factory.lock().unwrap().clone() {
Some(f) => f,
None => return json_error(StatusCode::NOT_IMPLEMENTED, "no datastore factory configured"),
};
let storage_path = params.get("storage_path").cloned();
match fac.start_datastore(storage_path) {
Ok(provider) => {
*state.datastore.lock().unwrap() = Some(provider);
json_response(r#"{"ok":true}"#.to_string())
}
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
async fn handle_ds_shutdown(State(state): State<AppState>) -> Response {
let provider = match get_ds_provider(&state.datastore) {
Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"),
};
match provider.shutdown_datastore() {
Ok(()) => {
*state.datastore.lock().unwrap() = None;
json_response(r#"{"ok":true}"#.to_string())
}
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
// ── Peer Management API ─────────────────────────────────────────────────
async fn handle_peers_list(State(state): State<AppState>) -> Response {
let maybe_auth = state.peer_auth.lock().unwrap().clone();
match maybe_auth {
Some(auth) => {
let list = auth.lock().unwrap();
let is_open = list.is_open();
let peers: Vec<serde_json::Value> = list
.list_peers()
.iter()
.map(|p| {
serde_json::json!({
"node_id": p.node_id,
"label": p.label,
})
})
.collect();
let json = serde_json::json!({
"mode": if is_open { "open" } else { "allow-list" },
"peers": peers,
})
.to_string();
json_response(json)
}
None => {
let json = serde_json::json!({
"mode": "open",
"peers": [],
})
.to_string();
json_response(json)
}
}
}
async fn handle_peers_add(State(state): State<AppState>, body: String) -> Response {
let maybe_auth = state.peer_auth.lock().unwrap().clone();
let auth = match maybe_auth {
Some(a) => a,
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
};
let parsed: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
};
let node_id_str = match parsed.get("node_id").and_then(|v| v.as_str()) {
Some(s) => s,
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
};
let label = parsed
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Accept hex (64 chars) or base58 (~44 chars)
let bytes: [u8; 32] = if let Some(b) = distribution::identity::hex_decode(node_id_str) {
match b.try_into() {
Ok(arr) => arr,
Err(_) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid node_id (hex decoded to wrong length)",
);
}
}
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
arr
} else {
return json_error(
StatusCode::BAD_REQUEST,
"invalid node_id (expected 64-char hex or base58)",
);
};
let relay_url = parsed
.get("relay_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let node_id = distribution::types::NodeId(bytes);
let mut list = auth.lock().unwrap();
list.add_peer(node_id, label);
if let Err(e) = list.save() {
eprintln!("warning: failed to persist peers.json: {e}");
}
drop(list);
// Trigger a SWIM join for the newly added peer
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
let _ = tx.send((bytes, relay_url));
}
json_response(r#"{"ok":true}"#.to_string())
}
async fn handle_peers_remove(State(state): State<AppState>, body: String) -> Response {
let maybe_auth = state.peer_auth.lock().unwrap().clone();
let auth = match maybe_auth {
Some(a) => a,
None => return json_error(StatusCode::BAD_REQUEST, "peer auth not configured"),
};
let parsed: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => return json_error(StatusCode::BAD_REQUEST, &format!("invalid JSON: {e}")),
};
let node_id_hex = match parsed.get("node_id").and_then(|v| v.as_str()) {
Some(s) => s,
None => return json_error(StatusCode::BAD_REQUEST, "missing node_id field"),
};
let bytes = match distribution::identity::hex_decode(node_id_hex) {
Some(b) if b.len() == 32 => b,
_ => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid node_id hex (must be 64 hex chars)",
);
}
};
let node_id = distribution::types::NodeId(bytes.try_into().unwrap());
let mut list = auth.lock().unwrap();
list.remove_peer(&node_id);
if let Err(e) = list.save() {
eprintln!("warning: failed to persist peers.json: {e}");
}
json_response(r#"{"ok":true}"#.to_string())
}
// ── Replay server ───────────────────────────────────────────────────────
#[derive(Clone)]
pub(crate) struct ReplayState {
pub trace: Arc<RuntimeTrace>,
pub speed: f64,
}
async fn replay_page_dashboard() -> Response {
html_response(DASHBOARD_HTML, "replay")
}
async fn replay_page_actors() -> Response {
html_response(ACTORS_HTML, "replay")
}
async fn handle_replay_sse(
State(state): State<ReplayState>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<Event>(32);
tokio::spawn(async move {
let trace = &state.trace;
let speed = state.speed;
// Send replay metadata
let meta = serde_json::json!({
"total_events": trace.events.len(),
"total_stats": trace.stats_timeline.len(),
"speed": speed,
});
if tx.send(format_sse("replay_meta", &meta.to_string())).await.is_err() {
return;
}
// Find the earliest timestamp across events and stats
let base_time = trace
.events
.first()
.map(|e| e.timestamp_ms)
.into_iter()
.chain(trace.stats_timeline.first().map(|s| s.timestamp_ms))
.min()
.unwrap_or(0);
let playback_start = tokio::time::Instant::now();
let mut event_idx = 0;
let mut stats_idx = 0;
loop {
let elapsed_ms = (playback_start.elapsed().as_millis() as f64 * speed) as u64;
let virtual_time = base_time + elapsed_ms;
// Batch events up to virtual_time
let mut batch = Vec::new();
while event_idx < trace.events.len()
&& trace.events[event_idx].timestamp_ms <= virtual_time
{
batch.push(trace.events[event_idx].clone());
event_idx += 1;
}
if !batch.is_empty() {
let json = serde_json::to_string(&batch).unwrap();
if tx.send(format_sse("activity", &json)).await.is_err() {
return;
}
}
// Send stats snapshots up to virtual_time
while stats_idx < trace.stats_timeline.len()
&& trace.stats_timeline[stats_idx].timestamp_ms <= virtual_time
{
let json =
serde_json::to_string(&trace.stats_timeline[stats_idx].stats).unwrap();
if tx.send(format_sse("stats", &json)).await.is_err() {
return;
}
stats_idx += 1;
}
// Send progress
let total = trace.events.len() + trace.stats_timeline.len();
let done_count = event_idx + stats_idx;
let progress = if total > 0 {
done_count as f64 / total as f64
} else {
1.0
};
let progress_json = serde_json::json!({ "progress": progress });
if tx
.send(format_sse("replay_progress", &progress_json.to_string()))
.await
.is_err()
{
return;
}
// Check if replay is complete
if event_idx >= trace.events.len()
&& stats_idx >= trace.stats_timeline.len()
{
let _ = tx.send(format_sse("done", "{}")).await;
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
});
Sse::new(ReceiverStream::new(rx).map(Ok))
.keep_alive(KeepAlive::default())
}

View file

@ -34,6 +34,7 @@ pub struct ActorRow {
pub last_msg_type: Option<String>,
pub messages_processed: u64,
pub poisoned: bool,
pub name: Option<String>,
/// Per-actor mailbox sparkline (from local ring buffer).
pub sparkline_mailbox: Vec<u64>,
/// Per-actor message rate sparkline.
@ -191,7 +192,8 @@ impl App {
let addr = format!("{}", r.address).to_lowercase();
let msg = r.last_msg_type.as_deref().unwrap_or("").to_lowercase();
let worker = format!("w{}", r.worker_id);
addr.contains(&q) || msg.contains(&q) || worker.contains(&q)
let name = r.name.as_deref().unwrap_or("").to_lowercase();
addr.contains(&q) || msg.contains(&q) || worker.contains(&q) || name.contains(&q)
}).collect()
}
}
@ -335,6 +337,7 @@ impl App {
last_msg_type: a.last_msg_type.clone(),
messages_processed: a.messages_processed,
poisoned: a.poisoned,
name: a.name.clone(),
sparkline_rates: rates_buf.iter().copied().collect(),
sparkline_mailbox: mbox_buf.iter().copied().collect(),
message_type_counts: a.message_type_counts.clone(),

View file

@ -107,6 +107,7 @@ fn run_loop(
// Initial stats poll
let mut stats = runtime.stats();
collector.enrich(&mut stats);
crate::collector::enrich_names(&mut stats, &runtime);
app.update(stats);
loop {
@ -116,6 +117,7 @@ fn run_loop(
Ok(AppEvent::Tick) => {
let mut stats = runtime.stats();
collector.enrich(&mut stats);
crate::collector::enrich_names(&mut stats, &runtime);
app.update(stats);
#[cfg(feature = "distribution")]
if let Some(ref provider) = distribution {

View file

@ -306,21 +306,30 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
}
fn actor_row_cells(a: &super::app::ActorRow) -> Row<'static> {
let addr = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
let addr_short = if let Some(ref name) = a.name {
if a.poisoned {
format!("!{}", name)
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
name.clone()
}
} else {
addr
let addr = format!("{}", a.address);
if a.poisoned {
if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
}
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
} else {
addr
}
};
let addr_style = if a.poisoned {
Style::default().fg(Color::Red)
} else if a.name.is_some() {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
@ -465,21 +474,30 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta
let rows: Vec<Row> = focused_rows
.iter()
.map(|a| {
let addr = format!("{}", a.address);
let addr_short = if a.poisoned {
let s = if addr.len() > 16 {
format!("!{}...", &addr[..14])
let addr_short = if let Some(ref name) = a.name {
if a.poisoned {
format!("!{}", name)
} else {
format!("!{}", addr)
};
s
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
name.clone()
}
} else {
addr
let addr = format!("{}", a.address);
if a.poisoned {
if addr.len() > 16 {
format!("!{}...", &addr[..14])
} else {
format!("!{}", addr)
}
} else if addr.len() > 18 {
format!("{}...", &addr[..16])
} else {
addr
}
};
let addr_style = if a.poisoned {
Style::default().fg(Color::Red)
} else if a.name.is_some() {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
@ -563,32 +581,40 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
.map(|s| short_type_name(Some(s)))
.unwrap_or_else(|| "\u{2014}".to_string());
let info_lines = vec![
Line::from(vec![
let mut info_lines = Vec::new();
if let Some(ref name) = actor.name {
info_lines.push(Line::from(vec![
Span::styled(" Name: ", Style::default().fg(Color::DarkGray)),
Span::styled(name.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(" Address: ", Style::default().fg(Color::DarkGray)),
Span::styled(addr_str, Style::default().fg(Color::White)),
]));
} else {
info_lines.push(Line::from(vec![
Span::styled(" Address: ", Style::default().fg(Color::DarkGray)),
Span::styled(addr_str, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)),
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(actor.messages_processed),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor.mailbox_depth),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)),
Span::styled(msg_type, Style::default().fg(Color::Green)),
]),
];
]));
}
info_lines.push(Line::from(vec![
Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)),
Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
]));
info_lines.push(Line::from(vec![
Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format_num(actor.messages_processed),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}", actor.mailbox_depth),
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
),
Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)),
Span::styled(msg_type, Style::default().fg(Color::Green)),
]));
let info_block = Block::default()
.borders(Borders::ALL)
@ -782,7 +808,7 @@ fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) {
match &app.distribution {
Some(d) => (
&d.node_id[..d.node_id.len().min(16)],
d.listen_addr.as_str(),
d.listen_addr.as_deref().unwrap_or("—"),
d.alive_count,
d.suspect_count,
d.dead_count,
@ -863,7 +889,7 @@ fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, are
Row::new(vec![
Cell::from(m.state.clone()).style(state_style),
Cell::from(id_short),
Cell::from(m.addr.clone()),
Cell::from(m.addr.clone().unwrap_or_default()),
Cell::from(format!("{}", m.incarnation)),
])
})

View file

@ -227,6 +227,7 @@ mod tests {
fn make_actor(id: u8, depth: usize, msgs: u64, poisoned: bool) -> ActorInfo {
ActorInfo {
address: ActorAddress([id; 32]),
name: None,
worker_id: 0,
mailbox_depth: depth,
last_msg_type: None,

View file

@ -7,7 +7,7 @@ use std::sync::Arc;
use swactor::actor::ActorInterface;
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
use runtime_dashboard::command::{
use dashboard::command::{
from_query_params, parse_line, CommandContext, CommandResponse, CommandRouter,
};
@ -310,9 +310,9 @@ fn from_query_params_defaults_to_help() {
#[test]
fn custom_command_handler() {
struct PingCommand;
impl runtime_dashboard::command::CommandHandler for PingCommand {
fn meta(&self) -> runtime_dashboard::command::CommandMeta {
runtime_dashboard::command::CommandMeta {
impl dashboard::command::CommandHandler for PingCommand {
fn meta(&self) -> dashboard::command::CommandMeta {
dashboard::command::CommandMeta {
name: "ping",
description: "Respond with pong",
usage: "ping",

View file

@ -1,8 +1,8 @@
use std::sync::Arc;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::layer::{DashboardEvent, EventStore};
use runtime_dashboard::trace::RuntimeTrace;
use dashboard::collector::StatsCollector;
use dashboard::layer::{DashboardEvent, EventStore};
use dashboard::trace::RuntimeTrace;
use swactor::actor::ActorAddress;
use swactor::stats::{ActorSnapshot, StatsHook};

View file

@ -10,33 +10,23 @@ shared-types = { path = "../shared-types" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
blake3 = "1"
tiny_http = { version = "0.12", optional = true }
tiny_http = "0.12"
clap = { version = "4", features = ["derive"], optional = true }
ureq = { version = "2", features = ["json"], optional = true }
getrandom = { version = "0.2", optional = true }
ctrlc = { version = "3", optional = true }
runtime-dashboard = { path = "../runtime-dashboard", optional = true }
toml = { version = "0.8", optional = true }
dashboard = { path = "../dashboard" }
swactor-std = { path = "../std" }
[dev-dependencies]
serde_json = "1"
proptest = "1"
tempfile = "3"
swactor = { path = "../.." }
swactor-std = { path = "../std" }
ureq = { version = "2", features = ["json"] }
tiny_http = "0.12"
runtime-dashboard = { path = "../runtime-dashboard" }
[features]
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"]
cli = ["dep:clap", "dep:ureq", "dep:getrandom"]
[[bin]]
name = "swactor-store-node"
path = "src/bin/store_node.rs"
required-features = ["node"]
[[bin]]
name = "swactor-store"
path = "src/bin/store_cli.rs"

View file

@ -1,361 +0,0 @@
//! swactor-store-node — standalone datastore node with HTTP API.
//!
//! Starts the actor runtime, spawns datastore actors, and serves
//! a REST API for external tools (the `swactor-store` CLI).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use clap::Parser;
use serde::Deserialize;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
use swactor_datastore::api::start_api_server;
use swactor_datastore::auth::{AccessControlList, AuthzEngine};
use swactor_datastore::messages::{GatewayMsg, MetadataMsg};
use swactor_datastore::metrics::DatastoreMetrics;
use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend};
use swactor_datastore::DatastoreConfig;
use distribution::identity::{hex_encode, load_or_generate_keypair};
use distribution::types::NodeId;
#[derive(Parser)]
#[command(name = "swactor-store-node", about = "Swactor distributed datastore node")]
struct Args {
/// Path to a TOML config file
#[arg(long)]
config: Option<std::path::PathBuf>,
/// HTTP API port
#[arg(long)]
port: Option<u16>,
/// Storage directory (omit for in-memory)
#[arg(long)]
storage_path: Option<String>,
/// Dashboard HTTP port (omit to disable dashboard)
#[arg(long)]
dashboard_port: Option<u16>,
/// Chunk size in bytes
#[arg(long)]
chunk_size: Option<u32>,
/// GC interval in ticks (each tick is ~100ms)
#[arg(long)]
gc_interval: Option<u64>,
/// Dissemination interval in ticks
#[arg(long)]
disseminate_interval: Option<u64>,
/// Enable auth (generates owner keypair if needed)
#[arg(long)]
auth: bool,
/// Directory for owner.key.json + acl.json (default: "auth")
#[arg(long, default_value = "auth")]
auth_dir: String,
}
#[derive(Deserialize, Default)]
struct NodeConfig {
port: Option<u16>,
storage_path: Option<String>,
dashboard_port: Option<u16>,
chunk_size: Option<u32>,
gc_interval: Option<u64>,
disseminate_interval: Option<u64>,
}
/// Resolved configuration with CLI > config file > defaults applied.
struct ResolvedConfig {
port: u16,
storage_path: Option<String>,
dashboard_port: Option<u16>,
chunk_size: u32,
gc_interval: u64,
disseminate_interval: u64,
}
// Key file helpers are provided by distribution::identity
fn resolve_config(args: &Args) -> ResolvedConfig {
let file_cfg = match &args.config {
Some(path) => {
let contents = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read config file {}: {e}", path.display()));
toml::from_str::<NodeConfig>(&contents)
.unwrap_or_else(|e| panic!("failed to parse config file {}: {e}", path.display()))
}
None => NodeConfig::default(),
};
ResolvedConfig {
port: args.port.or(file_cfg.port).unwrap_or(9091),
storage_path: args.storage_path.clone().or(file_cfg.storage_path),
dashboard_port: args.dashboard_port.or(file_cfg.dashboard_port),
chunk_size: args.chunk_size.or(file_cfg.chunk_size).unwrap_or(1_048_576),
gc_interval: args.gc_interval.or(file_cfg.gc_interval).unwrap_or(1000),
disseminate_interval: args.disseminate_interval.or(file_cfg.disseminate_interval).unwrap_or(50),
}
}
fn main() {
let args = Args::parse();
let cfg = resolve_config(&args);
let stop = Arc::new(AtomicBool::new(false));
// Signal handler — double Ctrl-C forces immediate exit
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || {
if stop.load(Ordering::Relaxed) {
eprintln!("\nForced exit.");
std::process::exit(1);
}
stop.store(true, Ordering::Relaxed);
})
.expect("failed to set signal handler");
}
// Optionally start dashboard
let dash = cfg.dashboard_port.map(|port| {
let d = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig {
port,
..Default::default()
});
d.install_tracing();
d
});
// Create runtime
let num_threads = 2;
let collector = runtime_dashboard::collector::StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 1024,
channel_buffer_size: 2000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
// Generate or load node identity
let (node_id, owner_keypair) = if args.auth {
let auth_dir = std::path::PathBuf::from(&args.auth_dir);
std::fs::create_dir_all(&auth_dir).expect("failed to create auth directory");
let key_path = auth_dir.join("owner.key.json");
let keypair = load_or_generate_keypair(&key_path);
let nid = keypair.node_id();
eprintln!(
"Auth enabled — owner key: {}",
hex_encode(&nid.0)
);
eprintln!("Key file: {}", key_path.display());
(nid, Some((keypair, auth_dir)))
} else {
let node_id = {
let mut bytes = [0u8; 32];
for (i, b) in std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
.to_le_bytes()
.iter()
.enumerate()
{
bytes[i % 32] ^= *b;
}
// Mix in process id for uniqueness
let pid = std::process::id();
for (i, b) in pid.to_le_bytes().iter().enumerate() {
bytes[i + 16] ^= *b;
}
NodeId(bytes)
};
(node_id, None)
};
// Datastore config
let config = DatastoreConfig {
chunk_size: cfg.chunk_size,
storage_path: cfg
.storage_path
.as_ref()
.map(|s| s.into())
.unwrap_or_else(|| "datastore".into()),
gc_interval: cfg.gc_interval,
..Default::default()
};
// Create storage backend
let backend: Box<dyn swactor_datastore::StorageBackend> = match &cfg.storage_path {
Some(path) => {
let p = std::path::PathBuf::from(path);
std::fs::create_dir_all(&p).expect("failed to create storage directory");
Box::new(FilesystemBackend::new(p))
}
None => Box::new(InMemoryBackend::new()),
};
// Spawn actors before starting runtime threads
let blob_store_addr = rt
.spawn(BlobStoreActor::new(backend))
.expect("failed to spawn BlobStoreActor");
let mut metadata = MetadataActor::new(node_id, &config);
metadata.set_blob_store(blob_store_addr);
let metadata_addr = rt
.spawn(metadata)
.expect("failed to spawn MetadataActor");
let datastore_node = DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config);
let datastore_addr = rt
.spawn(datastore_node)
.expect("failed to spawn DatastoreNode");
// Spawn GatewayActor if auth is enabled
let gateway_addr = if let Some((_, ref auth_dir)) = owner_keypair {
let acl_path = auth_dir.join("acl.json");
let acl = AccessControlList::load_or_create(&acl_path, node_id)
.expect("failed to load/create ACL");
let engine = AuthzEngine::new(acl);
let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path));
let addr = rt
.spawn(gateway)
.expect("failed to spawn GatewayActor");
Some(addr)
} else {
None
};
// Start runtime
let handle = rt.run().expect("failed to start runtime");
// Load persisted entries from storage
{
let inbox = handle
.runtime
.new_inbox::<swactor_datastore::DatastoreResponse>()
.expect("failed to create inbox");
let _ = handle.runtime.send_to(
blob_store_addr,
swactor_datastore::BlobStoreMsg::LoadAll {
reply_to: *inbox.addr(),
},
);
// Poll for response (up to 5 seconds)
let start = std::time::Instant::now();
let mut loaded = false;
while start.elapsed() < Duration::from_secs(5) {
if let Some(resp) = inbox.try_recv() {
match resp {
swactor_datastore::DatastoreResponse::LoadedAll { entries } => {
let n = entries.len();
let _ = handle.runtime.send_to(
metadata_addr,
swactor_datastore::MetadataMsg::BulkLoad { entries },
);
if n > 0 {
eprintln!("Loaded {n} entries from storage");
}
loaded = true;
}
swactor_datastore::DatastoreResponse::Error { reason } => {
eprintln!("Warning: failed to load entries: {reason}");
loaded = true;
}
_ => {}
}
break;
}
thread::sleep(Duration::from_millis(1));
}
if !loaded {
eprintln!("Warning: timed out loading entries from storage");
}
}
// Create datastore metrics
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
let metrics = Arc::new(DatastoreMetrics::new());
metrics.set_node_id(node_hex.clone());
if let Some(ref d) = dash {
d.set_runtime(handle.runtime.clone(), collector);
d.set_datastore(Arc::clone(&metrics) as Arc<dyn runtime_dashboard::datastore_collector::DatastoreStatsProvider>);
}
// Start HTTP API
let (api_shutdown, _peers) = start_api_server(
handle.runtime.clone(),
datastore_addr,
metadata_addr,
blob_store_addr,
gateway_addr,
cfg.port,
Arc::clone(&metrics),
);
eprintln!("──────────────────────────────────────");
eprintln!(" swactor-store node {}", &node_hex[..16]);
eprintln!(" API: http://0.0.0.0:{}", cfg.port);
if let Some(port) = cfg.dashboard_port {
eprintln!(" Dashboard: http://0.0.0.0:{port}");
}
if cfg.storage_path.is_some() {
eprintln!(" Storage: {} (filesystem)", cfg.storage_path.as_ref().unwrap());
} else {
eprintln!(" Storage: in-memory");
}
if owner_keypair.is_some() {
eprintln!(" Auth: enabled (owner {})", &node_hex[..16]);
} else {
eprintln!(" Auth: disabled");
}
eprintln!("──────────────────────────────────────");
// Main loop
let mut round: u64 = 0;
while !stop.load(Ordering::Relaxed) {
round += 1;
if round % cfg.gc_interval == 0 {
let _ = handle
.runtime
.send_to(metadata_addr, MetadataMsg::GcTick);
if let Some(gw) = gateway_addr {
let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick);
}
}
if round % cfg.disseminate_interval == 0 {
let _ = handle
.runtime
.send_to(metadata_addr, MetadataMsg::DisseminateTick);
}
thread::sleep(Duration::from_millis(100));
}
eprintln!("\nShutting down...");
api_shutdown.store(true, Ordering::Relaxed);
handle.shutdown();
if let Some(d) = dash {
d.shutdown();
}
// Brief pause for threads to flush I/O, then exit.
// No join — cargo run already died from SIGINT so there's
// no parent waiting on us; just exit cleanly.
thread::sleep(Duration::from_millis(50));
eprintln!("Shutdown complete.");
std::process::exit(0);
}

View file

@ -3,21 +3,24 @@
//! and lifecycle management without depending on `swactor-datastore` types.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime};
use swactor_std::RuntimeNaming;
use distribution::types::NodeId;
use runtime_dashboard::datastore_collector::{
use dashboard::datastore_collector::{
DatastoreFactory, DatastoreStatsProvider, ListScope,
};
use crate::actors::{BlobStoreActor, DatastoreNode, MetadataActor};
use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
use crate::auth::{AccessControlList, AuthzEngine};
use crate::chunking::reassemble_blob;
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, MetadataMsg};
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg};
use crate::metrics::DatastoreMetrics;
use crate::storage::{FilesystemBackend, InMemoryBackend};
use crate::types::{ContentHash, DatastoreConfig};
@ -337,34 +340,71 @@ impl DatastoreFactory for DatastoreNodeFactory {
storage_path: Option<String>,
) -> Result<Arc<dyn DatastoreStatsProvider>, String> {
// Generate a unique node ID
let node_id = {
let mut bytes = [0u8; 32];
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
for (i, b) in nanos.to_le_bytes().iter().enumerate() {
bytes[i % 32] ^= *b;
}
let pid = std::process::id();
for (i, b) in pid.to_le_bytes().iter().enumerate() {
bytes[i + 16] ^= *b;
}
NodeId(bytes)
};
let node_id = generate_node_id();
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
let config = DatastoreConfig {
chunk_size: self.default_chunk_size,
storage_path: storage_path
let group = DatastoreGroup::spawn(
Arc::clone(&self.runtime),
DatastoreGroupConfig {
node_id,
node_id_hex: node_hex,
chunk_size: self.default_chunk_size,
storage_path,
auth: None,
gc_interval: 1000,
disseminate_interval: 50,
},
)?;
Ok(group.bridge().clone())
}
}
// ─── DatastoreGroup ─────────────────────────────────────────────────────────
/// Configuration for spawning a complete datastore actor group.
pub struct DatastoreGroupConfig {
pub node_id: NodeId,
pub node_id_hex: String,
pub chunk_size: u32,
pub storage_path: Option<String>,
pub auth: Option<DatastoreAuthConfig>,
pub gc_interval: u64,
pub disseminate_interval: u64,
}
/// Auth configuration for the datastore gateway.
pub struct DatastoreAuthConfig {
pub auth_dir: PathBuf,
}
/// Owns the full lifecycle of a datastore actor group: BlobStore, Metadata,
/// DatastoreNode, and optional GatewayActor.
pub struct DatastoreGroup {
metadata_addr: ActorAddress,
gateway_addr: Option<ActorAddress>,
bridge: Arc<dyn DatastoreStatsProvider>,
gc_interval: u64,
disseminate_interval: u64,
runtime: Arc<Runtime>,
}
impl DatastoreGroup {
/// Spawn all datastore actors, wire them together, and register names.
pub fn spawn(runtime: Arc<Runtime>, config: DatastoreGroupConfig) -> Result<Self, String> {
let ds_config = DatastoreConfig {
chunk_size: config.chunk_size,
storage_path: config
.storage_path
.as_ref()
.map(|s| s.into())
.unwrap_or_else(|| "datastore".into()),
..Default::default()
};
let backend: Box<dyn crate::StorageBackend> = match &storage_path {
let backend: Box<dyn crate::StorageBackend> = match &config.storage_path {
Some(path) => {
let p = std::path::PathBuf::from(path);
let p = PathBuf::from(path);
std::fs::create_dir_all(&p)
.map_err(|e| format!("failed to create storage directory: {e}"))?;
Box::new(FilesystemBackend::new(p))
@ -372,36 +412,108 @@ impl DatastoreFactory for DatastoreNodeFactory {
None => Box::new(InMemoryBackend::new()),
};
let blob_store_addr = self
.runtime
let blob_store_addr = runtime
.spawn(BlobStoreActor::new(backend))
.map_err(|e| format!("failed to spawn BlobStoreActor: {e}"))?;
let _ = runtime.register_name("BlobStore", blob_store_addr);
let mut metadata = MetadataActor::new(node_id, &config);
let mut metadata = MetadataActor::new(config.node_id, &ds_config);
metadata.set_blob_store(blob_store_addr);
let metadata_addr = self
.runtime
let metadata_addr = runtime
.spawn(metadata)
.map_err(|e| format!("failed to spawn MetadataActor: {e}"))?;
let _ = runtime.register_name("Metadata", metadata_addr);
let datastore_node = DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config);
let datastore_addr = self
.runtime
let datastore_node =
DatastoreNode::new(config.node_id, blob_store_addr, metadata_addr, ds_config);
let datastore_addr = runtime
.spawn(datastore_node)
.map_err(|e| format!("failed to spawn DatastoreNode: {e}"))?;
let _ = runtime.register_name("Datastore", datastore_addr);
// Spawn GatewayActor if auth is configured
let gateway_addr = if let Some(auth_cfg) = &config.auth {
std::fs::create_dir_all(&auth_cfg.auth_dir)
.map_err(|e| format!("failed to create auth directory: {e}"))?;
let acl_path = auth_cfg.auth_dir.join("acl.json");
let acl = AccessControlList::load_or_create(&acl_path, config.node_id)
.map_err(|e| format!("failed to load/create ACL: {e}"))?;
let engine = AuthzEngine::new(acl);
let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path));
let addr = runtime
.spawn(gateway)
.map_err(|e| format!("failed to spawn GatewayActor: {e}"))?;
let _ = runtime.register_name("Gateway", addr);
eprintln!("Auth: enabled (owner {})", &config.node_id_hex[..16]);
Some(addr)
} else {
eprintln!("Auth: disabled");
None
};
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
let metrics = Arc::new(DatastoreMetrics::new());
metrics.set_node_id(node_hex);
metrics.set_node_id(config.node_id_hex.clone());
let bridge = DatastoreBridge::new(
let bridge: Arc<dyn DatastoreStatsProvider> = Arc::new(DatastoreBridge::new(
metrics,
Arc::clone(&self.runtime),
Arc::clone(&runtime),
datastore_addr,
metadata_addr,
blob_store_addr,
);
));
Ok(Arc::new(bridge))
if config.storage_path.is_some() {
eprintln!(
"Datastore: persistent ({})",
config.storage_path.as_ref().unwrap()
);
} else {
eprintln!("Datastore: in-memory");
}
Ok(Self {
metadata_addr,
gateway_addr,
bridge,
gc_interval: config.gc_interval,
disseminate_interval: config.disseminate_interval,
runtime,
})
}
/// Send periodic ticks to the datastore actors based on the current round.
pub fn tick(&self, round: u64) {
if round % self.gc_interval == 0 {
let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick);
if let Some(gw) = self.gateway_addr {
let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick);
}
}
if round % self.disseminate_interval == 0 {
let _ = self
.runtime
.send_to(self.metadata_addr, MetadataMsg::DisseminateTick);
}
}
/// Access the bridge (as a trait object for the dashboard).
pub fn bridge(&self) -> &Arc<dyn DatastoreStatsProvider> {
&self.bridge
}
}
fn generate_node_id() -> NodeId {
let mut bytes = [0u8; 32];
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
for (i, b) in nanos.to_le_bytes().iter().enumerate() {
bytes[i % 32] ^= *b;
}
let pid = std::process::id();
for (i, b) in pid.to_le_bytes().iter().enumerate() {
bytes[i + 16] ^= *b;
}
NodeId(bytes)
}

View file

@ -6,11 +6,8 @@ pub mod actors;
pub mod auth;
pub mod cli;
pub mod metrics;
#[cfg(feature = "node")]
pub mod api;
#[cfg(feature = "node")]
pub mod ui_html;
#[cfg(feature = "node")]
pub mod bridge;
pub use types::{ChunkRef, ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest};
@ -18,3 +15,4 @@ pub use messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMs
pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError};
pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend};
pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor};
pub use bridge::{DatastoreGroup, DatastoreGroupConfig, DatastoreAuthConfig};

View file

@ -186,10 +186,9 @@ fn now_ms() -> u64 {
.as_millis() as u64
}
// ── Dashboard integration (only when runtime-dashboard is available) ────────
// ── Dashboard integration ────────────────────────────────────────────────────
#[cfg(feature = "node")]
impl runtime_dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics {
impl dashboard::datastore_collector::DatastoreStatsProvider for DatastoreMetrics {
fn snapshot_json(&self) -> Option<String> {
let snap = self.snapshot();
serde_json::to_string(&snap).ok()

View file

@ -1,5 +1,3 @@
#![cfg(feature = "node")]
//! Integration test: spins up a real datastore node with HTTP API and exercises
//! the full CRUD lifecycle over HTTP.
@ -34,7 +32,7 @@ fn http_crud_lifecycle() {
let base = format!("http://127.0.0.1:{port}");
// Set up runtime with worker threads (needed for HTTP server)
let collector = runtime_dashboard::collector::StatsCollector::new(2);
let collector = dashboard::collector::StatsCollector::new(2);
let mut rt = Runtime::new(RuntimeConfig {
num_threads: 2,
max_actors: 1024,

View file

@ -1,5 +1,3 @@
#![cfg(feature = "node")]
//! End-to-end integration test: spins up a datastore node with an HTTP API
//! **and** a runtime dashboard, performs CRUD over HTTP, then verifies:
//!
@ -46,12 +44,12 @@ fn dashboard_reflects_datastore_operations() {
// ── Infrastructure: runtime + actors + dashboard + API ──────────────
let dash = runtime_dashboard::start_dashboard(runtime_dashboard::DashboardConfig {
let dash = dashboard::start_dashboard(dashboard::DashboardConfig {
port: dash_port,
..Default::default()
});
let collector = runtime_dashboard::collector::StatsCollector::new(2);
let collector = dashboard::collector::StatsCollector::new(2);
let mut rt = Runtime::new(RuntimeConfig {
num_threads: 2,
max_actors: 1024,
@ -86,7 +84,7 @@ fn dashboard_reflects_datastore_operations() {
dash.set_runtime(handle.runtime.clone(), collector);
dash.set_datastore(
Arc::clone(&metrics)
as Arc<dyn runtime_dashboard::datastore_collector::DatastoreStatsProvider>,
as Arc<dyn dashboard::datastore_collector::DatastoreStatsProvider>,
);
let (api_shutdown, _peers) = start_api_server(

View file

@ -4,8 +4,6 @@
//! and uses ureq to prove that authorized requests succeed while unauthorized
//! ones get 403 and missing-auth requests get 401.
#![cfg(feature = "node")]
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::Ordering;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

View file

@ -7,6 +7,7 @@ edition = "2024"
default = ["tcp"]
tcp = []
iroh = ["dep:iroh", "dep:tokio"]
relay = ["iroh", "dep:iroh-relay"]
[dependencies]
swactor = { path = "../..", features = ["serde", "transport"] }
@ -17,6 +18,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
bs58 = "0.5"
iroh = { version = "0.96", optional = true }
iroh-relay = { version = "0.96", features = ["server"], optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
[dev-dependencies]

View file

@ -40,6 +40,16 @@ pub struct IrohDriverConfig {
pub node: DistributedNodeConfig,
/// Optional peer allow-list. If provided, only allowed peers can connect.
pub peer_auth: Option<Arc<Mutex<PeerAllowList>>>,
/// If set, start an embedded relay server on this address.
/// Requires the `relay` feature. On success, the driver uses the embedded
/// relay for `RelayMode::Custom`; on failure, falls back to `relay_mode`.
#[cfg(feature = "relay")]
pub embedded_relay_bind: Option<std::net::SocketAddr>,
/// Public IP to advertise in the relay URL instead of the bind address.
/// When `Some`, the relay URL uses this IP; when `None`, falls back to the
/// bind address (which may be `0.0.0.0`).
#[cfg(feature = "relay")]
pub relay_public_ip: Option<std::net::IpAddr>,
}
// ─── Pending join result ────────────────────────────────────────────────────
@ -64,6 +74,13 @@ pub struct IrohDriver {
peer_auth: Option<Arc<Mutex<PeerAllowList>>>,
/// Collects connections from background join tasks.
pending_joins: Arc<Mutex<Vec<JoinResult>>>,
/// Connections accepted by the background accept loop.
accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>>,
/// Embedded relay server (if started).
#[cfg(feature = "relay")]
relay_server: Option<iroh_relay::server::Server>,
/// URL of the embedded relay server (if started).
relay_url: Option<String>,
}
impl IrohDriver {
@ -71,13 +88,39 @@ impl IrohDriver {
///
/// Builds a tokio runtime, creates an iroh `Endpoint`, and initializes
/// the protocol-layer `DistributedNode`.
///
/// If `embedded_relay_bind` is set (requires `relay` feature), the driver
/// starts an embedded relay server on the tokio runtime before creating
/// the endpoint. On success the endpoint uses the embedded relay; on
/// failure it falls back to `config.relay_mode`.
pub fn new(config: IrohDriverConfig) -> Result<Self, Box<dyn std::error::Error>> {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
// Try to start embedded relay if configured
#[cfg(feature = "relay")]
let (relay_server, relay_url, effective_relay_mode) = match config.embedded_relay_bind {
Some(bind_addr) => {
match rt.block_on(start_embedded_relay(bind_addr, config.relay_public_ip)) {
Ok((server, url)) => {
let url_str = url.to_string();
eprintln!("Relay: embedded relay started at {url}");
(Some(server), Some(url_str), RelayMode::Custom(url.into()))
}
Err(e) => {
eprintln!("Relay: failed to start embedded relay: {e}, falling back");
(None, None, config.relay_mode)
}
}
}
None => (None, None, config.relay_mode),
};
#[cfg(not(feature = "relay"))]
let (relay_url, effective_relay_mode) = (None::<String>, config.relay_mode);
let endpoint = rt.block_on(async {
let mut builder = Endpoint::empty_builder(config.relay_mode)
let mut builder = Endpoint::empty_builder(effective_relay_mode)
.alpns(vec![ALPN.to_vec()]);
if let Some(key) = config.secret_key {
@ -93,6 +136,49 @@ impl IrohDriver {
let keypair = Keypair::from_bytes(&iroh_secret);
let node = DistributedNode::with_keypair(keypair, config.node);
// Spawn background accept loop so incoming connections are never missed
let accepted_conns: Arc<Mutex<Vec<(NodeId, Connection)>>> =
Arc::new(Mutex::new(Vec::new()));
{
let ep = endpoint.clone();
let peer_auth = config.peer_auth.clone();
let buf = Arc::clone(&accepted_conns);
rt.spawn(async move {
loop {
match ep.accept().await {
Some(incoming) => match incoming.await {
Ok(conn) => {
let remote_id = conn.remote_id();
let node_id = NodeId(*remote_id.as_bytes());
// Peer auth check
let allowed = match &peer_auth {
None => true,
Some(auth) => auth.lock().unwrap().is_allowed(&node_id),
};
if !allowed {
eprintln!(
"iroh driver: rejected connection from unauthorized peer {}",
crate::identity::hex_encode(&node_id.0[..4])
);
conn.close(0u32.into(), b"unauthorized");
continue;
}
eprintln!(
"iroh driver: accepted connection from {}",
crate::identity::hex_encode(&node_id.0[..4])
);
buf.lock().unwrap().push((node_id, conn));
}
Err(e) => {
eprintln!("iroh driver: incoming connection error: {e}");
}
},
None => break, // endpoint closed
}
}
});
}
Ok(Self {
node,
endpoint,
@ -100,9 +186,18 @@ impl IrohDriver {
connections: HashMap::new(),
peer_auth: config.peer_auth,
pending_joins: Arc::new(Mutex::new(Vec::new())),
accepted_conns,
#[cfg(feature = "relay")]
relay_server,
relay_url,
})
}
/// Get a handle to the tokio runtime owned by this driver.
pub fn tokio_handle(&self) -> tokio::runtime::Handle {
self.rt.handle().clone()
}
/// The node's identity.
pub fn node_id(&self) -> NodeId {
self.node.node_id()
@ -178,36 +273,71 @@ impl IrohDriver {
let pending = Arc::clone(&self.pending_joins);
self.rt.spawn(async move {
match endpoint.connect(seed_addr.clone(), ALPN).await {
Ok(conn) => {
// Send the join request over the new connection
let send_result: Result<(), String> = async {
let mut send = conn.open_uni().await.map_err(|e| e.to_string())?;
let tag_len = (tag.len() as u32).to_be_bytes();
send.write_all(&tag_len).await.map_err(|e| e.to_string())?;
send.write_all(tag.as_bytes()).await.map_err(|e| e.to_string())?;
send.write_all(&payload).await.map_err(|e| e.to_string())?;
send.finish().map_err(|e| e.to_string())?;
Ok(())
}
.await;
let mut delay = Duration::from_secs(2);
let max_delay = Duration::from_secs(30);
let max_attempts = 5;
match send_result {
Ok(()) => {
pending.lock().unwrap().push(JoinResult {
node_id: seed_node_id,
conn,
});
for attempt in 1..=max_attempts {
if attempt > 1 {
tokio::time::sleep(delay).await;
delay = (delay * 2).min(max_delay);
}
eprintln!("iroh driver: join attempt {attempt}/{max_attempts} connecting to {}...", seed_addr.id);
let connect_result = tokio::time::timeout(
Duration::from_secs(10),
endpoint.connect(seed_addr.clone(), ALPN),
).await;
match connect_result {
Ok(Ok(conn)) => {
eprintln!("iroh driver: join attempt {attempt}/{max_attempts} connected to {}, sending...", seed_addr.id);
let send_result: Result<(), String> = async {
let mut send = conn.open_uni().await.map_err(|e| e.to_string())?;
let tag_len = (tag.len() as u32).to_be_bytes();
send.write_all(&tag_len).await.map_err(|e| e.to_string())?;
send.write_all(tag.as_bytes()).await.map_err(|e| e.to_string())?;
send.write_all(&payload).await.map_err(|e| e.to_string())?;
send.finish().map_err(|e| e.to_string())?;
Ok(())
}
Err(e) => {
eprintln!("iroh driver: join send error to {}: {e}", seed_addr.id);
.await;
match send_result {
Ok(()) => {
eprintln!("iroh driver: join attempt {attempt}/{max_attempts} sent to {}", seed_addr.id);
pending.lock().unwrap().push(JoinResult {
node_id: seed_node_id,
conn,
});
return;
}
Err(e) => {
eprintln!(
"iroh driver: join attempt {attempt}/{max_attempts} send error to {}: {e}",
seed_addr.id
);
continue;
}
}
}
}
Err(e) => {
eprintln!("iroh driver: join connect error to {}: {e}", seed_addr.id);
Ok(Err(e)) => {
eprintln!(
"iroh driver: join attempt {attempt}/{max_attempts} connect error to {}: {e}",
seed_addr.id
);
continue;
}
Err(_) => {
eprintln!(
"iroh driver: join attempt {attempt}/{max_attempts} connect timeout to {}",
seed_addr.id
);
continue;
}
}
}
eprintln!("iroh driver: join failed after {max_attempts} attempts to {}", seed_addr.id);
});
}
@ -222,6 +352,9 @@ impl IrohDriver {
// Collect completed background join connections
{
let mut pending = self.pending_joins.lock().unwrap();
if !pending.is_empty() {
eprintln!("iroh driver: collecting {} pending join connection(s)", pending.len());
}
for result in pending.drain(..) {
self.connections.entry(result.node_id).or_insert(result.conn);
}
@ -381,35 +514,17 @@ impl IrohDriver {
async fn receive_pending(&self) -> (Vec<(String, Vec<u8>, PublicKey)>, Vec<(NodeId, Connection)>) {
let mut messages = Vec::new();
let mut new_connections = Vec::new();
// Poll for incoming connections with a short timeout
loop {
let accept_fut = self.endpoint.accept();
let result = tokio::time::timeout(Duration::from_millis(1), accept_fut).await;
// Drain connections accepted by the background accept loop
let new_connections: Vec<(NodeId, Connection)> = {
let mut buf = self.accepted_conns.lock().unwrap();
buf.drain(..).collect()
};
match result {
Ok(Some(incoming)) => {
if let Ok(conn) = incoming.await {
let remote_id = conn.remote_id();
let node_id = NodeId(*remote_id.as_bytes());
// Peer auth check
if !self.is_peer_allowed(&node_id) {
eprintln!(
"iroh driver: rejected connection from unauthorized peer {}",
crate::identity::hex_encode(&node_id.0[..4])
);
conn.close(0u32.into(), b"unauthorized");
continue;
}
self.read_streams(&conn, remote_id, &mut messages).await;
new_connections.push((node_id, conn));
}
}
_ => break,
}
// Read streams from newly accepted connections
for (node_id, conn) in &new_connections {
let remote_id = PublicKey::from_bytes(&node_id.0).unwrap();
self.read_streams(conn, remote_id, &mut messages).await;
}
// Also read from existing cached connections
@ -424,6 +539,10 @@ impl IrohDriver {
self.read_streams(&conn, remote_id, &mut messages).await;
}
if !messages.is_empty() {
eprintln!("iroh driver: received {} message(s)", messages.len());
}
(messages, new_connections)
}
@ -512,14 +631,65 @@ impl IrohDriver {
}
}
/// Shut down the iroh endpoint.
pub fn shutdown(&self) {
/// URL of the embedded relay server, if one was started.
pub fn relay_url(&self) -> Option<&str> {
self.relay_url.as_deref()
}
/// The endpoint's home relay URL (from RelayMode::Custom), if connected.
pub fn home_relay_url(&self) -> Option<iroh::RelayUrl> {
self.endpoint.addr().relay_urls().next().cloned()
}
/// Shut down the driver: stop the embedded relay (if any), then close the
/// iroh endpoint.
pub fn shutdown(&mut self) {
// Shut down embedded relay first (must stop before endpoint closes)
#[cfg(feature = "relay")]
if let Some(server) = self.relay_server.take() {
self.rt.block_on(async {
let _ = server.shutdown().await;
});
}
self.rt.block_on(async {
self.endpoint.close().await;
});
}
}
// ─── Embedded Relay ─────────────────────────────────────────────────────────
#[cfg(feature = "relay")]
async fn start_embedded_relay(
bind_addr: std::net::SocketAddr,
public_ip: Option<std::net::IpAddr>,
) -> Result<(iroh_relay::server::Server, iroh::RelayUrl), Box<dyn std::error::Error>> {
let server = iroh_relay::server::Server::spawn(
iroh_relay::server::ServerConfig::<(), ()> {
relay: Some(iroh_relay::server::RelayConfig {
http_bind_addr: bind_addr,
tls: None,
limits: Default::default(),
key_cache_capacity: Some(256),
access: iroh_relay::server::AccessConfig::Everyone,
}),
quic: None,
metrics_addr: None,
},
)
.await?;
let url: iroh::RelayUrl = match server.http_addr() {
Some(addr) => {
let host = public_ip.unwrap_or_else(|| addr.ip());
format!("http://{}:{}/", host, addr.port()).parse()?
}
None => return Err("relay server has no HTTP address".into()),
};
Ok((server, url))
}
// ─── Wire Framing Over QUIC Streams ─────────────────────────────────────────
/// Write a tagged message to a QUIC send stream.

View file

@ -11,6 +11,7 @@ pub mod kademlia;
pub mod cache;
pub mod node;
pub mod registry;
pub mod node_metadata;
pub mod snapshot;
#[cfg(feature = "tcp")]
pub mod driver;

View file

@ -10,6 +10,7 @@ use crate::crypto::Keypair;
use crate::kademlia::directory::{actor_addr_as_node_id, DirectoryShard};
use crate::kademlia::repair::{RepairQueue, RepublishTracker};
use crate::kademlia::routing_table::RoutingTable;
use crate::node_metadata::{NodeMetadataDisseminator, NodeMetadataEntry};
use crate::registry::{
pack_combined_piggyback, unpack_combined_piggyback, ClusterRegistry, RegistryConfig,
RegistryEntry, RegistryEvent,
@ -25,6 +26,8 @@ pub struct DistributedNodeConfig {
pub cache_capacity: usize,
pub republish_interval: u64,
pub registry: RegistryConfig,
/// Dissemination multiplier for node metadata (default: 3).
pub metadata_lambda: usize,
}
impl Default for DistributedNodeConfig {
@ -34,6 +37,7 @@ impl Default for DistributedNodeConfig {
cache_capacity: 10_000,
republish_interval: 1000,
registry: RegistryConfig::default(),
metadata_lambda: 3,
}
}
}
@ -51,6 +55,7 @@ pub struct DistributedNode {
repair_queue: RepairQueue,
republish: RepublishTracker,
registry: ClusterRegistry,
metadata: NodeMetadataDisseminator,
tick_count: u64,
}
@ -72,6 +77,7 @@ impl DistributedNode {
repair_queue: RepairQueue::new(),
republish: RepublishTracker::new(config.republish_interval),
registry: ClusterRegistry::new(config.registry),
metadata: NodeMetadataDisseminator::new(config.metadata_lambda),
tick_count: 0,
keypair,
}
@ -138,35 +144,41 @@ impl DistributedNode {
// Registry GC
self.registry.gc_tick();
// Wrap outgoing piggyback with registry entries
self.inject_registry_piggyback(actions)
// Wrap outgoing piggyback with registry + metadata entries
self.inject_piggyback(actions)
}
// ─── SWIM message handling (delegate to SwimNode) ───────────────────
pub fn handle_ping(&mut self, from: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback);
let (membership_bytes, registry_entries, metadata_entries) =
unpack_combined_piggyback(piggyback);
let actions = self.swim.handle_ping(from, sequence, &membership_bytes);
self.process_membership_changes(&actions);
self.merge_registry_entries(registry_entries);
self.merge_metadata_entries(metadata_entries);
self.maybe_update_routing_table(from);
self.inject_registry_piggyback(actions)
self.inject_piggyback(actions)
}
pub fn handle_ack(&mut self, from: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback);
let (membership_bytes, registry_entries, metadata_entries) =
unpack_combined_piggyback(piggyback);
let actions = self.swim.handle_ack(from, sequence, &membership_bytes);
self.process_membership_changes(&actions);
self.merge_registry_entries(registry_entries);
self.inject_registry_piggyback(actions)
self.merge_metadata_entries(metadata_entries);
self.inject_piggyback(actions)
}
pub fn handle_ping_req(&mut self, from: NodeId, target: NodeId, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
let (membership_bytes, registry_entries) = unpack_combined_piggyback(piggyback);
let (membership_bytes, registry_entries, metadata_entries) =
unpack_combined_piggyback(piggyback);
let actions = self.swim.handle_ping_req(from, target, sequence, &membership_bytes);
self.process_membership_changes(&actions);
self.merge_registry_entries(registry_entries);
self.inject_registry_piggyback(actions)
self.merge_metadata_entries(metadata_entries);
self.inject_piggyback(actions)
}
pub fn handle_join_request(&mut self, from: NodeId) -> Vec<NodeAction> {
@ -261,6 +273,24 @@ impl DistributedNode {
&self.registry
}
// ─── Node metadata (relay URL) ─────────────────────────────────────
/// Set this node's relay URL and begin gossiping it to the cluster.
pub fn set_relay_url(&mut self, url: Option<String>) {
self.metadata
.set_local(self.node_id(), url, self.cluster_size());
}
/// Look up a node's relay URL.
pub fn relay_url(&self, node_id: &NodeId) -> Option<&str> {
self.metadata.relay_url(node_id)
}
/// Read-only access to the metadata disseminator.
pub fn metadata(&self) -> &NodeMetadataDisseminator {
&self.metadata
}
// ─── Accessors ──────────────────────────────────────────────────────
pub fn routing_table(&self) -> &RoutingTable {
@ -306,15 +336,18 @@ impl DistributedNode {
match state {
MemberState::Alive => {
self.routing_table.insert(node_id);
// Re-disseminate registry entries so the recovering node
// catches up on state accumulated during the partition.
self.registry.re_disseminate_all(self.cluster_size());
// Re-disseminate registry + metadata entries so the recovering
// node catches up on state accumulated during the partition.
let size = self.cluster_size();
self.registry.re_disseminate_all(size);
self.metadata.re_disseminate_all(size);
}
MemberState::Dead => {
self.routing_table.remove(&node_id);
self.cache.invalidate_node(&node_id);
self.repair_queue.on_node_death(&node_id, &mut self.directory);
self.registry.tombstone_node(node_id, self.cluster_size());
self.metadata.remove_node(&node_id);
}
MemberState::Suspect => {
// Keep in routing table but could downprioritize
@ -326,24 +359,27 @@ impl DistributedNode {
self.swim.members().alive_count() + 1 // +1 for self
}
/// Post-process outgoing actions: wrap each piggyback with registry entries.
fn inject_registry_piggyback(&mut self, actions: Vec<NodeAction>) -> Vec<NodeAction> {
/// Post-process outgoing actions: wrap each piggyback with registry + metadata entries.
fn inject_piggyback(&mut self, actions: Vec<NodeAction>) -> Vec<NodeAction> {
actions
.into_iter()
.map(|action| match action {
NodeAction::SendPing { to, sequence, piggyback } => {
let registry_entries = self.registry.take_pending(8);
let combined = pack_combined_piggyback(piggyback, registry_entries);
let metadata_entries = self.metadata.take_pending(4);
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
NodeAction::SendPing { to, sequence, piggyback: combined }
}
NodeAction::SendAck { to, sequence, piggyback } => {
let registry_entries = self.registry.take_pending(8);
let combined = pack_combined_piggyback(piggyback, registry_entries);
let metadata_entries = self.metadata.take_pending(4);
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
NodeAction::SendAck { to, sequence, piggyback: combined }
}
NodeAction::SendPingReq { relay, target, sequence, piggyback } => {
let registry_entries = self.registry.take_pending(8);
let combined = pack_combined_piggyback(piggyback, registry_entries);
let metadata_entries = self.metadata.take_pending(4);
let combined = pack_combined_piggyback(piggyback, registry_entries, metadata_entries);
NodeAction::SendPingReq { relay, target, sequence, piggyback: combined }
}
other => other,
@ -357,6 +393,13 @@ impl DistributedNode {
self.registry.merge_batch(entries, self.cluster_size());
}
}
/// Merge metadata entries received from a piggyback payload.
fn merge_metadata_entries(&mut self, entries: Vec<NodeMetadataEntry>) {
if !entries.is_empty() {
self.metadata.apply_incoming(entries, self.cluster_size());
}
}
}
/// Result of resolving an actor's location.

View file

@ -0,0 +1,129 @@
//! Node metadata dissemination — gossip-propagated per-node metadata.
//!
//! Each node may have metadata (currently: relay URL) that should be visible
//! cluster-wide. Uses higher-generation-wins semantics and SWIM-style
//! dissemination budgets (Λ * log₂(n)).
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::types::NodeId;
/// A single metadata entry for one node.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeMetadataEntry {
pub node_id: NodeId,
pub relay_url: Option<String>,
pub generation: u64,
}
/// Dissemination state for one entry.
#[derive(Debug, Clone)]
struct PendingEntry {
entry: NodeMetadataEntry,
remaining: usize,
}
/// Per-node metadata store with gossip dissemination.
pub struct NodeMetadataDisseminator {
store: HashMap<NodeId, NodeMetadataEntry>,
pending: Vec<PendingEntry>,
lambda: usize,
local_generation: u64,
}
impl NodeMetadataDisseminator {
pub fn new(lambda: usize) -> Self {
Self {
store: HashMap::new(),
pending: Vec::new(),
lambda,
local_generation: 0,
}
}
/// Set this node's relay URL and enqueue for dissemination.
pub fn set_local(&mut self, node_id: NodeId, relay_url: Option<String>, cluster_size: usize) {
self.local_generation += 1;
let entry = NodeMetadataEntry {
node_id,
relay_url,
generation: self.local_generation,
};
self.store.insert(node_id, entry.clone());
self.enqueue(entry, cluster_size);
}
/// Merge incoming entries from gossip. Re-enqueues changed entries for forwarding.
pub fn apply_incoming(&mut self, entries: Vec<NodeMetadataEntry>, cluster_size: usize) {
for entry in entries {
let dominated = match self.store.get(&entry.node_id) {
Some(existing) => entry.generation <= existing.generation,
None => false,
};
if dominated {
continue;
}
self.store.insert(entry.node_id, entry.clone());
self.enqueue(entry, cluster_size);
}
}
/// Take pending entries for piggyback, up to `max_count`.
pub fn take_pending(&mut self, max_count: usize) -> Vec<NodeMetadataEntry> {
let count = max_count.min(self.pending.len());
let mut result = Vec::with_capacity(count);
for entry in self.pending.iter_mut().take(count) {
result.push(entry.entry.clone());
entry.remaining = entry.remaining.saturating_sub(1);
}
self.pending.retain(|e| e.remaining > 0);
result
}
/// Look up a node's relay URL.
pub fn relay_url(&self, node_id: &NodeId) -> Option<&str> {
self.store
.get(node_id)
.and_then(|e| e.relay_url.as_deref())
}
/// Remove metadata for a dead node.
pub fn remove_node(&mut self, node_id: &NodeId) {
self.store.remove(node_id);
self.pending.retain(|e| &e.entry.node_id != node_id);
}
/// Re-enqueue all entries for dissemination (anti-entropy on membership recovery).
pub fn re_disseminate_all(&mut self, cluster_size: usize) {
for entry in self.store.values().cloned().collect::<Vec<_>>() {
self.enqueue(entry, cluster_size);
}
}
fn transmit_budget(&self, cluster_size: usize) -> usize {
let n = cluster_size.max(2) as f64;
let log_n = n.log2().ceil() as usize;
self.lambda * log_n.max(1)
}
fn enqueue(&mut self, entry: NodeMetadataEntry, cluster_size: usize) {
let budget = self.transmit_budget(cluster_size);
// Replace existing pending entry for same node if present.
if let Some(existing) = self.pending.iter_mut().find(|e| e.entry.node_id == entry.node_id)
{
existing.entry = entry;
existing.remaining = budget;
return;
}
self.pending.push(PendingEntry {
entry,
remaining: budget,
});
}
}

View file

@ -9,6 +9,7 @@ use std::collections::{HashMap, VecDeque};
use serde::{Deserialize, Serialize};
use swactor::actor::ActorAddress;
use crate::node_metadata::NodeMetadataEntry;
use crate::types::NodeId;
// ─── Configuration ──────────────────────────────────────────────────────────
@ -53,13 +54,16 @@ pub struct RegistryEntry {
pub tombstone: bool,
}
/// Combined piggyback payload: membership bytes + registry entries.
/// Combined piggyback payload: membership bytes + registry entries + node metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PiggybackPayload {
/// Raw SWIM membership piggyback bytes (opaque to registry).
pub membership: Vec<u8>,
/// Registry entries to disseminate.
pub registry: Vec<RegistryEntry>,
/// Node metadata entries (relay URLs, etc.) to disseminate.
#[serde(default)]
pub node_metadata: Vec<NodeMetadataEntry>,
}
// ─── Events ─────────────────────────────────────────────────────────────────
@ -367,20 +371,30 @@ fn lww_wins(incoming: &RegistryEntry, existing: &RegistryEntry) -> bool {
// ─── Piggyback pack/unpack ──────────────────────────────────────────────────
/// Combine membership piggyback bytes and registry entries into a single payload.
pub fn pack_combined_piggyback(membership: Vec<u8>, registry: Vec<RegistryEntry>) -> Vec<u8> {
let payload = PiggybackPayload { membership, registry };
/// Combine membership piggyback bytes, registry entries, and node metadata into a single payload.
pub fn pack_combined_piggyback(
membership: Vec<u8>,
registry: Vec<RegistryEntry>,
node_metadata: Vec<NodeMetadataEntry>,
) -> Vec<u8> {
let payload = PiggybackPayload {
membership,
registry,
node_metadata,
};
serde_json::to_vec(&payload).unwrap_or_default()
}
/// Split a combined piggyback payload into membership bytes and registry entries.
/// Split a combined piggyback payload into membership bytes, registry entries, and node metadata.
/// If deserialization fails, treats the entire blob as membership bytes (backwards compat).
pub fn unpack_combined_piggyback(bytes: &[u8]) -> (Vec<u8>, Vec<RegistryEntry>) {
pub fn unpack_combined_piggyback(
bytes: &[u8],
) -> (Vec<u8>, Vec<RegistryEntry>, Vec<NodeMetadataEntry>) {
if bytes.is_empty() {
return (Vec::new(), Vec::new());
return (Vec::new(), Vec::new(), Vec::new());
}
match serde_json::from_slice::<PiggybackPayload>(bytes) {
Ok(payload) => (payload.membership, payload.registry),
Err(_) => (bytes.to_vec(), Vec::new()),
Ok(payload) => (payload.membership, payload.registry, payload.node_metadata),
Err(_) => (bytes.to_vec(), Vec::new(), Vec::new()),
}
}

View file

@ -19,6 +19,8 @@ pub struct MemberInfo {
pub is_authorized: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_url: Option<String>,
}
/// Snapshot of a node in the Kademlia routing table.
@ -109,6 +111,10 @@ pub struct DistributionNodeSnapshot {
/// Base58-encoded invite code for this node.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub invite_code: Option<String>,
/// This node's relay URL, if running an embedded relay server.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub relay_url: Option<String>,
}
fn node_id_hex(id: &NodeId) -> String {
@ -139,6 +145,7 @@ impl DistributedNode {
incarnation: m.incarnation,
is_authorized: None,
label: None,
relay_url: self.metadata().relay_url(&m.node_id).map(String::from),
})
.collect();
@ -205,6 +212,7 @@ impl DistributedNode {
authorized_peer_count: None,
node_name: None,
invite_code: None,
relay_url: self.metadata().relay_url(&self.node_id()).map(String::from),
}
}
}

View file

@ -22,6 +22,10 @@ pub fn make_driver() -> IrohDriver {
relay_mode: RelayMode::Disabled,
node: test_config(),
peer_auth: None,
#[cfg(feature = "relay")]
embedded_relay_bind: None,
#[cfg(feature = "relay")]
relay_public_ip: None,
})
.expect("failed to create iroh driver")
}
@ -32,6 +36,10 @@ pub fn make_driver_with_auth(auth: Arc<Mutex<PeerAllowList>>) -> IrohDriver {
relay_mode: RelayMode::Disabled,
node: test_config(),
peer_auth: Some(auth),
#[cfg(feature = "relay")]
embedded_relay_bind: None,
#[cfg(feature = "relay")]
relay_public_ip: None,
})
.expect("failed to create iroh driver")
}
@ -42,6 +50,10 @@ pub fn make_driver_with_relay(relay_url: iroh::RelayUrl) -> IrohDriver {
relay_mode: RelayMode::Custom(relay_url.into()),
node: test_config(),
peer_auth: None,
#[cfg(feature = "relay")]
embedded_relay_bind: None,
#[cfg(feature = "relay")]
relay_public_ip: None,
})
.expect("failed to create iroh driver with relay")
}
@ -197,8 +209,8 @@ impl IrohTestCluster {
}
/// Shut down all drivers.
pub fn shutdown(&self) {
for d in &self.drivers {
pub fn shutdown(&mut self) {
for d in &mut self.drivers {
d.shutdown();
}
}

View file

@ -25,6 +25,7 @@ pub fn test_config() -> DistributedNodeConfig {
cache_capacity: 100,
republish_interval: 50,
registry: RegistryConfig::default(),
metadata_lambda: 3,
}
}

View file

@ -23,8 +23,8 @@ use iroh::PublicKey;
#[test]
fn iroh_driver_creates_with_unique_identity() {
let d1 = make_driver();
let d2 = make_driver();
let mut d1 = make_driver();
let mut d2 = make_driver();
assert_ne!(d1.node_id(), d2.node_id());
d1.shutdown();
d2.shutdown();
@ -32,7 +32,7 @@ fn iroh_driver_creates_with_unique_identity() {
#[test]
fn iroh_driver_snapshot_contains_node_id() {
let driver = make_driver();
let mut driver = make_driver();
let snap = driver.snapshot();
assert!(!snap.node_id.is_empty());
assert_eq!(snap.members.len(), 0);
@ -41,7 +41,7 @@ fn iroh_driver_snapshot_contains_node_id() {
#[test]
fn iroh_driver_identity_matches_iroh_endpoint() {
let driver = make_driver();
let mut driver = make_driver();
let node_id = driver.node_id();
let snap = driver.snapshot();
let expected_hex: String = node_id.0.iter().map(|b| format!("{:02x}", b)).collect();

View file

@ -1,18 +0,0 @@
[package]
name = "local-runner"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "local-runner"
path = "src/main.rs"
[dependencies]
swactor = { path = "../..", features = ["serde"] }
swactor-ci = { path = "../ci", features = ["local"] }
runtime-dashboard = { path = "../runtime-dashboard", features = ["ci"] }
clap = { version = "4", features = ["derive"] }
ctrlc = "3"
iroh = "0.96"
tokio = { version = "1", features = ["rt-multi-thread"] }
serde_json = "1"

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,11 @@ impl StdExtension {
group_registry: GroupRegistry::new(),
}
}
/// Resolve a human-readable name for an actor address (reverse lookup).
pub fn resolve_name(&self, addr: &ActorAddress) -> Option<String> {
self.name_registry.lookup_by_addr(addr)
}
}
impl Default for StdExtension {

View file

@ -52,6 +52,11 @@ impl NameRegistry {
}
}
/// Look up the name bound to an actor address (reverse lookup).
pub fn lookup_by_addr(&self, addr: &ActorAddress) -> Option<String> {
self.reverse.read().unwrap().get(addr).cloned()
}
/// Return all registered names.
pub fn registered_names(&self) -> Vec<String> {
self.names.read().unwrap().keys().cloned().collect()

View file

@ -5,22 +5,22 @@ edition = "2024"
[dependencies]
swactor = { path = "../..", features = ["serde", "tracing", "transport"] }
runtime-dashboard = { path = "../runtime-dashboard", features = ["distribution"] }
swactor-datastore = { path = "../datastore", features = ["node"] }
swactor-std = { path = "../std" }
dashboard = { path = "../dashboard", features = ["distribution"] }
swactor-datastore = { path = "../datastore" }
distribution = { path = "../distribution" }
clap = { version = "4", features = ["derive"] }
ctrlc = "3"
iroh = { version = "0.96", optional = true }
mdns-sd = { version = "0.11", optional = true }
toml = "0.8"
serde = { version = "1", features = ["derive"] }
libc = "0.2"
[features]
default = ["iroh", "mdns"]
default = ["iroh", "relay"]
tcp = ["distribution/tcp"]
iroh = ["distribution/iroh", "dep:iroh"]
mdns = ["dep:mdns-sd"]
relay = ["iroh", "distribution/relay"]
[[bin]]
name = "swactor"

View file

@ -10,7 +10,6 @@ use serde::Deserialize;
pub struct SwactorNodeConfig {
pub transport: Option<String>,
pub dashboard_port: Option<u16>,
pub dashboard_token: Option<String>,
pub storage_path: Option<String>,
pub identity_dir: Option<String>,
pub peers_file: Option<String>,
@ -25,7 +24,10 @@ pub struct SwactorNodeConfig {
pub disseminate_interval: Option<u64>,
pub no_datastore: Option<bool>,
pub node_name: Option<String>,
pub mdns: Option<bool>,
pub relay: Option<bool>,
pub relay_port: Option<u16>,
pub relay_bind: Option<String>,
pub relay_hosts: Option<Vec<String>>,
}
impl SwactorNodeConfig {

View file

@ -14,7 +14,6 @@ use clap::{Parser, Subcommand};
use swactor::actor::{ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use distribution::crypto::Keypair;
use distribution::identity::{base58_encode, base58_decode, hex_encode, load_or_generate_keypair};
use distribution::node::DistributedNodeConfig;
@ -23,23 +22,18 @@ use distribution::snapshot::DistributionNodeSnapshot;
use distribution::swim::probe::SwimConfig;
use distribution::types::NodeId;
use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::distribution_collector::DistributionStatsProvider;
use runtime_dashboard::{start_dashboard, DashboardConfig};
use dashboard::collector::StatsCollector;
use dashboard::distribution_collector::DistributionStatsProvider;
use dashboard::{start_dashboard, DashboardConfig};
use swactor_datastore::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
use swactor_datastore::auth::{AccessControlList, AuthzEngine};
use swactor_datastore::bridge::{DatastoreBridge, DatastoreNodeFactory};
use swactor_datastore::messages::{GatewayMsg, MetadataMsg};
use swactor_datastore::metrics::DatastoreMetrics;
use swactor_datastore::storage::{FilesystemBackend, InMemoryBackend};
use swactor_datastore::DatastoreConfig;
use swactor_datastore::bridge::DatastoreNodeFactory;
use swactor_datastore::{DatastoreAuthConfig, DatastoreGroup, DatastoreGroupConfig};
mod config;
mod install;
#[cfg(feature = "mdns")]
mod mdns;
mod names;
#[cfg(feature = "relay")]
mod relay;
// ── CLI ──────────────────────────────────────────────────────────────────
@ -143,17 +137,21 @@ struct Args {
#[arg(long, default_value = "./auth")]
auth_dir: String,
/// Dashboard API token (omit to auto-generate from keypair)
#[arg(long)]
dashboard_token: Option<String>,
/// Human-readable node name (e.g. "swift-falcon")
#[arg(long)]
node_name: Option<String>,
/// Disable mDNS LAN discovery
/// Disable embedded relay server
#[arg(long)]
no_mdns: bool,
no_relay: bool,
/// Port for embedded relay server
#[arg(long, default_value = "3340")]
relay_port: u16,
/// Bind address for embedded relay server
#[arg(long, default_value = "0.0.0.0")]
relay_bind: String,
}
// ── Dummy actor ──────────────────────────────────────────────────────────
@ -215,6 +213,8 @@ identity_dir = "{dir}/identity"
peers_file = "{dir}/peers.json"
auth = true
auth_dir = "{dir}/auth"
relay = true
relay_port = 3340
"#,
dir = config_dir.display(),
);
@ -314,7 +314,6 @@ fn main() {
#[cfg(feature = "tcp")]
let seed = args.seed.clone().or(cfg.seed);
let auth_enabled = args.auth || cfg.auth.unwrap_or(false);
let dashboard_token = args.dashboard_token.clone().or(cfg.dashboard_token);
let actors = if args.actors != 0 {
args.actors
} else {
@ -336,7 +335,18 @@ fn main() {
cfg.disseminate_interval.unwrap_or(args.disseminate_interval)
};
let no_datastore = args.no_datastore || cfg.no_datastore.unwrap_or(false);
let mdns_enabled = !args.no_mdns && cfg.mdns.unwrap_or(true);
let relay_enabled = !args.no_relay && cfg.relay.unwrap_or(true);
let relay_port = if args.relay_port != 3340 {
args.relay_port
} else {
cfg.relay_port.unwrap_or(args.relay_port)
};
let relay_bind = if args.relay_bind != "0.0.0.0" {
args.relay_bind.clone()
} else {
cfg.relay_bind.unwrap_or_else(|| args.relay_bind.clone())
};
let relay_hosts = cfg.relay_hosts.unwrap_or_default();
#[cfg(feature = "tcp")]
let listen = args.listen.or_else(|| {
cfg.listen.as_ref().and_then(|s| s.parse().ok())
@ -468,21 +478,12 @@ fn main() {
};
let peer_auth = Arc::new(Mutex::new(peer_auth));
// Dashboard token
let dashboard_token = dashboard_token.unwrap_or_else(|| {
// Derive a token from the first 16 bytes of the node's secret key hash
let secret = keypair.secret_bytes();
hex_encode(&secret[..16])
});
eprintln!("Dashboard token: {dashboard_token}");
// Start dashboard
let dash = start_dashboard(DashboardConfig {
port: dashboard_port,
..Default::default()
});
dash.install_tracing();
dash.set_dashboard_token(dashboard_token);
dash.set_peer_auth(Arc::clone(&peer_auth));
// Create actor runtime
@ -493,102 +494,40 @@ fn main() {
max_actors: 1024,
channel_buffer_size: 2000,
..Default::default()
});
})
.with_extension(Arc::new(swactor_std::StdExtension::new()));
rt.set_stats_hook(collector.clone());
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(handle.runtime.clone(), collector);
// Datastore setup
let mut ds_metadata_addr = None;
let mut ds_gateway_addr = None;
let ds_gc_interval = gc_interval;
let ds_disseminate_interval = disseminate_interval;
if !no_datastore {
let config = DatastoreConfig {
chunk_size,
storage_path: storage_path
.as_ref()
.map(|s| s.into())
.unwrap_or_else(|| "datastore".into()),
..Default::default()
};
let backend: Box<dyn swactor_datastore::StorageBackend> = match &storage_path {
Some(path) => {
let p = std::path::PathBuf::from(path);
std::fs::create_dir_all(&p).expect("failed to create storage directory");
Box::new(FilesystemBackend::new(p))
}
None => Box::new(InMemoryBackend::new()),
};
let blob_store_addr = handle
.runtime
.spawn(BlobStoreActor::new(backend))
.expect("failed to spawn BlobStoreActor");
let mut metadata = MetadataActor::new(node_id, &config);
metadata.set_blob_store(blob_store_addr);
let metadata_addr = handle
.runtime
.spawn(metadata)
.expect("failed to spawn MetadataActor");
let datastore_node =
DatastoreNode::new(node_id, blob_store_addr, metadata_addr, config);
let datastore_addr = handle
.runtime
.spawn(datastore_node)
.expect("failed to spawn DatastoreNode");
// Spawn GatewayActor if auth is enabled
let gateway_addr = if auth_enabled {
let auth_dir = std::path::PathBuf::from(&auth_dir_str);
std::fs::create_dir_all(&auth_dir).expect("failed to create auth directory");
let acl_path = auth_dir.join("acl.json");
let acl = AccessControlList::load_or_create(&acl_path, node_id)
.expect("failed to load/create ACL");
let engine = AuthzEngine::new(acl);
let gateway = GatewayActor::new(engine, datastore_addr, Some(acl_path));
let addr = handle
.runtime
.spawn(gateway)
.expect("failed to spawn GatewayActor");
eprintln!("Auth: enabled (owner {})", &node_hex[..16]);
Some(addr)
} else {
eprintln!("Auth: disabled");
None
};
let metrics = Arc::new(DatastoreMetrics::new());
metrics.set_node_id(node_hex.clone());
let bridge = DatastoreBridge::new(
Arc::clone(&metrics),
Arc::clone(&handle.runtime),
datastore_addr,
metadata_addr,
blob_store_addr,
);
dash.set_datastore(Arc::new(bridge));
ds_metadata_addr = Some(metadata_addr);
ds_gateway_addr = gateway_addr;
if storage_path.is_some() {
eprintln!(
"Datastore: persistent ({})",
storage_path.as_ref().unwrap()
);
} else {
eprintln!("Datastore: in-memory");
}
let ds_group = if !no_datastore {
let group = DatastoreGroup::spawn(
handle.runtime.clone(),
DatastoreGroupConfig {
node_id,
node_id_hex: node_hex.clone(),
chunk_size,
storage_path: storage_path.clone(),
auth: if auth_enabled {
Some(DatastoreAuthConfig {
auth_dir: auth_dir_str.into(),
})
} else {
None
},
gc_interval,
disseminate_interval,
},
)
.expect("failed to start datastore");
dash.set_datastore(group.bridge().clone());
Some(group)
} else {
eprintln!("Datastore: disabled");
}
None
};
// Always wire up the factory so the UI can start/stop datastore
let factory = DatastoreNodeFactory::new(Arc::clone(&handle.runtime), chunk_size);
@ -609,30 +548,9 @@ fn main() {
..Default::default()
};
// Channel for triggering SWIM joins (fed by mDNS and dashboard "Add Peer")
let (join_tx, join_rx) = std::sync::mpsc::channel::<[u8; 32]>();
dash.set_join_sender(join_tx.clone());
// Start mDNS discovery (if enabled)
#[cfg(feature = "mdns")]
let _mdns_handle = if mdns_enabled {
eprintln!("mDNS: enabled (LAN discovery)");
Some(mdns::start_mdns(
&invite_code,
&node_name,
Arc::clone(&peer_auth),
join_tx,
))
} else {
drop(join_tx);
eprintln!("mDNS: disabled");
None
};
#[cfg(not(feature = "mdns"))]
{
let _ = mdns_enabled;
drop(join_tx);
}
// Channel for triggering SWIM joins (fed by dashboard "Add Peer")
let (join_tx, join_rx) = std::sync::mpsc::channel::<dashboard::JoinPeerInfo>();
dash.set_join_sender(join_tx);
match transport.as_str() {
#[cfg(feature = "iroh")]
@ -646,13 +564,14 @@ fn main() {
&handle,
&dash,
&stop,
ds_metadata_addr,
ds_gateway_addr,
ds_gc_interval,
ds_disseminate_interval,
&ds_group,
node_name,
invite_code,
join_rx,
relay_enabled,
&relay_bind,
relay_port,
relay_hosts,
),
#[cfg(feature = "tcp")]
"tcp" => run_tcp(
@ -666,10 +585,7 @@ fn main() {
&handle,
&dash,
&stop,
ds_metadata_addr,
ds_gateway_addr,
ds_gc_interval,
ds_disseminate_interval,
&ds_group,
node_name,
invite_code,
join_rx,
@ -686,10 +602,6 @@ fn main() {
}
eprintln!("\nShutting down...");
#[cfg(feature = "mdns")]
if let Some(h) = _mdns_handle {
h.shutdown();
}
handle.shutdown();
dash.shutdown();
handle.join();
@ -707,15 +619,12 @@ fn run_tcp(
keypair: Keypair,
peer_auth: Arc<Mutex<PeerAllowList>>,
handle: &swactor::runtime::RuntimeHandle,
dash: &runtime_dashboard::DashboardHandle,
dash: &dashboard::DashboardHandle,
stop: &Arc<AtomicBool>,
ds_metadata_addr: Option<swactor::actor::ActorAddress>,
ds_gateway_addr: Option<swactor::actor::ActorAddress>,
ds_gc_interval: u64,
ds_disseminate_interval: u64,
ds_group: &Option<DatastoreGroup>,
node_name: String,
invite_code: String,
_join_rx: std::sync::mpsc::Receiver<[u8; 32]>,
_join_rx: std::sync::mpsc::Receiver<dashboard::JoinPeerInfo>,
) {
use distribution::driver::NodeDriver;
@ -750,6 +659,8 @@ fn run_tcp(
};
dash.set_distribution(Arc::new(provider));
// Start dashboard HTTP on a standalone tokio runtime (no iroh runtime in TCP mode)
dash.start_http_standalone();
eprintln!("Dashboard at http://0.0.0.0:{dashboard_port}");
// Main loop
@ -770,18 +681,8 @@ fn run_tcp(
*cached_snapshot.lock().unwrap() = Some(snap);
// Datastore ticks
if let Some(metadata_addr) = ds_metadata_addr {
if round % ds_gc_interval == 0 {
let _ = handle.runtime.send_to(metadata_addr, MetadataMsg::GcTick);
if let Some(gw) = ds_gateway_addr {
let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick);
}
}
if round % ds_disseminate_interval == 0 {
let _ = handle
.runtime
.send_to(metadata_addr, MetadataMsg::DisseminateTick);
}
if let Some(group) = ds_group {
group.tick(round);
}
thread::sleep(Duration::from_millis(100));
@ -799,24 +700,85 @@ fn run_iroh(
keypair: Keypair,
peer_auth: Arc<Mutex<PeerAllowList>>,
handle: &swactor::runtime::RuntimeHandle,
dash: &runtime_dashboard::DashboardHandle,
dash: &dashboard::DashboardHandle,
stop: &Arc<AtomicBool>,
ds_metadata_addr: Option<swactor::actor::ActorAddress>,
ds_gateway_addr: Option<swactor::actor::ActorAddress>,
ds_gc_interval: u64,
ds_disseminate_interval: u64,
ds_group: &Option<DatastoreGroup>,
node_name: String,
invite_code: String,
join_rx: std::sync::mpsc::Receiver<[u8; 32]>,
join_rx: std::sync::mpsc::Receiver<dashboard::JoinPeerInfo>,
relay_enabled: bool,
relay_bind: &str,
relay_port: u16,
relay_hosts: Vec<String>,
) {
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
use iroh::{RelayMode, SecretKey};
// Evaluate relay candidacy and determine embedded relay bind address
#[cfg(feature = "relay")]
let (embedded_relay_bind, relay_public_ip) = if relay_enabled {
let bind_addr: std::net::SocketAddr = format!("{relay_bind}:{relay_port}")
.parse()
.unwrap_or_else(|e| {
eprintln!("Invalid relay bind address: {e}");
std::process::exit(1);
});
let rules: Vec<Box<dyn relay::CandidacyRule>> = vec![
Box::new(relay::PublicIpRule),
Box::new(relay::PortBindRule::new(bind_addr)),
];
match relay::evaluate_candidacy(&rules) {
Ok(()) => {
let public_ip = relay::PublicIpRule::outbound_ip();
if let Some(ip) = &public_ip {
eprintln!("Relay: candidacy passed, will start on {bind_addr} (public IP: {ip})");
} else {
eprintln!("Relay: candidacy passed, will start on {bind_addr} (no public IP detected)");
}
(Some(bind_addr), public_ip)
}
Err(reason) => {
eprintln!("Relay: not eligible — {reason}");
(None, None)
}
}
} else {
eprintln!("Relay: disabled");
(None, None)
};
#[cfg(not(feature = "relay"))]
let _ = (relay_enabled, relay_bind, relay_port);
// Compute relay mode from known relay hosts (if any)
let relay_mode = {
let urls: Vec<iroh::RelayUrl> = relay_hosts.iter()
.filter_map(|host| {
let url_str = format!("http://{host}:{relay_port}/");
match url_str.parse::<iroh::RelayUrl>() {
Ok(u) => Some(u),
Err(e) => { eprintln!("Relay: bad host {host}: {e}"); None }
}
})
.collect();
if urls.is_empty() {
RelayMode::Disabled
} else {
eprintln!("Relay: using {} known relay(s)", urls.len());
RelayMode::Custom(urls.into_iter().collect::<iroh::RelayMap>())
}
};
let iroh_config = IrohDriverConfig {
secret_key: Some(SecretKey::from_bytes(&keypair.secret_bytes())),
relay_mode: RelayMode::Default,
relay_mode,
node: node_config,
peer_auth: Some(peer_auth.clone()),
#[cfg(feature = "relay")]
embedded_relay_bind,
#[cfg(feature = "relay")]
relay_public_ip,
};
let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver");
@ -834,6 +796,14 @@ fn run_iroh(
// Spawn and register actors
let actor_addrs = spawn_actors(actors, handle, driver.node_mut());
// Announce relay URL to cluster gossip
let mut home_relay_set = if let Some(url) = driver.relay_url().map(|u| u.to_string()) {
driver.node_mut().set_relay_url(Some(url));
true
} else {
false
};
// Wire distribution snapshot to dashboard
let mut snap = driver.snapshot();
snap.node_name = Some(node_name.clone());
@ -845,6 +815,8 @@ fn run_iroh(
};
dash.set_distribution(Arc::new(provider));
// Start dashboard HTTP on IrohDriver's tokio runtime
dash.start_http(driver.tokio_handle());
eprintln!("Dashboard at http://0.0.0.0:{dashboard_port}");
// Main loop
@ -855,19 +827,32 @@ fn run_iroh(
driver.recv();
driver.tick();
// Drain discovered peers (mDNS + dashboard "Add Peer") and auto-join them
// Drain discovered peers (dashboard "Add Peer") and auto-join them
{
let mut new_peers = Vec::new();
while let Ok(bytes) = join_rx.try_recv() {
new_peers.push(bytes);
while let Ok(info) = join_rx.try_recv() {
new_peers.push(info);
}
if !new_peers.is_empty() {
let addrs: Vec<iroh::EndpointAddr> = new_peers
.iter()
.filter_map(|b| {
iroh::PublicKey::from_bytes(b).ok().map(|k| {
eprintln!("Auto-joining peer {}", base58_encode(b));
iroh::EndpointAddr::from(k)
.filter_map(|(bytes, relay_url)| {
iroh::PublicKey::from_bytes(bytes).ok().map(|k| {
let mut addr = iroh::EndpointAddr::from(k);
if let Some(url_str) = relay_url {
match url_str.parse::<iroh::RelayUrl>() {
Ok(url) => {
eprintln!("Auto-joining peer {} via relay {}", base58_encode(bytes), url);
addr = addr.with_relay_url(url);
}
Err(e) => {
eprintln!("Auto-joining peer {} (bad relay URL {}: {e})", base58_encode(bytes), url_str);
}
}
} else {
eprintln!("Auto-joining peer {} (no relay URL)", base58_encode(bytes));
}
addr
})
})
.collect();
@ -877,6 +862,15 @@ fn run_iroh(
}
}
// Lazily pick up home relay URL once the endpoint connects
if !home_relay_set {
if let Some(url) = driver.home_relay_url() {
eprintln!("Relay URL (home): {url}");
driver.node_mut().set_relay_url(Some(url.to_string()));
home_relay_set = true;
}
}
for addr in &actor_addrs {
let _ = handle.runtime.send_to(*addr, Heartbeat);
}
@ -887,23 +881,14 @@ fn run_iroh(
*cached_snapshot.lock().unwrap() = Some(snap);
// Datastore ticks
if let Some(metadata_addr) = ds_metadata_addr {
if round % ds_gc_interval == 0 {
let _ = handle.runtime.send_to(metadata_addr, MetadataMsg::GcTick);
if let Some(gw) = ds_gateway_addr {
let _ = handle.runtime.send_to(gw, GatewayMsg::NonceGcTick);
}
}
if round % ds_disseminate_interval == 0 {
let _ = handle
.runtime
.send_to(metadata_addr, MetadataMsg::DisseminateTick);
}
if let Some(group) = ds_group {
group.tick(round);
}
thread::sleep(Duration::from_millis(100));
}
// Driver shutdown handles embedded relay cleanup automatically
driver.shutdown();
}

View file

@ -1,143 +0,0 @@
//! mDNS LAN discovery for swactor nodes.
//!
//! Advertises this node on the local network and discovers peers
//! automatically. Discovered peers are added to the peer allow-list.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use distribution::identity::{base58_decode, hex_encode};
use distribution::peer_auth::PeerAllowList;
use distribution::types::NodeId;
const SERVICE_TYPE: &str = "_swactor._udp.local.";
/// Handle to the background mDNS thread.
pub struct MdnsHandle {
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl MdnsHandle {
pub fn shutdown(mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Start mDNS advertisement and discovery in a background thread.
///
/// Discovered peer node IDs are sent through `discovery_tx` so the main
/// loop can call `driver.join()` on them.
pub fn start_mdns(
invite_code: &str,
node_name: &str,
peer_auth: Arc<Mutex<PeerAllowList>>,
discovery_tx: Sender<[u8; 32]>,
) -> MdnsHandle {
let stop = Arc::new(AtomicBool::new(false));
let stop2 = Arc::clone(&stop);
let invite_code = invite_code.to_string();
let node_name = node_name.to_string();
let thread = thread::spawn(move || {
run_mdns(stop2, &invite_code, &node_name, peer_auth, discovery_tx);
});
MdnsHandle {
stop,
thread: Some(thread),
}
}
fn run_mdns(
stop: Arc<AtomicBool>,
invite_code: &str,
node_name: &str,
peer_auth: Arc<Mutex<PeerAllowList>>,
discovery_tx: Sender<[u8; 32]>,
) {
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
let mdns = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
eprintln!("mDNS: failed to start daemon: {e}");
return;
}
};
// Register our service
let instance_name = node_name.replace(' ', "-");
let hostname = format!("{}.local.", instance_name);
let properties = [("id", invite_code)];
match ServiceInfo::new(SERVICE_TYPE, &instance_name, &hostname, (), 0, &properties[..]) {
Ok(info) => {
if let Err(e) = mdns.register(info) {
eprintln!("mDNS: failed to register service: {e}");
}
}
Err(e) => {
eprintln!("mDNS: failed to create service info: {e}");
}
}
// Browse for peers
let receiver = match mdns.browse(SERVICE_TYPE) {
Ok(r) => r,
Err(e) => {
eprintln!("mDNS: failed to browse: {e}");
return;
}
};
while !stop.load(Ordering::Relaxed) {
match receiver.recv_timeout(Duration::from_secs(1)) {
Ok(event) => {
if let ServiceEvent::ServiceResolved(info) = event {
if let Some(peer_code) = info.get_property_val_str("id") {
// Skip ourselves
if peer_code == invite_code {
continue;
}
if let Some(peer_bytes) = base58_decode(peer_code) {
let peer_id = NodeId(peer_bytes);
let peer_hex = hex_encode(&peer_bytes);
let label = info.get_fullname().split('.').next()
.unwrap_or("mdns-peer")
.to_string();
let mut auth = peer_auth.lock().unwrap();
if !auth.is_allowed(&peer_id) {
eprintln!(
"mDNS: discovered peer {} ({})",
label, &peer_hex[..8]
);
auth.add_peer(peer_id, label);
if let Err(e) = auth.save() {
eprintln!("mDNS: failed to save peers: {e}");
}
}
// Always notify the main loop so it can join() the
// peer — even if already in the allow-list (e.g. added
// offline via `swactor join`). The main loop is
// idempotent on re-joining known members.
let _ = discovery_tx.send(peer_bytes);
}
}
}
}
Err(e) => {
if format!("{e:?}").contains("Disconnected") { break; }
// Timeout — continue loop
}
}
}
let _ = mdns.shutdown();
}

View file

@ -0,0 +1,86 @@
//! Relay candidacy evaluation.
//!
//! Determines whether this node is eligible to run an embedded relay server
//! by checking a set of extensible rules (public IP, port availability, etc.).
use std::net::{IpAddr, SocketAddr, UdpSocket};
// ─── Candidacy ──────────────────────────────────────────────────────────
/// Result of evaluating a single candidacy rule.
pub enum CandidacyResult {
Eligible,
Ineligible(String),
}
/// Extensible rule for relay candidacy evaluation.
pub trait CandidacyRule: Send {
fn evaluate(&self) -> CandidacyResult;
}
/// Checks whether the node's outbound IP is public (non-RFC1918, non-loopback).
pub struct PublicIpRule;
impl PublicIpRule {
pub fn outbound_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("192.0.2.1:80").ok()?; // RFC 5737 TEST-NET-1 (non-routable)
Some(sock.local_addr().ok()?.ip())
}
fn is_public(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
!v4.is_loopback()
&& !v4.is_private()
&& !v4.is_link_local()
&& !v4.is_unspecified()
}
IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_unspecified(),
}
}
}
impl CandidacyRule for PublicIpRule {
fn evaluate(&self) -> CandidacyResult {
match Self::outbound_ip() {
Some(ip) if Self::is_public(&ip) => CandidacyResult::Eligible,
Some(ip) => CandidacyResult::Ineligible(format!("outbound IP {ip} is private")),
None => CandidacyResult::Ineligible("could not determine outbound IP".into()),
}
}
}
/// Checks whether the desired relay port is available for binding.
pub struct PortBindRule {
addr: SocketAddr,
}
impl PortBindRule {
pub fn new(addr: SocketAddr) -> Self {
Self { addr }
}
}
impl CandidacyRule for PortBindRule {
fn evaluate(&self) -> CandidacyResult {
match std::net::TcpListener::bind(self.addr) {
Ok(_) => CandidacyResult::Eligible,
Err(e) => CandidacyResult::Ineligible(format!(
"cannot bind {}: {e}",
self.addr
)),
}
}
}
/// Evaluate all candidacy rules. Returns `Ok(())` if all pass, or
/// `Err(reason)` with the first failure reason.
pub fn evaluate_candidacy(rules: &[Box<dyn CandidacyRule>]) -> Result<(), String> {
for rule in rules {
if let CandidacyResult::Ineligible(reason) = rule.evaluate() {
return Err(reason);
}
}
Ok(())
}

View file

@ -1,12 +0,0 @@
[package]
name = "wasm"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
swactor = { path = "../..", default-features = false, features = ["wasm"] }
swactor-std = { path = "../std", default-features = false, features = ["wasm"] }
wasm-bindgen = "0.2"

View file

@ -1,5 +1,5 @@
[package]
name = "swactor-crypto-wasm"
name = "wasm-crypto"
version = "0.1.0"
edition = "2021"

View file

@ -0,0 +1,12 @@
[package]
name = "wasm-runtime"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
swactor = { path = "../../..", default-features = false, features = ["wasm"] }
swactor-std = { path = "../../std", default-features = false, features = ["wasm"] }
wasm-bindgen = "0.2"

View file

@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${1:-.}")" && pwd)/$(basename "${1:-traces}")"
PORT="${2:-8080}"
rm -rf "$DIR"
mkdir -p "$DIR"
echo "Running distribution sim tests with trace export..."
SWACTOR_TRACE_DIR="$DIR" cargo test -p simulation \
--test distribution_registry \
--test distribution_lifecycle \
--test distribution_properties || echo "WARNING: some tests failed (traces from passing tests are still available)"
COUNT=$(find "$DIR" -name '*.trace.json' 2>/dev/null | wc -l)
echo "$COUNT traces in $DIR/"
echo "Dashboard at http://localhost:$PORT"
cargo run -p simulation --features dashboard --example replay -- "$DIR" "$PORT"

View file

@ -152,6 +152,9 @@ pub struct ActorInfo {
/// Whether the actor has panicked and is no longer processing messages.
#[cfg_attr(feature = "serde", serde(default))]
pub poisoned: bool,
/// Human-readable name from the name registry (if registered).
#[cfg_attr(feature = "serde", serde(default))]
pub name: Option<String>,
/// Per-message-type counts, sorted descending by count. Top 32 types.
#[cfg_attr(feature = "serde", serde(default))]
pub message_type_counts: Vec<(String, u64)>,

View file

@ -391,6 +391,22 @@ pub fn wait_for_lan_convergence(
}
}
// ── Deploy simulation helpers ────────────────────────────────────────────────
/// Restart a specific container with fresh flags (simulates deploy lifecycle).
pub fn redeploy_node(service: &str) {
let status = Command::new("docker")
.args(["compose", "-f", &compose_file(), "up", "-d", "--force-recreate", service])
.status()
.expect("failed to redeploy node");
assert!(status.success(), "docker compose force-recreate {service} failed");
}
/// Fetch the node_id from a node's distribution snapshot.
pub fn get_node_id(port: u16) -> Option<String> {
poll_distribution(port).map(|snap| snap.node_id)
}
fn lan_hpz_compose_path() -> String {
let mut p = PathBuf::from(COMPOSE_DIR);
p.push("docker-compose.lan-hpz.yml");

View file

@ -0,0 +1,55 @@
//! Deploy lifecycle simulation test.
//!
//! Exercises the same verification flow that `cargo xtask deploy` runs
//! (health check, node_id collection, rolling redeploy, convergence)
//! against the local docker-compose cluster — no real SSH needed.
//!
//! Run with: `cargo test -p docker-tests -- --ignored deploy_lifecycle`
use std::collections::HashSet;
use std::time::Duration;
use docker_tests::*;
#[test]
#[ignore]
fn deploy_lifecycle_simulation() {
// Phase 1: Start cluster (simulates: build + deploy containers)
let mut cluster = ClusterHandle::start();
// Phase 2: Health check — all nodes respond to /api/distribution
for &port in &DASHBOARD_PORTS {
let snap = poll_distribution(port);
assert!(snap.is_some(), "node on port {port} not responding");
}
// Phase 3: Convergence — all nodes see 4 peers alive
wait_for_convergence(&DASHBOARD_PORTS, 4, Duration::from_secs(30))
.expect("initial convergence failed");
// Phase 4: Collect node IDs (deploy script does this via GET /api/distribution)
let node_ids: Vec<String> = DASHBOARD_PORTS
.iter()
.map(|&port| get_node_id(port).expect("missing node_id"))
.collect();
let unique: HashSet<&String> = node_ids.iter().collect();
assert_eq!(unique.len(), 5, "expected 5 unique node IDs, got {}", unique.len());
// Phase 5: Simulate rolling redeploy — recreate one node, verify it rejoins
redeploy_node("node-3");
wait_for_convergence(&DASHBOARD_PORTS, 4, Duration::from_secs(45))
.expect("convergence after redeploy failed");
// Phase 6: Final status report — all metrics healthy
for &port in &DASHBOARD_PORTS {
let snap = poll_distribution(port).unwrap();
assert!(snap.alive_count >= 4,
"port {port}: expected alive_count >= 4, got {}", snap.alive_count);
assert!(snap.routing_table_size >= 3,
"port {port}: expected routing_table_size >= 3, got {}", snap.routing_table_size);
assert!(snap.directory_entry_count >= 2,
"port {port}: expected directory_entry_count >= 2, got {}", snap.directory_entry_count);
}
cluster.stop();
}

696
xtask/src/deploy.rs Normal file
View file

@ -0,0 +1,696 @@
use std::path::Path;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use serde::Deserialize;
// ── Config structs ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct DeployConfig {
pub defaults: DeployDefaults,
pub machines: Vec<MachineConfig>,
}
#[derive(Deserialize, Clone)]
pub struct DeployDefaults {
pub image: String,
pub container: String,
pub dashboard_port: u16,
#[serde(default = "default_relay_port")]
pub relay_port: u16,
#[serde(default)]
pub swactor_flags: Vec<String>,
#[serde(default)]
pub relay_hosts: Vec<String>,
#[serde(default = "default_true")]
pub introduce_peers: bool,
#[serde(default = "default_health_timeout")]
pub health_timeout_secs: u64,
#[serde(default = "default_convergence_timeout")]
pub convergence_timeout_secs: u64,
}
fn default_true() -> bool { true }
fn default_relay_port() -> u16 { 3340 }
fn default_health_timeout() -> u64 { 30 }
fn default_convergence_timeout() -> u64 { 60 }
#[derive(Deserialize, Clone)]
pub struct MachineConfig {
pub name: String,
/// SSH destination — an alias from ~/.ssh/config (e.g. "thinkpad") or a hostname/IP.
/// Ignored when `local = true`.
#[serde(default)]
pub ssh: String,
/// When true, run docker commands directly instead of over SSH.
#[serde(default)]
pub local: bool,
pub dashboard_port: Option<u16>,
pub relay_port: Option<u16>,
pub container: Option<String>,
pub swactor_flags: Option<Vec<String>>,
}
impl MachineConfig {
fn effective_port(&self, defaults: &DeployDefaults) -> u16 {
self.dashboard_port.unwrap_or(defaults.dashboard_port)
}
fn effective_relay_port(&self, defaults: &DeployDefaults) -> u16 {
self.relay_port.unwrap_or(defaults.relay_port)
}
fn effective_container(&self, defaults: &DeployDefaults) -> String {
self.container.clone().unwrap_or_else(|| defaults.container.clone())
}
fn effective_flags(&self, defaults: &DeployDefaults) -> Vec<String> {
self.swactor_flags.clone().unwrap_or_else(|| defaults.swactor_flags.clone())
}
}
// ── SSH helpers ─────────────────────────────────────────────────────────────
fn ssh_cmd(machine: &MachineConfig, remote_cmd: &str) -> Command {
let mut cmd = Command::new("ssh");
cmd.arg(&machine.ssh);
cmd.arg(remote_cmd);
cmd
}
fn transfer_image(machine: &MachineConfig, archive: &Path) -> Result<(), String> {
if machine.local {
println!(" Loading image locally on {}...", machine.name);
let pipe_cmd = format!("gunzip < {} | docker load", archive.display());
let status = Command::new("sh")
.arg("-c")
.arg(&pipe_cmd)
.status()
.map_err(|e| format!("failed to run docker load: {e}"))?;
if !status.success() {
return Err(format!("local docker load on {} failed", machine.name));
}
} else {
println!(" Copying image to {}...", machine.name);
let status = Command::new("scp")
.arg(archive.as_os_str())
.arg(format!("{}:/tmp/swactor-deploy.tar.gz", machine.ssh))
.status()
.map_err(|e| format!("scp failed: {e}"))?;
if !status.success() {
return Err(format!("scp to {} failed", machine.name));
}
println!(" Loading image on {}...", machine.name);
let status = Command::new("ssh")
.arg(&machine.ssh)
.arg("gunzip < /tmp/swactor-deploy.tar.gz | docker load")
.status()
.map_err(|e| format!("docker load on {} failed: {e}", machine.name))?;
if !status.success() {
return Err(format!("docker load on {} failed", machine.name));
}
}
println!(" Image loaded on {}", machine.name);
Ok(())
}
// ── HTTP helpers (via curl, optionally over SSH) ────────────────────────────
/// Build a curl GET url, running locally or over SSH depending on the machine.
fn machine_curl_get(machine: &MachineConfig, defaults: &DeployDefaults, path: &str) -> Option<String> {
let port = machine.effective_port(defaults);
let url = format!("http://localhost:{port}{path}");
let output = if machine.local {
Command::new("curl")
.args(["-s", "--max-time", "3", &url])
.output()
.ok()?
} else {
let curl_cmd = format!("curl -s --max-time 3 '{url}'");
Command::new("ssh")
.arg(&machine.ssh)
.arg(&curl_cmd)
.output()
.ok()?
};
if !output.status.success() {
return None;
}
let body = String::from_utf8_lossy(&output.stdout).to_string();
if body.is_empty() || body == "{}" {
return None;
}
Some(body)
}
fn machine_curl_post(machine: &MachineConfig, defaults: &DeployDefaults, path: &str, json_body: &str) -> Result<String, String> {
let port = machine.effective_port(defaults);
let url = format!("http://localhost:{port}{path}");
let output = if machine.local {
let mut cmd = Command::new("curl");
cmd.args(["-s", "--max-time", "5", "-X", "POST"]);
cmd.args(["-H", "Content-Type: application/json"]);
cmd.arg("-d").arg(json_body);
cmd.arg(&url);
cmd.output().map_err(|e| format!("curl failed: {e}"))?
} else {
// Escape single quotes in json_body for the shell
let escaped = json_body.replace('\'', "'\\''");
let curl_cmd = format!(
"curl -s --max-time 5 -X POST -H 'Content-Type: application/json' -d '{escaped}' '{url}'"
);
Command::new("ssh")
.arg(&machine.ssh)
.arg(&curl_cmd)
.output()
.map_err(|e| format!("ssh curl failed: {e}"))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("POST {url} failed: {stderr}"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
// ── Deploy orchestration ────────────────────────────────────────────────────
pub fn run_deploy(
root: &Path,
config_path: &str,
skip_build: bool,
skip_verify: bool,
skip_peers: bool,
) {
// Phase 1: Load config
println!("=== Phase 1: Loading config ===\n");
let config = load_deploy_config(root, config_path);
println!(" Loaded {} machine(s): {}", config.machines.len(),
config.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>().join(", "));
println!();
// Phase 2: Build image
let archive = std::env::temp_dir().join("swactor-deploy.tar.gz");
if !skip_build {
println!("=== Phase 2: Building Docker image ===\n");
build_image(root, &config.defaults.image, &archive);
println!();
} else {
println!("=== Phase 2: Skipping build ===\n");
if !archive.exists() {
eprintln!("Warning: --skip-build but {} does not exist", archive.display());
eprintln!(" Run without --skip-build first, or ensure the archive exists.\n");
}
}
// Phase 3: Deploy to each machine
println!("=== Phase 3: Deploying to machines ===\n");
for machine in &config.machines {
deploy_to_machine(machine, &config.defaults, &archive);
}
println!();
// Phase 4: Health check
if !skip_verify {
println!("=== Phase 4: Health check ===\n");
for machine in &config.machines {
health_check(machine, &config.defaults);
}
println!();
}
// Phase 4b: Inject relay_hosts into container configs
if !config.defaults.relay_hosts.is_empty() {
println!("=== Phase 4b: Injecting relay_hosts config ===\n");
inject_relay_hosts(&config.machines, &config.defaults);
println!();
}
// Phase 5: Peer introduction + convergence
if !skip_verify && !skip_peers && config.defaults.introduce_peers {
println!("=== Phase 5: Peer introduction ===\n");
introduce_peers(&config.machines, &config.defaults);
println!("\n=== Phase 5b: Waiting for convergence ===\n");
wait_for_cluster_convergence(&config.machines, &config.defaults);
println!();
}
// Phase 6: Report
println!("=== Phase 6: Cluster status ===\n");
print_cluster_status(&config.machines, &config.defaults);
}
fn load_deploy_config(root: &Path, config_path: &str) -> DeployConfig {
let path = if Path::new(config_path).is_absolute() {
config_path.to_string()
} else {
root.join(config_path).to_string_lossy().to_string()
};
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("Failed to read config '{}': {e}", path);
eprintln!("Create a deploy.toml in the workspace root. See the plan for the format.");
std::process::exit(1);
});
let config: DeployConfig = toml::from_str(&content).unwrap_or_else(|e| {
eprintln!("Failed to parse config '{}': {e}", path);
std::process::exit(1);
});
if config.machines.is_empty() {
eprintln!("Error: deploy config has no [[machines]] entries");
std::process::exit(1);
}
config
}
fn build_image(root: &Path, image: &str, archive: &Path) {
println!(" Building swactor-node (musl, static, release)...");
let status = Command::new("cargo")
.args(["build", "--release", "-p", "swactor-node", "--target", "x86_64-unknown-linux-musl"])
.current_dir(root)
.status()
.unwrap_or_else(|e| {
eprintln!("Failed to run cargo build: {e}");
std::process::exit(1);
});
if !status.success() {
eprintln!("cargo build failed");
std::process::exit(1);
}
println!(" Packaging Docker image '{image}'...");
let status = Command::new("docker")
.args(["build", "-t", image, "."])
.current_dir(root)
.status()
.unwrap_or_else(|e| {
eprintln!("Failed to run docker build: {e}");
std::process::exit(1);
});
if !status.success() {
eprintln!("docker build failed");
std::process::exit(1);
}
println!(" Saving image to {}...", archive.display());
let pipe_cmd = format!(
"docker save {} | gzip > {}",
image,
archive.display()
);
let status = Command::new("sh")
.arg("-c")
.arg(&pipe_cmd)
.status()
.unwrap_or_else(|e| {
eprintln!("Failed to save docker image: {e}");
std::process::exit(1);
});
if !status.success() {
eprintln!("docker save | gzip failed");
std::process::exit(1);
}
let size = std::fs::metadata(archive).map(|m| m.len()).unwrap_or(0);
println!(" Image archive: {:.1} MB", size as f64 / 1_048_576.0);
}
fn deploy_to_machine(machine: &MachineConfig, defaults: &DeployDefaults, archive: &Path) {
let container = machine.effective_container(defaults);
let port = machine.effective_port(defaults);
let relay_port = machine.effective_relay_port(defaults);
let flags = machine.effective_flags(defaults);
let image = &defaults.image;
println!(" Deploying to {}...", machine.name);
// Step 1: Transfer image
if let Err(e) = transfer_image(machine, archive) {
eprintln!(" Error: {e}");
std::process::exit(1);
}
// Step 2: Stop + remove existing containers
// Kill the named container and any other containers from the same image
// so stale instances don't hold ports (e.g. relay port 3340).
let stop_cmd = format!(
concat!(
"docker kill {container} 2>/dev/null; docker rm {container} 2>/dev/null; ",
"for cid in $(docker ps -q --filter ancestor={image} 2>/dev/null); do ",
"docker kill $cid 2>/dev/null; docker rm $cid 2>/dev/null; ",
"done; sleep 1; true",
),
container = container,
image = image,
);
if machine.local {
let _ = Command::new("sh").arg("-c").arg(&stop_cmd).status();
} else {
let status = ssh_cmd(machine, &stop_cmd).status();
if let Err(e) = status {
eprintln!(" Warning: failed to stop/rm old container: {e}");
}
}
// Step 3: Start new container
let mut run_parts = vec![
"docker".to_string(), "run".to_string(), "-d".to_string(),
"--name".to_string(), container.clone(),
"--network".to_string(), "host".to_string(),
"--restart".to_string(), "unless-stopped".to_string(),
image.clone(),
"--dashboard-port".to_string(), port.to_string(),
"--relay-port".to_string(), relay_port.to_string(),
];
run_parts.extend(flags);
let status = if machine.local {
Command::new(&run_parts[0])
.args(&run_parts[1..])
.status()
.unwrap_or_else(|e| {
eprintln!(" Failed to start container on {}: {e}", machine.name);
std::process::exit(1);
})
} else {
let run_cmd = run_parts.iter()
.map(|a| if a.contains(' ') { format!("'{a}'") } else { a.clone() })
.collect::<Vec<_>>()
.join(" ");
ssh_cmd(machine, &run_cmd)
.status()
.unwrap_or_else(|e| {
eprintln!(" Failed to start container on {}: {e}", machine.name);
std::process::exit(1);
})
};
if !status.success() {
eprintln!(" Container start failed on {}", machine.name);
std::process::exit(1);
}
println!(" {} deployed (container: {container}, dashboard: {port}, relay: {relay_port})", machine.name);
}
fn health_check(machine: &MachineConfig, defaults: &DeployDefaults) {
let timeout = Duration::from_secs(defaults.health_timeout_secs);
let start = Instant::now();
print!(" Checking {}...", machine.name);
loop {
if start.elapsed() > timeout {
println!(" TIMEOUT");
eprintln!(" Health check failed for {} ({}s timeout)", machine.name, defaults.health_timeout_secs);
std::process::exit(1);
}
if let Some(body) = machine_curl_get(machine, defaults, "/api/stats") {
if serde_json::from_str::<serde_json::Value>(&body).is_ok() {
println!(" OK");
return;
}
}
thread::sleep(Duration::from_secs(2));
}
}
fn inject_relay_hosts(machines: &[MachineConfig], defaults: &DeployDefaults) {
// Format the TOML line to append
let hosts_toml = format!(
"relay_hosts = [{}]\n",
defaults.relay_hosts.iter()
.map(|h| format!("\"{h}\""))
.collect::<Vec<_>>()
.join(", ")
);
// The container has no shell, so we use docker cp to modify the config:
// 1. docker cp container:/.swactor/node.toml /tmp/...
// 2. append relay_hosts line
// 3. docker cp /tmp/... container:/.swactor/node.toml
for machine in machines {
let container = machine.effective_container(defaults);
let tmp_file = format!("/tmp/swactor-relay-inject-{}.toml", machine.name);
let result = if machine.local {
inject_relay_hosts_local(&container, &tmp_file, &hosts_toml)
} else {
inject_relay_hosts_remote(machine, &container, &hosts_toml)
};
match result {
Ok(()) => println!(" {} relay_hosts injected", machine.name),
Err(e) => eprintln!(" Warning: inject on {} failed: {e}", machine.name),
}
}
// Restart containers to pick up the new config
println!(" Restarting containers...");
for machine in machines {
let container = machine.effective_container(defaults);
let restart_cmd = format!("docker restart {container}");
let status = if machine.local {
Command::new("sh").arg("-c").arg(&restart_cmd).status()
} else {
ssh_cmd(machine, &restart_cmd).status()
};
match status {
Ok(s) if s.success() => println!(" {} restarted", machine.name),
Ok(s) => eprintln!(" Warning: restart {} exited {}", machine.name, s),
Err(e) => eprintln!(" Warning: restart {} failed: {e}", machine.name),
}
}
// Brief pause for containers to come back up
println!(" Waiting for containers to restart...");
thread::sleep(Duration::from_secs(5));
// Re-run health checks after restart
for machine in machines {
health_check(machine, defaults);
}
}
fn inject_relay_hosts_local(container: &str, tmp_file: &str, hosts_toml: &str) -> Result<(), String> {
// Copy config out of container
let cp_out = format!("docker cp {container}:/.swactor/node.toml {tmp_file}");
let status = Command::new("sh").arg("-c").arg(&cp_out)
.status().map_err(|e| format!("docker cp out: {e}"))?;
if !status.success() {
return Err("docker cp out failed".into());
}
// Replace or append relay_hosts
let contents = std::fs::read_to_string(tmp_file)
.map_err(|e| format!("read tmp: {e}"))?;
let updated = if contents.contains("relay_hosts") {
contents.lines()
.map(|line| {
if line.trim_start().starts_with("relay_hosts") {
hosts_toml.trim_end()
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n") + "\n"
} else {
let mut s = contents;
if !s.ends_with('\n') && !s.is_empty() { s.push('\n'); }
s.push_str(hosts_toml);
s
};
std::fs::write(tmp_file, &updated)
.map_err(|e| format!("write tmp: {e}"))?;
// Copy config back into container
let cp_in = format!("docker cp {tmp_file} {container}:/.swactor/node.toml");
let status = Command::new("sh").arg("-c").arg(&cp_in)
.status().map_err(|e| format!("docker cp in: {e}"))?;
if !status.success() {
return Err("docker cp in failed".into());
}
let _ = std::fs::remove_file(tmp_file);
Ok(())
}
fn inject_relay_hosts_remote(machine: &MachineConfig, container: &str, hosts_toml: &str) -> Result<(), String> {
// Write the desired line to a local temp file, scp it, then use a simple
// shell script on the remote to merge it into the container's config.
let local_tmp = format!("/tmp/swactor-relay-line-{}.txt", machine.name);
std::fs::write(&local_tmp, hosts_toml)
.map_err(|e| format!("write local tmp: {e}"))?;
// scp the line file to the remote
let scp_status = Command::new("scp")
.args([&local_tmp, &format!("{}:/tmp/swactor-relay-line.txt", machine.ssh)])
.status().map_err(|e| format!("scp: {e}"))?;
if !scp_status.success() {
let _ = std::fs::remove_file(&local_tmp);
return Err("scp relay line failed".into());
}
let _ = std::fs::remove_file(&local_tmp);
// On the remote: docker cp out, filter+append, docker cp back
let remote_cmd = format!(
concat!(
"docker cp {container}:/.swactor/node.toml /tmp/swactor-inject.toml && ",
"grep -v '^relay_hosts' /tmp/swactor-inject.toml > /tmp/swactor-inject2.toml && ",
"cat /tmp/swactor-relay-line.txt >> /tmp/swactor-inject2.toml && ",
"docker cp /tmp/swactor-inject2.toml {container}:/.swactor/node.toml && ",
"rm -f /tmp/swactor-inject.toml /tmp/swactor-inject2.toml /tmp/swactor-relay-line.txt"
),
container = container,
);
let status = ssh_cmd(machine, &remote_cmd)
.status().map_err(|e| format!("ssh: {e}"))?;
if !status.success() {
return Err("remote inject failed".into());
}
Ok(())
}
fn introduce_peers(machines: &[MachineConfig], defaults: &DeployDefaults) {
// Step 1: Collect node_id and relay_url from each machine
let mut node_info: Vec<(String, String, Option<String>)> = Vec::new(); // (name, node_id, relay_url)
for machine in machines {
let body = machine_curl_get(machine, defaults, "/api/distribution").unwrap_or_else(|| {
eprintln!(" Failed to get node_id from {}", machine.name);
std::process::exit(1);
});
let snap: serde_json::Value = serde_json::from_str(&body).unwrap_or_else(|e| {
eprintln!(" Invalid JSON from {}: {e}", machine.name);
std::process::exit(1);
});
let node_id = snap.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or_else(|| {
eprintln!(" No node_id in response from {}", machine.name);
std::process::exit(1);
})
.to_string();
let relay_url = snap.get("relay_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
println!(" {} node_id: {}...{}{}", machine.name, &node_id[..8], &node_id[node_id.len()-8..],
relay_url.as_ref().map(|u| format!(" relay: {u}")).unwrap_or_default());
node_info.push((machine.name.clone(), node_id, relay_url));
}
// Step 2: For each pair, POST /api/peers/add
println!();
for (i, machine) in machines.iter().enumerate() {
for (j, (peer_name, peer_node_id, peer_relay_url)) in node_info.iter().enumerate() {
if i == j { continue; }
let mut payload = serde_json::json!({
"node_id": peer_node_id,
"label": peer_name,
});
if let Some(url) = peer_relay_url {
payload["relay_url"] = serde_json::json!(url);
}
let body = payload.to_string();
match machine_curl_post(machine, defaults, "/api/peers/add", &body) {
Ok(_) => println!(" {} <- added peer {}", machine.name, peer_name),
Err(e) => {
eprintln!(" Warning: failed to add peer {} to {}: {e}", peer_name, machine.name);
}
}
}
}
}
fn wait_for_cluster_convergence(machines: &[MachineConfig], defaults: &DeployDefaults) {
let expected_alive = machines.len() - 1;
let timeout = Duration::from_secs(defaults.convergence_timeout_secs);
let start = Instant::now();
println!(" Waiting for all nodes to see >= {expected_alive} alive peers...");
loop {
if start.elapsed() > timeout {
println!(" TIMEOUT after {}s", defaults.convergence_timeout_secs);
for machine in machines {
match machine_curl_get(machine, defaults, "/api/distribution") {
Some(body) => {
if let Ok(snap) = serde_json::from_str::<serde_json::Value>(&body) {
let alive = snap.get("alive_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
eprintln!(" {}: alive_count={}", machine.name, alive);
}
}
None => eprintln!(" {}: unreachable", machine.name),
}
}
eprintln!("\n Convergence failed. Nodes may still be joining.");
std::process::exit(1);
}
let all_converged = machines.iter().all(|m| {
machine_curl_get(m, defaults, "/api/distribution")
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
.and_then(|snap| snap.get("alive_count").and_then(|v| v.as_u64()))
.map(|alive| alive as usize >= expected_alive)
.unwrap_or(false)
});
if all_converged {
println!(" Converged! All nodes see >= {expected_alive} alive peers.");
return;
}
thread::sleep(Duration::from_secs(3));
}
}
fn print_cluster_status(machines: &[MachineConfig], defaults: &DeployDefaults) {
println!(" {:<12} {:>5} {:>7} {:>5} {:>5}",
"NAME", "ALIVE", "ROUTING", "DIR", "CACHE");
println!(" {}", "-".repeat(45));
for machine in machines {
match machine_curl_get(machine, defaults, "/api/distribution") {
Some(body) => {
if let Ok(snap) = serde_json::from_str::<serde_json::Value>(&body) {
let alive = snap.get("alive_count").and_then(|v| v.as_u64()).unwrap_or(0);
let routing = snap.get("routing_table_size").and_then(|v| v.as_u64()).unwrap_or(0);
let dir = snap.get("directory_entry_count").and_then(|v| v.as_u64()).unwrap_or(0);
let cache = snap.get("cache_size").and_then(|v| v.as_u64()).unwrap_or(0);
println!(" {:<12} {:>5} {:>7} {:>5} {:>5}",
machine.name, alive, routing, dir, cache);
} else {
println!(" {:<12} -- invalid JSON --", machine.name);
}
}
None => {
println!(" {:<12} -- unreachable --", machine.name);
}
}
}
println!();
}

View file

@ -1,3 +1,5 @@
mod deploy;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
@ -39,26 +41,22 @@ enum Cmd {
list: bool,
},
/// Start a datastore node
/// Start a local swactor node (full features, no cluster)
#[command(trailing_var_arg = true)]
Node {
/// Port for the node
/// Dashboard HTTP port
#[arg(long)]
port: Option<u16>,
/// Storage path
/// Storage path for persistent datastore (omit for in-memory)
#[arg(long)]
storage_path: Option<String>,
/// Enable auth (bare --auth → true, --auth=false → false)
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
auth: Option<bool>,
/// Auth directory
/// Build in release mode
#[arg(long)]
auth_dir: Option<String>,
release: bool,
/// Extra arguments forwarded to the underlying binary
/// Extra arguments forwarded to the swactor binary
#[arg(allow_hyphen_values = true)]
extra: Vec<String>,
},
@ -108,26 +106,39 @@ enum Cmd {
/// Identity directories to include
dirs: Vec<String>,
},
/// Deploy swactor to remote machines
Deploy {
/// Deploy via Docker over SSH (build image, push, run containers)
#[arg(long)]
docker: bool,
/// Path to deploy config file
#[arg(long, default_value = ".deploy/deploy.toml")]
config: String,
/// Skip Docker image build (use existing archive)
#[arg(long)]
skip_build: bool,
/// Skip health check and convergence verification
#[arg(long)]
skip_verify: bool,
/// Skip peer introduction (deploy only)
#[arg(long)]
skip_peers: bool,
},
}
// ── Config file ─────────────────────────────────────────────────────
#[derive(Deserialize, Default)]
struct Config {
#[serde(default)]
node: NodeConfig,
#[serde(default)]
cli: CliConfig,
}
#[derive(Deserialize, Default)]
struct NodeConfig {
port: Option<u16>,
storage_path: Option<String>,
auth: Option<bool>,
auth_dir: Option<String>,
}
#[derive(Deserialize, Default)]
struct CliConfig {
url: Option<String>,
@ -210,13 +221,11 @@ const INTEGRATED: Group = Group {
description: "HTTP API + dashboard end-to-end tests",
steps: &[
TestStep {
label: "datastore integration (node features)",
label: "datastore integration",
args: &[
"test",
"-p",
"swactor-datastore",
"--features",
"node",
"--test",
"api_integration_test",
"--test",
@ -224,8 +233,8 @@ const INTEGRATED: Group = Group {
],
},
TestStep {
label: "runtime dashboard",
args: &["test", "-p", "runtime-dashboard"],
label: "dashboard",
args: &["test", "-p", "dashboard"],
},
],
};
@ -267,7 +276,9 @@ USAGE: cargo xtask <COMMAND>
COMMANDS:
test <GROUP> Run a test group
dev-node [OPTS] Launch a dev node (distribution + dashboard + datastore)
node [OPTS] Start a local swactor node (full features, no cluster)
dev-node [OPTS] Launch a dev node (legacy)
build Build the swactor binary (release)
TEST GROUPS:
core Actor runtime, message delivery, property tests
@ -280,14 +291,11 @@ TEST GROUPS:
TEST FLAGS:
--list Show all groups and the cargo commands they run
DEV OPTIONS:
--port PORT Dashboard port (default: 9090)
--actors N Dummy heartbeat actors (default: 3)
--storage PATH Persistent storage dir (omit for in-memory)
--no-datastore Disable datastore entirely
--tcp Use TCP transport instead of iroh (requires --listen)
--listen ADDR TCP listen address (e.g. 127.0.0.1:7000)
--release Build in release mode"
NODE OPTIONS:
--port PORT Dashboard port (default: 9091)
--storage-path PATH Persistent storage dir (omit for in-memory)
--release Build in release mode
-- [EXTRA...] Extra args forwarded to swactor binary"
);
}
@ -518,29 +526,21 @@ fn run_dev(extra_args: Vec<String>) {
fn run_node(
port: Option<u16>,
storage_path: Option<String>,
auth: Option<bool>,
auth_dir: Option<String>,
release: bool,
extra: Vec<String>,
cfg: &NodeConfig,
) {
ignore_sigint();
let port = port.or(cfg.port).unwrap_or(9091);
let storage_path = storage_path
.or_else(|| cfg.storage_path.clone())
.unwrap_or_else(|| "./datastore".into());
let auth_enabled = auth.or(cfg.auth).unwrap_or(true);
let auth_dir = auth_dir
.or_else(|| cfg.auth_dir.clone())
.unwrap_or_else(|| "./auth".into());
// Build first, then run the binary directly (not via `cargo run`).
// This avoids cargo sitting in the middle of the process chain and
// dying from SIGINT before the node finishes its shutdown.
// Build the full swactor binary (same one produced by `cargo xtask build`).
let mut build_args = vec![
"build", "-p", "swactor-node",
];
if release {
build_args.push("--release");
}
let build_status = Command::new("cargo")
.args([
"build", "-p", "swactor-datastore", "--features", "node",
"--bin", "swactor-store-node",
])
.args(&build_args)
.status();
match build_status {
Ok(s) if !s.success() => std::process::exit(s.code().unwrap_or(1)),
@ -553,27 +553,50 @@ fn run_node(
// Locate the built binary
let root = workspace_root();
let binary = root.join("target/debug/swactor-store-node");
let profile = if release { "release" } else { "debug" };
let binary = root.join(format!("target/{profile}/swactor"));
if !binary.exists() {
eprintln!("Binary not found at {}", binary.display());
std::process::exit(1);
}
// Use a local working directory so the node doesn't write into ~/.swactor
let work_dir = root.join(".dev-node");
std::fs::create_dir_all(&work_dir).expect("failed to create .dev-node directory");
let identity_dir = work_dir.join("identity");
let auth_dir = work_dir.join("auth");
let default_storage = work_dir.join("datastore");
std::fs::create_dir_all(&identity_dir).expect("failed to create identity dir");
std::fs::create_dir_all(&auth_dir).expect("failed to create auth dir");
// Write a minimal config so the swactor binary doesn't auto-create ~/.swactor
let config_path = work_dir.join("node.toml");
let storage = storage_path.unwrap_or_else(|| default_storage.to_string_lossy().into_owned());
let port = port.unwrap_or(9091);
let config_content = format!(
r#"transport = "iroh"
dashboard_port = {port}
storage_path = "{storage}"
identity_dir = "{identity}"
auth = true
auth_dir = "{auth}"
"#,
identity = identity_dir.display(),
auth = auth_dir.display(),
);
std::fs::write(&config_path, &config_content).expect("failed to write dev config");
let mut bin_args: Vec<String> = vec![
"--port".into(),
port.to_string(),
"--storage-path".into(),
storage_path,
"--config".into(),
config_path.to_string_lossy().into_owned(),
];
if auth_enabled {
bin_args.push("--auth".into());
bin_args.push("--auth-dir".into());
bin_args.push(auth_dir);
}
bin_args.extend(extra);
println!(" {} {}", binary.display(), bin_args.join(" "));
println!();
let status = Command::new(&binary).args(&bin_args).status();
match status {
Ok(s) if !s.success() => std::process::exit(s.code().unwrap_or(1)),
@ -656,7 +679,7 @@ fn run_wasm() {
"build",
"--target", "wasm32-unknown-unknown",
"--release",
"-p", "swactor-crypto-wasm",
"-p", "wasm-crypto",
])
.status();
@ -672,7 +695,7 @@ fn run_wasm() {
_ => {}
}
let src = root.join("target/wasm32-unknown-unknown/release/swactor_crypto_wasm.wasm");
let src = root.join("target/wasm32-unknown-unknown/release/wasm_crypto.wasm");
let dst = root.join("crates/datastore/src/crypto_wasm.wasm");
std::fs::copy(&src, &dst).unwrap_or_else(|e| {
@ -896,10 +919,9 @@ fn main() {
Cmd::Node {
port,
storage_path,
auth,
auth_dir,
release,
extra,
} => run_node(port, storage_path, auth, auth_dir, extra, &config.node),
} => run_node(port, storage_path, release, extra),
Cmd::DevNode { extra } => run_dev(extra),
Cmd::Cli {
url,
@ -908,5 +930,15 @@ fn main() {
} => run_cli(url, key, extra, &config.cli),
Cmd::InitNode { role, dir } => run_init_node(&role, dir.as_deref()),
Cmd::GenPeers { dirs } => run_gen_peers(&dirs),
Cmd::Deploy { docker, config, skip_build, skip_verify, skip_peers } => {
if docker {
deploy::run_deploy(&root, &config, skip_build, skip_verify, skip_peers);
} else {
eprintln!("Unimplemented. In the future `cargo xtask deploy` will take a \
`deploy.toml` config, and attempt to deploy/update the nodes provided \
in the config.");
std::process::exit(1);
}
}
}
}