refactor: consolidate crate functions #50

Merged
zacheryasc merged 3 commits from consolidate into master 2026-02-24 09:12:30 +00:00
328 changed files with 4102 additions and 40297 deletions

2262
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,20 @@
[workspace] [workspace]
members = [ members = [
".", ".",
"crates/python", "crates/bindings/python",
"crates/wasm/runtime", "crates/bindings/wasm-runtime",
"crates/bin-runner",
"crates/simulation", "crates/simulation",
"crates/dashboard", "crates/dashboard",
"crates/distribution", "crates/distribution",
"crates/std",
"crates/process", "crates/process",
"crates/datastore", "crates/datastore",
"crates/shared-types", "crates/transport",
"crates/swactor-node", "crates/node",
"crates/streams",
"tests/docker", "tests/docker",
"crates/ci", "tests/integration",
"xtask", "xtask",
] ]
exclude = ["tools/depgraph", "crates/wasm/crypto"] exclude = ["crates/bindings/wasm-crypto"]
[package] [package]
name = "swactor" name = "swactor"
@ -33,7 +30,8 @@ strip = false
crate-type = ["rlib"] crate-type = ["rlib"]
[features] [features]
default = ["getrandom"] default = ["getrandom", "std"]
std = [] # OTP patterns: supervisors, registries, timers, routers
getrandom = ["dep:getrandom"] getrandom = ["dep:getrandom"]
serde = ["dep:serde"] serde = ["dep:serde"]
tracing = ["dep:tracing"] tracing = ["dep:tracing"]
@ -54,7 +52,6 @@ crossbeam-utils = "0.8.21"
criterion = { version = "0.5", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }
proptest = "1" proptest = "1"
proptest-state-machine = "0.3" proptest-state-machine = "0.3"
swactor-std = { path = "crates/std" }
[[bench]] [[bench]]
name = "runtime_benchmarks" name = "runtime_benchmarks"
@ -68,6 +65,3 @@ harness = false
name = "hasher_benchmarks" name = "hasher_benchmarks"
harness = false harness = false
[[example]]
name = "tcp_ping_pong"
required-features = ["transport"]

154
README.md
View file

@ -1,152 +1,24 @@
# swactor # swactor
Minimal actor runtime for Rust. Single-threaded or multi-threaded, with Minimal actor runtime for Rust. One trait, one message type.
Python and WebAssembly bindings. Single-threaded (`tick()`) or multi-threaded (`run()`).
## Quick Start ## Description
```rust Core runtime is `src/`. Actors implement `ActorInterface` (in `actor.rs`),
use swactor::actor::{ActorAddress, ActorInterface}; interact through `Ctx` (in `runtime.rs`), and run on worker threads (`worker.rs`).
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
#[derive(Clone)] `crates/` builds upward: `std` adds OTP patterns (supervision, monitoring, groups),
struct Greet { name: String, reply_to: ActorAddress } `distribution` adds clustering, everything else composes from there.
#[derive(Clone)] ## Dev commands
struct Greeting(String);
struct Greeter; `cargo xtask --help` for available test groups.
impl ActorInterface for Greeter { ## Testing
type Incoming = Greet;
type Response = Greeting;
fn handle(&mut self, ctx: &Ctx, msg: Greet) {
let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
}
}
fn main() {
let rt = Runtime::new(RuntimeConfig::default());
let addr = rt.spawn(Greeter).unwrap();
let inbox = rt.new_inbox::<Greeting>().unwrap();
rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
rt.tick();
rt.tick();
println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
}
``` ```
cargo check --workspace
## Features cargo xtask test <your-feature-crate>
cargo xtask test essential
### Actor Model
Actors implement one trait (`ActorInterface`), receive one message type, and
hold mutable state. No lifecycle hooks, no supervision trees, no async.
Every actor gets a 32-byte globally unique `ActorAddress`. The same
`ctx.send(addr, msg)` call works whether the target is on the same worker,
a different worker thread, an external inbox, or a remote process.
Single-threaded mode (`rt.tick()`) gives deterministic frame-level control.
Multi-threaded mode (`rt.run()`) spawns OS threads with adaptive backoff.
See [docs/runtime/actor-model.md](docs/runtime/actor-model.md) and
[docs/runtime/runtime.md](docs/runtime/runtime.md) for the full model.
### Transport
Pluggable cross-process messaging. User-provided codecs handle serialization
(gRPC/protobuf, bincode, hand-rolled — no serde bounds imposed) and
user-provided transports handle delivery (TCP, in-memory, gRPC channel).
```bash
cargo build --features transport
cargo run --example tcp_ping_pong --features transport -- receiver # terminal 1
cargo run --example tcp_ping_pong --features transport -- sender # terminal 2
``` ```
See [docs/distribution/transport.md](docs/distribution/transport.md) for the routing chain, codec
registry, and address resolution.
### Runtime Dashboard
Live web dashboard for monitoring actors, message throughput, and mailbox
depths. Supports trace recording and replay at configurable speed.
Includes hand-authored SVG diagrams (actor lifecycle, message lifecycle,
tick cycle, transport routing) and generated diagrams from DOT sources
(architecture, dataflow, type erasure).
See [crates/runtime-dashboard/](crates/runtime-dashboard/README.md).
### Language Bindings
**Python** — PyO3 via Maturin. Spawn actors from Python callables, pass
dicts as messages, single-threaded or multi-threaded.
```bash
cd crates/swactor-python && maturin develop
```
Examples in `examples/python/` (single-thread, async, Jupyter notebook).
**WASM** — wasm-bindgen. Runs single-threaded with deterministic addressing
(`no_random` feature).
```bash
cd crates/swactor-wasm && wasm-pack build --target nodejs
```
### Connectome Analysis
Structural analysis of the internal dependency graph.
- **depgraph** (`tools/depgraph/`) — AST-based extraction of module
dependencies, outputs GraphViz DOT
- **spectral** (`tools/spectral/`) — Laplacian eigenvalue analysis,
Connectome Complexity Index (CCI), coupling heatmaps, interactive HTML
dashboard
```bash
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
python tools/spectral/spectral_analysis.py deps.dot
```
See [docs/connectome/connectome.md](docs/connectome/connectome.md) for metric interpretation.
## Building & Testing
```bash
cargo test # all tests
cargo test --features transport # include transport tests
cargo run --example hello # single actor example
cargo run --example ring # 500-actor ring topology
cargo bench # benchmarks (criterion)
```
## Feature Flags
| Flag | Default | What it does |
|------|---------|--------------|
| `getrandom` | yes | System RNG for actor addresses |
| `no_random` | no | Deterministic counter (WASM / reproducible tests) |
| `transport` | no | Pluggable remote messaging (codec + transport) |
| `tracing` | no | `tracing` instrumentation for runtime internals |
| `serde` | no | Serde derives for stats types |
| `python` | no | PyO3 bindings (cdylib wheel) |
## Documentation
| Document | Covers |
|----------|--------|
| [Actor Model](docs/runtime/actor-model.md) | Traits, type erasure, addresses |
| [Runtime](docs/runtime/runtime.md) | Runtime, Ctx, Inbox, RuntimeHandle, stats |
| [Worker Thread](docs/runtime/worker-thread.md) | Tick phases, backoff, routing, full system topology |
| [Channels](docs/runtime/channels.md) | HybridChannel, AddressMap, Placement |
| [Transport](docs/distribution/transport.md) | Codec, Transport, remote messaging, address resolution |
| [Distribution](docs/distribution/distribution.md) | SWIM membership, Kademlia, NodeDriver |
| [Connectome](docs/connectome/connectome.md) | CCI metrics, spectral analysis interpretation |
| [Dashboard](crates/runtime-dashboard/README.md) | Live web UI, trace recording, diagram index |

View file

@ -7,7 +7,7 @@ use swactor::{
config::RuntimeConfig, config::RuntimeConfig,
runtime::{Ctx, Runtime}, runtime::{Ctx, Runtime},
}; };
use swactor_std::{RuntimeGroups, RuntimeNaming, StdExtension}; use swactor::std::{RuntimeGroups, RuntimeNaming, StdExtension};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper // Helper

View file

@ -1,14 +0,0 @@
[package]
name = "swactor-bin-runner"
version = "0.1.0"
edition = "2024"
[dependencies]
swactor = { path = "../.." }
wasmtime = "29"
[dev-dependencies]
swactor = { path = "../..", features = ["getrandom"] }
swactor-std = { path = "../std" }
wat = "1"
proptest = "1"

View file

@ -1,28 +0,0 @@
use std::path::Path;
use std::process::Command;
fn main() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let guests_dir = Path::new(manifest_dir).join("tests/guests");
for guest in &["echo", "double", "silent"] {
let guest_dir = guests_dir.join(guest);
println!(
"cargo:rerun-if-changed={}",
guest_dir.join("src/lib.rs").display()
);
println!(
"cargo:rerun-if-changed={}",
guest_dir.join("Cargo.toml").display()
);
let status = Command::new("cargo")
.args(["build", "--target", "wasm32-unknown-unknown", "--release"])
.current_dir(&guest_dir)
.status()
.unwrap_or_else(|e| panic!("failed to run cargo build for {guest} guest: {e}"));
assert!(status.success(), "failed to build {guest} guest");
}
}

View file

@ -1,63 +0,0 @@
use swactor::actor::{ActorInterface, Ctx};
use wasmtime::{Memory, Store, TypedFunc};
use crate::ByteMessage;
/// State accessible to host functions during guest execution.
#[derive(Default)]
pub(crate) struct HostState {
pub outbox: Vec<(swactor::actor::ActorAddress, Vec<u8>)>,
}
/// An actor whose logic is defined by a WebAssembly guest module.
///
/// Messages arrive as [`ByteMessage`], are copied into Wasm linear memory,
/// and processed by the guest's `handle` export. The guest can send messages
/// back via the `swactor.send` host import.
pub struct WasmActor {
pub(crate) store: Store<HostState>,
pub(crate) memory: Memory,
pub(crate) alloc: TypedFunc<i32, i32>,
pub(crate) handle: TypedFunc<(i32, i32), ()>,
}
impl ActorInterface for WasmActor {
type Incoming = ByteMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: ByteMessage) {
let bytes = &msg.0;
let len: i32 = match i32::try_from(bytes.len()) {
Ok(n) => n,
Err(_) => return, // message too large for i32 ABI
};
// 1. Allocate space in guest memory
let ptr = match self.alloc.call(&mut self.store, len) {
Ok(ptr) if ptr < 0 => return, // invalid pointer
Ok(0) if len > 0 => return, // OOM — drop message
Ok(ptr) => ptr,
Err(_) => return, // alloc trapped — drop message
};
// 2. Write message bytes into guest memory
let mem = self.memory.data_mut(&mut self.store);
let end = (ptr as usize).saturating_add(bytes.len());
if end > mem.len() {
return; // alloc returned OOB pointer — drop message
}
mem[ptr as usize..end].copy_from_slice(bytes);
// 3. Call guest handle
if self.handle.call(&mut self.store, (ptr, len)).is_err() {
self.store.data_mut().outbox.clear(); // discard sends from incomplete operation
return; // handle trapped — drop message, keep actor alive
}
// 4. Drain outbox → send via ctx
let outbox: Vec<_> = self.store.data_mut().outbox.drain(..).collect();
for (dest, payload) in outbox {
let _ = ctx.send(dest, ByteMessage(payload));
}
}
}

View file

@ -1,109 +0,0 @@
use swactor::actor::ActorAddress;
use wasmtime::{Linker, Module, Store, TypedFunc};
use crate::actor::{HostState, WasmActor};
use crate::engine::SharedEngine;
use crate::error::WasmActorError;
/// Compiles a Wasm module and produces a ready-to-use [`WasmActor`].
pub struct WasmActorBuilder {
engine: SharedEngine,
wasm_bytes: Vec<u8>,
}
impl WasmActorBuilder {
pub fn new(engine: SharedEngine, wasm_bytes: impl Into<Vec<u8>>) -> Self {
Self {
engine,
wasm_bytes: wasm_bytes.into(),
}
}
/// Compile the module, link host functions, and instantiate.
pub fn build(self) -> Result<WasmActor, WasmActorError> {
let engine = self.engine.inner();
let module = Module::new(engine, &self.wasm_bytes)?;
let mut linker: Linker<HostState> = Linker::new(engine);
Self::link_send(&mut linker)?;
let mut store = Store::new(engine, HostState::default());
let instance = linker.instantiate(&mut store, &module)?;
// Extract required exports
let memory = instance
.get_memory(&mut store, "memory")
.ok_or(WasmActorError::MissingExport("memory"))?;
let alloc: TypedFunc<i32, i32> = instance
.get_typed_func(&mut store, "alloc")
.map_err(|_| WasmActorError::MissingExport("alloc"))?;
let handle: TypedFunc<(i32, i32), ()> = instance
.get_typed_func(&mut store, "handle")
.map_err(|_| WasmActorError::MissingExport("handle"))?;
Ok(WasmActor {
store,
memory,
alloc,
handle,
})
}
/// Link the `swactor.send` host import.
fn link_send(linker: &mut Linker<HostState>) -> Result<(), WasmActorError> {
linker.func_wrap(
"swactor",
"send",
|mut caller: wasmtime::Caller<'_, HostState>,
dest_ptr: i32,
payload_ptr: i32,
payload_len: i32|
-> Result<(), wasmtime::Error> {
let mem = caller
.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or_else(|| wasmtime::Error::msg("guest must export memory"))?;
let data = mem.data(&caller);
let mem_len = data.len();
// Validate non-negative arguments
if dest_ptr < 0 || payload_ptr < 0 || payload_len < 0 {
return Err(wasmtime::Error::msg(
"negative argument in swactor.send",
));
}
let dest_ptr = dest_ptr as usize;
let payload_ptr = payload_ptr as usize;
let payload_len = payload_len as usize;
// Bounds-check with overflow protection
let dest_end = dest_ptr
.checked_add(32)
.ok_or_else(|| wasmtime::Error::msg("dest_ptr overflow"))?;
let payload_end = payload_ptr
.checked_add(payload_len)
.ok_or_else(|| wasmtime::Error::msg("payload range overflow"))?;
if dest_end > mem_len || payload_end > mem_len {
return Err(wasmtime::Error::msg(
"out-of-bounds memory access in swactor.send",
));
}
// Read 32-byte destination address
let mut addr_bytes = [0u8; 32];
addr_bytes.copy_from_slice(&data[dest_ptr..dest_end]);
let dest = ActorAddress(addr_bytes);
// Read payload
let payload = data[payload_ptr..payload_end].to_vec();
caller.data_mut().outbox.push((dest, payload));
Ok(())
},
)?;
Ok(())
}
}

View file

@ -1,35 +0,0 @@
use std::sync::Arc;
use wasmtime::Engine;
/// A shared, cheaply-cloneable Wasm engine.
///
/// Created once and reused across multiple [`WasmActor`](crate::WasmActor) instances.
/// Configured with maximum sandboxing — no threads, no SIMD, no reference types.
#[derive(Clone)]
pub struct SharedEngine(Arc<Engine>);
impl std::fmt::Debug for SharedEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedEngine").field(&"<Engine>").finish()
}
}
impl SharedEngine {
/// Create a new engine with sandboxed defaults.
pub fn new() -> Result<Self, wasmtime::Error> {
let mut config = wasmtime::Config::new();
config.wasm_threads(false);
config.wasm_simd(false);
config.wasm_relaxed_simd(false);
config.wasm_reference_types(false);
config.wasm_multi_value(false);
config.wasm_bulk_memory(true);
let engine = Engine::new(&config)?;
Ok(Self(Arc::new(engine)))
}
pub(crate) fn inner(&self) -> &Engine {
&self.0
}
}

View file

@ -1,27 +0,0 @@
use std::fmt;
/// Errors that can occur when building or running a WasmActor.
#[derive(Debug)]
pub enum WasmActorError {
/// A required export is missing from the Wasm module.
MissingExport(&'static str),
/// The Wasm module failed to compile or instantiate.
Wasmtime(wasmtime::Error),
}
impl fmt::Display for WasmActorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingExport(name) => write!(f, "missing required export: `{name}`"),
Self::Wasmtime(e) => write!(f, "wasmtime error: {e}"),
}
}
}
impl std::error::Error for WasmActorError {}
impl From<wasmtime::Error> for WasmActorError {
fn from(e: wasmtime::Error) -> Self {
Self::Wasmtime(e)
}
}

View file

@ -1,13 +0,0 @@
mod actor;
mod builder;
mod engine;
mod error;
pub use actor::WasmActor;
pub use builder::WasmActorBuilder;
pub use engine::SharedEngine;
pub use error::WasmActorError;
/// A message carrying raw bytes, suitable for passing to/from Wasm guests.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ByteMessage(pub Vec<u8>);

View file

@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "double-guest"
version = "0.1.0"

View file

@ -1,13 +0,0 @@
[workspace]
[package]
name = "double-guest"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true

View file

@ -1,63 +0,0 @@
#![no_std]
use core::cell::UnsafeCell;
use core::panic::PanicInfo;
// --- bump allocator ---
const HEAP_SIZE: usize = 65536;
struct BumpAlloc {
heap: UnsafeCell<[u8; HEAP_SIZE]>,
offset: UnsafeCell<usize>,
}
unsafe impl Sync for BumpAlloc {}
static ALLOC: BumpAlloc = BumpAlloc {
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
offset: UnsafeCell::new(0),
};
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: i32) -> i32 {
unsafe {
let offset = &mut *ALLOC.offset.get();
let heap = &mut *ALLOC.heap.get();
let align = 8;
let start = (*offset + align - 1) & !(align - 1);
let end = start + size as usize;
if end > heap.len() {
return 0; // OOM
}
*offset = end;
heap.as_ptr().add(start) as i32
}
}
// --- host import ---
#[link(wasm_import_module = "swactor")]
unsafe extern "C" {
#[link_name = "send"]
fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32);
}
/// Message format: first 32 bytes = destination address, rest = payload.
/// Sends the payload back twice to demonstrate multi-send.
#[unsafe(no_mangle)]
pub extern "C" fn handle(ptr: i32, len: i32) {
if len < 32 {
return;
}
let dest_ptr = ptr;
let payload_ptr = ptr + 32;
let payload_len = len - 32;
unsafe {
host_send(dest_ptr, payload_ptr, payload_len);
host_send(dest_ptr, payload_ptr, payload_len);
}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

View file

@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "echo-guest"
version = "0.1.0"

View file

@ -1,13 +0,0 @@
[workspace]
[package]
name = "echo-guest"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true

View file

@ -1,62 +0,0 @@
#![no_std]
use core::cell::UnsafeCell;
use core::panic::PanicInfo;
// --- bump allocator ---
const HEAP_SIZE: usize = 65536;
struct BumpAlloc {
heap: UnsafeCell<[u8; HEAP_SIZE]>,
offset: UnsafeCell<usize>,
}
unsafe impl Sync for BumpAlloc {}
static ALLOC: BumpAlloc = BumpAlloc {
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
offset: UnsafeCell::new(0),
};
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: i32) -> i32 {
unsafe {
let offset = &mut *ALLOC.offset.get();
let heap = &mut *ALLOC.heap.get();
let align = 8;
let start = (*offset + align - 1) & !(align - 1);
let end = start + size as usize;
if end > heap.len() {
return 0; // OOM
}
*offset = end;
heap.as_ptr().add(start) as i32
}
}
// --- host import ---
#[link(wasm_import_module = "swactor")]
unsafe extern "C" {
#[link_name = "send"]
fn host_send(dest_ptr: i32, payload_ptr: i32, payload_len: i32);
}
/// Message format: first 32 bytes = destination address, rest = payload.
/// Echo sends the payload portion back to the specified destination.
#[unsafe(no_mangle)]
pub extern "C" fn handle(ptr: i32, len: i32) {
if len < 32 {
return;
}
let dest_ptr = ptr;
let payload_ptr = ptr + 32;
let payload_len = len - 32;
unsafe {
host_send(dest_ptr, payload_ptr, payload_len);
}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

View file

@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "silent-guest"
version = "0.1.0"

View file

@ -1,13 +0,0 @@
[workspace]
[package]
name = "silent-guest"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true

View file

@ -1,45 +0,0 @@
#![no_std]
use core::cell::UnsafeCell;
use core::panic::PanicInfo;
// --- bump allocator ---
const HEAP_SIZE: usize = 65536;
struct BumpAlloc {
heap: UnsafeCell<[u8; HEAP_SIZE]>,
offset: UnsafeCell<usize>,
}
unsafe impl Sync for BumpAlloc {}
static ALLOC: BumpAlloc = BumpAlloc {
heap: UnsafeCell::new([0u8; HEAP_SIZE]),
offset: UnsafeCell::new(0),
};
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: i32) -> i32 {
unsafe {
let offset = &mut *ALLOC.offset.get();
let heap = &mut *ALLOC.heap.get();
let align = 8;
let start = (*offset + align - 1) & !(align - 1);
let end = start + size as usize;
if end > heap.len() {
return 0; // OOM
}
*offset = end;
heap.as_ptr().add(start) as i32
}
}
#[unsafe(no_mangle)]
pub extern "C" fn handle(_ptr: i32, _len: i32) {
// Silent: receive bytes, do nothing
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

View file

@ -1,8 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc f8674b306ba8187ad75bcc5a33c4a679e497a0053dc6fdb2234ccaeaae8f85b7 # shrinks to guest_idx = 0, n_msgs = 0, ticks_before = 1, ticks_after = 1
cc db98a9c0726c0477ba56ec6098a1f21518faaff4551d90e915790ce3c890dd53 # shrinks to payload = []

File diff suppressed because it is too large Load diff

View file

@ -8,5 +8,5 @@ name = "swactor"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
swactor = { path = "../.." } swactor = { path = "../../.." }
pyo3 = { version = "0.23", features = ["extension-module"] } pyo3 = { version = "0.23", features = ["extension-module"] }

View file

@ -7,6 +7,5 @@ edition = "2024"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
swactor = { path = "../../..", default-features = false, features = ["wasm"] } swactor = { path = "../../..", default-features = false, features = ["wasm", "std"] }
swactor-std = { path = "../../std", default-features = false, features = ["wasm"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"

View file

@ -4,7 +4,7 @@ use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorExited, ActorInterface}; use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
use swactor_std::{CtxGroups, CtxWatching, RuntimeNaming, RuntimeGroups, StdExtension}; use swactor::std::{CtxGroups, CtxWatching, RuntimeNaming, RuntimeGroups, StdExtension};
// ─── Core JS-facing types ─────────────────────────────────────────────────── // ─── Core JS-facing types ───────────────────────────────────────────────────

View file

@ -1,60 +0,0 @@
[package]
name = "swactor-ci"
version = "0.1.0"
edition = "2024"
[dependencies]
swactor = { path = "../..", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1"
# 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 = [
"dep:tiny_http",
"dep:ureq",
"dep:hmac",
"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

@ -1,243 +0,0 @@
//! ci-relay — webhook relay for VPS side.
//!
//! Receives Forgejo webhook POSTs over HTTP, then forwards the parsed
//! `WebhookEvent` payloads to the Thinkpad local-runner over iroh.
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use iroh::{Endpoint, RelayMode};
use tokio::sync::Mutex as TokioMutex;
use swactor_ci::webhook_server::parse_webhook_json;
use swactor_ci::{EventType, WebhookEvent};
/// ALPN protocol identifier for CI relay traffic over iroh.
const ALPN: &[u8] = b"swactor/ci/1";
/// Wire tag for WebhookEvent messages.
const WEBHOOK_TAG: &str = "ci::WebhookEvent";
#[derive(Parser)]
#[command(name = "ci-relay", about = "Webhook relay: Forgejo → iroh → local-runner")]
struct Args {
/// HTTP port for receiving Forgejo webhooks.
#[arg(long, default_value = "8787")]
port: u16,
/// Webhook secret for HMAC-SHA256 verification (empty to skip).
#[arg(long, default_value = "")]
secret: String,
}
fn main() {
let args = Args::parse();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime");
let endpoint = rt.block_on(async {
Endpoint::builder()
.alpns(vec![ALPN.to_vec()])
.relay_mode(RelayMode::Default)
.bind()
.await
.expect("failed to bind iroh endpoint")
});
let node_id = endpoint.id();
eprintln!("ci-relay started");
eprintln!(" Iroh Node ID: {node_id}");
eprintln!(" Webhook HTTP: http://0.0.0.0:{}", args.port);
eprintln!();
eprintln!("Waiting for runner to connect...");
// Shared state: the active connection from the Thinkpad runner.
let connection: Arc<TokioMutex<Option<iroh::endpoint::Connection>>> =
Arc::new(TokioMutex::new(None));
// Spawn a task that accepts inbound iroh connections from the runner.
{
let endpoint = endpoint.clone();
let connection = Arc::clone(&connection);
rt.spawn(async move {
loop {
match endpoint.accept().await {
Some(incoming) => match incoming.await {
Ok(conn) => {
let remote = conn.remote_id();
eprintln!("Runner connected: {remote}");
*connection.lock().await = Some(conn);
}
Err(e) => {
eprintln!("iroh accept error: {e}");
}
},
None => {
eprintln!("iroh endpoint closed");
break;
}
}
}
});
}
// Run the HTTP webhook listener on a standard thread (blocking).
let secret = args.secret.clone();
let server = tiny_http::Server::http(format!("0.0.0.0:{}", args.port))
.expect("failed to start HTTP server");
eprintln!("Listening for webhooks...");
for mut request in server.incoming_requests() {
let response = handle_webhook(&mut request, &secret, &connection, &rt);
let _ = request.respond(response);
}
}
/// Handle an incoming webhook HTTP request.
///
/// Parses and verifies the webhook, then forwards the event over iroh.
fn handle_webhook(
request: &mut tiny_http::Request,
secret: &str,
connection: &Arc<TokioMutex<Option<iroh::endpoint::Connection>>>,
rt: &tokio::runtime::Runtime,
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
if request.method() != &tiny_http::Method::Post {
return tiny_http::Response::from_string("method not allowed").with_status_code(405);
}
// Read body.
let mut body = String::new();
if let Err(e) = std::io::Read::read_to_string(&mut request.as_reader(), &mut body) {
eprintln!("webhook: failed to read body: {e}");
return tiny_http::Response::from_string("bad request").with_status_code(400);
}
// Verify HMAC-SHA256 signature if secret is non-empty.
if !secret.is_empty() {
let sig_header = request
.headers()
.iter()
.find(|h| h.field.equiv("X-Forgejo-Signature"))
.map(|h| h.value.as_str().to_string());
match sig_header {
Some(sig_hex) => {
type HmacSha256 = Hmac<Sha256>;
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key creation");
hmac::Mac::update(&mut mac, body.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if sig_hex != expected {
eprintln!("webhook: signature mismatch");
return tiny_http::Response::from_string("unauthorized").with_status_code(401);
}
}
None => {
eprintln!("webhook: missing signature header");
return tiny_http::Response::from_string("unauthorized").with_status_code(401);
}
}
}
// Determine event type from Forgejo header.
let event_header = request
.headers()
.iter()
.find(|h| h.field.equiv("X-Forgejo-Event"))
.map(|h| h.value.as_str().to_string())
.unwrap_or_default();
let event_type = match event_header.as_str() {
"push" => EventType::Push,
"create" => EventType::Tag,
"pull_request" => EventType::Merge,
other => {
eprintln!("webhook: ignoring event type '{other}'");
return tiny_http::Response::from_string("ignored").with_status_code(200);
}
};
// Parse JSON body.
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
eprintln!("webhook: failed to parse JSON: {e}");
return tiny_http::Response::from_string("bad json").with_status_code(400);
}
};
let webhook_event = match parse_webhook_json(&json, event_type) {
Some(e) => e,
None => {
eprintln!("webhook: could not extract fields from JSON");
return tiny_http::Response::from_string("bad payload").with_status_code(400);
}
};
eprintln!(
"webhook: {} {} on {}/{}",
webhook_event.commit_sha.get(..8).unwrap_or(&webhook_event.commit_sha),
webhook_event.branch,
webhook_event.repo_owner,
webhook_event.repo_name,
);
// Forward over iroh.
match forward_event(&webhook_event, connection, rt) {
Ok(()) => {
eprintln!(" → forwarded to runner");
tiny_http::Response::from_string("ok").with_status_code(200)
}
Err(e) => {
eprintln!(" → forward failed: {e}");
tiny_http::Response::from_string("relay error").with_status_code(502)
}
}
}
/// Serialize and send a WebhookEvent over the iroh connection.
fn forward_event(
event: &WebhookEvent,
connection: &Arc<TokioMutex<Option<iroh::endpoint::Connection>>>,
rt: &tokio::runtime::Runtime,
) -> Result<(), Box<dyn std::error::Error>> {
let payload = serde_json::to_vec(event)?;
rt.block_on(async {
let guard = connection.lock().await;
let conn = guard.as_ref().ok_or("no runner connected")?;
let mut send = conn.open_uni().await?;
write_tagged_message(&mut send, WEBHOOK_TAG.as_bytes(), &payload).await?;
send.finish()?;
// Wait briefly for the stream to flush.
tokio::time::sleep(Duration::from_millis(50)).await;
Ok(())
})
}
/// Write a tagged message to a QUIC send stream.
///
/// Frame format: `[4B tag_len][tag_bytes][payload_bytes]`
async fn write_tagged_message(
send: &mut iroh::endpoint::SendStream,
tag: &[u8],
payload: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
let tag_len = (tag.len() as u32).to_be_bytes();
send.write_all(&tag_len).await?;
send.write_all(tag).await?;
send.write_all(payload).await?;
Ok(())
}

View file

@ -1,386 +0,0 @@
//! local-runner — single-machine CI runner for the Thinkpad.
//!
//! Receives Forgejo webhooks, queues pipelines, and executes jobs
//! one at a time for benchmark isolation.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use clap::Parser;
use swactor::actor::ActorAddress;
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use dashboard::ci_collector::{
CiSnapshot, CiStatsProvider, JobSnapshot, PipelineSnapshot, ProvisionerStatus,
};
use swactor_ci::local_coordinator::{LocalCiSnapshot, LocalCoordinator, LocalCoordinatorMsg};
use swactor_ci::pipeline::PipelineExecution;
use swactor_ci::status_reporter::StatusReporter;
use swactor_ci::webhook_server;
use swactor_ci::yaml;
use swactor_ci::{CiConfig, LocalCiConfig};
#[derive(Parser)]
#[command(name = "local-runner", about = "Swactor local CI runner")]
struct Args {
/// Webhook listen port.
#[arg(long, default_value = "8787")]
port: u16,
/// Forgejo instance URL.
#[arg(long, default_value = "")]
forgejo_url: String,
/// Forgejo API token.
#[arg(long, default_value = "")]
forgejo_token: String,
/// Webhook secret for HMAC verification (empty to skip).
#[arg(long, default_value = "")]
secret: String,
/// Path to .ci.yml file.
#[arg(long, default_value = ".ci.yml")]
yaml: String,
/// Base directory for git checkouts.
#[arg(long, default_value = "./ci-work")]
work_dir: String,
/// Git clone URL for the repository.
#[arg(long, default_value = "")]
repo_url: String,
/// Dashboard HTTP port (omit to disable).
#[arg(long)]
dashboard_port: Option<u16>,
/// Iroh Node ID of the ci-relay on the VPS (hex).
/// When set, webhooks arrive via iroh instead of HTTP.
#[arg(long)]
relay_node_id: Option<String>,
}
/// Bridge from LocalCiSnapshot to CiSnapshot for the dashboard.
struct LocalCiSnapshotProvider {
snapshot: Arc<Mutex<LocalCiSnapshot>>,
}
impl CiStatsProvider for LocalCiSnapshotProvider {
fn snapshot(&self) -> CiSnapshot {
let local = self.snapshot.lock().unwrap().clone();
CiSnapshot {
active_pipelines: local
.active_pipelines
.iter()
.map(pipeline_to_dashboard)
.collect(),
recent_pipelines: local
.recent_pipelines
.iter()
.map(pipeline_to_dashboard)
.collect(),
provisioner_status: ProvisionerStatus::Online,
active_instances: if local.has_running_job { 1 } else { 0 },
}
}
}
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: 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,
jobs: p
.jobs
.values()
.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(),
}
}
fn main() {
let args = Args::parse();
let stop = Arc::new(AtomicBool::new(false));
// Signal handler.
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || {
stop.store(true, Ordering::Relaxed);
})
.expect("failed to set signal handler");
}
// Optionally start dashboard.
let dash = args.dashboard_port.map(|port| {
let d = dashboard::start_dashboard(dashboard::DashboardConfig {
port,
..Default::default()
});
d.install_tracing();
d
});
// Create 2-thread runtime.
let num_threads = 2;
let collector = dashboard::collector::StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 256,
channel_buffer_size: 2000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
// Build config.
let ci_config = CiConfig {
webhook_port: args.port,
webhook_secret: args.secret.clone(),
forgejo_url: args.forgejo_url.clone(),
forgejo_token: args.forgejo_token.clone(),
data_dir: args.work_dir.clone(),
};
let local_config = LocalCiConfig {
ci: ci_config,
repo_url: args.repo_url.clone(),
work_dir: args.work_dir.clone(),
ci_yaml_path: args.yaml.clone(),
};
// Spawn StatusReporter.
let reporter_addr = rt
.spawn(StatusReporter::new())
.expect("failed to spawn StatusReporter");
// Spawn LocalCoordinator.
let coordinator = LocalCoordinator::new(local_config).with_status_reporter(reporter_addr);
let ci_snapshot = coordinator.ci_snapshot();
let coordinator_addr = rt
.spawn(coordinator)
.expect("failed to spawn LocalCoordinator");
// Load CI YAML from disk.
let yaml_content = std::fs::read_to_string(&args.yaml)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", args.yaml));
let ci_yaml = yaml::parse_ci_yaml(&yaml_content)
.unwrap_or_else(|e| panic!("failed to parse CI YAML: {e}"));
// Start runtime.
let handle = rt.run().expect("failed to start runtime");
// Send CiYaml to coordinator.
let _ = handle
.runtime
.send_to(coordinator_addr, LocalCoordinatorMsg::SetCiYaml(ci_yaml));
// Wire dashboard.
if let Some(ref d) = dash {
d.set_runtime(Arc::clone(&handle.runtime), collector);
let provider = Arc::new(LocalCiSnapshotProvider {
snapshot: ci_snapshot,
});
d.set_ci(provider);
}
// Start webhook source: iroh relay or HTTP listener.
if let Some(ref relay_id_hex) = args.relay_node_id {
start_iroh_receiver(
relay_id_hex,
Arc::clone(&handle.runtime),
coordinator_addr,
Arc::clone(&stop),
);
} else {
let _webhook_handle = webhook_server::start_webhook_listener(
args.port,
args.secret,
Arc::clone(&handle.runtime),
coordinator_addr,
);
}
eprintln!("Local CI runner started");
if args.relay_node_id.is_some() {
eprintln!(" Webhook: via iroh relay");
} else {
eprintln!(" Webhook: http://0.0.0.0:{}", args.port);
}
eprintln!(" YAML: {}", args.yaml);
eprintln!(" Workdir: {}", args.work_dir);
if let Some(port) = args.dashboard_port {
eprintln!(" Dashboard: http://0.0.0.0:{port}");
}
// Main loop — just wait for ctrlc.
while !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(100));
}
eprintln!("\nShutting down...");
handle.shutdown();
if let Some(d) = dash {
d.shutdown();
}
handle.join();
}
// ─── Iroh Webhook Receiver ──────────────────────────────────────────────────
/// ALPN protocol identifier — must match ci-relay.
const CI_ALPN: &[u8] = b"swactor/ci/1";
/// Connect to the VPS ci-relay via iroh and receive WebhookEvents.
///
/// Runs in a background thread with its own tokio runtime.
fn start_iroh_receiver(
relay_id_hex: &str,
swactor_rt: Arc<Runtime>,
coordinator_addr: ActorAddress,
stop: Arc<AtomicBool>,
) {
let relay_key: iroh::PublicKey = relay_id_hex
.parse()
.unwrap_or_else(|e| panic!("invalid relay node ID '{relay_id_hex}': {e}"));
thread::Builder::new()
.name("iroh-receiver".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build tokio runtime for iroh receiver");
rt.block_on(async move {
let endpoint = iroh::Endpoint::builder()
.alpns(vec![CI_ALPN.to_vec()])
.relay_mode(iroh::RelayMode::Default)
.bind()
.await
.expect("failed to bind iroh endpoint");
eprintln!(" Iroh local ID: {}", endpoint.id());
// Outer reconnection loop: reconnect when the connection drops.
while !stop.load(Ordering::Relaxed) {
eprintln!(" Connecting to relay {relay_key}...");
let conn = match endpoint.connect(relay_key, CI_ALPN).await {
Ok(c) => c,
Err(e) => {
eprintln!("iroh: connect failed: {e}, retrying in 5s...");
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
eprintln!(" Connected to relay!");
// Receive loop: the relay opens uni streams to send us events.
while !stop.load(Ordering::Relaxed) {
match tokio::time::timeout(Duration::from_secs(1), conn.accept_uni()).await
{
Ok(Ok(mut recv)) => {
match read_tagged_message(&mut recv).await {
Ok((tag, payload)) => {
if tag == "ci::WebhookEvent" {
match serde_json::from_slice::<
swactor_ci::WebhookEvent,
>(
&payload
) {
Ok(event) => {
eprintln!(
"iroh: received webhook {} on {}",
event
.commit_sha
.get(..8)
.unwrap_or(&event.commit_sha),
event.branch,
);
let _ = swactor_rt.send_to(
coordinator_addr,
LocalCoordinatorMsg::Webhook(event),
);
}
Err(e) => {
eprintln!(
"iroh: failed to deserialize event: {e}"
)
}
}
} else {
eprintln!("iroh: unknown tag '{tag}', ignoring");
}
}
Err(e) => {
eprintln!("iroh: read error: {e}");
break;
}
}
}
Ok(Err(e)) => {
eprintln!("iroh: connection lost: {e}, reconnecting...");
break;
}
Err(_) => {
// 1s poll timeout — just loop and check stop flag.
}
}
}
}
endpoint.close().await;
});
})
.expect("failed to spawn iroh-receiver thread");
}
/// Read a tagged message from a QUIC recv stream.
///
/// Frame format: `[4B tag_len][tag_bytes][payload_bytes]`
async fn read_tagged_message(
recv: &mut iroh::endpoint::RecvStream,
) -> Result<(String, Vec<u8>), Box<dyn std::error::Error>> {
let mut tag_len_buf = [0u8; 4];
recv.read_exact(&mut tag_len_buf).await?;
let tag_len = u32::from_be_bytes(tag_len_buf) as usize;
if tag_len > 1024 {
return Err("tag too large".into());
}
let mut tag_buf = vec![0u8; tag_len];
recv.read_exact(&mut tag_buf).await?;
let tag = String::from_utf8(tag_buf)?;
let payload = recv.read_to_end(64 * 1024).await?;
Ok((tag, payload))
}

View file

@ -1,430 +0,0 @@
//! Coordinator actor: central brain of the CI system.
//!
//! Receives webhook events, manages pipeline lifecycles, dispatches jobs
//! to the Provisioner and RunnerSupervisor actors.
use std::collections::HashMap;
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::pipeline::PipelineExecution;
use crate::yaml::{self, CiYaml};
use crate::{
CiConfig, JobComplete, JobId, JobProgress, JobStatus, PipelineId, ProvisionRequest,
ProvisionResponse, StatusUpdate, TerminateRequest, WebhookEvent,
};
/// Messages the Coordinator can receive.
#[derive(Debug, Clone)]
pub enum CoordinatorMsg {
/// A webhook event from Forgejo.
Webhook(WebhookEvent),
/// The CI YAML config to use (loaded externally or from the repo).
SetCiYaml(CiYaml),
/// Response from the Provisioner.
ProvisionResponse(ProvisionResponse),
/// Streamed output from a RunnerSupervisor.
JobProgress(JobProgress),
/// Final result from a RunnerSupervisor.
JobComplete(JobComplete),
/// Notification that the provisioner is offline (detected via SWIM).
ProvisionerOffline,
/// Notification that the provisioner is back online.
ProvisionerOnline,
}
/// The Coordinator actor state.
pub struct Coordinator {
config: CiConfig,
ci_yaml: Option<CiYaml>,
pipelines: HashMap<PipelineId, PipelineExecution>,
next_pipeline_id: u64,
provisioner_addr: Option<ActorAddress>,
provisioner_online: bool,
/// Maps job_id → runner supervisor address.
runner_addrs: HashMap<JobId, ActorAddress>,
/// Captured status updates (for testing/simulation).
status_updates: Vec<StatusUpdate>,
/// Jobs waiting for the provisioner to come online.
queued_provisions: Vec<ProvisionRequest>,
}
impl Coordinator {
pub fn new(config: CiConfig) -> Self {
Self {
config,
ci_yaml: None,
pipelines: HashMap::new(),
next_pipeline_id: 1,
provisioner_addr: None,
provisioner_online: false,
runner_addrs: HashMap::new(),
status_updates: Vec::new(),
queued_provisions: Vec::new(),
}
}
pub fn with_provisioner(mut self, addr: ActorAddress) -> Self {
self.provisioner_addr = Some(addr);
self.provisioner_online = true;
self
}
pub fn with_ci_yaml(mut self, yaml: CiYaml) -> Self {
self.ci_yaml = Some(yaml);
self
}
pub fn pipelines(&self) -> &HashMap<PipelineId, PipelineExecution> {
&self.pipelines
}
pub fn status_updates(&self) -> &[StatusUpdate] {
&self.status_updates
}
fn handle_webhook(&mut self, ctx: &Ctx, event: WebhookEvent) {
let ci = match &self.ci_yaml {
Some(ci) => ci.clone(),
None => return,
};
let matched = yaml::matching_pipelines(&ci, &event);
for pipeline_name in matched {
let pipeline_def = &ci.pipelines[&pipeline_name];
let pipeline_id = PipelineId(self.next_pipeline_id);
self.next_pipeline_id += 1;
// Build job definitions.
let job_defs: Vec<_> = pipeline_def
.jobs
.iter()
.map(|(name, def)| yaml::to_job_definition(name, def))
.collect();
let pipeline = PipelineExecution::new(
pipeline_id,
pipeline_name.clone(),
event.repo_owner.clone(),
event.repo_name.clone(),
event.commit_sha.clone(),
event.branch.clone(),
job_defs,
);
// Set pending status on Forgejo.
self.emit_status_update(StatusUpdate {
repo_owner: event.repo_owner.clone(),
repo_name: event.repo_name.clone(),
commit_sha: event.commit_sha.clone(),
state: "pending".into(),
context: format!("ci/{pipeline_name}"),
description: format!("Pipeline '{pipeline_name}' is pending"),
target_url: None,
});
self.pipelines.insert(pipeline_id, pipeline);
// Start eligible jobs.
self.advance_pipeline(ctx, pipeline_id);
}
}
fn advance_pipeline(&mut self, ctx: &Ctx, pipeline_id: PipelineId) {
let pipeline = match self.pipelines.get(&pipeline_id) {
Some(p) => p,
None => return,
};
// If pipeline is already terminal, emit final status.
if pipeline.status.is_terminal() {
let update = StatusUpdate {
repo_owner: pipeline.repo_owner.clone(),
repo_name: pipeline.repo_name.clone(),
commit_sha: pipeline.commit_sha.clone(),
state: pipeline.status.forgejo_state().into(),
context: format!("ci/{}", pipeline.pipeline_name),
description: format!(
"Pipeline '{}' {}",
pipeline.pipeline_name,
pipeline.status.forgejo_state()
),
target_url: None,
};
self.emit_status_update(update);
return;
}
let eligible = pipeline.eligible_jobs();
let repo_owner = pipeline.repo_owner.clone();
let repo_name = pipeline.repo_name.clone();
let commit_sha = pipeline.commit_sha.clone();
for job_name in eligible {
let job_id = JobId {
pipeline_id,
job_name: job_name.clone(),
};
let spec = {
let pipeline = self.pipelines.get(&pipeline_id).unwrap();
let job = &pipeline.jobs[&job_name];
crate::InstanceSpec {
docker_required: job.definition.docker,
..Default::default()
}
};
if self.provisioner_online {
// Request provisioning.
if let Some(prov_addr) = self.provisioner_addr {
let request = ProvisionRequest {
job_id: job_id.clone(),
instance_spec: spec,
};
let _ = ctx.send(prov_addr, crate::provisioner::ProvisionerMsg::Provision(request));
}
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
job.status = JobStatus::Provisioning;
}
}
} else {
// Queue for later.
let request = ProvisionRequest {
job_id: job_id.clone(),
instance_spec: spec,
};
self.queued_provisions.push(request);
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
job.status = JobStatus::WaitingForProvisioner;
}
}
}
// Emit per-job status.
self.emit_status_update(StatusUpdate {
repo_owner: repo_owner.clone(),
repo_name: repo_name.clone(),
commit_sha: commit_sha.clone(),
state: "pending".into(),
context: format!("ci/{job_name}"),
description: format!("Job '{job_name}' is provisioning"),
target_url: None,
});
}
}
fn handle_provision_response(&mut self, ctx: &Ctx, response: ProvisionResponse) {
let pipeline_id = response.job_id.pipeline_id;
let job_name = response.job_id.job_name.clone();
match response.result {
Ok(instance) => {
// Store instance_id for cleanup.
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
job.instance_id = Some(instance.instance_id.clone());
job.status = JobStatus::Running;
}
}
// Spawn a RunnerSupervisor for this job.
let pipeline = &self.pipelines[&pipeline_id];
let job = &pipeline.jobs[&job_name];
let start_job = crate::StartJob {
job_id: response.job_id.clone(),
instance: instance.clone(),
repo_url: format!(
"{}/{}/{}",
self.config.forgejo_url, pipeline.repo_owner, pipeline.repo_name
),
commit_sha: pipeline.commit_sha.clone(),
job_def: job.definition.clone(),
};
let runner = crate::runner::RunnerSupervisor::new(
ctx.self_addr(),
start_job,
);
match ctx.spawn(runner) {
Ok(runner_addr) => {
self.runner_addrs.insert(response.job_id, runner_addr);
}
Err(_) => {
// Failed to spawn runner — mark job as failed.
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
pipeline.set_job_status(
&job_name,
JobStatus::Failed {
reason: "failed to spawn runner".into(),
},
);
}
self.advance_pipeline(ctx, pipeline_id);
}
}
}
Err(err) => {
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
pipeline.set_job_status(
&job_name,
JobStatus::Failed {
reason: err.to_string(),
},
);
}
self.advance_pipeline(ctx, pipeline_id);
}
}
}
fn handle_job_complete(&mut self, ctx: &Ctx, complete: JobComplete) {
let pipeline_id = complete.job_id.pipeline_id;
let job_name = complete.job_id.job_name.clone();
// Send terminate request for the instance.
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
if let Some(job) = pipeline.jobs.get(&job_name) {
if let Some(ref instance_id) = job.instance_id {
let terminate = TerminateRequest {
job_id: complete.job_id.clone(),
instance_id: instance_id.clone(),
};
if let Some(prov_addr) = self.provisioner_addr {
let _ = ctx.send(
prov_addr,
crate::provisioner::ProvisionerMsg::Terminate(terminate),
);
}
}
}
}
// Update job status.
let status = match complete.result {
Ok(_) => JobStatus::Passed,
Err(ref failure) => JobStatus::Failed {
reason: failure.to_string(),
},
};
let (repo_owner, repo_name, commit_sha) = {
let pipeline = match self.pipelines.get(&pipeline_id) {
Some(p) => p,
None => return,
};
(
pipeline.repo_owner.clone(),
pipeline.repo_name.clone(),
pipeline.commit_sha.clone(),
)
};
// Emit per-job final status.
self.emit_status_update(StatusUpdate {
repo_owner,
repo_name,
commit_sha,
state: match &status {
JobStatus::Passed => "success".into(),
_ => "failure".into(),
},
context: format!("ci/{job_name}"),
description: format!("Job '{job_name}' completed"),
target_url: None,
});
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
pipeline.set_job_status(&job_name, status);
}
// Remove runner address.
self.runner_addrs.remove(&complete.job_id);
// Advance pipeline to schedule downstream jobs.
self.advance_pipeline(ctx, pipeline_id);
}
fn handle_provisioner_offline(&mut self) {
self.provisioner_online = false;
// Mark all provisioning jobs as waiting.
for pipeline in self.pipelines.values_mut() {
for job in pipeline.jobs.values_mut() {
if job.status == JobStatus::Provisioning {
job.status = JobStatus::WaitingForProvisioner;
}
}
}
}
fn handle_provisioner_online(&mut self, ctx: &Ctx) {
self.provisioner_online = true;
// Flush queued provision requests.
let queued = std::mem::take(&mut self.queued_provisions);
for request in queued {
if let Some(prov_addr) = self.provisioner_addr {
let _ = ctx.send(prov_addr, crate::provisioner::ProvisionerMsg::Provision(request));
}
}
// Re-advance pipelines that have waiting jobs.
let pipeline_ids: Vec<PipelineId> = self.pipelines.keys().copied().collect();
for pid in pipeline_ids {
// Move waiting jobs back to provisioning.
if let Some(pipeline) = self.pipelines.get_mut(&pid) {
let waiting_jobs: Vec<String> = pipeline
.jobs
.iter()
.filter(|(_, j)| j.status == JobStatus::WaitingForProvisioner)
.map(|(name, _)| name.clone())
.collect();
for job_name in waiting_jobs {
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
job.status = JobStatus::Pending;
}
}
}
self.advance_pipeline(ctx, pid);
}
}
fn emit_status_update(&mut self, update: StatusUpdate) {
self.status_updates.push(update);
}
}
impl ActorInterface for Coordinator {
type Incoming = CoordinatorMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: CoordinatorMsg) {
match msg {
CoordinatorMsg::Webhook(event) => self.handle_webhook(ctx, event),
CoordinatorMsg::SetCiYaml(yaml) => {
self.ci_yaml = Some(yaml);
}
CoordinatorMsg::ProvisionResponse(resp) => {
self.handle_provision_response(ctx, resp);
}
CoordinatorMsg::JobProgress(progress) => {
if let Some(pipeline) = self.pipelines.get_mut(&progress.job_id.pipeline_id) {
if let Some(job) = pipeline.jobs.get_mut(&progress.job_id.job_name) {
job.output_lines.push(progress.output_line);
}
}
}
CoordinatorMsg::JobComplete(complete) => {
self.handle_job_complete(ctx, complete);
}
CoordinatorMsg::ProvisionerOffline => self.handle_provisioner_offline(),
CoordinatorMsg::ProvisionerOnline => self.handle_provisioner_online(ctx),
}
}
}

View file

@ -1,315 +0,0 @@
pub mod coordinator;
pub mod local_coordinator;
pub mod local_runner;
pub mod pipeline;
pub mod provisioner;
pub mod runner;
pub mod status_reporter;
pub mod webhook_server;
pub mod yaml;
use std::collections::HashMap;
use std::fmt;
use std::net::IpAddr;
use serde::{Deserialize, Serialize};
// ─── Core Identifiers ───────────────────────────────────────────────────────
/// Unique identifier for a pipeline execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PipelineId(pub u64);
impl fmt::Display for PipelineId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "pipeline-{}", self.0)
}
}
/// 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,
}
impl fmt::Display for JobId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.pipeline_id, self.job_name)
}
}
// ─── Job Status ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum JobStatus {
Pending,
WaitingForProvisioner,
Provisioning,
Running,
Passed,
Failed { reason: String },
Skipped,
Interrupted,
}
impl JobStatus {
pub fn is_terminal(&self) -> bool {
matches!(
self,
JobStatus::Passed | JobStatus::Failed { .. } | JobStatus::Skipped | JobStatus::Interrupted
)
}
}
// ─── Pipeline Status ────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PipelineStatus {
Pending,
Running,
Passed,
Failed,
Error { reason: String },
}
impl PipelineStatus {
pub fn is_terminal(&self) -> bool {
matches!(
self,
PipelineStatus::Passed | PipelineStatus::Failed | PipelineStatus::Error { .. }
)
}
/// Convert to Forgejo commit status string.
pub fn forgejo_state(&self) -> &'static str {
match self {
PipelineStatus::Pending => "pending",
PipelineStatus::Running => "pending",
PipelineStatus::Passed => "success",
PipelineStatus::Failed => "failure",
PipelineStatus::Error { .. } => "error",
}
}
}
// ─── Instance Types ─────────────────────────────────────────────────────────
/// Specification for a spot instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceSpec {
pub min_cpus: u32,
pub min_ram_mb: u32,
pub min_disk_gb: u32,
pub docker_required: bool,
pub region_preferences: Vec<String>,
}
impl Default for InstanceSpec {
fn default() -> Self {
Self {
min_cpus: 2,
min_ram_mb: 2048,
min_disk_gb: 20,
docker_required: false,
region_preferences: Vec::new(),
}
}
}
/// Connection details for a provisioned spot instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceReady {
pub instance_id: String,
pub ip: IpAddr,
pub ssh_port: u16,
pub ssh_host_key: String,
}
// ─── Provisioner ↔ Coordinator Messages ─────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvisionRequest {
pub job_id: JobId,
pub instance_spec: InstanceSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvisionResponse {
pub job_id: JobId,
pub result: Result<InstanceReady, ProvisionError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProvisionError {
NoCapacity,
ProviderError(String),
Timeout,
ProvisionerOffline,
}
impl fmt::Display for ProvisionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProvisionError::NoCapacity => write!(f, "no capacity available"),
ProvisionError::ProviderError(msg) => write!(f, "provider error: {msg}"),
ProvisionError::Timeout => write!(f, "provisioning timed out"),
ProvisionError::ProvisionerOffline => write!(f, "provisioner is offline"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminateRequest {
pub job_id: JobId,
pub instance_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminateAck {
pub job_id: JobId,
}
// ─── Coordinator ↔ RunnerSupervisor Messages ────────────────────────────────
/// Sent from Coordinator to RunnerSupervisor to begin a job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartJob {
pub job_id: JobId,
pub instance: InstanceReady,
pub repo_url: String,
pub commit_sha: String,
pub job_def: JobDefinition,
}
/// Streamed output from RunnerSupervisor back to Coordinator.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobProgress {
pub job_id: JobId,
pub output_line: String,
}
/// Final result from RunnerSupervisor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobComplete {
pub job_id: JobId,
pub result: Result<JobSuccess, JobFailure>,
pub artifacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobSuccess;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum JobFailure {
CommandFailed { exit_code: i32, last_lines: Vec<String> },
SshError(String),
ExecError(String),
Timeout,
Interrupted,
}
impl fmt::Display for JobFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
JobFailure::CommandFailed { exit_code, .. } => {
write!(f, "command exited with code {exit_code}")
}
JobFailure::SshError(msg) => write!(f, "SSH error: {msg}"),
JobFailure::ExecError(msg) => write!(f, "exec error: {msg}"),
JobFailure::Timeout => write!(f, "job timed out"),
JobFailure::Interrupted => write!(f, "spot instance interrupted"),
}
}
}
// ─── Job Definition ─────────────────────────────────────────────────────────
/// A parsed job from the .ci.yml file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobDefinition {
pub name: String,
pub run: Vec<String>,
pub needs: Vec<String>,
pub timeout_secs: u64,
pub docker: bool,
pub artifacts: Vec<String>,
pub env: HashMap<String, String>,
}
// ─── Webhook Types ──────────────────────────────────────────────────────────
/// Parsed webhook event from Forgejo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEvent {
pub event_type: EventType,
pub repo_owner: String,
pub repo_name: String,
pub branch: String,
pub commit_sha: String,
pub tag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventType {
Push,
Tag,
Merge,
}
// ─── Forgejo Status Updates ─────────────────────────────────────────────────
/// A commit status update to send to Forgejo's API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusUpdate {
pub repo_owner: String,
pub repo_name: String,
pub commit_sha: String,
pub state: String,
pub context: String,
pub description: String,
pub target_url: Option<String>,
}
// ─── Coordinator Config ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiConfig {
pub webhook_port: u16,
pub webhook_secret: String,
pub forgejo_url: String,
pub forgejo_token: String,
pub data_dir: String,
}
impl Default for CiConfig {
fn default() -> Self {
Self {
webhook_port: 8787,
webhook_secret: String::new(),
forgejo_url: String::new(),
forgejo_token: String::new(),
data_dir: "./ci-data".into(),
}
}
}
// ─── Local CI Types ──────────────────────────────────────────────────────────
/// Job execution request for local runner (no InstanceReady needed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalStartJob {
pub job_id: JobId,
pub work_dir: String,
pub job_def: JobDefinition,
pub env_overrides: HashMap<String, String>,
}
/// Configuration for the local CI runner.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalCiConfig {
pub ci: CiConfig,
pub repo_url: String,
pub work_dir: String,
pub ci_yaml_path: String,
}

View file

@ -1,565 +0,0 @@
//! LocalCoordinator actor: single-machine CI brain.
//!
//! Receives webhook events, queues pipelines, executes jobs one at a time
//! directly on the host. Supports branch-level supersede for queued pipelines.
use std::collections::{HashMap, VecDeque};
use std::process::Command;
use std::sync::{Arc, Mutex};
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::pipeline::PipelineExecution;
use crate::status_reporter::{JobOutput, StatusReporterMsg};
use crate::yaml::{self, CiYaml};
use crate::{
JobComplete, JobId, JobProgress, JobStatus, LocalCiConfig, LocalStartJob, PipelineId,
PipelineStatus, StatusUpdate, WebhookEvent,
};
/// Messages the LocalCoordinator can receive.
#[derive(Debug, Clone)]
pub enum LocalCoordinatorMsg {
Webhook(WebhookEvent),
SetCiYaml(CiYaml),
JobProgress(JobProgress),
JobComplete(JobComplete),
GitReady {
pipeline_id: PipelineId,
job_name: String,
work_dir: String,
},
}
/// Lightweight snapshot of coordinator state (no dashboard dependency).
/// The binary layer converts this to `CiSnapshot` for the dashboard.
#[derive(Debug, Clone, Default)]
pub struct LocalCiSnapshot {
pub active_pipelines: Vec<PipelineExecution>,
pub recent_pipelines: Vec<PipelineExecution>,
pub has_running_job: bool,
}
/// The LocalCoordinator actor state.
pub struct LocalCoordinator {
config: LocalCiConfig,
ci_yaml: Option<CiYaml>,
pipelines: HashMap<PipelineId, PipelineExecution>,
next_pipeline_id: u64,
/// FIFO queue of pipeline IDs awaiting execution.
queue: VecDeque<PipelineId>,
/// The pipeline currently being executed.
active_pipeline: Option<PipelineId>,
/// At most one running job (job_id, runner actor address).
running_job: Option<(JobId, ActorAddress)>,
/// Status reporter actor address.
status_reporter_addr: Option<ActorAddress>,
/// Bounded ring of finished pipelines.
completed: VecDeque<PipelineExecution>,
/// Shared snapshot for external consumers (e.g. dashboard binary).
ci_snapshot: Arc<Mutex<LocalCiSnapshot>>,
}
impl LocalCoordinator {
pub fn new(config: LocalCiConfig) -> Self {
Self {
config,
ci_yaml: None,
pipelines: HashMap::new(),
next_pipeline_id: 1,
queue: VecDeque::new(),
active_pipeline: None,
running_job: None,
status_reporter_addr: None,
completed: VecDeque::new(),
ci_snapshot: Arc::new(Mutex::new(LocalCiSnapshot::default())),
}
}
pub fn with_status_reporter(mut self, addr: ActorAddress) -> Self {
self.status_reporter_addr = Some(addr);
self
}
pub fn with_ci_yaml(mut self, yaml: CiYaml) -> Self {
self.ci_yaml = Some(yaml);
self
}
pub fn ci_snapshot(&self) -> Arc<Mutex<LocalCiSnapshot>> {
Arc::clone(&self.ci_snapshot)
}
pub fn pipelines(&self) -> &HashMap<PipelineId, PipelineExecution> {
&self.pipelines
}
pub fn completed(&self) -> &VecDeque<PipelineExecution> {
&self.completed
}
pub fn queue(&self) -> &VecDeque<PipelineId> {
&self.queue
}
pub fn active_pipeline(&self) -> Option<PipelineId> {
self.active_pipeline
}
pub fn running_job(&self) -> Option<&(JobId, ActorAddress)> {
self.running_job.as_ref()
}
fn handle_webhook(&mut self, ctx: &Ctx, event: WebhookEvent) {
let ci = match &self.ci_yaml {
Some(ci) => ci.clone(),
None => return,
};
let matched = yaml::matching_pipelines(&ci, &event);
for pipeline_name in matched {
let pipeline_def = &ci.pipelines[&pipeline_name];
let pipeline_id = PipelineId(self.next_pipeline_id);
self.next_pipeline_id += 1;
let job_defs: Vec<_> = pipeline_def
.jobs
.iter()
.map(|(name, def)| yaml::to_job_definition(name, def))
.collect();
let pipeline = PipelineExecution::new(
pipeline_id,
pipeline_name.clone(),
event.repo_owner.clone(),
event.repo_name.clone(),
event.commit_sha.clone(),
event.branch.clone(),
job_defs,
);
self.emit_status(
ctx,
StatusUpdate {
repo_owner: event.repo_owner.clone(),
repo_name: event.repo_name.clone(),
commit_sha: event.commit_sha.clone(),
state: "pending".into(),
context: format!("ci/{pipeline_name}"),
description: format!("Pipeline '{pipeline_name}' is pending"),
target_url: None,
},
);
self.pipelines.insert(pipeline_id, pipeline);
self.enqueue_pipeline(pipeline_id, &event.branch);
}
self.try_schedule_next(ctx);
}
/// Enqueue a pipeline, superseding any queued pipeline for the same branch.
fn enqueue_pipeline(&mut self, pipeline_id: PipelineId, branch: &str) {
// Scan queue for entry with same branch (not the active pipeline).
let supersede_idx = self.queue.iter().position(|&qid| {
self.pipelines
.get(&qid)
.map(|p| p.branch == branch)
.unwrap_or(false)
});
if let Some(idx) = supersede_idx {
let old_id = self.queue[idx];
// Mark old pipeline as superseded.
if let Some(old_pipeline) = self.pipelines.get_mut(&old_id) {
old_pipeline.status = PipelineStatus::Error {
reason: "superseded".into(),
};
// Mark all pending jobs as skipped.
let job_names: Vec<String> = old_pipeline.jobs.keys().cloned().collect();
for name in job_names {
if old_pipeline.jobs[&name].status == JobStatus::Pending {
old_pipeline.set_job_status(&name, JobStatus::Skipped);
}
}
}
// Archive the superseded pipeline.
if let Some(old_pipeline) = self.pipelines.remove(&old_id) {
self.archive_pipeline(old_pipeline);
}
// Replace queue entry.
self.queue[idx] = pipeline_id;
} else {
self.queue.push_back(pipeline_id);
}
}
/// Core scheduling: one job at a time.
fn try_schedule_next(&mut self, ctx: &Ctx) {
// If a job is already running, nothing to do.
if self.running_job.is_some() {
return;
}
// If we have an active pipeline, try to find eligible jobs.
if let Some(active_id) = self.active_pipeline {
if let Some(pipeline) = self.pipelines.get(&active_id) {
let eligible = pipeline.eligible_jobs();
if !eligible.is_empty() {
let job_name = eligible[0].clone();
self.start_job(ctx, active_id, &job_name);
return;
}
// No eligible jobs — check if pipeline is terminal.
if pipeline.status.is_terminal() {
let pipeline = self.pipelines.remove(&active_id).unwrap();
self.emit_status(
ctx,
StatusUpdate {
repo_owner: pipeline.repo_owner.clone(),
repo_name: pipeline.repo_name.clone(),
commit_sha: pipeline.commit_sha.clone(),
state: pipeline.status.forgejo_state().into(),
context: format!("ci/{}", pipeline.pipeline_name),
description: format!(
"Pipeline '{}' {}",
pipeline.pipeline_name,
pipeline.status.forgejo_state()
),
target_url: None,
},
);
self.emit_pipeline_comment(ctx, &pipeline);
self.archive_pipeline(pipeline);
self.active_pipeline = None;
// Recurse to pick next from queue.
self.try_schedule_next(ctx);
return;
}
}
// Pipeline exists but no eligible jobs and not terminal — waiting for running job.
return;
}
// No active pipeline — pop from queue.
if let Some(next_id) = self.queue.pop_front() {
self.active_pipeline = Some(next_id);
self.try_schedule_next(ctx);
}
}
fn start_job(&mut self, ctx: &Ctx, pipeline_id: PipelineId, job_name: &str) {
let pipeline = match self.pipelines.get_mut(&pipeline_id) {
Some(p) => p,
None => return,
};
let job = match pipeline.jobs.get_mut(job_name) {
Some(j) => j,
None => return,
};
job.status = JobStatus::Running;
let work_dir = format!(
"{}/pipeline-{}",
self.config.work_dir, pipeline_id.0
);
// Build CI env overrides.
let mut env_overrides = HashMap::new();
env_overrides.insert("CI".into(), "true".into());
env_overrides.insert("CI_COMMIT_SHA".into(), pipeline.commit_sha.clone());
env_overrides.insert("CI_BRANCH".into(), pipeline.branch.clone());
env_overrides.insert("CI_PIPELINE_ID".into(), pipeline_id.0.to_string());
env_overrides.insert("CI_JOB_NAME".into(), job_name.to_string());
let start_job = LocalStartJob {
job_id: job.job_id.clone(),
work_dir: work_dir.clone(),
job_def: job.definition.clone(),
env_overrides,
};
// Perform git checkout inline (blocks this worker, acceptable for local runner).
let sha = pipeline.commit_sha.clone();
let repo_url = self.config.repo_url.clone();
let git_ok = self.git_checkout(&repo_url, &sha, &work_dir);
if !git_ok {
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
pipeline.set_job_status(
job_name,
JobStatus::Failed {
reason: "git checkout failed".into(),
},
);
}
self.try_schedule_next(ctx);
return;
}
// Emit per-job running status.
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
self.emit_status(
ctx,
StatusUpdate {
repo_owner: pipeline.repo_owner.clone(),
repo_name: pipeline.repo_name.clone(),
commit_sha: pipeline.commit_sha.clone(),
state: "pending".into(),
context: format!("ci/{job_name}"),
description: format!("Job '{job_name}' is running"),
target_url: None,
},
);
}
// Spawn LocalRunner actor.
let runner =
crate::local_runner::LocalRunner::new(ctx.self_addr(), start_job);
match ctx.spawn(runner) {
Ok(runner_addr) => {
let job_id = JobId {
pipeline_id,
job_name: job_name.to_string(),
};
self.running_job = Some((job_id, runner_addr));
}
Err(_) => {
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
pipeline.set_job_status(
job_name,
JobStatus::Failed {
reason: "failed to spawn runner".into(),
},
);
}
self.try_schedule_next(ctx);
}
}
}
fn git_checkout(&self, repo_url: &str, sha: &str, work_dir: &str) -> bool {
let path = std::path::Path::new(work_dir);
if path.join(".git").exists() {
// Already cloned — fetch and checkout.
let fetch = Command::new("git")
.args(["fetch", "origin"])
.current_dir(work_dir)
.output();
if fetch.is_err() || !fetch.unwrap().status.success() {
return false;
}
let checkout = Command::new("git")
.args(["checkout", sha])
.current_dir(work_dir)
.output();
checkout.map(|o| o.status.success()).unwrap_or(false)
} else {
// Fresh clone.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let clone = Command::new("git")
.args(["clone", repo_url, work_dir])
.output();
if clone.is_err() || !clone.as_ref().unwrap().status.success() {
return false;
}
let checkout = Command::new("git")
.args(["checkout", sha])
.current_dir(work_dir)
.output();
checkout.map(|o| o.status.success()).unwrap_or(false)
}
}
fn handle_job_complete(&mut self, ctx: &Ctx, complete: JobComplete) {
let pipeline_id = complete.job_id.pipeline_id;
let job_name = complete.job_id.job_name.clone();
let status = match complete.result {
Ok(_) => JobStatus::Passed,
Err(ref failure) => JobStatus::Failed {
reason: failure.to_string(),
},
};
// Build a description with output tail for failures.
let description = match &status {
JobStatus::Passed => format!("Job '{job_name}' passed"),
JobStatus::Failed { reason } => {
let output_tail = self
.pipelines
.get(&pipeline_id)
.and_then(|p| p.jobs.get(&job_name))
.map(|j| {
let lines: Vec<&str> = j
.output_lines
.iter()
.rev()
.take(10)
.map(|s| s.as_str())
.collect();
lines.into_iter().rev().collect::<Vec<_>>().join("\n")
})
.unwrap_or_default();
let mut desc = format!("Job '{job_name}' failed: {reason}");
if !output_tail.is_empty() {
desc.push_str("\n");
desc.push_str(&output_tail);
}
// Cap at ~250 chars for the status description field.
if desc.len() > 250 {
desc.truncate(247);
desc.push_str("...");
}
desc
}
_ => format!("Job '{job_name}' completed"),
};
// Emit per-job final status.
if let Some(pipeline) = self.pipelines.get(&pipeline_id) {
self.emit_status(
ctx,
StatusUpdate {
repo_owner: pipeline.repo_owner.clone(),
repo_name: pipeline.repo_name.clone(),
commit_sha: pipeline.commit_sha.clone(),
state: match &status {
JobStatus::Passed => "success".into(),
_ => "failure".into(),
},
context: format!("ci/{job_name}"),
description,
target_url: None,
},
);
}
if let Some(pipeline) = self.pipelines.get_mut(&pipeline_id) {
pipeline.set_job_status(&job_name, status);
}
// Clear running job.
self.running_job = None;
// Schedule next.
self.try_schedule_next(ctx);
}
fn emit_status(&self, ctx: &Ctx, update: StatusUpdate) {
if let Some(reporter_addr) = self.status_reporter_addr {
let _ = ctx.send(
reporter_addr,
StatusReporterMsg::Report {
update,
forgejo_url: self.config.ci.forgejo_url.clone(),
forgejo_token: self.config.ci.forgejo_token.clone(),
},
);
}
}
fn emit_pipeline_comment(&self, ctx: &Ctx, pipeline: &crate::pipeline::PipelineExecution) {
let reporter_addr = match self.status_reporter_addr {
Some(addr) => addr,
None => return,
};
let job_outputs: Vec<JobOutput> = pipeline
.jobs
.values()
.map(|job| {
let passed = job.status == JobStatus::Passed;
let failure_reason = match &job.status {
JobStatus::Failed { reason } => Some(reason.clone()),
_ => None,
};
JobOutput {
job_name: job.definition.name.clone(),
passed,
failure_reason,
output_lines: job.output_lines.clone(),
}
})
.collect();
let _ = ctx.send(
reporter_addr,
StatusReporterMsg::PostPipelineComment {
repo_owner: pipeline.repo_owner.clone(),
repo_name: pipeline.repo_name.clone(),
commit_sha: pipeline.commit_sha.clone(),
branch: pipeline.branch.clone(),
pipeline_name: pipeline.pipeline_name.clone(),
pipeline_state: pipeline.status.forgejo_state().to_string(),
job_outputs,
forgejo_url: self.config.ci.forgejo_url.clone(),
forgejo_token: self.config.ci.forgejo_token.clone(),
},
);
}
fn archive_pipeline(&mut self, pipeline: PipelineExecution) {
self.completed.push_back(pipeline);
if self.completed.len() > 50 {
self.completed.pop_front();
}
}
fn update_snapshot(&self) {
let active: Vec<PipelineExecution> = self.pipelines.values().cloned().collect();
let recent: Vec<PipelineExecution> = self
.completed
.iter()
.rev()
.take(20)
.cloned()
.collect();
if let Ok(mut snap) = self.ci_snapshot.lock() {
snap.active_pipelines = active;
snap.recent_pipelines = recent;
snap.has_running_job = self.running_job.is_some();
}
}
}
impl ActorInterface for LocalCoordinator {
type Incoming = LocalCoordinatorMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: LocalCoordinatorMsg) {
match msg {
LocalCoordinatorMsg::Webhook(event) => self.handle_webhook(ctx, event),
LocalCoordinatorMsg::SetCiYaml(yaml) => {
self.ci_yaml = Some(yaml);
}
LocalCoordinatorMsg::JobProgress(progress) => {
if let Some(pipeline) = self.pipelines.get_mut(&progress.job_id.pipeline_id) {
if let Some(job) = pipeline.jobs.get_mut(&progress.job_id.job_name) {
job.output_lines.push(progress.output_line);
}
}
}
LocalCoordinatorMsg::JobComplete(complete) => {
self.handle_job_complete(ctx, complete);
}
LocalCoordinatorMsg::GitReady {
pipeline_id,
job_name,
work_dir,
} => {
// Git ready is used in the async variant; for now handled inline in start_job.
let _ = (pipeline_id, job_name, work_dir);
}
}
self.update_snapshot();
}
}

View file

@ -1,304 +0,0 @@
//! LocalRunner actor: executes job commands directly on the host via shell.
//!
//! Short-lived actor, one per job. Spawned by LocalCoordinator when a job
//! is ready to execute.
use std::io::BufRead;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::local_coordinator::LocalCoordinatorMsg;
use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, LocalStartJob};
/// Messages the LocalRunner can receive.
#[derive(Debug, Clone)]
pub enum LocalRunnerMsg {
/// Begin executing the job (sent to self in on_start).
Execute,
/// Simulated: job completed (for testing without real shell).
SimComplete(Result<(), String>),
}
/// LocalRunner actor state.
pub struct LocalRunner {
coordinator_addr: ActorAddress,
start_job: LocalStartJob,
}
impl LocalRunner {
pub fn new(coordinator_addr: ActorAddress, start_job: LocalStartJob) -> Self {
Self {
coordinator_addr,
start_job,
}
}
/// Execute all commands in the job definition, streaming output back.
fn execute(&self, ctx: &Ctx) {
let job_id = &self.start_job.job_id;
let work_dir = &self.start_job.work_dir;
let timeout_secs = self.start_job.job_def.timeout_secs;
eprintln!(
"[runner] job {}/{} starting ({} commands, timeout {}s, workdir {})",
job_id.pipeline_id.0,
job_id.job_name,
self.start_job.job_def.run.len(),
timeout_secs,
work_dir,
);
for cmd_str in &self.start_job.job_def.run {
eprintln!("[runner] exec: {cmd_str}");
// Send progress: command being run.
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobProgress(JobProgress {
job_id: job_id.clone(),
output_line: format!("$ {cmd_str}"),
}),
);
let child_result = Command::new("sh")
.arg("-c")
.arg(cmd_str)
.current_dir(work_dir)
.envs(&self.start_job.env_overrides)
.envs(&self.start_job.job_def.env)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match child_result {
Ok(c) => c,
Err(e) => {
eprintln!("[runner] spawn failed: {e}");
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::ExecError(e.to_string())),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
};
// Timeout: spawn a thread that kills the child after timeout_secs.
let child_id = child.id();
let kill_flag = Arc::new(Mutex::new(false));
let kill_flag_clone = Arc::clone(&kill_flag);
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let timeout_handle = std::thread::spawn(move || {
if done_rx
.recv_timeout(std::time::Duration::from_secs(timeout_secs))
.is_err()
{
*kill_flag_clone.lock().unwrap() = true;
// Actually kill the child process so the pipe readers unblock.
let _ = std::process::Command::new("kill")
.args(["-9", &child_id.to_string()])
.status();
}
});
// Read stdout and stderr concurrently to avoid pipe-buffer deadlock.
let (last_lines, timed_out) =
drain_child_output(&mut child, job_id, ctx, self.coordinator_addr, &kill_flag);
let status = child.wait();
// Signal timeout thread that we're done.
let _ = done_tx.send(());
let _ = timeout_handle.join();
if timed_out || *kill_flag.lock().unwrap() {
eprintln!("[runner] command timed out after {timeout_secs}s");
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::Timeout),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
match status {
Ok(exit) if exit.success() => {
eprintln!("[runner] command succeeded");
}
Ok(exit) => {
let exit_code = exit.code().unwrap_or(-1);
eprintln!("[runner] command failed (exit {exit_code})");
for line in &last_lines {
eprintln!("[runner] {line}");
}
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::CommandFailed {
exit_code,
last_lines,
}),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
Err(e) => {
eprintln!("[runner] wait failed: {e}");
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Err(JobFailure::ExecError(e.to_string())),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
return;
}
}
}
eprintln!(
"[runner] job {}/{} passed",
job_id.pipeline_id.0, job_id.job_name
);
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(JobComplete {
job_id: job_id.clone(),
result: Ok(JobSuccess),
artifacts: Vec::new(),
}),
);
ctx.stop_self();
}
}
/// Drain stdout and stderr from a child process concurrently.
///
/// Spawns a background thread for stderr so that both pipes are consumed
/// in parallel, preventing the classic pipe-buffer deadlock where the child
/// blocks writing to a full stderr while the parent blocks reading stdout.
///
/// Returns (last_lines, timed_out).
fn drain_child_output(
child: &mut Child,
job_id: &crate::JobId,
ctx: &Ctx,
coordinator_addr: ActorAddress,
kill_flag: &Arc<Mutex<bool>>,
) -> (Vec<String>, bool) {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// Collect stderr on a background thread.
let stderr_job_id = job_id.clone();
let stderr_kill = Arc::clone(kill_flag);
let stderr_handle = std::thread::spawn(move || {
let mut lines = Vec::new();
if let Some(stderr) = stderr {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines() {
if *stderr_kill.lock().unwrap() {
break;
}
if let Ok(line) = line {
lines.push(line);
}
}
}
lines
});
// Read stdout on the current thread, streaming progress.
let mut last_lines: Vec<String> = Vec::new();
if let Some(stdout) = stdout {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
if let Ok(line) = line {
let _ = ctx.send(
coordinator_addr,
LocalCoordinatorMsg::JobProgress(JobProgress {
job_id: job_id.clone(),
output_line: line.clone(),
}),
);
last_lines.push(line);
if last_lines.len() > 50 {
last_lines.remove(0);
}
}
}
}
// Join stderr thread and stream its lines as progress.
let timed_out = *kill_flag.lock().unwrap();
let stderr_lines = stderr_handle.join().unwrap_or_default();
for line in &stderr_lines {
let _ = ctx.send(
coordinator_addr,
LocalCoordinatorMsg::JobProgress(JobProgress {
job_id: stderr_job_id.clone(),
output_line: format!("[stderr] {line}"),
}),
);
}
// Merge stderr into last_lines tail.
for line in stderr_lines {
last_lines.push(line);
if last_lines.len() > 50 {
last_lines.remove(0);
}
}
(last_lines, timed_out)
}
impl ActorInterface for LocalRunner {
type Incoming = LocalRunnerMsg;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
let _ = ctx.send(ctx.self_addr(), LocalRunnerMsg::Execute);
}
fn handle(&mut self, ctx: &Ctx, msg: LocalRunnerMsg) {
match msg {
LocalRunnerMsg::Execute => {
self.execute(ctx);
}
LocalRunnerMsg::SimComplete(result) => {
let complete = JobComplete {
job_id: self.start_job.job_id.clone(),
result: match result {
Ok(()) => Ok(JobSuccess),
Err(msg) => Err(JobFailure::CommandFailed {
exit_code: 1,
last_lines: vec![msg],
}),
},
artifacts: Vec::new(),
};
let _ = ctx.send(
self.coordinator_addr,
LocalCoordinatorMsg::JobComplete(complete),
);
ctx.stop_self();
}
}
}
}

View file

@ -1,86 +0,0 @@
//! Provisioner actor: manages spot instance lifecycle via pluggable provider scripts.
//!
//! In real deployment, runs on the developer's laptop and calls cloud provider APIs.
//! In simulation, provisions are driven by the simulation harness.
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::coordinator::CoordinatorMsg;
use crate::{
InstanceReady, ProvisionError, ProvisionRequest, ProvisionResponse, TerminateRequest,
};
/// Messages the Provisioner can receive.
#[derive(Debug, Clone)]
pub enum ProvisionerMsg {
/// Request to provision a new spot instance.
Provision(ProvisionRequest),
/// Request to terminate a spot instance.
Terminate(TerminateRequest),
/// Simulated: provisioning result delivered asynchronously.
SimProvisionResult {
request: ProvisionRequest,
result: Result<InstanceReady, ProvisionError>,
},
}
/// Provisioner actor state.
///
/// In real deployment, this would invoke provider scripts.
/// In simulation, the sim harness controls provision outcomes.
pub struct Provisioner {
coordinator_addr: ActorAddress,
/// Active instances tracked for cleanup.
active_instances: Vec<String>,
}
impl Provisioner {
pub fn new(coordinator_addr: ActorAddress) -> Self {
Self {
coordinator_addr,
active_instances: Vec::new(),
}
}
pub fn active_instances(&self) -> &[String] {
&self.active_instances
}
}
impl ActorInterface for Provisioner {
type Incoming = ProvisionerMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: ProvisionerMsg) {
match msg {
ProvisionerMsg::Provision(_request) => {
// In real deployment: invoke provider script, await result.
// In simulation: the sim harness sends SimProvisionResult.
}
ProvisionerMsg::Terminate(request) => {
self.active_instances.retain(|id| id != &request.instance_id);
// In real deployment: invoke provider destroy script.
// In simulation: just track the termination.
let _ = ctx.send(
self.coordinator_addr,
CoordinatorMsg::ProvisionResponse(ProvisionResponse {
job_id: request.job_id.clone(),
result: Err(ProvisionError::NoCapacity), // placeholder, terminate doesn't need response
}),
);
}
ProvisionerMsg::SimProvisionResult { request, result } => {
if let Ok(ref instance) = result {
self.active_instances.push(instance.instance_id.clone());
}
let _ = ctx.send(
self.coordinator_addr,
CoordinatorMsg::ProvisionResponse(ProvisionResponse {
job_id: request.job_id,
result,
}),
);
}
}
}
}

View file

@ -1,84 +0,0 @@
//! RunnerSupervisor actor: manages SSH session and job execution on a spot instance.
//!
//! Spawned per-job by the Coordinator. Owns the connection to the spot instance.
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::coordinator::CoordinatorMsg;
use crate::{JobComplete, JobFailure, JobProgress, JobSuccess, StartJob};
/// Messages the RunnerSupervisor can receive.
#[derive(Debug, Clone)]
pub enum RunnerMsg {
/// Begin executing the job (sent immediately after spawn via on_start).
Execute,
/// Simulated: job command output line.
OutputLine(String),
/// Simulated: job completed successfully.
SimComplete(Result<(), String>),
}
/// RunnerSupervisor actor state.
///
/// In real deployment, this would manage an SSH connection.
/// In simulation, job execution is driven by external messages.
pub struct RunnerSupervisor {
coordinator_addr: ActorAddress,
start_job: StartJob,
}
impl RunnerSupervisor {
pub fn new(coordinator_addr: ActorAddress, start_job: StartJob) -> Self {
Self {
coordinator_addr,
start_job,
}
}
}
impl ActorInterface for RunnerSupervisor {
type Incoming = RunnerMsg;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
// In simulation, the sim harness will send SimComplete messages.
// In real deployment, this would initiate SSH connection + command execution.
let _ = ctx.send(ctx.self_addr(), RunnerMsg::Execute);
}
fn handle(&mut self, ctx: &Ctx, msg: RunnerMsg) {
match msg {
RunnerMsg::Execute => {
// In real mode, we'd SSH into the instance and run commands.
// In simulation, this is a no-op; SimComplete drives completion.
}
RunnerMsg::OutputLine(line) => {
let _ = ctx.send(
self.coordinator_addr,
CoordinatorMsg::JobProgress(JobProgress {
job_id: self.start_job.job_id.clone(),
output_line: line,
}),
);
}
RunnerMsg::SimComplete(result) => {
let complete = JobComplete {
job_id: self.start_job.job_id.clone(),
result: match result {
Ok(()) => Ok(JobSuccess),
Err(msg) => Err(JobFailure::CommandFailed {
exit_code: 1,
last_lines: vec![msg],
}),
},
artifacts: Vec::new(),
};
let _ = ctx.send(
self.coordinator_addr,
CoordinatorMsg::JobComplete(complete),
);
ctx.stop_self();
}
}
}
}

View file

@ -1,327 +0,0 @@
//! StatusReporter actor: fire-and-forget Forgejo commit status updates.
//!
//! Receives status update messages and POSTs them to the Forgejo API.
//! Can also post pipeline summary comments to PRs.
use swactor::actor::{ActorInterface, Ctx};
use crate::StatusUpdate;
/// Captured output for a single job, used to build PR comments.
#[derive(Debug, Clone)]
pub struct JobOutput {
pub job_name: String,
pub passed: bool,
pub failure_reason: Option<String>,
pub output_lines: Vec<String>,
}
/// Messages the StatusReporter can receive.
#[derive(Debug, Clone)]
pub enum StatusReporterMsg {
Report {
update: StatusUpdate,
forgejo_url: String,
forgejo_token: String,
},
PostPipelineComment {
repo_owner: String,
repo_name: String,
commit_sha: String,
branch: String,
pipeline_name: String,
pipeline_state: String,
job_outputs: Vec<JobOutput>,
forgejo_url: String,
forgejo_token: String,
},
}
/// StatusReporter actor state.
pub struct StatusReporter;
impl StatusReporter {
pub fn new() -> Self {
Self
}
#[cfg(feature = "local")]
fn post_status(update: &StatusUpdate, forgejo_url: &str, forgejo_token: &str) {
let url = format!(
"{}/api/v1/repos/{}/{}/statuses/{}",
forgejo_url.trim_end_matches('/'),
update.repo_owner,
update.repo_name,
update.commit_sha,
);
let mut body = serde_json::json!({
"state": update.state,
"context": update.context,
"description": update.description,
});
if let Some(ref target_url) = update.target_url {
body["target_url"] = serde_json::Value::String(target_url.clone());
}
let result = ureq::post(&url)
.set("Authorization", &format!("token {forgejo_token}"))
.set("Content-Type", "application/json")
.send_string(&body.to_string());
if let Err(e) = result {
eprintln!("StatusReporter: failed to post status to {url}: {e}");
}
}
#[cfg(feature = "local")]
fn find_pr_for_branch(
forgejo_url: &str,
forgejo_token: &str,
repo_owner: &str,
repo_name: &str,
branch: &str,
) -> Option<u64> {
let url = format!(
"{}/api/v1/repos/{}/{}/pulls?state=open&limit=50",
forgejo_url.trim_end_matches('/'),
repo_owner,
repo_name,
);
let response = ureq::get(&url)
.set("Authorization", &format!("token {forgejo_token}"))
.call();
let response = match response {
Ok(r) => r,
Err(e) => {
eprintln!("StatusReporter: failed to list PRs: {e}");
return None;
}
};
let body: serde_json::Value = match response.into_json() {
Ok(v) => v,
Err(e) => {
eprintln!("StatusReporter: failed to parse PR list: {e}");
return None;
}
};
let prs = body.as_array()?;
for pr in prs {
let head_ref = pr.get("head")?.get("ref")?.as_str()?;
if head_ref == branch {
return pr.get("number")?.as_u64();
}
}
None
}
#[cfg(feature = "local")]
fn post_pr_comment(
forgejo_url: &str,
forgejo_token: &str,
repo_owner: &str,
repo_name: &str,
pr_number: u64,
body_text: &str,
) -> Option<String> {
let url = format!(
"{}/api/v1/repos/{}/{}/issues/{}/comments",
forgejo_url.trim_end_matches('/'),
repo_owner,
repo_name,
pr_number,
);
let body = serde_json::json!({
"body": body_text,
});
let result = ureq::post(&url)
.set("Authorization", &format!("token {forgejo_token}"))
.set("Content-Type", "application/json")
.send_string(&body.to_string());
match result {
Ok(response) => {
let json: serde_json::Value = response.into_json().ok()?;
json.get("html_url")?.as_str().map(|s| s.to_string())
}
Err(e) => {
eprintln!("StatusReporter: failed to post PR comment: {e}");
None
}
}
}
#[cfg(feature = "local")]
fn build_pipeline_comment(
pipeline_name: &str,
pipeline_state: &str,
commit_sha: &str,
job_outputs: &[JobOutput],
) -> String {
let mut md = format!("## Pipeline `{pipeline_name}` — {pipeline_state}\n\n");
let short_sha = if commit_sha.len() > 7 {
&commit_sha[..7]
} else {
commit_sha
};
md.push_str(&format!("Commit: `{short_sha}`\n\n"));
for job in job_outputs {
let status_label = if job.passed {
"passed".to_string()
} else {
match &job.failure_reason {
Some(reason) => format!("failed: {reason}"),
None => "failed".to_string(),
}
};
md.push_str(&format!(
"<details>\n<summary>{} — {}</summary>\n\n",
job.job_name, status_label
));
let max_lines = 100;
let total = job.output_lines.len();
let lines: &[String] = if total > max_lines {
md.push_str(&format!("_Showing last {max_lines} of {total} lines_\n\n"));
&job.output_lines[total - max_lines..]
} else {
&job.output_lines
};
md.push_str("```\n");
for line in lines {
md.push_str(line);
md.push('\n');
}
md.push_str("```\n\n</details>\n\n");
}
md
}
#[cfg(feature = "local")]
fn handle_pipeline_comment(
repo_owner: &str,
repo_name: &str,
commit_sha: &str,
branch: &str,
pipeline_name: &str,
pipeline_state: &str,
job_outputs: &[JobOutput],
forgejo_url: &str,
forgejo_token: &str,
) {
let pr_number = match Self::find_pr_for_branch(
forgejo_url,
forgejo_token,
repo_owner,
repo_name,
branch,
) {
Some(n) => n,
None => {
eprintln!(
"StatusReporter: no open PR for branch '{branch}', skipping comment"
);
return;
}
};
let comment_body =
Self::build_pipeline_comment(pipeline_name, pipeline_state, commit_sha, job_outputs);
let comment_url = Self::post_pr_comment(
forgejo_url,
forgejo_token,
repo_owner,
repo_name,
pr_number,
&comment_body,
);
// Re-post pipeline status with target_url pointing to the comment.
if let Some(ref url) = comment_url {
let update = StatusUpdate {
repo_owner: repo_owner.to_string(),
repo_name: repo_name.to_string(),
commit_sha: commit_sha.to_string(),
state: pipeline_state.to_string(),
context: format!("ci/{pipeline_name}"),
description: format!("Pipeline '{pipeline_name}' {pipeline_state}"),
target_url: Some(url.clone()),
};
Self::post_status(&update, forgejo_url, forgejo_token);
}
}
}
impl ActorInterface for StatusReporter {
type Incoming = StatusReporterMsg;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, msg: StatusReporterMsg) {
match msg {
StatusReporterMsg::Report {
update,
forgejo_url,
forgejo_token,
} => {
#[cfg(feature = "local")]
Self::post_status(&update, &forgejo_url, &forgejo_token);
#[cfg(not(feature = "local"))]
{
let _ = (update, forgejo_url, forgejo_token);
}
}
StatusReporterMsg::PostPipelineComment {
repo_owner,
repo_name,
commit_sha,
branch,
pipeline_name,
pipeline_state,
job_outputs,
forgejo_url,
forgejo_token,
} => {
#[cfg(feature = "local")]
Self::handle_pipeline_comment(
&repo_owner,
&repo_name,
&commit_sha,
&branch,
&pipeline_name,
&pipeline_state,
&job_outputs,
&forgejo_url,
&forgejo_token,
);
#[cfg(not(feature = "local"))]
{
let _ = (
repo_owner,
repo_name,
commit_sha,
branch,
pipeline_name,
pipeline_state,
job_outputs,
forgejo_url,
forgejo_token,
);
}
}
}
}
}

View file

@ -1,188 +0,0 @@
//! Webhook HTTP listener: receives Forgejo webhook POSTs and forwards
//! them to the LocalCoordinator actor.
//!
//! Runs as a standard thread (not an actor) using `tiny_http`.
use crate::{EventType, WebhookEvent};
/// Start the webhook listener in a new thread.
///
/// Returns a join handle for the listener thread.
#[cfg(feature = "local")]
pub fn start_webhook_listener(
port: u16,
secret: String,
runtime: std::sync::Arc<swactor::runtime::Runtime>,
coordinator_addr: swactor::actor::ActorAddress,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("webhook-listener".into())
.spawn(move || {
let server = tiny_http::Server::http(format!("0.0.0.0:{port}"))
.expect("failed to start webhook server");
eprintln!("Webhook listener on http://0.0.0.0:{port}");
for mut request in server.incoming_requests() {
let response = handle_request(&mut request, &secret, &runtime, coordinator_addr);
let _ = request.respond(response);
}
})
.expect("failed to spawn webhook listener thread")
}
#[cfg(feature = "local")]
fn handle_request(
request: &mut tiny_http::Request,
secret: &str,
runtime: &std::sync::Arc<swactor::runtime::Runtime>,
coordinator_addr: swactor::actor::ActorAddress,
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::local_coordinator::LocalCoordinatorMsg;
// Only accept POST.
if request.method() != &tiny_http::Method::Post {
return tiny_http::Response::from_string("method not allowed")
.with_status_code(405);
}
// Read body.
let mut body = String::new();
if let Err(e) = std::io::Read::read_to_string(&mut request.as_reader(), &mut body) {
eprintln!("webhook: failed to read body: {e}");
return tiny_http::Response::from_string("bad request")
.with_status_code(400);
}
// Verify HMAC-SHA256 signature if secret is non-empty.
if !secret.is_empty() {
let sig_header = request
.headers()
.iter()
.find(|h| h.field.equiv("X-Forgejo-Signature"))
.map(|h| h.value.as_str().to_string());
match sig_header {
Some(sig_hex) => {
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC key creation");
hmac::Mac::update(&mut mac, body.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if sig_hex != expected {
eprintln!("webhook: signature mismatch");
return tiny_http::Response::from_string("unauthorized")
.with_status_code(401);
}
}
None => {
eprintln!("webhook: missing signature header");
return tiny_http::Response::from_string("unauthorized")
.with_status_code(401);
}
}
}
// Determine event type from Forgejo header.
let event_header = request
.headers()
.iter()
.find(|h| h.field.equiv("X-Forgejo-Event"))
.map(|h| h.value.as_str().to_string())
.unwrap_or_default();
let event_type = match event_header.as_str() {
"push" => EventType::Push,
"create" => EventType::Tag,
"pull_request" => EventType::Merge,
other => {
eprintln!("webhook: ignoring event type '{other}'");
return tiny_http::Response::from_string("ignored").with_status_code(200);
}
};
// Parse JSON body to extract fields.
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
eprintln!("webhook: failed to parse JSON: {e}");
return tiny_http::Response::from_string("bad json").with_status_code(400);
}
};
let webhook_event = match parse_webhook_json(&json, event_type) {
Some(e) => e,
None => {
eprintln!("webhook: could not extract webhook fields from JSON");
return tiny_http::Response::from_string("bad payload").with_status_code(400);
}
};
// Send to coordinator.
let _ = runtime.send_to(coordinator_addr, LocalCoordinatorMsg::Webhook(webhook_event));
tiny_http::Response::from_string("ok").with_status_code(200)
}
/// Parse a Forgejo webhook JSON payload into a WebhookEvent.
pub fn parse_webhook_json(json: &serde_json::Value, event_type: EventType) -> Option<WebhookEvent> {
let repo = json.get("repository")?;
let repo_owner = repo
.get("owner")
.and_then(|o| o.get("login"))
.or_else(|| repo.get("owner").and_then(|o| o.get("username")))
.and_then(|v| v.as_str())?
.to_string();
let repo_name = repo.get("name").and_then(|v| v.as_str())?.to_string();
let (branch, commit_sha, tag) = match event_type {
EventType::Push => {
let reference = json.get("ref").and_then(|v| v.as_str()).unwrap_or("");
let branch = reference.strip_prefix("refs/heads/").unwrap_or(reference);
let sha = json
.get("after")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(branch.to_string(), sha, None)
}
EventType::Tag => {
let reference = json.get("ref").and_then(|v| v.as_str()).unwrap_or("");
let tag_name = reference.strip_prefix("refs/tags/").unwrap_or(reference);
let sha = json
.get("sha")
.or_else(|| json.get("after"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(String::new(), sha, Some(tag_name.to_string()))
}
EventType::Merge => {
let pr = json.get("pull_request")?;
let branch = pr
.get("head")
.and_then(|h| h.get("ref"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let sha = pr
.get("head")
.and_then(|h| h.get("sha"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(branch, sha, None)
}
};
Some(WebhookEvent {
event_type,
repo_owner,
repo_name,
branch,
commit_sha,
tag,
})
}

View file

@ -9,13 +9,13 @@ to stdout (one object per line). Human-readable output goes to stderr.
### Launching ### Launching
```bash ```bash
cargo run -p runtime-dashboard --example investigate_demo cargo run -p dashboard --example investigate_demo
``` ```
Or programmatically against any running runtime: Or programmatically against any running runtime:
```rust ```rust
use runtime_dashboard::investigate::run_investigate; use dashboard::investigate::run_investigate;
run_investigate(runtime_arc, collector_arc)?; // blocks on stdin run_investigate(runtime_arc, collector_arc)?; // blocks on stdin
``` ```

View file

@ -5,7 +5,6 @@ edition = "2024"
[dependencies] [dependencies]
swactor = { path = "../..", features = ["serde", "tracing"] } swactor = { path = "../..", features = ["serde", "tracing"] }
swactor-std = { path = "../std", default-features = false }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry"] } tracing-subscriber = { version = "0.3", features = ["registry"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@ -16,25 +15,14 @@ tokio-stream = "0.1"
crossbeam-queue = "0.3.12" crossbeam-queue = "0.3.12"
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", optional = true } crossterm = { version = "0.28", optional = true }
distribution = { path = "../distribution", optional = true }
clap = { version = "4", features = ["derive"], optional = true } clap = { version = "4", features = ["derive"], optional = true }
ctrlc = "3" ctrlc = "3"
iroh = { version = "0.96", optional = true }
[features] [features]
default = ["distribution"] default = []
tui = ["dep:ratatui", "dep:crossterm"] tui = ["dep:ratatui", "dep:crossterm"]
distribution = ["dep:distribution"]
node = ["distribution", "dep:clap", "swactor/transport", "tcp"]
tcp = ["distribution/tcp"]
iroh = ["distribution/iroh", "dep:iroh"]
[[bin]] [[bin]]
name = "swactor-tui" name = "swactor-tui"
path = "src/bin/tui.rs" path = "src/bin/tui.rs"
required-features = ["tui"] required-features = ["tui"]
[[bin]]
name = "swactor-node"
path = "src/bin/swactor-node.rs"
required-features = ["node"]

View file

@ -1,4 +1,4 @@
# runtime-dashboard # dashboard
Visual dashboard for the swactor runtime. Provides a live HTTP dashboard, a Visual dashboard for the swactor runtime. Provides a live HTTP dashboard, a
terminal UI (TUI), trace recording/replay, and an HTTP API for programmatic terminal UI (TUI), trace recording/replay, and an HTTP API for programmatic
@ -16,7 +16,7 @@ runtime investigation.
Start the dashboard demo and open it in a browser: Start the dashboard demo and open it in a browser:
```bash ```bash
cargo run -p runtime-dashboard --example dashboard_demo cargo run -p dashboard --example dashboard_demo
``` ```
Pages: Pages:
@ -33,9 +33,9 @@ membership and actor registrations in the directory/cache.
A standalone binary that connects to any running dashboard over SSE: A standalone binary that connects to any running dashboard over SSE:
```bash ```bash
cargo run -p runtime-dashboard --features tui --bin swactor-tui cargo run -p dashboard --features tui --bin swactor-tui
# or point at a specific endpoint # or point at a specific endpoint
cargo run -p runtime-dashboard --features tui --bin swactor-tui -- http://localhost:9090 cargo run -p dashboard --features tui --bin swactor-tui -- http://localhost:9090
``` ```
Views (cycle with Tab): Views (cycle with Tab):
@ -70,7 +70,7 @@ All examples are run from the workspace root.
**HTTP dashboard** — live workload with distribution cluster, Ctrl+C to stop: **HTTP dashboard** — live workload with distribution cluster, Ctrl+C to stop:
```bash ```bash
cargo run -p runtime-dashboard --example dashboard_demo cargo run -p dashboard --example dashboard_demo
# http://localhost:9090 — runtime overview # http://localhost:9090 — runtime overview
# http://localhost:9090/distribution — cluster view # http://localhost:9090/distribution — cluster view
``` ```
@ -78,14 +78,14 @@ cargo run -p runtime-dashboard --example dashboard_demo
**Benchmarks** — four automated scenarios (~20 s total): **Benchmarks** — four automated scenarios (~20 s total):
```bash ```bash
cargo run -p runtime-dashboard --example bench_dashboard cargo run -p dashboard --example bench_dashboard
# open http://localhost:9090 # open http://localhost:9090
``` ```
**Record & replay** — records ~10 s of activity, then serves a replay: **Record & replay** — records ~10 s of activity, then serves a replay:
```bash ```bash
cargo run -p runtime-dashboard --example record_and_replay_demo cargo run -p dashboard --example record_and_replay_demo
# live dashboard at http://localhost:9090 during recording # live dashboard at http://localhost:9090 during recording
# replay dashboard at http://localhost:9091 after recording finishes # replay dashboard at http://localhost:9091 after recording finishes
# Ctrl+C to stop # Ctrl+C to stop

View file

@ -1,224 +0,0 @@
use std::thread;
use std::time::{Duration, Instant};
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::{BackoffPolicy, RuntimeConfig};
use swactor::runtime::Runtime;
use dashboard::collector::StatsCollector;
use dashboard::{start_dashboard, DashboardConfig};
// ---------------------------------------------------------------------------
// Actors
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct Work;
struct SinkActor {
count: u64,
}
impl SinkActor {
fn new() -> Self {
Self { count: 0 }
}
}
impl ActorInterface for SinkActor {
type Incoming = Work;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Work) {
self.count += 1;
}
}
#[derive(Clone)]
struct RingMsg;
struct RingActor {
next: ActorAddress,
}
impl ActorInterface for RingActor {
type Incoming = RingMsg;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: RingMsg) {
let _ = ctx.send(self.next, RingMsg);
}
}
#[derive(Clone)]
struct SpawnCmd;
struct SpawnerActor {
spawned: u64,
}
impl SpawnerActor {
fn new() -> Self {
Self { spawned: 0 }
}
}
impl ActorInterface for SpawnerActor {
type Incoming = SpawnCmd;
type Response = ();
fn handle(&mut self, ctx: &Ctx, _msg: SpawnCmd) {
for _ in 0..20 {
let _ = ctx.spawn(SinkActor::new());
self.spawned += 1;
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn bench_config(threads: usize, max_actors: usize, max_messages: usize) -> RuntimeConfig {
RuntimeConfig {
num_threads: threads,
max_actors,
channel_buffer_size: max_messages,
backoff_policy: BackoffPolicy {
spin_threshold: 32,
yield_threshold: 64,
sleep_increment_us: 10,
sleep_max_us: 100,
},
..Default::default()
}
}
fn run_for(duration: Duration, mut tick: impl FnMut()) {
let deadline = Instant::now() + duration;
while Instant::now() < deadline {
tick();
}
}
// ---------------------------------------------------------------------------
// Scenarios
// ---------------------------------------------------------------------------
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));
rt.set_stats_hook(collector.clone());
let addr = rt.spawn(SinkActor::new()).unwrap();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
for _ in 0..100 {
let _ = handle.runtime.send_to(addr, Work);
}
thread::sleep(Duration::from_millis(10));
});
handle.shutdown();
handle.join();
}
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));
rt.set_stats_hook(collector.clone());
let addrs: Vec<_> = (0..50)
.map(|_| rt.spawn(SinkActor::new()).unwrap())
.collect();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
for &addr in &addrs {
for _ in 0..10 {
let _ = handle.runtime.send_to(addr, Work);
}
}
thread::sleep(Duration::from_millis(20));
});
handle.shutdown();
handle.join();
}
fn scenario_ring(dash: &dashboard::DashboardHandle) {
eprintln!(" [3/4] Ring topology (5s)");
let ring_size = 100;
let collector = StatsCollector::new(4);
let mut rt = Runtime::new(bench_config(4, ring_size + 64, 1_024));
rt.set_stats_hook(collector.clone());
// Build ring backwards: last spawned actor is the entry point
let mut addrs = Vec::with_capacity(ring_size);
// First actor has no valid next yet — will be the tail of the chain
let first = rt.spawn(RingActor { next: ActorAddress::default() }).unwrap();
addrs.push(first);
let mut prev = first;
for _ in 1..ring_size {
let addr = rt.spawn(RingActor { next: prev }).unwrap();
addrs.push(addr);
prev = addr;
}
// The first actor's "next" should be the last actor to close the ring,
// but we can't mutate it. Instead, we inject at the last actor and
// the message flows: last -> second-to-last -> ... -> first -> (dead end).
// For dashboard visualization, a chain is fine — it creates sustained cross-worker traffic.
let entry = *addrs.last().unwrap();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
let _ = handle.runtime.send_to(entry, RingMsg);
thread::sleep(Duration::from_millis(50));
});
handle.shutdown();
handle.join();
}
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));
rt.set_stats_hook(collector.clone());
let spawner = rt.spawn(SpawnerActor::new()).unwrap();
let handle = rt.run().unwrap();
dash.set_runtime(handle.runtime.clone(), collector);
run_for(Duration::from_secs(5), || {
let _ = handle.runtime.send_to(spawner, SpawnCmd);
thread::sleep(Duration::from_millis(200));
});
handle.shutdown();
handle.join();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
fn main() {
let dash = start_dashboard(DashboardConfig {
port: 9090,
..Default::default()
});
dash.install_tracing();
eprintln!("Dashboard at http://localhost:9090");
eprintln!("Running 4 benchmark scenarios (~20s total)...\n");
scenario_single_actor(&dash);
scenario_multi_actor(&dash);
scenario_ring(&dash);
scenario_spawn_storm(&dash);
eprintln!("\nAll scenarios complete. Shutting down.");
dash.shutdown();
}

View file

@ -1,503 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
use distribution::snapshot::DistributionNodeSnapshot;
use distribution::swim::node::NodeAction;
use distribution::swim::probe::SwimConfig;
use distribution::types::NodeId;
use dashboard::collector::StatsCollector;
use dashboard::distribution_collector::DistributionStatsProvider;
use dashboard::{start_dashboard, DashboardConfig};
// ── Demo actors ─────────────────────────────────────────────────────────
#[derive(Clone)]
struct Ping(ActorAddress);
struct PingActor {
count: u32,
}
impl PingActor {
fn new() -> Self {
Self { count: 0 }
}
}
impl ActorInterface for PingActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
// Forward to the target — creates cross-worker traffic
if self.count < 10_000 {
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
}
}
}
#[derive(Clone)]
struct Tick;
struct CounterActor {
ticks: u64,
}
impl CounterActor {
fn new() -> Self {
Self { ticks: 0 }
}
}
impl ActorInterface for CounterActor {
type Incoming = Tick;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {
self.ticks += 1;
}
}
// ── Snapshot provider ───────────────────────────────────────────────────
struct SnapshotProvider {
snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>>,
}
impl DistributionStatsProvider for SnapshotProvider {
fn snapshot(&self) -> Option<DistributionNodeSnapshot> {
self.snapshot.lock().unwrap().clone()
}
}
// ── In-process action delivery ──────────────────────────────────────────
/// Tick all live nodes and deliver their actions to other nodes.
fn tick_all_and_deliver(
nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId],
) {
let n = nodes.len();
// Collect tick actions from all live nodes.
let mut all_actions: Vec<(usize, Vec<NodeAction>)> = Vec::new();
for idx in 0..n {
if let Some(ref mut node) = nodes[idx] {
let actions = node.tick();
if !actions.is_empty() {
all_actions.push((idx, actions));
}
}
}
// Deliver all actions and collect responses.
for (sender_idx, actions) in all_actions {
let tagged_responses = deliver_actions_tagged(
&actions,
node_ids[sender_idx],
nodes,
node_ids,
);
for (responder_idx, response_actions) in tagged_responses {
deliver_actions_tagged(
&response_actions,
node_ids[responder_idx],
nodes,
node_ids,
);
}
}
}
/// Deliver actions to the appropriate target nodes.
/// Returns responses tagged with the index of the responding node.
/// `None` nodes (killed) silently drop actions — simulates network loss.
fn deliver_actions_tagged(
actions: &[NodeAction],
sender_id: NodeId,
nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId],
) -> Vec<(usize, Vec<NodeAction>)> {
let mut tagged_responses: Vec<(usize, Vec<NodeAction>)> = Vec::new();
for action in actions {
match action {
NodeAction::SendPing {
to,
sequence,
piggyback,
} => {
if let Some(idx) = node_ids.iter().position(|id| id == to) {
if let Some(ref mut node) = nodes[idx] {
let resp =
node.handle_ping(sender_id, *sequence, piggyback);
if !resp.is_empty() {
tagged_responses.push((idx, resp));
}
}
}
}
NodeAction::SendAck {
to,
sequence,
piggyback,
} => {
if let Some(idx) = node_ids.iter().position(|id| id == to) {
if let Some(ref mut node) = nodes[idx] {
let resp = node.handle_ack(sender_id, *sequence, piggyback);
if !resp.is_empty() {
tagged_responses.push((idx, resp));
}
}
}
}
NodeAction::SendJoinResponse { to, members, .. } => {
if let Some(idx) = node_ids.iter().position(|id| id == to) {
if let Some(ref mut node) = nodes[idx] {
let resp = node.handle_join_response(members.clone());
if !resp.is_empty() {
tagged_responses.push((idx, resp));
}
}
}
}
NodeAction::SendPingReq {
relay,
target,
sequence,
piggyback,
} => {
if let Some(idx) = node_ids.iter().position(|id| id == relay) {
if let Some(ref mut node) = nodes[idx] {
let resp = node.handle_ping_req(
sender_id,
*target,
*sequence,
piggyback,
);
if !resp.is_empty() {
tagged_responses.push((idx, resp));
}
}
}
}
NodeAction::ForwardAck { to, target, sequence, piggyback } => {
if let Some(idx) = node_ids.iter().position(|id| id == to) {
if let Some(ref mut node) = nodes[idx] {
let resp = node.handle_indirect_ack(*target, *sequence, piggyback);
if !resp.is_empty() {
tagged_responses.push((idx, resp));
}
}
}
}
NodeAction::MembershipChanged { .. } => {
// Notifications — no delivery needed
}
}
}
tagged_responses
}
/// Simulate a join handshake: the joining node sends a join request to the
/// seed, and the seed's response is delivered back.
fn simulate_join(
joining_idx: usize,
seed_idx: usize,
nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId],
) {
let joining_id = node_ids[joining_idx];
// Seed handles the join request
let response_actions = if let Some(ref mut seed) = nodes[seed_idx] {
seed.handle_join_request(joining_id)
} else {
return;
};
// Deliver responses (SendJoinResponse) back to the joining node
let seed_id = node_ids[seed_idx];
let tagged = deliver_actions_tagged(&response_actions, seed_id, nodes, node_ids);
for (responder_idx, response_actions) in tagged {
deliver_actions_tagged(&response_actions, node_ids[responder_idx], nodes, node_ids);
}
}
// ── Main ────────────────────────────────────────────────────────────────
fn main() {
let stop = Arc::new(AtomicBool::new(false));
// Handle Ctrl+C gracefully
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || {
stop.store(true, Ordering::Relaxed);
})
.expect("failed to set Ctrl+C handler");
}
let dash = start_dashboard(DashboardConfig {
port: 9090,
..Default::default()
});
dash.install_tracing();
let num_threads = 4;
let 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());
// Spawn ping actors for cross-worker traffic
let mut ping_addrs = Vec::new();
for _ in 0..16 {
let addr = rt.spawn(PingActor::new()).unwrap();
ping_addrs.push(addr);
}
// Spawn counter actors for sustained traffic
let mut counter_addrs = Vec::new();
for _ in 0..20 {
let addr = rt.spawn(CounterActor::new()).unwrap();
counter_addrs.push(addr);
}
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(handle.runtime.clone(), collector);
// ── Distribution cluster ────────────────────────────────────────────
let swim_config = SwimConfig {
probe_interval: 5,
probe_timeout: 2,
indirect_probes: 2,
suspicion_timeout: 20,
dead_reprobe_interval: 50,
};
let num_nodes = 9; // 1 main + 8 peers
let mut nodes: Vec<Option<DistributedNode>> = Vec::with_capacity(num_nodes);
let mut node_ids: Vec<NodeId> = Vec::with_capacity(num_nodes);
for i in 0..num_nodes {
let config = DistributedNodeConfig {
swim: swim_config.clone(),
cache_capacity: if i == 0 { 1000 } else { 100 },
republish_interval: 500,
..Default::default()
};
let node = DistributedNode::new(config);
node_ids.push(node.node_id());
nodes.push(Some(node));
}
// Join handshakes: nodes[1..] join via seed (node 0).
for i in 1..num_nodes {
simulate_join(i, 0, &mut nodes, &node_ids);
}
// Settle: let SWIM converge initial membership.
for _ in 0..5 {
tick_all_and_deliver(&mut nodes, &node_ids);
}
// Register spawned actors in the main node's directory.
for addr in ping_addrs.iter().chain(counter_addrs.iter()) {
if let Some(ref mut node) = nodes[0] {
node.register_actor(*addr, 1);
}
}
// Snapshot provider for the dashboard.
let cached_snapshot = Arc::new(Mutex::new(
nodes[0].as_ref().map(|n| n.snapshot()),
));
let provider = SnapshotProvider {
snapshot: Arc::clone(&cached_snapshot),
};
dash.set_distribution(Arc::new(provider));
// ── Run ─────────────────────────────────────────────────────────────
eprintln!("Dashboard at http://localhost:9090 — press Ctrl+C to stop");
eprintln!("Distribution at http://localhost:9090/distribution");
// Kick off ping-pong chains
for i in 0..ping_addrs.len() {
let target = ping_addrs[(i + 1) % ping_addrs.len()];
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
}
let mut round: u64 = 0;
while !stop.load(Ordering::Relaxed) {
// Send ticks to all counter actors
for addr in &counter_addrs {
let _ = handle.runtime.send_to(*addr, Tick);
}
// Periodically spawn more actors and register them
if round % 150 == 75 && counter_addrs.len() < 500 {
for _ in 0..8 {
match handle.runtime.spawn(CounterActor::new()) {
Ok(addr) => {
counter_addrs.push(addr);
if let Some(ref mut node) = nodes[0] {
node.register_actor(addr, 1);
}
}
Err(_) => break,
}
}
}
// Periodically re-kick ping chains
if round % 80 == 0 && round > 0 {
for i in 0..ping_addrs.len() {
let target = ping_addrs[(i + 1) % ping_addrs.len()];
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
}
}
// Tick all distribution nodes and deliver SWIM actions
tick_all_and_deliver(&mut nodes, &node_ids);
// Periodically resolve actors from main node
if round % 50 == 25 {
if let Some(ref mut main_node) = nodes[0] {
let actor = ping_addrs[(round as usize / 50) % ping_addrs.len()];
match main_node.resolve_actor(&actor) {
ResolveResult::Cached(found_on) => {
tracing::info!(actor = ?&actor.0[..4], ?found_on, "resolved actor (cached)");
}
ResolveResult::NeedsLookup { .. } => {
tracing::info!(actor = ?&actor.0[..4], "resolve: needs lookup");
}
ResolveResult::NotFound => {
tracing::info!(actor = ?&actor.0[..4], "resolve: not found");
}
}
}
}
// Periodically register actors on a peer and propagate entries to main node
if round % 100 == 0 && round > 0 {
let peer_idx = 1 + ((round as usize / 100) % (num_nodes - 1));
// Register on the peer, collect entries
let mut entries = Vec::new();
if let Some(ref mut peer) = nodes[peer_idx] {
for _ in 0..3 {
let actor = ActorAddress::new_random();
let entry = peer.register_actor(actor, round);
entries.push(entry);
}
}
// Propagate to main node (separate borrow)
if let Some(ref mut main_node) = nodes[0] {
for entry in entries {
main_node.store_directory_entry(entry);
}
}
}
// ── Churn cycle (repeats every 400 rounds, starts at round 200) ──
//
// Offsets within each 400-round cycle:
// 0 → kill peer 8 (simulated crash)
// 150 → revive peer 8 (rejoin cluster)
// 200 → graceful leave for peer 7
// 350 → rejoin peer 7
if round >= 200 {
let churn_pos = (round - 200) % 400;
// Kill peer 8 (simulated crash — set to None)
if churn_pos == 0 {
nodes[8] = None;
tracing::info!("killed peer 8 (simulated crash)");
}
// Revive peer 8 (new node + rejoin)
if churn_pos == 150 {
let config = DistributedNodeConfig {
swim: swim_config.clone(),
cache_capacity: 100,
republish_interval: 500,
..Default::default()
};
let revived = DistributedNode::new(config);
nodes[8] = Some(revived);
node_ids[8] = nodes[8].as_ref().unwrap().node_id();
simulate_join(8, 0, &mut nodes, &node_ids);
tracing::info!("revived peer 8 (rejoined cluster)");
}
// Graceful leave for peer 7
if churn_pos == 200 {
let leave_actions = nodes[7]
.as_mut()
.map(|n| n.leave())
.unwrap_or_default();
if !leave_actions.is_empty() {
let tagged_responses = deliver_actions_tagged(
&leave_actions,
node_ids[7],
&mut nodes,
&node_ids,
);
for (responder_idx, response_actions) in tagged_responses {
deliver_actions_tagged(
&response_actions,
node_ids[responder_idx],
&mut nodes,
&node_ids,
);
}
}
nodes[7] = None;
tracing::info!("peer 7 gracefully left the cluster");
}
// Rejoin peer 7
if churn_pos == 350 {
let config = DistributedNodeConfig {
swim: swim_config.clone(),
cache_capacity: 100,
republish_interval: 500,
..Default::default()
};
let revived = DistributedNode::new(config);
nodes[7] = Some(revived);
node_ids[7] = nodes[7].as_ref().unwrap().node_id();
simulate_join(7, 0, &mut nodes, &node_ids);
tracing::info!("peer 7 rejoined the cluster");
}
}
// Update cached snapshot for the dashboard
*cached_snapshot.lock().unwrap() = nodes[0].as_ref().map(|n| n.snapshot());
round += 1;
thread::sleep(Duration::from_millis(200));
}
eprintln!("\nShutting down...");
handle.shutdown();
dash.shutdown();
handle.join();
}

View file

@ -1,162 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use dashboard::collector::StatsCollector;
use dashboard::{serve_replay, start_dashboard, DashboardConfig, ReplayConfig};
// ── Demo actors ─────────────────────────────────────────────────────────
#[derive(Clone)]
struct Ping(ActorAddress);
struct PingActor {
count: u32,
}
impl PingActor {
fn new() -> Self {
Self { count: 0 }
}
}
impl ActorInterface for PingActor {
type Incoming = Ping;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
if self.count < 100 {
let _ = ctx.send(msg.0, Ping(ctx.self_addr()));
}
}
}
#[derive(Clone)]
struct Tick;
struct CounterActor;
impl ActorInterface for CounterActor {
type Incoming = Tick;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Tick) {}
}
// ── Main ────────────────────────────────────────────────────────────────
fn main() {
// ── Phase 1: Record ─────────────────────────────────────────────────
let dash = start_dashboard(DashboardConfig {
port: 9090,
record: true,
..Default::default()
});
dash.install_tracing();
let num_threads = 4;
let collector = StatsCollector::new(num_threads);
let mut rt = Runtime::new(RuntimeConfig {
num_threads,
max_actors: 512,
channel_buffer_size: 1000,
..Default::default()
});
rt.set_stats_hook(collector.clone());
let mut ping_addrs = Vec::new();
for _ in 0..12 {
let addr = rt.spawn(PingActor::new()).unwrap();
ping_addrs.push(addr);
}
let mut counter_addrs = Vec::new();
for _ in 0..16 {
let addr = rt.spawn(CounterActor).unwrap();
counter_addrs.push(addr);
}
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(handle.runtime.clone(), collector);
eprintln!("Recording trace for ~10 seconds...");
eprintln!("Live dashboard at http://localhost:9090");
// Kick off ping-pong chains
for i in 0..ping_addrs.len() {
let target = ping_addrs[(i + 1) % ping_addrs.len()];
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
}
for round in 0..50 {
for addr in &counter_addrs {
let _ = handle.runtime.send_to(*addr, Tick);
}
if round == 20 {
for _ in 0..8 {
let addr = handle.runtime.spawn(CounterActor).unwrap();
counter_addrs.push(addr);
}
eprintln!(" Spawned 8 more actors");
}
if round == 25 {
for i in 0..ping_addrs.len() {
let target = ping_addrs[(i + 1) % ping_addrs.len()];
let _ = handle.runtime.send_to(ping_addrs[i], Ping(target));
}
}
thread::sleep(Duration::from_millis(200));
}
handle.shutdown();
dash.shutdown();
handle.join();
// Save trace
let path = "runtime_trace.json";
match dash.save_trace(path) {
Ok(()) => eprintln!("Trace saved to {path}"),
Err(e) => {
eprintln!("Failed to save trace: {e}");
std::process::exit(1);
}
}
// ── Phase 2: Replay ─────────────────────────────────────────────────
let stop = Arc::new(AtomicBool::new(false));
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || {
stop.store(true, Ordering::Relaxed);
})
.expect("failed to set Ctrl+C handler");
}
eprintln!("\nStarting replay at 2x speed — press Ctrl+C to stop");
// Spawn replay server in a background thread so we can check Ctrl+C
let replay_path = path.to_string();
thread::spawn(move || {
if let Err(e) = serve_replay(&replay_path, ReplayConfig { port: 9091, speed: 2.0 }) {
eprintln!("Replay error: {e}");
}
});
while !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(200));
}
eprintln!("Done.");
}

View file

@ -123,8 +123,8 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
<nav class="nav-links"> <nav class="nav-links">
<a href="/" class="nav-link">Overview</a> <a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link">Actors</a> <a href="/actors" class="nav-link">Actors</a>
<a href="/distribution" class="nav-link">Distribution</a> <a href="/plugin/distribution" class="nav-link">Distribution</a>
<a href="/datastore" class="nav-link">Datastore</a> <a href="/plugin/datastore" class="nav-link">Datastore</a>
</nav> </nav>
</div> </div>
</div> </div>

View file

@ -151,8 +151,8 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
<nav class="nav-links"> <nav class="nav-links">
<a href="/" class="nav-link">Overview</a> <a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link active">Actors</a> <a href="/actors" class="nav-link active">Actors</a>
<a href="/distribution" class="nav-link">Distribution</a> <a href="/plugin/distribution" class="nav-link">Distribution</a>
<a href="/datastore" class="nav-link">Datastore</a> <a href="/plugin/datastore" class="nav-link">Datastore</a>
</nav> </nav>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -1,307 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use clap::Parser;
use swactor::actor::{ActorInterface, Ctx};
use swactor::config::RuntimeConfig;
use swactor::runtime::Runtime;
use distribution::node::DistributedNodeConfig;
use distribution::registry::RegistryConfig;
use distribution::snapshot::DistributionNodeSnapshot;
use distribution::swim::probe::SwimConfig;
use dashboard::collector::StatsCollector;
use dashboard::distribution_collector::DistributionStatsProvider;
use dashboard::{start_dashboard, DashboardConfig};
// ── CLI ──────────────────────────────────────────────────────────────────
#[derive(Parser)]
#[command(name = "swactor-node", about = "Swactor distributed node")]
struct Args {
/// Transport to use: tcp or iroh
#[arg(long, default_value = "tcp")]
transport: String,
/// Address to listen on for TCP transport (e.g. 10.0.1.10:7000)
#[arg(long)]
listen: Option<std::net::SocketAddr>,
/// Seed node address to join (TCP mode: host:port)
#[arg(long)]
seed: Option<String>,
/// Seed node's iroh public key (iroh mode: hex-encoded 32-byte key)
#[arg(long)]
seed_node_id: Option<String>,
/// Dashboard HTTP port
#[arg(long, default_value = "9090")]
dashboard_port: u16,
/// Number of dummy actors to register in the directory
#[arg(long, default_value = "0")]
actors: usize,
}
// ── Dummy actor ──────────────────────────────────────────────────────────
#[derive(Clone)]
struct Heartbeat;
struct HeartbeatActor;
impl ActorInterface for HeartbeatActor {
type Incoming = Heartbeat;
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: Heartbeat) {}
}
// ── Snapshot provider ────────────────────────────────────────────────────
struct SnapshotProvider {
snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>>,
}
impl DistributionStatsProvider for SnapshotProvider {
fn snapshot(&self) -> Option<DistributionNodeSnapshot> {
self.snapshot.lock().unwrap().clone()
}
}
// ── Main ─────────────────────────────────────────────────────────────────
fn main() {
let args = Args::parse();
let stop = Arc::new(AtomicBool::new(false));
// Handle SIGTERM / Ctrl+C
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || {
stop.store(true, Ordering::Relaxed);
})
.expect("failed to set signal handler");
}
// Start dashboard
let dash = start_dashboard(DashboardConfig {
port: args.dashboard_port,
..Default::default()
});
dash.install_tracing();
dash.start_http_standalone();
// Create actor runtime
let num_threads = 2;
let 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());
let handle = rt.run().expect("failed to start runtime");
dash.set_runtime(handle.runtime.clone(), collector);
// Distribution config (shared between transports)
let swim_config = SwimConfig {
probe_interval: 5,
probe_timeout: 3,
indirect_probes: 2,
suspicion_timeout: 20,
dead_reprobe_interval: 50,
};
let node_config = DistributedNodeConfig {
swim: swim_config,
cache_capacity: 1000,
republish_interval: 500,
..Default::default()
};
match args.transport.as_str() {
#[cfg(feature = "tcp")]
"tcp" => run_tcp(args, node_config, &handle, &dash, &stop),
#[cfg(feature = "iroh")]
"iroh" => run_iroh(args, node_config, &handle, &dash, &stop),
other => {
eprintln!("Unknown or unavailable transport: {other}");
eprintln!("Available transports:");
#[cfg(feature = "tcp")]
eprintln!(" tcp");
#[cfg(feature = "iroh")]
eprintln!(" iroh");
std::process::exit(1);
}
}
eprintln!("\nShutting down...");
handle.shutdown();
dash.shutdown();
handle.join();
}
// ── TCP transport ────────────────────────────────────────────────────────
#[cfg(feature = "tcp")]
fn run_tcp(
args: Args,
node_config: DistributedNodeConfig,
handle: &swactor::runtime::RuntimeHandle,
dash: &dashboard::DashboardHandle,
stop: &Arc<AtomicBool>,
) {
use distribution::driver::NodeDriver;
let listen_addr = args.listen.expect("--listen is required for TCP mode");
let mut driver = NodeDriver::new(listen_addr, node_config).expect("failed to create node driver");
eprintln!(
"Node {} listening on {} (TCP)",
hex(&driver.node_id().0[..4]),
driver.listen_addr(),
);
// Join seed if provided
if let Some(seed) = args.seed {
let seed_addr: std::net::SocketAddr = seed.parse().expect("invalid seed address");
eprintln!("Joining cluster via seed {seed_addr}");
driver.join(&[seed_addr]);
}
// Spawn and register actors
let actor_addrs = spawn_actors(args.actors, handle, driver.node_mut());
// Wire distribution snapshot to dashboard
let cached_snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>> =
Arc::new(Mutex::new(Some(driver.snapshot())));
let provider = SnapshotProvider {
snapshot: Arc::clone(&cached_snapshot),
};
dash.set_distribution(Arc::new(provider));
eprintln!("Dashboard at http://0.0.0.0:{}", args.dashboard_port);
// Main loop
while !stop.load(Ordering::Relaxed) {
driver.recv();
driver.tick();
for addr in &actor_addrs {
let _ = handle.runtime.send_to(*addr, Heartbeat);
}
*cached_snapshot.lock().unwrap() = Some(driver.snapshot());
thread::sleep(Duration::from_millis(100));
}
}
// ── iroh transport ───────────────────────────────────────────────────────
#[cfg(feature = "iroh")]
fn run_iroh(
args: Args,
node_config: DistributedNodeConfig,
handle: &swactor::runtime::RuntimeHandle,
dash: &dashboard::DashboardHandle,
stop: &Arc<AtomicBool>,
) {
use distribution::iroh_driver::{IrohDriver, IrohDriverConfig};
use iroh::RelayMode;
let iroh_config = IrohDriverConfig {
secret_key: None,
relay_mode: RelayMode::Default,
node: node_config,
peer_auth: None,
additional_alpns: vec![],
};
let mut driver = IrohDriver::new(iroh_config).expect("failed to create iroh driver");
eprintln!(
"Node {} started (iroh)",
hex(&driver.node_id().0[..4]),
);
// Join seed if provided
if let Some(seed_hex) = args.seed_node_id {
let seed_bytes = hex_to_bytes(&seed_hex).expect("invalid seed node ID hex");
let seed_key = iroh::PublicKey::from_bytes(&seed_bytes).expect("invalid seed public key");
eprintln!("Joining cluster via seed {}", &seed_hex[..8]);
driver.join(&[seed_key]);
}
// Spawn and register actors
let actor_addrs = spawn_actors(args.actors, handle, driver.node_mut());
// Wire distribution snapshot to dashboard
let cached_snapshot: Arc<Mutex<Option<DistributionNodeSnapshot>>> =
Arc::new(Mutex::new(Some(driver.snapshot())));
let provider = SnapshotProvider {
snapshot: Arc::clone(&cached_snapshot),
};
dash.set_distribution(Arc::new(provider));
eprintln!("Dashboard at http://0.0.0.0:{}", args.dashboard_port);
// Main loop
while !stop.load(Ordering::Relaxed) {
driver.recv();
driver.tick();
for addr in &actor_addrs {
let _ = handle.runtime.send_to(*addr, Heartbeat);
}
*cached_snapshot.lock().unwrap() = Some(driver.snapshot());
thread::sleep(Duration::from_millis(100));
}
driver.shutdown();
}
// ── Helpers ──────────────────────────────────────────────────────────────
fn spawn_actors(
count: usize,
handle: &swactor::runtime::RuntimeHandle,
node: &mut distribution::node::DistributedNode,
) -> Vec<swactor::actor::ActorAddress> {
let mut addrs = Vec::new();
for _ in 0..count {
match handle.runtime.spawn(HeartbeatActor) {
Ok(addr) => {
node.register_actor(addr, 1);
addrs.push(addr);
}
Err(e) => eprintln!("failed to spawn actor: {e}"),
}
}
if !addrs.is_empty() {
eprintln!("Registered {} actors", addrs.len());
}
addrs
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(feature = "iroh")]
fn hex_to_bytes(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for i in 0..32 {
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
}
Some(bytes)
}

View file

@ -1,242 +0,0 @@
//! CI Dashboard extension: provides HTTP API endpoints and stats for CI pipelines.
use serde::{Deserialize, Serialize};
// 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 ─────────────────────────────────────────────────────────
/// Trait for providing CI snapshot data to the dashboard.
pub trait CiStatsProvider: Send + Sync {
fn snapshot(&self) -> CiSnapshot;
}
/// Point-in-time snapshot of CI system state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiSnapshot {
pub active_pipelines: Vec<PipelineSnapshot>,
pub recent_pipelines: Vec<PipelineSnapshot>,
pub provisioner_status: ProvisionerStatus,
pub active_instances: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProvisionerStatus {
Online,
Offline,
Unknown,
}
/// Snapshot of a single pipeline execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineSnapshot {
pub pipeline_id: PipelineId,
pub pipeline_name: String,
pub repo_owner: String,
pub repo_name: String,
pub commit_sha: String,
pub branch: String,
pub status: PipelineStatus,
pub jobs: Vec<JobSnapshot>,
}
/// Snapshot of a single job within a pipeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobSnapshot {
pub job_id: JobId,
pub job_name: String,
pub status: JobStatus,
pub output_line_count: usize,
}
// ─── HTTP API Responses ─────────────────────────────────────────────────────
/// Response for GET /api/ci/pipelines
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineListResponse {
pub pipelines: Vec<PipelineSnapshot>,
}
/// Response for GET /api/ci/pipelines/{id}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineDetailResponse {
pub pipeline: PipelineSnapshot,
}
/// Response for GET /api/ci/status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemStatusResponse {
pub provisioner_status: ProvisionerStatus,
pub active_pipelines: usize,
pub active_instances: usize,
}
/// Response for GET /api/ci/pipelines/{id}/jobs/{job_id}/log
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobLogResponse {
pub job_id: JobId,
pub lines: Vec<String>,
}
// ─── Route Matching ─────────────────────────────────────────────────────────
/// Parsed API route for the CI dashboard.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CiRoute {
ListPipelines,
PipelineDetail { id: u64 },
JobLog { pipeline_id: u64, job_name: String },
Artifact { job_id: String, path: String },
SystemStatus,
NotFound,
}
/// Parse a request path into a CiRoute.
pub fn parse_route(path: &str) -> CiRoute {
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
match parts.as_slice() {
["api", "ci", "pipelines"] => CiRoute::ListPipelines,
["api", "ci", "pipelines", id] => {
if let Ok(id) = id.parse() {
CiRoute::PipelineDetail { id }
} else {
CiRoute::NotFound
}
}
["api", "ci", "pipelines", id, "jobs", job_name, "log"] => {
if let Ok(pipeline_id) = id.parse() {
CiRoute::JobLog {
pipeline_id,
job_name: job_name.to_string(),
}
} else {
CiRoute::NotFound
}
}
["api", "ci", "artifacts", job_id, rest @ ..] if !rest.is_empty() => CiRoute::Artifact {
job_id: job_id.to_string(),
path: rest.join("/"),
},
["api", "ci", "status"] => CiRoute::SystemStatus,
_ => CiRoute::NotFound,
}
}
/// Render a CiSnapshot into a JSON response for the given route.
pub fn handle_route(route: &CiRoute, snapshot: &CiSnapshot) -> Option<String> {
match route {
CiRoute::ListPipelines => {
let resp = PipelineListResponse {
pipelines: snapshot
.active_pipelines
.iter()
.chain(snapshot.recent_pipelines.iter())
.cloned()
.collect(),
};
serde_json::to_string(&resp).ok()
}
CiRoute::PipelineDetail { id } => {
let pipeline = snapshot
.active_pipelines
.iter()
.chain(snapshot.recent_pipelines.iter())
.find(|p| p.pipeline_id.0 == *id)?;
let resp = PipelineDetailResponse {
pipeline: pipeline.clone(),
};
serde_json::to_string(&resp).ok()
}
CiRoute::SystemStatus => {
let resp = SystemStatusResponse {
provisioner_status: snapshot.provisioner_status.clone(),
active_pipelines: snapshot.active_pipelines.len(),
active_instances: snapshot.active_instances,
};
serde_json::to_string(&resp).ok()
}
CiRoute::JobLog { .. } | CiRoute::Artifact { .. } => {
// These require access to stored data beyond the snapshot.
None
}
CiRoute::NotFound => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_parsing() {
assert_eq!(parse_route("/api/ci/pipelines"), CiRoute::ListPipelines);
assert_eq!(
parse_route("/api/ci/pipelines/42"),
CiRoute::PipelineDetail { id: 42 }
);
assert_eq!(
parse_route("/api/ci/pipelines/1/jobs/test/log"),
CiRoute::JobLog {
pipeline_id: 1,
job_name: "test".into()
}
);
assert_eq!(
parse_route("/api/ci/artifacts/job-1/target/release/bin"),
CiRoute::Artifact {
job_id: "job-1".into(),
path: "target/release/bin".into()
}
);
assert_eq!(parse_route("/api/ci/status"), CiRoute::SystemStatus);
assert_eq!(parse_route("/api/ci/unknown"), CiRoute::NotFound);
}
#[test]
fn handle_system_status() {
let snapshot = CiSnapshot {
active_pipelines: vec![],
recent_pipelines: vec![],
provisioner_status: ProvisionerStatus::Online,
active_instances: 2,
};
let route = CiRoute::SystemStatus;
let json = handle_route(&route, &snapshot).unwrap();
let resp: SystemStatusResponse = serde_json::from_str(&json).unwrap();
assert_eq!(resp.provisioner_status, ProvisionerStatus::Online);
assert_eq!(resp.active_instances, 2);
}
}

View file

@ -55,7 +55,7 @@ pub fn enrich_names(stats: &mut RuntimeStats, runtime: &Runtime) {
Some(ext) => ext, Some(ext) => ext,
None => return, None => return,
}; };
let std_ext = match ext.as_any().downcast_ref::<swactor_std::StdExtension>() { let std_ext = match ext.as_any().downcast_ref::<swactor::std::StdExtension>() {
Some(ext) => ext, Some(ext) => ext,
None => return, None => return,
}; };

View file

@ -1,6 +1,6 @@
//! Built-in command handlers for runtime inspection and management. //! Built-in command handlers for runtime inspection and management.
//! //!
//! Extracted from `crates/runtime-dashboard/src/investigate.rs`. //! Extracted from `crates/dashboard/src/investigate.rs`.
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -435,11 +435,10 @@ impl CommandHandler for PhasesCommand {
let mut results = Vec::new(); let mut results = Vec::new();
for (i, timings) in stats.tick_timings.iter().enumerate() { for (i, timings) in stats.tick_timings.iter().enumerate() {
if let Some(wid) = worker_filter { if let Some(wid) = worker_filter
if i != wid { && i != wid {
continue; continue;
} }
}
let breakdown = compute_phase_breakdown(timings); let breakdown = compute_phase_breakdown(timings);
results.push(serde_json::json!({ results.push(serde_json::json!({
"worker_id": i, "worker_id": i,

View file

@ -153,6 +153,12 @@ pub struct CommandRouter {
handlers: HashMap<String, Box<dyn CommandHandler>>, handlers: HashMap<String, Box<dyn CommandHandler>>,
} }
impl Default for CommandRouter {
fn default() -> Self {
Self::new()
}
}
impl CommandRouter { impl CommandRouter {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {

View file

@ -152,8 +152,8 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
<nav class="nav-links"> <nav class="nav-links">
<a href="/" class="nav-link active">Overview</a> <a href="/" class="nav-link active">Overview</a>
<a href="/actors" class="nav-link">Actors</a> <a href="/actors" class="nav-link">Actors</a>
<a href="/distribution" class="nav-link">Distribution</a> <a href="/plugin/distribution" class="nav-link">Distribution</a>
<a href="/datastore" class="nav-link">Datastore</a> <a href="/plugin/datastore" class="nav-link">Datastore</a>
</nav> </nav>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -1,75 +0,0 @@
//! Datastore stats provider for the runtime dashboard.
//!
//! The trait returns a pre-serialized JSON string so that `runtime-dashboard`
//! has no compile-time dependency on `swactor-datastore` (which would create a
//! circular dependency since `swactor-datastore[node]` depends on us).
//!
//! The `swactor-datastore` crate implements this trait in its `node` feature.
use std::sync::Arc;
/// Scope filter for listing objects.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListScope {
Local,
Swarm,
}
/// Trait for providing datastore stats and CRUD operations to the dashboard.
///
/// Implementations capture a point-in-time snapshot as serialized JSON.
/// The dashboard polls this every ~200ms via SSE.
///
/// All command methods have default implementations returning `Err` so that
/// existing `DatastoreMetrics` impls continue to compile without changes.
pub trait DatastoreStatsProvider: Send + Sync {
/// Return a JSON-serialized datastore snapshot, or `None` if unavailable.
fn snapshot_json(&self) -> Option<String>;
/// List objects as a JSON string. `scope` selects local-only or swarm-wide.
fn list_objects(&self, _name_filter: Option<&str>, _scope: ListScope) -> Result<String, String> {
Err("not supported".into())
}
/// Get a single object's metadata + manifest as JSON.
fn get_object(&self, _hash: &str) -> Result<String, String> {
Err("not supported".into())
}
/// Get the raw binary data for an object.
fn get_data(&self, _hash: &str) -> Result<Vec<u8>, String> {
Err("not supported".into())
}
/// Store data, optionally with a name. Returns JSON with `content_hash`.
fn put_data(&self, _data: Vec<u8>, _name: Option<String>) -> Result<String, String> {
Err("not supported".into())
}
/// Delete an object by hash. Returns JSON confirmation.
fn delete_object(&self, _hash: &str) -> Result<String, String> {
Err("not supported".into())
}
/// Get node status as JSON.
fn node_status(&self) -> Result<String, String> {
Err("not supported".into())
}
/// Whether the datastore is currently running.
fn is_running(&self) -> bool {
false
}
/// Shut down the datastore actors.
fn shutdown_datastore(&self) -> Result<(), String> {
Err("not supported".into())
}
}
/// Factory for creating a new datastore instance from the dashboard.
pub trait DatastoreFactory: Send + Sync {
/// Start a datastore with optional persistent storage path.
/// Returns a provider that can be installed into the dashboard.
fn start_datastore(&self, storage_path: Option<String>) -> Result<Arc<dyn DatastoreStatsProvider>, String>;
}

View file

@ -1,35 +0,0 @@
//! Distribution stats provider for the runtime dashboard.
//!
//! The application implements `DistributionStatsProvider` to let the dashboard
//! read a single node's distribution state (SWIM membership, Kademlia routing,
//! LRU cache, etc.) without reaching out to other nodes.
use std::sync::{Arc, Mutex};
use distribution::snapshot::DistributionNodeSnapshot;
/// Trait for providing distribution stats to the dashboard.
///
/// Implementations capture a point-in-time snapshot of the local
/// `DistributedNode`'s state. The dashboard polls this every ~200ms.
pub trait DistributionStatsProvider: Send + Sync {
fn snapshot(&self) -> Option<DistributionNodeSnapshot>;
}
/// Simple implementation wrapping an `Arc<Mutex<T>>` where T implements
/// a `snapshot()` method (e.g. `DistributedNode`).
pub struct DistributionCollector<T> {
inner: Arc<Mutex<T>>,
}
impl<T> DistributionCollector<T> {
pub fn new(inner: Arc<Mutex<T>>) -> Self {
Self { inner }
}
}
impl DistributionStatsProvider for DistributionCollector<distribution::node::DistributedNode> {
fn snapshot(&self) -> Option<DistributionNodeSnapshot> {
self.inner.lock().ok().map(|node| node.snapshot())
}
}

View file

@ -212,16 +212,14 @@ where
if let Some(scope) = ctx.event_scope(event) { if let Some(scope) = ctx.event_scope(event) {
for span in scope { for span in scope {
let exts = span.extensions(); let exts = span.extensions();
if worker_id.is_none() { if worker_id.is_none()
if let Some(wid) = exts.get::<WorkerIdField>() { && let Some(wid) = exts.get::<WorkerIdField>() {
worker_id = Some(wid.0); worker_id = Some(wid.0);
} }
} if actor_addr.is_none()
if actor_addr.is_none() { && let Some(aa) = exts.get::<ActorAddrField>() {
if let Some(aa) = exts.get::<ActorAddrField>() {
actor_addr = Some(aa.0.clone()); actor_addr = Some(aa.0.clone());
} }
}
if worker_id.is_some() && actor_addr.is_some() { if worker_id.is_some() && actor_addr.is_some() {
break; break;
} }
@ -229,16 +227,14 @@ where
} }
// Also check if worker_id or actor_addr was a field on the event itself // Also check if worker_id or actor_addr was a field on the event itself
if worker_id.is_none() { if worker_id.is_none()
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") { && let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
worker_id = n.as_u64().map(|v| v as usize); worker_id = n.as_u64().map(|v| v as usize);
} }
} if actor_addr.is_none()
if actor_addr.is_none() { && let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
actor_addr = Some(s.clone()); actor_addr = Some(s.clone());
} }
}
let dashboard_event = DashboardEvent { let dashboard_event = DashboardEvent {
seq: 0, // filled by push() seq: 0, // filled by push()
@ -259,11 +255,10 @@ where
attrs.record(&mut visitor); attrs.record(&mut visitor);
if let Some(span) = ctx.span(id) { if let Some(span) = ctx.span(id) {
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") { if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id")
if let Some(wid) = n.as_u64() { && let Some(wid) = n.as_u64() {
span.extensions_mut().insert(WorkerIdField(wid as usize)); span.extensions_mut().insert(WorkerIdField(wid as usize));
} }
}
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") { if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
span.extensions_mut().insert(ActorAddrField(s.clone())); span.extensions_mut().insert(ActorAddrField(s.clone()));
} }

View file

@ -3,6 +3,7 @@ pub mod command;
pub mod history; pub mod history;
pub mod investigate; pub mod investigate;
pub mod layer; pub mod layer;
pub mod plugin;
pub mod trace; pub mod trace;
pub mod warnings; pub mod warnings;
mod actor_detail_html; mod actor_detail_html;
@ -15,16 +16,6 @@ mod topology_html;
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
pub mod tui; pub mod tui;
#[cfg(feature = "distribution")]
mod distribution_html;
#[cfg(feature = "distribution")]
pub mod distribution_collector;
mod datastore_html;
pub mod datastore_collector;
pub mod ci_collector;
use std::io; use std::io;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -40,6 +31,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use crate::collector::StatsCollector; use crate::collector::StatsCollector;
use crate::history::{DashboardHistory, HistoryConfig}; use crate::history::{DashboardHistory, HistoryConfig};
use crate::layer::{now_ms, DashboardLayer, EventStore}; use crate::layer::{now_ms, DashboardLayer, EventStore};
use crate::plugin::PluginRegistry;
use crate::trace::{RuntimeTrace, TimestampedStats}; use crate::trace::{RuntimeTrace, TimestampedStats};
/// Peer info sent through the join channel: (public_key, optional_relay_url). /// Peer info sent through the join channel: (public_key, optional_relay_url).
@ -99,13 +91,7 @@ pub struct DashboardHandle {
history: Arc<DashboardHistory>, history: Arc<DashboardHistory>,
recording: bool, recording: bool,
port: u16, port: u16,
#[cfg(feature = "distribution")] plugin_registry: Arc<PluginRegistry>,
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>>>>,
ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>>,
peer_auth: Arc<Mutex<Option<Arc<Mutex<distribution::peer_auth::PeerAllowList>>>>>,
join_sender: Arc<Mutex<Option<std::sync::mpsc::Sender<JoinPeerInfo>>>>,
standalone_rt: Mutex<Option<tokio::runtime::Runtime>>, standalone_rt: Mutex<Option<tokio::runtime::Runtime>>,
} }
@ -133,40 +119,9 @@ impl DashboardHandle {
self.recording self.recording
} }
/// Attach a distribution stats provider, enabling the `/distribution` page. /// Register a plugin with the dashboard.
#[cfg(feature = "distribution")] pub fn register_plugin(&self, plugin: Arc<dyn plugin::DashboardPlugin>) {
pub fn set_distribution(&self, provider: Arc<dyn distribution_collector::DistributionStatsProvider>) { self.plugin_registry.register(plugin);
*self.distribution.lock().unwrap() = Some(provider);
}
/// Attach a datastore stats provider, enabling the `/datastore` page.
pub fn set_datastore(&self, provider: Arc<dyn datastore_collector::DatastoreStatsProvider>) {
*self.datastore.lock().unwrap() = Some(provider);
}
/// Attach a datastore factory, enabling start/stop from the dashboard.
pub fn set_datastore_factory(&self, factory: Arc<dyn datastore_collector::DatastoreFactory>) {
*self.datastore_factory.lock().unwrap() = Some(factory);
}
/// Get the shared datastore provider mutex (for external wiring).
pub fn datastore_provider(&self) -> &Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>> {
&self.datastore
}
/// Attach a CI stats provider, enabling the `/api/ci/*` endpoints.
pub fn set_ci(&self, provider: Arc<dyn ci_collector::CiStatsProvider>) {
*self.ci.lock().unwrap() = Some(provider);
}
/// 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<JoinPeerInfo>) {
*self.join_sender.lock().unwrap() = Some(tx);
} }
/// Access the time-series history store (for TUI sparklines, etc.). /// Access the time-series history store (for TUI sparklines, etc.).
@ -212,13 +167,7 @@ impl DashboardHandle {
shutdown_notify: Arc::clone(&self.shutdown_notify), shutdown_notify: Arc::clone(&self.shutdown_notify),
history: Arc::clone(&self.history), history: Arc::clone(&self.history),
cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()), cmd_router: Arc::new(crate::command::CommandRouter::with_builtins()),
#[cfg(feature = "distribution")] plugins: Arc::clone(&self.plugin_registry),
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),
} }
} }
@ -228,8 +177,7 @@ impl DashboardHandle {
/// This drains the recording buffers — each call consumes the buffered data. /// This drains the recording buffers — each call consumes the buffered data.
pub fn save_trace(&self, path: &str) -> io::Result<()> { pub fn save_trace(&self, path: &str) -> io::Result<()> {
let events = self.store.all_events().ok_or_else(|| { let events = self.store.all_events().ok_or_else(|| {
io::Error::new( io::Error::other(
io::ErrorKind::Other,
"recording not enabled (set DashboardConfig::record = true)", "recording not enabled (set DashboardConfig::record = true)",
) )
})?; })?;
@ -242,7 +190,7 @@ impl DashboardHandle {
stats_timeline, stats_timeline,
}; };
let json = serde_json::to_string(&trace) let json = serde_json::to_string(&trace)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; .map_err(|e| io::Error::other(e))?;
std::fs::write(path, json) std::fs::write(path, json)
} }
} }
@ -266,25 +214,6 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1))); let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
let history = Arc::new(DashboardHistory::new(HistoryConfig::default())); let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
#[cfg(feature = "distribution")]
let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> =
Arc::new(Mutex::new(None));
let datastore: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreStatsProvider>>>> =
Arc::new(Mutex::new(None));
let datastore_factory: Arc<Mutex<Option<Arc<dyn datastore_collector::DatastoreFactory>>>> =
Arc::new(Mutex::new(None));
let ci: Arc<Mutex<Option<Arc<dyn ci_collector::CiStatsProvider>>>> =
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<JoinPeerInfo>>>> =
Arc::new(Mutex::new(None));
// Start stats recorder thread when recording is enabled // Start stats recorder thread when recording is enabled
if config.record { if config.record {
let rt_ref = Arc::clone(&runtime); let rt_ref = Arc::clone(&runtime);
@ -314,6 +243,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
} }
let port = config.port; let port = config.port;
let plugin_registry = Arc::new(PluginRegistry::new());
DashboardHandle { DashboardHandle {
store, store,
@ -325,13 +255,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
history, history,
recording: config.record, recording: config.record,
port, port,
#[cfg(feature = "distribution")] plugin_registry,
distribution,
datastore,
datastore_factory,
ci,
peer_auth,
join_sender,
standalone_rt: Mutex::new(None), standalone_rt: Mutex::new(None),
} }
} }
@ -368,7 +292,7 @@ pub fn serve_replay(path: &str, config: ReplayConfig) -> io::Result<()> {
.worker_threads(1) .worker_threads(1)
.enable_all() .enable_all()
.build() .build()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; .map_err(|e| io::Error::other(e))?;
let state = server::ReplayState { let state = server::ReplayState {
trace: Arc::new(trace), trace: Arc::new(trace),

View file

@ -0,0 +1,87 @@
//! Dashboard plugin system.
//!
//! Plugins provide subsystem-specific metrics, API endpoints, and UI pages
//! to the dashboard without the dashboard knowing about the subsystem.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Response from a plugin's request handler.
pub enum PluginResponse {
Json(String),
Binary { content_type: String, data: Vec<u8> },
Error { status: u16, message: String },
NotFound,
}
impl PluginResponse {
pub fn not_found() -> Self {
Self::NotFound
}
pub fn json(s: String) -> Self {
Self::Json(s)
}
pub fn error(status: u16, msg: impl Into<String>) -> Self {
Self::Error {
status,
message: msg.into(),
}
}
}
/// A composable dashboard plugin.
///
/// Plugins provide subsystem-specific metrics, API endpoints, and UI pages
/// to the dashboard without the dashboard knowing about the subsystem.
pub trait DashboardPlugin: Send + Sync {
/// Unique name — used as SSE event type and API route prefix `/api/plugin/{name}/...`
fn name(&self) -> &str;
/// JSON snapshot polled every ~200ms via SSE. Return None if no data available.
fn snapshot_json(&self) -> Option<String>;
/// Handle an API request to `/api/plugin/{name}/{path}`.
fn handle_request(
&self,
_method: &str,
_path: &str,
_query: &HashMap<String, String>,
_body: &[u8],
) -> PluginResponse {
PluginResponse::not_found()
}
/// Optional HTML page content. Dashboard will serve at `/plugin/{name}`.
fn html_page(&self) -> Option<&str> {
None
}
}
/// Thread-safe registry of plugins.
pub struct PluginRegistry {
plugins: Mutex<Vec<Arc<dyn DashboardPlugin>>>,
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
impl PluginRegistry {
pub fn new() -> Self {
Self {
plugins: Mutex::new(Vec::new()),
}
}
pub fn register(&self, plugin: Arc<dyn DashboardPlugin>) {
self.plugins.lock().unwrap().push(plugin);
}
pub fn snapshot(&self) -> Vec<Arc<dyn DashboardPlugin>> {
self.plugins.lock().unwrap().clone()
}
}

View file

@ -1,392 +0,0 @@
pub const POOL_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime – Pool</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header-left { display: flex; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px; padding: 12px;
}
.panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; overflow: hidden;
}
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.full-width { grid-column: 1 / -1; }
.stats-cards {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px;
}
.stat-card {
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
.members-table { max-height: 400px; overflow-y: auto; }
.members-table table { width: 100%; border-collapse: collapse; }
.members-table th, .members-table td {
padding: 4px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
white-space: nowrap;
}
.members-table th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
.content-table { max-height: 400px; overflow-y: auto; }
.content-table table { width: 100%; border-collapse: collapse; }
.content-table th, .content-table td {
padding: 4px 8px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
white-space: nowrap;
}
.content-table th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
/* Capacity bars */
.cap-bar-wrap { margin-bottom: 6px; }
.cap-bar-label {
display: flex; justify-content: space-between; font-size: 10px; color: #888; margin-bottom: 2px;
}
.cap-bar {
height: 14px; background: #1c1f2e; border-radius: 3px; overflow: hidden;
}
.cap-bar-fill {
height: 100%; background: #6366f1; border-radius: 3px;
transition: width 0.3s ease;
}
.cap-bar-fill.warn { background: #ff9800; }
.cap-bar-fill.crit { background: #f44336; }
/* Buttons */
button {
background: #1e2030; color: #e0e0e0; border: 1px solid #2a2d3e;
border-radius: 4px; padding: 6px 14px; font-family: inherit;
font-size: 13px; cursor: pointer; min-height: 38px;
transition: border-color 0.15s;
}
button:hover { border-color: #6366f1; color: #fff; }
button:disabled { opacity: 0.4; cursor: default; }
button.primary { background: #6366f1; border-color: #6366f1; color: #fff; font-weight: 600; }
button.primary:hover { background: #5558e6; }
button.danger:hover { border-color: #f44336; }
/* No-pool message */
.no-pool {
display: flex; align-items: center; justify-content: center;
height: calc(100vh - 49px); color: #555; font-size: 16px;
flex-direction: column; gap: 8px;
}
/* Toast */
.toast {
position: fixed; bottom: 20px; right: 20px; padding: 10px 16px;
border-radius: 4px; font-size: 12px; z-index: 100; opacity: 0;
transition: opacity 0.3s; pointer-events: none;
}
.toast.show { opacity: 1; }
.toast.success { background: #4caf50; color: #fff; }
.toast.error { background: #f44336; color: #fff; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>
Swactor Runtime Dashboard
<span id="statusDot" class="status-dot disconnected"></span>
</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link">Actors</a>
<a href="/distribution" class="nav-link">Distribution</a>
<a href="/datastore" class="nav-link">Datastore</a>
<a href="/pool" class="nav-link active">Pool</a>
</nav>
</div>
<div class="header-right">
<button id="joinBtn" class="primary" onclick="joinPool()" style="display:none">Join Pool</button>
<button id="leaveBtn" class="danger" onclick="leavePool()" style="display:none">Leave Pool</button>
</div>
</div>
<div id="noPool" class="no-pool" style="display:none">
<div>No pool configured</div>
<div style="font-size:12px;color:#444;">Start the node with --pool-name to enable pooled storage</div>
</div>
<div id="poolContent" style="display:none">
<div class="grid">
<!-- Summary cards -->
<div class="panel full-width">
<h2>Pool Summary</h2>
<div class="stats-cards">
<div class="stat-card"><div class="value" id="statPoolName">-</div><div class="label">Pool Name</div></div>
<div class="stat-card"><div class="value" id="statMembers">0</div><div class="label">Members</div></div>
<div class="stat-card"><div class="value" id="statContent">0</div><div class="label">Content Items</div></div>
<div class="stat-card"><div class="value" id="statTotal">0</div><div class="label">Total Capacity</div></div>
<div class="stat-card"><div class="value" id="statUsed">0</div><div class="label">Used</div></div>
</div>
</div>
<!-- Capacity chart -->
<div class="panel">
<h2>Capacity by Node</h2>
<div id="capacityChart"></div>
<div id="capEmpty" style="color:#555;font-size:11px;">No members yet</div>
</div>
<!-- Members table -->
<div class="panel">
<h2>Members <span id="memberCount" style="color:#555;font-weight:400;"></span></h2>
<div class="members-table">
<table>
<thead><tr><th>Node ID</th><th>State</th><th>Total</th><th>Used</th><th>Free</th></tr></thead>
<tbody id="membersBody"></tbody>
</table>
</div>
</div>
<!-- Content locations -->
<div class="panel full-width">
<h2>Content Location Map <span id="contentCount" style="color:#555;font-weight:400;"></span></h2>
<div class="content-table">
<table>
<thead><tr><th>Content Hash</th><th>Replicas</th><th>Nodes</th></tr></thead>
<tbody id="contentBody"></tbody>
</table>
</div>
</div>
<!-- ACL panel -->
<div class="panel full-width">
<h2>Access Control <span id="aclMode" style="color:#555;font-weight:400;"></span></h2>
<div id="aclOpen" style="color:#555;font-size:11px;">Open mode &mdash; any node may join the pool</div>
<div id="aclTable" class="content-table" style="display:none">
<table>
<thead><tr><th>Node ID</th><th>Granted By</th><th>Status</th></tr></thead>
<tbody id="aclBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function() {
var dot = document.getElementById('statusDot');
var poolConfigured = false;
function $(id) { return document.getElementById(id); }
function formatBytes(b) {
if (b === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(b) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (b / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
window.toast = function(msg, type) {
var t = $('toast');
t.textContent = msg;
t.className = 'toast show ' + type;
setTimeout(function() { t.className = 'toast'; }, 2500);
};
function updatePool(snap) {
if (!snap) {
if (!poolConfigured) {
$('noPool').style.display = 'flex';
$('poolContent').style.display = 'none';
$('joinBtn').style.display = 'none';
$('leaveBtn').style.display = 'none';
}
return;
}
poolConfigured = true;
$('noPool').style.display = 'none';
$('poolContent').style.display = 'block';
$('joinBtn').style.display = 'inline-block';
$('leaveBtn').style.display = 'inline-block';
// Summary cards
$('statPoolName').textContent = snap.pool_name || '-';
$('statPoolName').style.fontSize = '14px';
$('statMembers').textContent = snap.member_count;
$('statContent').textContent = snap.content_count;
$('statTotal').textContent = formatBytes(snap.total_bytes);
$('statUsed').textContent = formatBytes(snap.used_bytes);
// Members table
var mbody = $('membersBody');
mbody.innerHTML = '';
$('memberCount').textContent = '(' + snap.members.length + ')';
for (var i = 0; i < snap.members.length; i++) {
var m = snap.members[i];
var free = m.total_bytes - m.used_bytes;
var tr = document.createElement('tr');
tr.innerHTML =
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(m.node_id) + '">' + m.node_id.substring(0, 16) + '\u2026</td>' +
'<td style="color:#4caf50;">' + escapeHtml(m.state) + '</td>' +
'<td style="color:#888;">' + formatBytes(m.total_bytes) + '</td>' +
'<td style="color:#888;">' + formatBytes(m.used_bytes) + '</td>' +
'<td style="color:#888;">' + formatBytes(free) + '</td>';
mbody.appendChild(tr);
}
// Capacity chart
var chart = $('capacityChart');
chart.innerHTML = '';
var capEmpty = $('capEmpty');
if (snap.members.length === 0) {
capEmpty.style.display = 'block';
} else {
capEmpty.style.display = 'none';
for (var i = 0; i < snap.members.length; i++) {
var m = snap.members[i];
var pct = m.total_bytes > 0 ? Math.round((m.used_bytes / m.total_bytes) * 100) : 0;
var fillClass = 'cap-bar-fill';
if (pct > 90) fillClass += ' crit';
else if (pct > 70) fillClass += ' warn';
var wrap = document.createElement('div');
wrap.className = 'cap-bar-wrap';
wrap.innerHTML =
'<div class="cap-bar-label"><span>' + m.node_id.substring(0, 12) + '\u2026</span><span>' + pct + '% (' + formatBytes(m.used_bytes) + ' / ' + formatBytes(m.total_bytes) + ')</span></div>' +
'<div class="cap-bar"><div class="' + fillClass + '" style="width:' + pct + '%"></div></div>';
chart.appendChild(wrap);
}
}
// Content locations
var cbody = $('contentBody');
cbody.innerHTML = '';
$('contentCount').textContent = '(' + snap.content_locations.length + ')';
for (var i = 0; i < snap.content_locations.length; i++) {
var cl = snap.content_locations[i];
var nodeList = cl.nodes.map(function(n) { return n.substring(0, 12) + '\u2026'; }).join(', ');
var tr = document.createElement('tr');
tr.innerHTML =
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(cl.content_hash) + '">' + cl.content_hash.substring(0, 16) + '\u2026</td>' +
'<td>' + cl.replica_count + '</td>' +
'<td style="color:#888;font-size:10px;">' + escapeHtml(nodeList) + '</td>';
cbody.appendChild(tr);
}
// ACL panel
var aclMode = snap.acl_mode || 'open';
var aclEntries = snap.acl || [];
$('aclMode').textContent = '(' + aclMode + ')';
if (aclMode === 'open' || aclEntries.length === 0) {
$('aclOpen').style.display = 'block';
$('aclTable').style.display = 'none';
} else {
$('aclOpen').style.display = 'none';
$('aclTable').style.display = 'block';
var abody = $('aclBody');
abody.innerHTML = '';
for (var i = 0; i < aclEntries.length; i++) {
var a = aclEntries[i];
var status = a.revoked ? 'revoked' : 'granted';
var statusColor = a.revoked ? '#f44336' : '#4caf50';
var tr = document.createElement('tr');
tr.innerHTML =
'<td style="color:#6366f1;font-size:11px;" title="' + escapeHtml(a.node_id) + '">' + a.node_id.substring(0, 16) + '\u2026</td>' +
'<td style="color:#888;font-size:10px;" title="' + escapeHtml(a.granted_by) + '">' + a.granted_by.substring(0, 16) + '\u2026</td>' +
'<td style="color:' + statusColor + ';font-size:10px;">' + status + '</td>';
abody.appendChild(tr);
}
}
}
// Join/Leave actions
window.joinPool = function() {
$('joinBtn').disabled = true;
fetch('/api/pool/join', { method: 'POST' })
.then(function(r) {
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
return r.json();
})
.then(function() { toast('Joined pool', 'success'); })
.catch(function(e) { toast('Join failed: ' + e.message, 'error'); })
.finally(function() { $('joinBtn').disabled = false; });
};
window.leavePool = function() {
$('leaveBtn').disabled = true;
fetch('/api/pool/leave', { method: 'POST' })
.then(function(r) {
if (!r.ok) return r.json().then(function(j) { throw new Error(j.error || r.statusText); });
return r.json();
})
.then(function() { toast('Left pool', 'success'); })
.catch(function(e) { toast('Leave failed: ' + e.message, 'error'); })
.finally(function() { $('leaveBtn').disabled = false; });
};
// SSE connection
var es = new EventSource('/events');
es.addEventListener('pool', function(e) {
try {
var snap = JSON.parse(e.data);
updatePool(snap);
} catch(err) { console.error('pool parse error', err); }
});
es.addEventListener('done', function() {
dot.className = 'status-dot done';
es.close();
});
es.onerror = function() { dot.className = 'status-dot disconnected'; };
es.onopen = function() { dot.className = 'status-dot'; };
})();
</script>
</body>
</html>
"##;

View file

@ -9,7 +9,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::get;
use axum::Router; use axum::Router;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
@ -21,20 +21,13 @@ use crate::actors_html::ACTORS_HTML;
use crate::collector::StatsCollector; use crate::collector::StatsCollector;
use crate::command::CommandRouter; use crate::command::CommandRouter;
use crate::dashboard_html::DASHBOARD_HTML; 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::history::DashboardHistory;
use crate::layer::EventStore; use crate::layer::EventStore;
use crate::topology; use crate::topology;
use crate::topology_html::TOPOLOGY_HTML; use crate::topology_html::TOPOLOGY_HTML;
use crate::warnings::{WarningConfig, WarningDetector}; use crate::warnings::{WarningConfig, WarningDetector};
#[cfg(feature = "distribution")] use crate::plugin::PluginRegistry;
use crate::distribution_collector::DistributionStatsProvider;
#[cfg(feature = "distribution")]
use crate::distribution_html::DISTRIBUTION_HTML;
use crate::ci_collector::CiStatsProvider;
use crate::trace::RuntimeTrace; use crate::trace::RuntimeTrace;
@ -54,13 +47,7 @@ pub(crate) struct AppState {
pub shutdown_notify: Arc<tokio::sync::Notify>, pub shutdown_notify: Arc<tokio::sync::Notify>,
pub history: Arc<DashboardHistory>, pub history: Arc<DashboardHistory>,
pub cmd_router: Arc<CommandRouter>, pub cmd_router: Arc<CommandRouter>,
#[cfg(feature = "distribution")] pub plugins: Arc<PluginRegistry>,
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 ───────────────────────────────────────────────────── // ── Router builders ─────────────────────────────────────────────────────
@ -70,34 +57,16 @@ pub(crate) fn build_live_router(state: AppState) -> Router {
.route("/", get(page_dashboard)) .route("/", get(page_dashboard))
.route("/actors", get(page_actors)) .route("/actors", get(page_actors))
.route("/topology", get(page_topology)) .route("/topology", get(page_topology))
.route("/datastore", get(page_datastore))
.route("/events", get(handle_live_sse)) .route("/events", get(handle_live_sse))
.route("/api/stats", get(handle_stats_api)) .route("/api/stats", get(handle_stats_api))
.route("/api/history", get(handle_history_api)) .route("/api/history", get(handle_history_api))
.route("/api/topology", get(handle_topology_api)) .route("/api/topology", get(handle_topology_api))
.route("/api/investigate", get(handle_investigate_api)) .route("/api/investigate", get(handle_investigate_api))
.route("/api/datastore", get(handle_datastore_api))
.route("/api/logs", get(handle_logs_api)) .route("/api/logs", get(handle_logs_api))
.route("/api/datastore/list", get(handle_ds_list)) .route("/actor/{hex}", get(handle_actor_detail))
.route("/api/datastore/get", get(handle_ds_get)) // Plugin routes
.route("/api/datastore/data", get(handle_ds_data)) .route("/api/plugin/{name}/{*rest}", get(handle_plugin_get).post(handle_plugin_post))
.route("/api/datastore/status", get(handle_ds_status)) .route("/plugin/{name}", get(handle_plugin_page));
.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/sync", post(handle_peers_sync))
.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) router.with_state(state)
} }
@ -151,15 +120,6 @@ async fn page_topology() -> Response {
html_response(TOPOLOGY_HTML, "live") 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 { async fn handle_actor_detail(Path(hex_addr): Path<String>) -> Response {
let html = ACTOR_DETAIL_HTML let html = ACTOR_DETAIL_HTML
.replace("__DASHBOARD_MODE__", "live") .replace("__DASHBOARD_MODE__", "live")
@ -201,13 +161,11 @@ async fn handle_live_sse(
// Run warning detection // Run warning detection
let warnings = warning_detector.check(&stats); let warnings = warning_detector.check(&stats);
if !warnings.is_empty() { if !warnings.is_empty()
if let Ok(wjson) = serde_json::to_string(&warnings) { && let Ok(wjson) = serde_json::to_string(&warnings)
if tx.send(format_sse("warnings", &wjson)).await.is_err() { && tx.send(format_sse("warnings", &wjson)).await.is_err() {
return; return;
} }
}
}
let json = serde_json::to_string(&stats).unwrap(); let json = serde_json::to_string(&stats).unwrap();
if tx.send(format_sse("stats", &json)).await.is_err() { if tx.send(format_sse("stats", &json)).await.is_err() {
@ -216,67 +174,22 @@ async fn handle_live_sse(
// Send topology every 5th tick (~1/sec) // Send topology every 5th tick (~1/sec)
tick_count += 1; tick_count += 1;
if tick_count % 5 == 0 { if tick_count.is_multiple_of(5) {
let topo = topology::worker_topology(&stats); let topo = topology::worker_topology(&stats);
if let Ok(tjson) = serde_json::to_string(&topo) { if let Ok(tjson) = serde_json::to_string(&topo)
if tx.send(format_sse("topology", &tjson)).await.is_err() { && tx.send(format_sse("topology", &tjson)).await.is_err() {
return; return;
} }
}
} }
} }
} }
// Send distribution snapshot if provider is attached // Poll all registered plugins
#[cfg(feature = "distribution")] for plugin in state.plugins.snapshot() {
{ if let Some(json) = plugin.snapshot_json()
let maybe_dist = state.distribution.lock().unwrap().clone(); && tx.send(format_sse(plugin.name(), &json)).await.is_err() {
if let Some(provider) = maybe_dist { return;
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 // Send new activity events
@ -361,54 +274,6 @@ async fn handle_investigate_api(
json_response(json) 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 { async fn handle_topology_api(State(state): State<AppState>) -> Response {
let maybe_rt = state.runtime.lock().unwrap().clone(); let maybe_rt = state.runtime.lock().unwrap().clone();
let json = match maybe_rt { let json = match maybe_rt {
@ -453,397 +318,77 @@ async fn handle_history_api(State(state): State<AppState>) -> Response {
json_response(json) json_response(json)
} }
// ── Datastore CRUD API handlers ───────────────────────────────────────── // ── Plugin handlers ─────────────────────────────────────────────────────
fn get_ds_provider( async fn handle_plugin_get(
datastore: &Arc<Mutex<Option<Arc<dyn DatastoreStatsProvider>>>>,
) -> Option<Arc<dyn DatastoreStatsProvider>> {
datastore.lock().unwrap().clone()
}
async fn handle_ds_list(
State(state): State<AppState>, State(state): State<AppState>,
Path((name, rest)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
) -> Response { ) -> Response {
let provider = match get_ds_provider(&state.datastore) { let plugins = state.plugins.snapshot();
let plugin = match plugins.iter().find(|p| p.name() == name) {
Some(p) => p, Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), None => return json_error(StatusCode::NOT_FOUND, &format!("plugin '{name}' not found")),
}; };
let scope = match params.get("scope").map(|s| s.as_str()) { match plugin.handle_request("GET", &rest, &params, &[]) {
Some("local") => ListScope::Local, crate::plugin::PluginResponse::Json(json) => json_response(json),
_ => ListScope::Swarm, crate::plugin::PluginResponse::Binary { content_type, data } => {
}; ([(header::CONTENT_TYPE, content_type)], data).into_response()
let name_filter = params.get("name").map(|s| s.as_str()); }
match provider.list_objects(name_filter, scope) { crate::plugin::PluginResponse::Error { status, message } => {
Ok(json) => json_response(json), let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), json_error(code, &message)
} }
} crate::plugin::PluginResponse::NotFound => {
json_error(StatusCode::NOT_FOUND, "not found")
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 { async fn handle_plugin_post(
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>, State(state): State<AppState>,
Path((name, rest)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
body: Bytes, body: Bytes,
) -> Response { ) -> Response {
let provider = match get_ds_provider(&state.datastore) { let plugins = state.plugins.snapshot();
let plugin = match plugins.iter().find(|p| p.name() == name) {
Some(p) => p, Some(p) => p,
None => return json_error(StatusCode::SERVICE_UNAVAILABLE, "datastore not running"), None => return json_error(StatusCode::NOT_FOUND, &format!("plugin '{name}' not found")),
}; };
let name = params.get("name").cloned(); match plugin.handle_request("POST", &rest, &params, &body) {
match provider.put_data(body.to_vec(), name) { crate::plugin::PluginResponse::Json(json) => json_response(json),
Ok(json) => json_response(json), crate::plugin::PluginResponse::Binary { content_type, data } => {
Err(e) => json_error(StatusCode::INTERNAL_SERVER_ERROR, &e), ([(header::CONTENT_TYPE, content_type)], data).into_response()
}
crate::plugin::PluginResponse::Error { status, message } => {
let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
json_error(code, &message)
}
crate::plugin::PluginResponse::NotFound => {
json_error(StatusCode::NOT_FOUND, "not found")
}
} }
} }
async fn handle_ds_delete( async fn handle_plugin_page(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>, Path(name): Path<String>,
) -> Response { ) -> Response {
let provider = match get_ds_provider(&state.datastore) { let plugins = state.plugins.snapshot();
let plugin = match plugins.iter().find(|p| p.name() == name) {
Some(p) => p, 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 => { None => {
let json = serde_json::json!({ return (StatusCode::NOT_FOUND, "plugin not found").into_response();
"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_sync(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 peers = match parsed.get("peers").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return json_error(StatusCode::BAD_REQUEST, "missing peers array"),
};
// Parse all peers first, bail on any error
let mut parsed_peers: Vec<(distribution::types::NodeId, String)> = Vec::new();
for peer in peers {
let node_id_str = match peer.get("node_id").and_then(|v| v.as_str()) {
Some(s) => s,
None => return json_error(StatusCode::BAD_REQUEST, "peer missing node_id"),
};
let label = peer
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
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,
&format!("invalid node_id hex length for {node_id_str}"),
);
}
}
} else if let Some(arr) = distribution::identity::base58_decode(node_id_str) {
arr
} else {
return json_error(
StatusCode::BAD_REQUEST,
&format!("invalid node_id: {node_id_str}"),
);
};
parsed_peers.push((distribution::types::NodeId(bytes), label));
}
// Add all peers in a single lock acquisition
{
let mut list = auth.lock().unwrap();
for (node_id, label) in &parsed_peers {
list.add_peer(*node_id, label.clone());
}
if let Err(e) = list.save() {
eprintln!("warning: failed to persist peers.json: {e}");
}
}
// Trigger a SWIM join to the seed peer if specified
let join_seed = parsed.get("join_seed").and_then(|v| v.as_str());
if let Some(seed_str) = join_seed {
let seed_bytes: Option<[u8; 32]> =
if let Some(b) = distribution::identity::hex_decode(seed_str) {
b.try_into().ok()
} else {
distribution::identity::base58_decode(seed_str)
};
if let Some(bytes) = seed_bytes {
// Find the relay_url for the seed from the peers array
let relay_url = peers.iter().find_map(|p| {
let nid = p.get("node_id").and_then(|v| v.as_str())?;
// Match by checking if this peer's node_id resolves to the same bytes
let peer_bytes: [u8; 32] =
if let Some(b) = distribution::identity::hex_decode(nid) {
b.try_into().ok()?
} else {
distribution::identity::base58_decode(nid)?
};
if peer_bytes == bytes {
p.get("relay_url").and_then(|v| v.as_str()).map(|s| s.to_string())
} else {
None
}
});
if let Some(tx) = state.join_sender.lock().unwrap().as_ref() {
let _ = tx.send((bytes, relay_url));
}
}
}
let added = parsed_peers.len();
json_response(format!(r#"{{"ok":true,"added":{added}}}"#))
}
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)",
);
} }
}; };
match plugin.html_page() {
let node_id = distribution::types::NodeId(bytes.try_into().unwrap()); Some(html) => {
let mut list = auth.lock().unwrap(); let rendered = html.replace("__DASHBOARD_MODE__", "live");
list.remove_peer(&node_id); ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], rendered).into_response()
if let Err(e) = list.save() { }
eprintln!("warning: failed to persist peers.json: {e}"); None => (StatusCode::NOT_FOUND, "no page for this plugin").into_response(),
} }
json_response(r#"{"ok":true}"#.to_string())
} }
// ── Replay server ─────────────────────────────────────────────────────── // ── Replay server ───────────────────────────────────────────────────────

View file

@ -46,8 +46,8 @@ pub const TOPOLOGY_HTML: &str = r##"<!DOCTYPE html>
<a href="/" class="nav-link">Overview</a> <a href="/" class="nav-link">Overview</a>
<a href="/actors" class="nav-link">Actors</a> <a href="/actors" class="nav-link">Actors</a>
<a href="/topology" class="nav-link active">Topology</a> <a href="/topology" class="nav-link active">Topology</a>
<a href="/distribution" class="nav-link">Distribution</a> <a href="/plugin/distribution" class="nav-link">Distribution</a>
<a href="/datastore" class="nav-link">Datastore</a> <a href="/plugin/datastore" class="nav-link">Datastore</a>
</nav> </nav>
</div> </div>
</div> </div>

View file

@ -79,8 +79,6 @@ pub enum ViewMode {
Overview, Overview,
WorkerDetail, WorkerDetail,
ActorDetail, ActorDetail,
#[cfg(feature = "distribution")]
Distribution,
} }
pub struct App { pub struct App {
@ -104,11 +102,6 @@ pub struct App {
pub search_locked: bool, pub search_locked: bool,
pub warnings: Vec<Warning>, pub warnings: Vec<Warning>,
#[cfg(feature = "distribution")]
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
#[cfg(feature = "distribution")]
pub dist_member_selected: usize,
prev_messages: Vec<u64>, prev_messages: Vec<u64>,
prev_time: Instant, prev_time: Instant,
/// Rolling msg rates (smoothed) /// Rolling msg rates (smoothed)
@ -152,10 +145,6 @@ impl App {
search_query: String::new(), search_query: String::new(),
search_locked: false, search_locked: false,
warnings: Vec::new(), warnings: Vec::new(),
#[cfg(feature = "distribution")]
distribution: None,
#[cfg(feature = "distribution")]
dist_member_selected: 0,
prev_messages: Vec::new(), prev_messages: Vec::new(),
prev_time: Instant::now(), prev_time: Instant::now(),
msg_rates: Vec::new(), msg_rates: Vec::new(),
@ -175,13 +164,6 @@ impl App {
self.event_store = Some(store); self.event_store = Some(store);
} }
#[cfg(feature = "distribution")]
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
self.dist_member_selected = self.dist_member_selected.min(max);
self.distribution = Some(snapshot);
}
/// Get visible actor rows (filtered by search query if active). /// Get visible actor rows (filtered by search query if active).
pub fn visible_actor_rows(&self) -> Vec<&ActorRow> { pub fn visible_actor_rows(&self) -> Vec<&ActorRow> {
if self.search_query.is_empty() { if self.search_query.is_empty() {
@ -451,12 +433,7 @@ impl App {
self.view_mode = match self.view_mode { self.view_mode = match self.view_mode {
ViewMode::Overview => ViewMode::WorkerDetail, ViewMode::Overview => ViewMode::WorkerDetail,
ViewMode::ActorDetail => ViewMode::Overview, ViewMode::ActorDetail => ViewMode::Overview,
#[cfg(feature = "distribution")]
ViewMode::WorkerDetail => ViewMode::Distribution,
#[cfg(not(feature = "distribution"))]
ViewMode::WorkerDetail => ViewMode::Overview, ViewMode::WorkerDetail => ViewMode::Overview,
#[cfg(feature = "distribution")]
ViewMode::Distribution => ViewMode::Overview,
}; };
return; return;
} }
@ -467,8 +444,6 @@ impl App {
ViewMode::Overview => self.handle_key_overview(key), ViewMode::Overview => self.handle_key_overview(key),
ViewMode::WorkerDetail => self.handle_key_worker_detail(key), ViewMode::WorkerDetail => self.handle_key_worker_detail(key),
ViewMode::ActorDetail => self.handle_key_actor_detail(key), ViewMode::ActorDetail => self.handle_key_actor_detail(key),
#[cfg(feature = "distribution")]
ViewMode::Distribution => self.handle_key_distribution(key),
} }
} }
@ -560,35 +535,6 @@ impl App {
} }
} }
#[cfg(feature = "distribution")]
fn handle_key_distribution(&mut self, key: KeyEvent) {
let max = self
.distribution
.as_ref()
.map(|d| if d.members.is_empty() { 0 } else { d.members.len() - 1 })
.unwrap_or(0);
match key.code {
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
self.view_mode = ViewMode::Overview;
}
KeyCode::Up | KeyCode::Char('k') => {
self.dist_member_selected = self.dist_member_selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.dist_member_selected = (self.dist_member_selected + 1).min(max);
}
KeyCode::PageUp => {
self.dist_member_selected = self.dist_member_selected.saturating_sub(20);
}
KeyCode::PageDown => {
self.dist_member_selected = (self.dist_member_selected + 20).min(max);
}
KeyCode::Home => { self.dist_member_selected = 0; }
KeyCode::End => { self.dist_member_selected = max; }
_ => {}
}
}
pub fn visible_table_height(&self) -> usize { pub fn visible_table_height(&self) -> usize {
// Will be set by the UI based on actual chunk size // Will be set by the UI based on actual chunk size
20 20

View file

@ -14,10 +14,6 @@ pub enum AppEvent {
source: RuntimeEndpoint, source: RuntimeEndpoint,
stats: Box<RuntimeStats>, stats: Box<RuntimeStats>,
}, },
#[cfg(feature = "distribution")]
DistributionUpdate {
snapshot: Box<distribution::snapshot::DistributionNodeSnapshot>,
},
} }
pub struct EventLoop { pub struct EventLoop {

View file

@ -16,8 +16,6 @@ use ratatui::widgets::TableState;
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use crate::collector::StatsCollector; use crate::collector::StatsCollector;
#[cfg(feature = "distribution")]
use crate::distribution_collector::DistributionStatsProvider;
use crate::layer::EventStore; use crate::layer::EventStore;
use self::app::App; use self::app::App;
use self::event::{AppEvent, EventLoop}; use self::event::{AppEvent, EventLoop};
@ -45,8 +43,6 @@ impl Default for TuiConfig {
pub fn start_tui( pub fn start_tui(
runtime: Arc<Runtime>, runtime: Arc<Runtime>,
collector: Arc<StatsCollector>, collector: Arc<StatsCollector>,
#[cfg(feature = "distribution")]
distribution: Option<Arc<dyn DistributionStatsProvider>>,
config: TuiConfig, config: TuiConfig,
event_store: Option<Arc<EventStore>>, event_store: Option<Arc<EventStore>>,
) -> io::Result<()> { ) -> io::Result<()> {
@ -71,8 +67,6 @@ pub fn start_tui(
&mut terminal, &mut terminal,
runtime, runtime,
collector, collector,
#[cfg(feature = "distribution")]
distribution,
config, config,
event_store, event_store,
); );
@ -92,8 +86,6 @@ fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
runtime: Arc<Runtime>, runtime: Arc<Runtime>,
collector: Arc<StatsCollector>, collector: Arc<StatsCollector>,
#[cfg(feature = "distribution")]
distribution: Option<Arc<dyn DistributionStatsProvider>>,
config: TuiConfig, config: TuiConfig,
event_store: Option<Arc<EventStore>>, event_store: Option<Arc<EventStore>>,
) -> io::Result<()> { ) -> io::Result<()> {
@ -119,12 +111,6 @@ fn run_loop(
collector.enrich(&mut stats); collector.enrich(&mut stats);
crate::collector::enrich_names(&mut stats, &runtime); crate::collector::enrich_names(&mut stats, &runtime);
app.update(stats); app.update(stats);
#[cfg(feature = "distribution")]
if let Some(ref provider) = distribution {
if let Some(snapshot) = provider.snapshot() {
app.update_distribution(snapshot);
}
}
} }
Ok(AppEvent::Key(key)) => { Ok(AppEvent::Key(key)) => {
app.handle_key(key); app.handle_key(key);
@ -132,10 +118,6 @@ fn run_loop(
Ok(AppEvent::StatsUpdate { stats, .. }) => { Ok(AppEvent::StatsUpdate { stats, .. }) => {
app.update(*stats); app.update(*stats);
} }
#[cfg(feature = "distribution")]
Ok(AppEvent::DistributionUpdate { snapshot }) => {
app.update_distribution(*snapshot);
}
Err(_) => { Err(_) => {
// Channel closed, exit // Channel closed, exit
break; break;
@ -209,10 +191,6 @@ fn run_loop_remote(
Ok(AppEvent::Key(key)) => { Ok(AppEvent::Key(key)) => {
app.handle_key(key); app.handle_key(key);
} }
#[cfg(feature = "distribution")]
Ok(AppEvent::DistributionUpdate { snapshot }) => {
app.update_distribution(*snapshot);
}
Err(_) => { Err(_) => {
break; break;
} }

View file

@ -151,27 +151,6 @@ fn parse_sse_events<R: BufRead>(
debug_log("received done event"); debug_log("received done event");
return Ok(()); return Ok(());
} }
#[cfg(feature = "distribution")]
if current_event == "distribution" && !data_buf.is_empty() {
match serde_json::from_str::<distribution::snapshot::DistributionNodeSnapshot>(&data_buf) {
Ok(snapshot) => {
let event = AppEvent::DistributionUpdate {
snapshot: Box::new(snapshot),
};
if tx.send(event).is_err() {
debug_log("channel closed, exiting");
return Ok(());
}
}
Err(e) => {
debug_log(&format!(
"distribution JSON parse error: {} data={}",
e,
&data_buf[..data_buf.len().min(200)]
));
}
}
}
current_event.clear(); current_event.clear();
data_buf.clear(); data_buf.clear();
} else if let Some(event_type) = trimmed.strip_prefix("event: ") { } else if let Some(event_type) = trimmed.strip_prefix("event: ") {

View file

@ -12,8 +12,6 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
ViewMode::Overview => draw_overview(f, app, table_state), ViewMode::Overview => draw_overview(f, app, table_state),
ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state), ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state),
ViewMode::ActorDetail => draw_actor_detail(f, app), ViewMode::ActorDetail => draw_actor_detail(f, app),
#[cfg(feature = "distribution")]
ViewMode::Distribution => draw_distribution(f, app, table_state),
} }
} }
@ -776,248 +774,6 @@ fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(paragraph, area); f.render_widget(paragraph, area);
} }
// ─── Distribution View ──────────────────────────────────────────────────────
#[cfg(feature = "distribution")]
fn draw_distribution(f: &mut Frame, app: &App, table_state: &mut TableState) {
let chunks = Layout::vertical([
Constraint::Length(3), // Summary bar
Constraint::Fill(1), // Members table
Constraint::Length(10), // Bottom panels: cache + routing
Constraint::Length(1), // Help bar
])
.split(f.area());
draw_dist_summary(f, app, chunks[0]);
draw_dist_members(f, app, table_state, chunks[1]);
let bottom = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(chunks[2]);
draw_dist_cache(f, app, bottom[0]);
draw_dist_routing(f, app, bottom[1]);
draw_dist_help(f, chunks[3]);
}
#[cfg(feature = "distribution")]
fn draw_dist_summary(f: &mut Frame, app: &App, area: Rect) {
let (node_id, listen_addr, alive, suspect, dead, cache, dir, rt_size, rt_buckets, repair) =
match &app.distribution {
Some(d) => (
&d.node_id[..d.node_id.len().min(16)],
d.listen_addr.as_deref().unwrap_or("—"),
d.alive_count,
d.suspect_count,
d.dead_count,
d.cache_size,
d.directory_entry_count,
d.routing_table_size,
d.routing_buckets.len(),
d.repair_queue_size,
),
None => ("—", "—", 0, 0, 0, 0, 0, 0, 0, 0),
};
let lines = vec![
Line::from(vec![
Span::styled(" Node: ", Style::default().fg(Color::DarkGray)),
Span::styled(
node_id.to_string(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(" Addr: ", Style::default().fg(Color::DarkGray)),
Span::styled(listen_addr.to_string(), Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" Members: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{alive}"), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(" alive, ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{suspect}"),
if suspect > 0 { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) },
),
Span::styled(" suspect, ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{dead}"),
if dead > 0 { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) },
),
Span::styled(" dead", Style::default().fg(Color::DarkGray)),
Span::styled(format!(" Cache: {cache}"), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" Directory: {dir}"), Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(format!(" Routing: {rt_size} nodes, {rt_buckets} buckets"), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" Repair queue: {repair}"), Style::default().fg(Color::DarkGray)),
]),
];
let block = Block::default().borders(Borders::ALL).title(" Distribution ");
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
#[cfg(feature = "distribution")]
fn draw_dist_members(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) {
let header_cells = ["STATE", "NODE ID", "ADDRESS", "INCARNATION"].iter().map(|&h| {
Cell::from(h).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = match &app.distribution {
Some(d) => d
.members
.iter()
.map(|m| {
let state_style = match m.state.as_str() {
"alive" => Style::default().fg(Color::Green),
"suspect" => Style::default().fg(Color::Yellow),
"dead" => Style::default().fg(Color::Red),
_ => Style::default(),
};
let id_short = if m.node_id.len() > 16 {
format!("{}...", &m.node_id[..14])
} else {
m.node_id.clone()
};
Row::new(vec![
Cell::from(m.state.clone()).style(state_style),
Cell::from(id_short),
Cell::from(m.addr.clone().unwrap_or_default()),
Cell::from(format!("{}", m.incarnation)),
])
})
.collect(),
None => vec![],
};
table_state.select(Some(app.dist_member_selected));
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Min(18),
Constraint::Length(22),
Constraint::Length(12),
],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Members "),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(table, area, table_state);
}
#[cfg(feature = "distribution")]
fn draw_dist_cache(f: &mut Frame, app: &App, area: Rect) {
let rows: Vec<Row> = match &app.distribution {
Some(d) => d
.cache_entries
.iter()
.take(area.height.saturating_sub(2) as usize)
.map(|e| {
let actor_short = if e.actor_addr.len() > 16 {
format!("{}...", &e.actor_addr[..14])
} else {
e.actor_addr.clone()
};
let node_short = if e.node_id.len() > 12 {
format!("{}...", &e.node_id[..10])
} else {
e.node_id.clone()
};
Row::new(vec![
Cell::from(actor_short),
Cell::from(node_short),
])
})
.collect(),
None => vec![],
};
let header = Row::new(vec![
Cell::from("ACTOR").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Cell::from("NODE").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
])
.height(1);
let table = Table::new(
rows,
[Constraint::Percentage(55), Constraint::Percentage(45)],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Cache "));
f.render_widget(table, area);
}
#[cfg(feature = "distribution")]
fn draw_dist_routing(f: &mut Frame, app: &App, area: Rect) {
let buckets: Vec<(usize, usize)> = match &app.distribution {
Some(d) => d.routing_buckets.clone(),
None => vec![],
};
let max_count = buckets.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
let bar_max_width = area.width.saturating_sub(16) as usize; // space for "[NNN] " + " N"
let lines: Vec<Line> = buckets
.iter()
.take(area.height.saturating_sub(2) as usize)
.map(|(idx, count)| {
let bar_len = (*count as f64 / max_count as f64 * bar_max_width as f64).round() as usize;
let bar_len = bar_len.max(1);
Line::from(vec![
Span::styled(
format!(" [{:>3}] ", idx),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"\u{2588}".repeat(bar_len),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!(" {}", count),
Style::default().fg(Color::White),
),
])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title(" Routing Buckets ");
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
#[cfg(feature = "distribution")]
fn draw_dist_help(f: &mut Frame, area: Rect) {
let help = Line::from(vec![
Span::styled(
" Tab: views \u{2191}\u{2193}: scroll Esc: overview q: quit",
Style::default().fg(Color::DarkGray),
),
]);
f.render_widget(Paragraph::new(help), area);
}
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
fn short_type_name(full: Option<&str>) -> String { fn short_type_name(full: Option<&str>) -> String {

View file

@ -8,17 +8,19 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
[dependencies] [dependencies]
swactor = { path = "../..", features = ["serde", "transport"] } swactor = { path = "../..", features = ["serde", "transport"] }
distribution = { path = "../distribution" } ed25519-dalek = { version = "2", features = ["rand_core"] }
shared-types = { path = "../shared-types" } rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
blake3 = "1" blake3 = "1"
crossbeam-queue = "0.3.12"
tokio = { version = "1", features = ["rt", "sync", "io-util"] }
iroh = "0.96"
getrandom = "0.2"
tiny_http = { version = "0.12", optional = true } tiny_http = { version = "0.12", optional = true }
clap = { version = "4", features = ["derive"], optional = true } clap = { version = "4", features = ["derive"], optional = true }
ureq = { version = "2", features = ["json"], optional = true } ureq = { version = "2", features = ["json"], optional = true }
getrandom = { version = "0.2", optional = true }
ctrlc = { version = "3", optional = true } ctrlc = { version = "3", optional = true }
runtime-dashboard = { path = "../runtime-dashboard", optional = true }
toml = { version = "0.8", optional = true } toml = { version = "0.8", optional = true }
[dev-dependencies] [dev-dependencies]
@ -26,22 +28,13 @@ serde_json = "1"
proptest = "1" proptest = "1"
proptest-state-machine = "0.3" proptest-state-machine = "0.3"
tempfile = "3" tempfile = "3"
distribution = { path = "../distribution" }
swactor = { path = "../.." } swactor = { path = "../.." }
swactor-std = { path = "../std" }
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
tiny_http = "0.12"
runtime-dashboard = { path = "../runtime-dashboard" }
stateright = "0.31" stateright = "0.31"
[features] [features]
node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:runtime-dashboard", "dep:toml"] node = ["dep:tiny_http", "dep:clap", "dep:ctrlc", "dep:toml"]
cli = ["dep:clap", "dep:ureq", "dep:getrandom"] cli = ["dep:clap", "dep:ureq"]
[[bin]]
name = "swactor-store-node"
path = "src/bin/store_node.rs"
required-features = ["node"]
[[bin]] [[bin]]
name = "swactor-store" name = "swactor-store"

View file

@ -11,7 +11,7 @@ use std::sync::Arc;
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use distribution::types::NodeId; use swactor::transport::NodeId;
use crate::actors::stream_downloader::StreamDownloader; use crate::actors::stream_downloader::StreamDownloader;
use crate::actors::stream_server::StreamServer; use crate::actors::stream_server::StreamServer;
@ -310,7 +310,7 @@ impl DatastoreNode {
fn handle_stream_offer( fn handle_stream_offer(
&self, &self,
ctx: &Ctx, ctx: &Ctx,
stream_id: swactor_streams::types::StreamId, stream_id: crate::streams::types::StreamId,
content_hash: ContentHash, content_hash: ContentHash,
_from_node: [u8; 32], _from_node: [u8; 32],
stream_manager: ActorAddress, stream_manager: ActorAddress,

View file

@ -17,7 +17,7 @@ use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use crate::auth::{AccessRequestInfo, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason}; use crate::auth::{AccessRequestInfo, AuthzEngine, AuthzResult, DatastoreAction, DeniedReason};
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg}; use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg};
use distribution::types::NodeId; use swactor::transport::NodeId;
/// The auth gateway actor wrapping an `AuthzEngine`. /// The auth gateway actor wrapping an `AuthzEngine`.
pub struct GatewayActor { pub struct GatewayActor {

View file

@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet};
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use distribution::types::NodeId; use swactor::transport::NodeId;
use crate::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg}; use crate::messages::{BlobStoreMsg, DatastoreResponse, MetadataMsg};
use crate::types::{ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest}; use crate::types::{ContentHash, DatastoreConfig, ObjectEntry, ObjectManifest};
@ -120,7 +120,7 @@ impl MetadataActor {
/// `GcUnreferenced` to BlobStoreActor to delete orphaned chunks. /// `GcUnreferenced` to BlobStoreActor to delete orphaned chunks.
fn gc_tick(&mut self, ctx: &Ctx) { fn gc_tick(&mut self, ctx: &Ctx) {
self.tick_count += 1; self.tick_count += 1;
if self.tick_count % self.gc_interval != 0 { if !self.tick_count.is_multiple_of(self.gc_interval) {
return; return;
} }

View file

@ -10,8 +10,8 @@ use std::sync::Arc;
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use swactor_streams::messages::{StreamManagerMsg, StreamNotification}; use crate::streams::messages::{StreamManagerMsg, StreamNotification};
use swactor_streams::types::{StreamConfig, StreamMode}; use crate::streams::types::{StreamConfig, StreamMode};
use crate::blob_transfer::{encode_metadata, recv_blob, BlobTransferMetadata}; use crate::blob_transfer::{encode_metadata, recv_blob, BlobTransferMetadata};
use crate::messages::{BlobStoreMsg, DatastoreNodeMsg}; use crate::messages::{BlobStoreMsg, DatastoreNodeMsg};

View file

@ -3,8 +3,8 @@
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor_streams::messages::{StreamManagerMsg, StreamNotification}; use crate::streams::messages::{StreamManagerMsg, StreamNotification};
use swactor_streams::types::StreamMode; use crate::streams::types::StreamMode;
use crate::blob_transfer::parse_metadata; use crate::blob_transfer::parse_metadata;
use crate::messages::DatastoreNodeMsg; use crate::messages::DatastoreNodeMsg;

View file

@ -11,8 +11,8 @@ use std::time::Duration;
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use swactor_streams::messages::{StreamManagerMsg, StreamNotification}; use crate::streams::messages::{StreamManagerMsg, StreamNotification};
use swactor_streams::types::StreamId; use crate::streams::types::StreamId;
use crate::blob_transfer::{poll_inbox, send_blob, BlobTransferError}; use crate::blob_transfer::{poll_inbox, send_blob, BlobTransferError};
use crate::messages::{BlobStoreMsg, DatastoreResponse}; use crate::messages::{BlobStoreMsg, DatastoreResponse};

View file

@ -12,7 +12,7 @@ use std::collections::HashSet;
use swactor::actor::{ActorAddress, ActorInterface, Ctx}; use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use distribution::types::NodeId; use swactor::transport::NodeId;
use crate::messages::{DatastoreResponse, TransferMsg}; use crate::messages::{DatastoreResponse, TransferMsg};
use crate::types::{ContentHash, ObjectManifest, TransferStatus}; use crate::types::{ContentHash, ObjectManifest, TransferStatus};

View file

@ -12,7 +12,7 @@ use std::time::{Duration, Instant};
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime}; use swactor::runtime::{Inbox, Runtime};
use distribution::types::NodeId; use swactor::transport::NodeId;
use crate::auth::SignedRequest; use crate::auth::SignedRequest;
use crate::chunking::reassemble_blob; use crate::chunking::reassemble_blob;
@ -753,7 +753,7 @@ fn try_remote_get(
let _ = state.runtime.send_to( let _ = state.runtime.send_to(
peer.metadata, peer.metadata,
MetadataMsg::HandleFindObject { MetadataMsg::HandleFindObject {
from: distribution::types::NodeId([0; 32]), // placeholder from: swactor::transport::NodeId([0; 32]), // placeholder
content_hash, content_hash,
reply_to: *find_inbox.addr(), reply_to: *find_inbox.addr(),
}, },

View file

@ -12,9 +12,10 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use distribution::crypto; use crate::crypto;
use distribution::types::{NodeId, Signature}; use swactor::transport::NodeId;
use shared_types::ContentHash; use crate::crypto::Signature;
use crate::content_hash::ContentHash;
// ─── Access Request / Authorized Key Info ────────────────────────────────── // ─── Access Request / Authorized Key Info ──────────────────────────────────
@ -132,7 +133,7 @@ impl AccessControlList {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let json = serde_json::to_string_pretty(self) let json = serde_json::to_string_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; .map_err(|e| io::Error::other(e))?;
std::fs::write(path, json) std::fs::write(path, json)
} }
} }
@ -228,7 +229,7 @@ impl AuthzEngine {
// 2. Timestamp freshness // 2. Timestamp freshness
let ts = request.payload.timestamp; let ts = request.payload.timestamp;
let diff = if now >= ts { now - ts } else { ts - now }; let diff = now.abs_diff(ts);
if diff > self.timestamp_window { if diff > self.timestamp_window {
return AuthzResult::Denied(DeniedReason::RequestExpired); return AuthzResult::Denied(DeniedReason::RequestExpired);
} }
@ -257,7 +258,7 @@ impl AuthzEngine {
// 2. Timestamp freshness // 2. Timestamp freshness
let ts = request.payload.timestamp; let ts = request.payload.timestamp;
let diff = if now >= ts { now - ts } else { ts - now }; let diff = now.abs_diff(ts);
if diff > self.timestamp_window { if diff > self.timestamp_window {
return AuthzResult::Denied(DeniedReason::RequestExpired); return AuthzResult::Denied(DeniedReason::RequestExpired);
} }
@ -320,7 +321,7 @@ impl AuthzEngine {
/// Evict nonces whose timestamps fall outside the current window. /// Evict nonces whose timestamps fall outside the current window.
pub fn gc_nonces(&mut self, now: u64) { pub fn gc_nonces(&mut self, now: u64) {
self.seen_nonces.retain(|_nonce, ts| { self.seen_nonces.retain(|_nonce, ts| {
let diff = if now >= *ts { now - *ts } else { *ts - now }; let diff = now.abs_diff(*ts);
diff <= self.timestamp_window diff <= self.timestamp_window
}); });
} }

View file

@ -10,8 +10,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use distribution::crypto::Keypair; use swactor_datastore::crypto::Keypair;
use shared_types::ContentHash; use swactor_datastore::content_hash::ContentHash;
use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload}; use swactor_datastore::auth::{sign_request, DatastoreAction, SignedRequestPayload};
#[derive(Parser)] #[derive(Parser)]

View file

@ -18,7 +18,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swactor::runtime::Inbox; use swactor::runtime::Inbox;
use swactor::actor::Message; use swactor::actor::Message;
use swactor_streams::handle::{RecvHalf, SendHalf}; use crate::streams::handle::{RecvHalf, SendHalf};
use crate::types::{ContentHash, ObjectManifest}; use crate::types::{ContentHash, ObjectManifest};
@ -264,8 +264,8 @@ pub async fn poll_inbox<M: Message>(inbox: &Inbox<M>, timeout: Duration) -> Opti
mod tests { mod tests {
use super::*; use super::*;
use crate::chunking::chunk_blob; use crate::chunking::chunk_blob;
use swactor_streams::handle::create_stream_handle; use crate::streams::handle::create_stream_handle;
use swactor_streams::types::{StreamConfig, StreamId}; use crate::streams::types::{StreamConfig, StreamId};
/// Helper: create a pair of (SendHalf, RecvHalf) connected via tokio tasks /// Helper: create a pair of (SendHalf, RecvHalf) connected via tokio tasks
/// that relay data through a DuplexStream. /// that relay data through a DuplexStream.
@ -299,39 +299,39 @@ mod tests {
} }
fn spawn_send_task( fn spawn_send_task(
mut cmd_rx: tokio::sync::mpsc::Receiver<swactor_streams::channel::SendCommand>, mut cmd_rx: tokio::sync::mpsc::Receiver<crate::streams::channel::SendCommand>,
evt_tx: tokio::sync::mpsc::Sender<swactor_streams::channel::SendEvent>, evt_tx: tokio::sync::mpsc::Sender<crate::streams::channel::SendEvent>,
pool: swactor_streams::BufferPool, pool: crate::streams::BufferPool,
mut writer: tokio::io::WriteHalf<tokio::io::DuplexStream>, mut writer: tokio::io::WriteHalf<tokio::io::DuplexStream>,
) { ) {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
tokio::spawn(async move { tokio::spawn(async move {
while let Some(cmd) = cmd_rx.recv().await { while let Some(cmd) = cmd_rx.recv().await {
match cmd { match cmd {
swactor_streams::channel::SendCommand::Data(buf) => { crate::streams::channel::SendCommand::Data(buf) => {
let data = buf.written(); let data = buf.written();
// Write length-prefixed frame // Write length-prefixed frame
let len = (data.len() as u32).to_be_bytes(); let len = (data.len() as u32).to_be_bytes();
if writer.write_all(&len).await.is_err() { if writer.write_all(&len).await.is_err() {
pool.checkin(buf); pool.checkin(buf);
let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error( let _ = evt_tx.send(crate::streams::channel::SendEvent::Error(
swactor_streams::StreamError::Disconnected, crate::streams::StreamError::Disconnected,
)).await; )).await;
return; return;
} }
if writer.write_all(data).await.is_err() { if writer.write_all(data).await.is_err() {
pool.checkin(buf); pool.checkin(buf);
let _ = evt_tx.send(swactor_streams::channel::SendEvent::Error( let _ = evt_tx.send(crate::streams::channel::SendEvent::Error(
swactor_streams::StreamError::Disconnected, crate::streams::StreamError::Disconnected,
)).await; )).await;
return; return;
} }
pool.checkin(buf); pool.checkin(buf);
} }
swactor_streams::channel::SendCommand::Flush => { crate::streams::channel::SendCommand::Flush => {
let _ = writer.flush().await; let _ = writer.flush().await;
} }
swactor_streams::channel::SendCommand::Close => { crate::streams::channel::SendCommand::Close => {
let _ = writer.shutdown().await; let _ = writer.shutdown().await;
break; break;
} }
@ -342,8 +342,8 @@ mod tests {
fn spawn_recv_task( fn spawn_recv_task(
mut reader: tokio::io::ReadHalf<tokio::io::DuplexStream>, mut reader: tokio::io::ReadHalf<tokio::io::DuplexStream>,
evt_tx: tokio::sync::mpsc::Sender<swactor_streams::channel::RecvEvent>, evt_tx: tokio::sync::mpsc::Sender<crate::streams::channel::RecvEvent>,
pool: swactor_streams::BufferPool, pool: crate::streams::BufferPool,
) { ) {
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
tokio::spawn(async move { tokio::spawn(async move {
@ -353,7 +353,7 @@ mod tests {
match reader.read_exact(&mut len_buf).await { match reader.read_exact(&mut len_buf).await {
Ok(_) => {} Ok(_) => {}
Err(_) => { Err(_) => {
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await; let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await;
return; return;
} }
} }
@ -362,7 +362,7 @@ mod tests {
match reader.read_exact(&mut data).await { match reader.read_exact(&mut data).await {
Ok(_) => {} Ok(_) => {}
Err(_) => { Err(_) => {
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Closed).await; let _ = evt_tx.send(crate::streams::channel::RecvEvent::Closed).await;
return; return;
} }
} }
@ -373,15 +373,15 @@ mod tests {
let mut buf = match pool.checkout() { let mut buf = match pool.checkout() {
Some(b) => b, Some(b) => b,
None => { None => {
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Error( let _ = evt_tx.send(crate::streams::channel::RecvEvent::Error(
swactor_streams::StreamError::BufferExhausted, crate::streams::StreamError::BufferExhausted,
)).await; )).await;
return; return;
} }
}; };
let written = buf.write(&data[offset..]); let written = buf.write(&data[offset..]);
offset += written; offset += written;
let _ = evt_tx.send(swactor_streams::channel::RecvEvent::Data(buf)).await; let _ = evt_tx.send(crate::streams::channel::RecvEvent::Data(buf)).await;
} }
} }
}); });

View file

@ -1,364 +1,23 @@
//! Bridge between the runtime dashboard's `DatastoreStatsProvider` trait and //! Datastore actor group lifecycle management.
//! the datastore actor system. Allows the dashboard to perform CRUD operations //!
//! and lifecycle management without depending on `swactor-datastore` types. //! `DatastoreGroup` owns the full lifecycle of a datastore actor group:
//! BlobStore, Metadata, DatastoreNode, and optional GatewayActor.
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::runtime::{Inbox, Runtime}; use swactor::runtime::Runtime;
use swactor_std::RuntimeNaming; use swactor::std::RuntimeNaming;
use distribution::types::NodeId; use swactor::transport::NodeId;
use dashboard::datastore_collector::{
DatastoreFactory, DatastoreStatsProvider, ListScope,
};
use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor}; use crate::actors::{BlobStoreActor, DatastoreNode, GatewayActor, MetadataActor};
use crate::auth::{AccessControlList, AuthzEngine}; use crate::auth::{AccessControlList, AuthzEngine};
use crate::chunking::reassemble_blob; use crate::messages::{DatastoreNodeMsg, GatewayMsg, MetadataMsg};
use crate::messages::{DatastoreNodeMsg, DatastoreResponse, GatewayMsg, MetadataMsg};
use crate::metrics::DatastoreMetrics; use crate::metrics::DatastoreMetrics;
use crate::storage::{FilesystemBackend, InMemoryBackend}; use crate::storage::{FilesystemBackend, InMemoryBackend};
use crate::types::{ContentHash, DatastoreConfig}; use crate::types::DatastoreConfig;
const POLL_TIMEOUT: Duration = Duration::from_secs(5);
const POLL_INTERVAL: Duration = Duration::from_millis(1);
fn poll_response(inbox: &Inbox<DatastoreResponse>, timeout: Duration) -> Option<DatastoreResponse> {
let start = Instant::now();
loop {
if let Some(resp) = inbox.try_recv() {
return Some(resp);
}
if start.elapsed() > timeout {
return None;
}
thread::sleep(POLL_INTERVAL);
}
}
fn entry_to_json(entry: &crate::types::ObjectEntry) -> serde_json::Value {
let node_hex: String = entry.node_id.0.iter().map(|b| format!("{b:02x}")).collect();
serde_json::json!({
"content_hash": entry.content_hash.to_hex(),
"name": entry.name,
"node_id": node_hex,
"tags": entry.tags,
"size_bytes": entry.size_bytes,
"created_at": entry.created_at,
})
}
fn manifest_to_json(manifest: &crate::types::ObjectManifest) -> serde_json::Value {
let chunks: Vec<serde_json::Value> = manifest
.chunks
.iter()
.map(|c| {
serde_json::json!({
"hash": c.hash.to_hex(),
"offset": c.offset,
"size": c.size,
})
})
.collect();
serde_json::json!({
"content_hash": manifest.content_hash.to_hex(),
"chunks": chunks,
"total_size": manifest.total_size,
"chunk_size": manifest.chunk_size,
"content_type": manifest.content_type,
})
}
fn entries_to_json(entries: &[crate::types::ObjectEntry]) -> Vec<serde_json::Value> {
entries.iter().map(entry_to_json).collect()
}
/// Bridges the dashboard trait to the datastore actor system.
pub struct DatastoreBridge {
metrics: Arc<DatastoreMetrics>,
runtime: Arc<Runtime>,
datastore_addr: ActorAddress,
metadata_addr: ActorAddress,
#[allow(dead_code)]
blob_store_addr: ActorAddress,
}
impl DatastoreBridge {
pub fn new(
metrics: Arc<DatastoreMetrics>,
runtime: Arc<Runtime>,
datastore_addr: ActorAddress,
metadata_addr: ActorAddress,
blob_store_addr: ActorAddress,
) -> Self {
Self {
metrics,
runtime,
datastore_addr,
metadata_addr,
blob_store_addr,
}
}
}
impl DatastoreStatsProvider for DatastoreBridge {
fn snapshot_json(&self) -> Option<String> {
let snap = self.metrics.snapshot();
serde_json::to_string(&snap).ok()
}
fn is_running(&self) -> bool {
true
}
fn list_objects(&self, name_filter: Option<&str>, scope: ListScope) -> Result<String, String> {
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
match scope {
ListScope::Local => {
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::List {
name_filter: name_filter.map(|s| s.to_string()),
all: false,
reply_to: *inbox.addr(),
},
);
}
ListScope::Swarm => {
let _ = self.runtime.send_to(
self.metadata_addr,
MetadataMsg::ListLocal {
name_filter: name_filter.map(|s| s.to_string()),
reply_to: *inbox.addr(),
},
);
}
}
match poll_response(&inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::ListOk { entries }) => {
let json = serde_json::json!({ "entries": entries_to_json(&entries) }).to_string();
Ok(json)
}
Some(DatastoreResponse::Error { reason }) => Err(reason),
_ => Err("timeout".into()),
}
}
fn get_object(&self, hash: &str) -> Result<String, String> {
let content_hash = ContentHash::from_hex(hash)
.ok_or_else(|| "invalid content hash hex".to_string())?;
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::Get {
content_hash,
reply_to: *inbox.addr(),
},
);
match poll_response(&inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::GetOk { entry, manifest }) => {
self.metrics.record_get(&content_hash.to_hex());
let json = serde_json::json!({
"entry": entry_to_json(&entry),
"manifest": manifest_to_json(&manifest),
})
.to_string();
Ok(json)
}
Some(DatastoreResponse::NotFound) => Err("not found".into()),
Some(DatastoreResponse::Error { reason }) => Err(reason),
_ => Err("timeout".into()),
}
}
fn get_data(&self, hash: &str) -> Result<Vec<u8>, String> {
let content_hash = ContentHash::from_hex(hash)
.ok_or_else(|| "invalid content hash hex".to_string())?;
// Get manifest
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::Get {
content_hash,
reply_to: *inbox.addr(),
},
);
self.metrics.record_get(&content_hash.to_hex());
let manifest = match poll_response(&inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::GetOk { manifest, .. }) => manifest,
Some(DatastoreResponse::NotFound) => return Err("not found".into()),
Some(DatastoreResponse::Error { reason }) => return Err(reason),
_ => return Err("timeout".into()),
};
// Read chunks
let mut chunk_data = Vec::new();
for chunk_ref in &manifest.chunks {
let chunk_inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::ReadChunk {
hash: chunk_ref.hash,
reply_to: *chunk_inbox.addr(),
},
);
match poll_response(&chunk_inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::ChunkOk { hash, data }) => {
chunk_data.push((hash, data));
}
_ => return Err("failed to read chunk".into()),
}
}
reassemble_blob(&manifest, &chunk_data)
.map_err(|e| format!("reassembly failed: {e:?}"))
}
fn put_data(&self, data: Vec<u8>, name: Option<String>) -> Result<String, String> {
let body_len = data.len();
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::Put {
data,
name: name.clone(),
tags: BTreeMap::new(),
reply_to: *inbox.addr(),
},
);
match poll_response(&inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::PutOk { content_hash }) => {
let hex = content_hash.to_hex();
self.metrics.record_put(&hex, name.as_deref(), body_len as u64);
let json = serde_json::json!({ "content_hash": hex }).to_string();
Ok(json)
}
Some(DatastoreResponse::Error { reason }) => Err(reason),
_ => Err("timeout waiting for put response".into()),
}
}
fn delete_object(&self, hash: &str) -> Result<String, String> {
let content_hash = ContentHash::from_hex(hash)
.ok_or_else(|| "invalid content hash hex".to_string())?;
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::Delete {
content_hash,
reply_to: *inbox.addr(),
},
);
match poll_response(&inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::DeleteOk { content_hash }) => {
let hex = content_hash.to_hex();
self.metrics.record_delete(&hex, 0);
let json = serde_json::json!({ "content_hash": hex }).to_string();
Ok(json)
}
Some(DatastoreResponse::NotFound) => Err("not found".into()),
Some(DatastoreResponse::Error { reason }) => Err(reason),
_ => Err("timeout".into()),
}
}
fn node_status(&self) -> Result<String, String> {
let inbox = self.runtime.new_inbox::<DatastoreResponse>()
.map_err(|e| format!("failed to create inbox: {e}"))?;
let _ = self.runtime.send_to(
self.datastore_addr,
DatastoreNodeMsg::Status {
reply_to: *inbox.addr(),
},
);
match poll_response(&inbox, POLL_TIMEOUT) {
Some(DatastoreResponse::NodeStatus { node_id }) => {
let hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
let json = serde_json::json!({ "node_id": hex }).to_string();
Ok(json)
}
_ => Err("timeout".into()),
}
}
fn shutdown_datastore(&self) -> Result<(), String> {
// We can't actually stop the actors from here without a runtime handle,
// but we can signal shutdown. The caller (server handler) clears the
// provider reference which effectively disables the datastore.
Ok(())
}
}
/// Factory that can spawn a new set of datastore actors on a shared runtime.
pub struct DatastoreNodeFactory {
runtime: Arc<Runtime>,
default_chunk_size: u32,
}
impl DatastoreNodeFactory {
pub fn new(runtime: Arc<Runtime>, default_chunk_size: u32) -> Self {
Self {
runtime,
default_chunk_size,
}
}
}
impl DatastoreFactory for DatastoreNodeFactory {
fn start_datastore(
&self,
storage_path: Option<String>,
) -> Result<Arc<dyn DatastoreStatsProvider>, String> {
// Generate a unique node ID
let node_id = generate_node_id();
let node_hex: String = node_id.0.iter().map(|b| format!("{b:02x}")).collect();
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 ───────────────────────────────────────────────────────── // ─── DatastoreGroup ─────────────────────────────────────────────────────────
@ -384,7 +43,7 @@ pub struct DatastoreGroup {
datastore_addr: ActorAddress, datastore_addr: ActorAddress,
metadata_addr: ActorAddress, metadata_addr: ActorAddress,
gateway_addr: Option<ActorAddress>, gateway_addr: Option<ActorAddress>,
bridge: Arc<dyn DatastoreStatsProvider>, metrics: Arc<DatastoreMetrics>,
gc_interval: u64, gc_interval: u64,
disseminate_interval: u64, disseminate_interval: u64,
runtime: Arc<Runtime>, runtime: Arc<Runtime>,
@ -455,14 +114,6 @@ impl DatastoreGroup {
let metrics = Arc::new(DatastoreMetrics::new()); let metrics = Arc::new(DatastoreMetrics::new());
metrics.set_node_id(config.node_id_hex.clone()); metrics.set_node_id(config.node_id_hex.clone());
let bridge: Arc<dyn DatastoreStatsProvider> = Arc::new(DatastoreBridge::new(
metrics,
Arc::clone(&runtime),
datastore_addr,
metadata_addr,
blob_store_addr,
));
if config.storage_path.is_some() { if config.storage_path.is_some() {
eprintln!( eprintln!(
"Datastore: persistent ({})", "Datastore: persistent ({})",
@ -476,7 +127,7 @@ impl DatastoreGroup {
datastore_addr, datastore_addr,
metadata_addr, metadata_addr,
gateway_addr, gateway_addr,
bridge, metrics,
gc_interval: config.gc_interval, gc_interval: config.gc_interval,
disseminate_interval: config.disseminate_interval, disseminate_interval: config.disseminate_interval,
runtime, runtime,
@ -485,22 +136,32 @@ impl DatastoreGroup {
/// Send periodic ticks to the datastore actors based on the current round. /// Send periodic ticks to the datastore actors based on the current round.
pub fn tick(&self, round: u64) { pub fn tick(&self, round: u64) {
if round % self.gc_interval == 0 { if round.is_multiple_of(self.gc_interval) {
let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick); let _ = self.runtime.send_to(self.metadata_addr, MetadataMsg::GcTick);
if let Some(gw) = self.gateway_addr { if let Some(gw) = self.gateway_addr {
let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick); let _ = self.runtime.send_to(gw, GatewayMsg::NonceGcTick);
} }
} }
if round % self.disseminate_interval == 0 { if round.is_multiple_of(self.disseminate_interval) {
let _ = self let _ = self
.runtime .runtime
.send_to(self.metadata_addr, MetadataMsg::DisseminateTick); .send_to(self.metadata_addr, MetadataMsg::DisseminateTick);
} }
} }
/// Access the bridge (as a trait object for the dashboard). /// Access the metrics accumulator.
pub fn bridge(&self) -> &Arc<dyn DatastoreStatsProvider> { pub fn metrics(&self) -> &Arc<DatastoreMetrics> {
&self.bridge &self.metrics
}
/// Address of the DatastoreNode actor.
pub fn datastore_addr(&self) -> ActorAddress {
self.datastore_addr
}
/// Address of the MetadataActor.
pub fn metadata_addr(&self) -> ActorAddress {
self.metadata_addr
} }
/// Configure stream support: sends ConfigureStreams to DatastoreNode and /// Configure stream support: sends ConfigureStreams to DatastoreNode and
@ -521,25 +182,9 @@ impl DatastoreGroup {
// Spawn StreamListener // Spawn StreamListener
use crate::actors::stream_listener::StreamListener; use crate::actors::stream_listener::StreamListener;
use swactor_std::RuntimeNaming; use swactor::std::RuntimeNaming;
if let Ok(addr) = self.runtime.spawn(StreamListener::new(self.datastore_addr, stream_manager)) { if let Ok(addr) = self.runtime.spawn(StreamListener::new(self.datastore_addr, stream_manager)) {
let _ = self.runtime.register_name("StreamListener", addr); let _ = self.runtime.register_name("StreamListener", addr);
} }
} }
} }
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,7 +6,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use distribution::types::NodeId; use swactor::transport::NodeId;
/// Top-level CLI commands for `swactor-store`. /// Top-level CLI commands for `swactor-store`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -1,14 +1,9 @@
//! Shared types used across the swactor crate ecosystem. //! Blake3-based content hash for blob addressing.
//!
//! Contains `ContentHash` — the blake3-based content address used by
//! both the datastore and distribution layers.
use std::fmt; use std::fmt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ─── ContentHash ────────────────────────────────────────────────────────────
/// A blake3 content hash (32 bytes). /// A blake3 content hash (32 bytes).
/// ///
/// The primary identifier for blobs and the DHT key. XOR distance for DHT /// The primary identifier for blobs and the DHT key. XOR distance for DHT

View file

@ -0,0 +1,96 @@
//! Ed25519 cryptographic primitives for the datastore.
use std::fmt;
use ed25519_dalek::{Signer, Verifier};
use serde::{Deserialize, Serialize};
use swactor::transport::NodeId;
// ─── Signature ──────────────────────────────────────────────────────────────
/// An ed25519 signature (64 bytes).
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Signature(pub [u8; 64]);
impl Serialize for Signature {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_bytes(&self.0)
}
}
impl<'de> Deserialize<'de> for Signature {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
if bytes.len() != 64 {
return Err(serde::de::Error::custom(format!(
"expected 64 bytes for Signature, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 64];
arr.copy_from_slice(&bytes);
Ok(Signature(arr))
}
}
impl fmt::Debug for Signature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Sig(")?;
for b in &self.0[..4] {
write!(f, "{:02x}", b)?;
}
write!(f, "\u{2026})")
}
}
// ─── Keypair ────────────────────────────────────────────────────────────────
/// Node identity keypair — wraps ed25519-dalek.
pub struct Keypair {
inner: ed25519_dalek::SigningKey,
}
impl Keypair {
/// Generate a new random keypair.
pub fn generate() -> Self {
let mut csprng = rand_core::OsRng;
Self {
inner: ed25519_dalek::SigningKey::generate(&mut csprng),
}
}
/// Reconstruct from raw secret key bytes (32 bytes).
pub fn from_bytes(secret: &[u8; 32]) -> Self {
Self {
inner: ed25519_dalek::SigningKey::from_bytes(secret),
}
}
/// The public key as a `NodeId`.
pub fn node_id(&self) -> NodeId {
NodeId(self.inner.verifying_key().to_bytes())
}
/// Raw secret key bytes.
pub fn secret_bytes(&self) -> [u8; 32] {
self.inner.to_bytes()
}
/// Sign arbitrary bytes.
pub fn sign(&self, msg: &[u8]) -> Signature {
let sig = self.inner.sign(msg);
Signature(sig.to_bytes())
}
}
// ─── Verification ───────────────────────────────────────────────────────────
/// Verify a signature against a `NodeId` (public key) and message bytes.
pub fn verify(node_id: &NodeId, msg: &[u8], sig: &Signature) -> bool {
let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&node_id.0) else {
return false;
};
let signature = ed25519_dalek::Signature::from_bytes(&sig.0);
vk.verify(msg, &signature).is_ok()
}

View file

@ -1,11 +1,16 @@
pub mod content_hash;
pub mod crypto;
pub mod types; pub mod types;
pub mod messages; pub mod messages;
pub mod chunking; pub mod chunking;
pub mod storage; pub mod storage;
pub mod actors; pub mod actors;
pub mod auth; pub mod auth;
pub mod blob_transfer;
pub mod bridge;
pub mod cli; pub mod cli;
pub mod metrics; pub mod metrics;
pub mod streams;
#[cfg(feature = "node")] #[cfg(feature = "node")]
pub mod api; pub mod api;
#[cfg(feature = "node")] #[cfg(feature = "node")]
@ -18,3 +23,4 @@ pub use messages::{BlobStoreMsg, DatastoreNodeMsg, DatastoreResponse, MetadataMs
pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError}; pub use chunking::{chunk_blob, reassemble_blob, verify_integrity, ChunkingError};
pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend}; pub use storage::{StorageBackend, FilesystemBackend, InMemoryBackend};
pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor}; pub use actors::{BlobStoreActor, DatastoreNode, MetadataActor, TransferActor};
pub use bridge::{DatastoreAuthConfig, DatastoreGroup, DatastoreGroupConfig};

View file

@ -16,8 +16,8 @@ use swactor::actor::ActorAddress;
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use swactor::transport::NetworkMessage; use swactor::transport::NetworkMessage;
use distribution::types::NodeId; use swactor::transport::NodeId;
use swactor_streams::types::StreamId; use crate::streams::types::StreamId;
use crate::auth::{AccessRequestInfo, AuthorizedKeyInfo, DeniedReason, SignedRequest}; use crate::auth::{AccessRequestInfo, AuthorizedKeyInfo, DeniedReason, SignedRequest};
use crate::types::{ContentHash, ObjectEntry, ObjectManifest}; use crate::types::{ContentHash, ObjectEntry, ObjectManifest};

View file

@ -1,8 +1,8 @@
//! Thread-safe metrics for the datastore, consumed by the runtime dashboard. //! Thread-safe metrics for the datastore.
//! //!
//! `DatastoreMetrics` accumulates counters and event history from any thread //! `DatastoreMetrics` accumulates counters and event history from any thread
//! (API handlers run on `tiny_http` worker threads). The dashboard polls //! (API handlers run on `tiny_http` worker threads). The dashboard polls
//! `snapshot()` every ~200ms via the `DatastoreStatsProvider` trait. //! `snapshot()` every ~200ms via the plugin system.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@ -71,6 +71,12 @@ pub struct DatastoreMetrics {
transfers: Mutex<Vec<TransferProgress>>, transfers: Mutex<Vec<TransferProgress>>,
} }
impl Default for DatastoreMetrics {
fn default() -> Self {
Self::new()
}
}
impl DatastoreMetrics { impl DatastoreMetrics {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -186,11 +192,3 @@ fn now_ms() -> u64 {
.as_millis() as u64 .as_millis() as u64
} }
// ── Dashboard integration ────────────────────────────────────────────────────
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,274 +0,0 @@
//! Pool coordinator actor — placement-aware CRUD facade.
//!
//! Owns an `Arc<Mutex<PoolDisseminator>>` for query access and delegates
//! storage operations to the co-located `DatastoreNode` actor.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
use distribution::types::NodeId;
use shared_types::ContentHash;
use shared_types::pool::PoolConfig;
use crate::messages::{DatastoreNodeMsg, DatastoreResponse};
use super::disseminator::PoolDisseminator;
use super::messages::PoolCoordinatorMsg;
/// The pool coordinator actor.
pub struct PoolCoordinator {
node_id: NodeId,
pool_config: PoolConfig,
disseminator: Arc<Mutex<PoolDisseminator>>,
// Co-located actor addresses
datastore_addr: ActorAddress,
tick_count: u64,
}
impl PoolCoordinator {
pub fn new(
node_id: NodeId,
pool_config: PoolConfig,
disseminator: Arc<Mutex<PoolDisseminator>>,
datastore_addr: ActorAddress,
) -> Self {
Self {
node_id,
pool_config,
disseminator,
datastore_addr,
tick_count: 0,
}
}
/// Access the shared disseminator.
pub fn disseminator(&self) -> &Arc<Mutex<PoolDisseminator>> {
&self.disseminator
}
fn cluster_size(&self) -> usize {
let d = self.disseminator.lock().unwrap();
d.member_count().max(1)
}
// ─── Message handlers ──────────────────────────────────────────────
fn handle_pool_put(
&self,
ctx: &Ctx,
data: Vec<u8>,
name: Option<String>,
tags: BTreeMap<String, String>,
reply_to: ActorAddress,
) {
// Delegate to local DatastoreNode for now.
// Future: check capacity and redirect to best node.
let _ = ctx.send(
self.datastore_addr,
DatastoreNodeMsg::Put {
data,
name,
tags,
reply_to,
},
);
// Note: content announcement happens after PutOk is received.
// For now, caller is responsible for announcing content via PoolTick
// or a future PutOk callback.
}
fn handle_pool_get(
&self,
ctx: &Ctx,
content_hash: ContentHash,
reply_to: ActorAddress,
) {
// Try local first via DatastoreNode
let _ = ctx.send(
self.datastore_addr,
DatastoreNodeMsg::Get {
content_hash,
reply_to,
},
);
// Future: if local not found, use disseminator.locate_content()
// to fetch from a specific peer instead of fan-out.
}
fn handle_pool_delete(
&self,
ctx: &Ctx,
content_hash: ContentHash,
reply_to: ActorAddress,
) {
// Delete locally
let _ = ctx.send(
self.datastore_addr,
DatastoreNodeMsg::Delete {
content_hash,
reply_to,
},
);
// Announce tombstone via gossip
let cluster_size = self.cluster_size();
self.disseminator
.lock()
.unwrap()
.remove_content(content_hash, cluster_size);
}
fn handle_pool_list(
&self,
ctx: &Ctx,
name_filter: Option<String>,
reply_to: ActorAddress,
) {
let _ = ctx.send(
self.datastore_addr,
DatastoreNodeMsg::List {
name_filter,
all: false,
reply_to,
},
);
}
fn handle_pool_status(&self, ctx: &Ctx, reply_to: ActorAddress) {
let d = self.disseminator.lock().unwrap();
let (total_bytes, used_bytes) = d.pool_capacity_summary();
let members: Vec<String> = d
.active_members()
.iter()
.map(|id| id.0.iter().map(|b| format!("{b:02x}")).collect())
.collect();
let member_count = d.member_count();
let content_count = d.content_count();
drop(d);
let json = serde_json::json!({
"pool_name": self.pool_config.pool_name,
"pool_id": self.pool_config.pool_id.to_hex(),
"member_count": member_count,
"content_count": content_count,
"total_bytes": total_bytes,
"used_bytes": used_bytes,
"members": members,
});
let _ = ctx.send(
reply_to,
DatastoreResponse::PoolStatus {
json: json.to_string(),
},
);
}
fn handle_join_pool(&mut self, ctx: &Ctx, reply_to: ActorAddress) {
let cluster_size = self.cluster_size();
let mut d = self.disseminator.lock().unwrap();
if !d.is_node_authorized(&self.node_id) {
let _ = ctx.send(
reply_to,
DatastoreResponse::Error {
reason: "not authorized to join pool".into(),
},
);
return;
}
d.join(cluster_size);
d.announce_capacity(self.pool_config.capacity_bytes, 0, cluster_size);
drop(d);
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
}
fn handle_leave_pool(&mut self, ctx: &Ctx, reply_to: ActorAddress) {
let cluster_size = self.cluster_size();
self.disseminator.lock().unwrap().leave(cluster_size);
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
}
fn handle_grant_access(&mut self, ctx: &Ctx, target: NodeId, reply_to: ActorAddress) {
let cluster_size = self.cluster_size();
self.disseminator
.lock()
.unwrap()
.grant_access(target, cluster_size);
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
}
fn handle_revoke_access(&mut self, ctx: &Ctx, target: NodeId, reply_to: ActorAddress) {
let cluster_size = self.cluster_size();
self.disseminator
.lock()
.unwrap()
.revoke_access(target, cluster_size);
let _ = ctx.send(reply_to, DatastoreResponse::Bool(true));
}
fn handle_pool_tick(&mut self) {
self.tick_count += 1;
// Periodic capacity re-announcement (every 100 ticks)
if self.tick_count % 100 == 0 {
let cluster_size = self.cluster_size();
self.disseminator.lock().unwrap().announce_capacity(
self.pool_config.capacity_bytes,
0, // TODO: query actual usage from BlobStore
cluster_size,
);
}
}
}
impl ActorInterface for PoolCoordinator {
type Incoming = PoolCoordinatorMsg;
type Response = DatastoreResponse;
fn handle(&mut self, ctx: &Ctx, msg: PoolCoordinatorMsg) {
match msg {
PoolCoordinatorMsg::PoolPut {
data,
name,
tags,
reply_to,
} => self.handle_pool_put(ctx, data, name, tags, reply_to),
PoolCoordinatorMsg::PoolGet {
content_hash,
reply_to,
} => self.handle_pool_get(ctx, content_hash, reply_to),
PoolCoordinatorMsg::PoolDelete {
content_hash,
reply_to,
} => self.handle_pool_delete(ctx, content_hash, reply_to),
PoolCoordinatorMsg::PoolList {
name_filter,
reply_to,
} => self.handle_pool_list(ctx, name_filter, reply_to),
PoolCoordinatorMsg::PoolStatus { reply_to } => {
self.handle_pool_status(ctx, reply_to)
}
PoolCoordinatorMsg::JoinPool { reply_to } => {
self.handle_join_pool(ctx, reply_to)
}
PoolCoordinatorMsg::LeavePool { reply_to } => {
self.handle_leave_pool(ctx, reply_to)
}
PoolCoordinatorMsg::GrantPoolAccess { target, reply_to } => {
self.handle_grant_access(ctx, target, reply_to)
}
PoolCoordinatorMsg::RevokePoolAccess { target, reply_to } => {
self.handle_revoke_access(ctx, target, reply_to)
}
PoolCoordinatorMsg::PoolTick => self.handle_pool_tick(),
}
}
}

View file

@ -1,716 +0,0 @@
//! Pool disseminator — gossip-converged state for pool membership,
//! capacity, content locations, and ACL.
//!
//! Implements `GossipChannel` to plug into the generic gossip system
//! via `DistributedNode::register_channel()`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use distribution::gossip_channel::{DisseminationBuffer, GossipChannel, deserialize_each, serialize_each};
use distribution::types::NodeId;
use shared_types::ContentHash;
use shared_types::pool::*;
// ─── PoolDisseminator ──────────────────────────────────────────────────────
/// Configuration for the pool disseminator.
#[derive(Debug, Clone)]
pub struct PoolDisseminatorConfig {
pub tombstone_ttl: u64,
pub gc_interval: u64,
}
impl Default for PoolDisseminatorConfig {
fn default() -> Self {
Self {
tombstone_ttl: 3600,
gc_interval: 1000,
}
}
}
/// Manages converged pool state via gossip dissemination.
#[derive(Debug)]
pub struct PoolDisseminator {
pool_id: PoolId,
pool_name: String,
local_node_id: NodeId,
// Converged state maps
members: HashMap<[u8; 32], PoolMemberEntry>,
capacity: HashMap<[u8; 32], PoolCapacityEntry>,
content_locations: HashMap<(ContentHash, [u8; 32]), ContentLocationEntry>,
acl: HashMap<[u8; 32], PoolACLEntry>,
// Dissemination buffer
buffer: DisseminationBuffer<PoolEntry>,
// Local generation counters
local_member_gen: u64,
local_capacity_gen: u64,
config: PoolDisseminatorConfig,
tick_count: u64,
}
impl PoolDisseminator {
pub fn new(pool_id: PoolId, pool_name: String, local_node_id: NodeId, lambda: usize) -> Self {
Self {
pool_id,
pool_name,
local_node_id,
members: HashMap::new(),
capacity: HashMap::new(),
content_locations: HashMap::new(),
acl: HashMap::new(),
buffer: DisseminationBuffer::new(lambda),
local_member_gen: 0,
local_capacity_gen: 0,
config: PoolDisseminatorConfig::default(),
tick_count: 0,
}
}
pub fn with_config(mut self, config: PoolDisseminatorConfig) -> Self {
self.config = config;
self
}
pub fn pool_id(&self) -> PoolId {
self.pool_id
}
// ─── Lifecycle methods ──────────────────────────────────────────────
/// Join the pool. Announces Active membership.
pub fn join(&mut self, cluster_size: usize) {
self.local_member_gen += 1;
let entry = PoolMemberEntry {
pool_id: self.pool_id,
node_id: self.local_node_id.0,
state: PoolMemberState::Active,
generation: self.local_member_gen,
};
self.merge_membership(entry.clone());
self.buffer.enqueue(PoolEntry::Membership(entry), cluster_size);
}
/// Leave the pool. Announces Left membership.
pub fn leave(&mut self, cluster_size: usize) {
self.local_member_gen += 1;
let entry = PoolMemberEntry {
pool_id: self.pool_id,
node_id: self.local_node_id.0,
state: PoolMemberState::Left,
generation: self.local_member_gen,
};
self.merge_membership(entry.clone());
self.buffer.enqueue(PoolEntry::Membership(entry), cluster_size);
}
/// Announce storage capacity.
pub fn announce_capacity(&mut self, total: u64, used: u64, cluster_size: usize) {
self.local_capacity_gen += 1;
let entry = PoolCapacityEntry {
pool_id: self.pool_id,
node_id: self.local_node_id.0,
total_bytes: total,
used_bytes: used,
generation: self.local_capacity_gen,
};
self.merge_capacity(entry.clone());
self.buffer.enqueue(PoolEntry::Capacity(entry), cluster_size);
}
/// Announce that this node has a piece of content.
pub fn announce_content(&mut self, hash: ContentHash, cluster_size: usize) {
let key = (hash, self.local_node_id.0);
let next_gen = self.content_locations.get(&key).map_or(1, |e| e.generation + 1);
let entry = ContentLocationEntry {
pool_id: self.pool_id,
content_hash: hash,
node_id: self.local_node_id.0,
generation: next_gen,
tombstone: false,
};
self.merge_content_location(entry.clone());
self.buffer.enqueue(PoolEntry::ContentLocation(entry), cluster_size);
}
/// Remove content announcement (tombstone).
pub fn remove_content(&mut self, hash: ContentHash, cluster_size: usize) {
let key = (hash, self.local_node_id.0);
let next_gen = self.content_locations.get(&key).map_or(1, |e| e.generation + 1);
let entry = ContentLocationEntry {
pool_id: self.pool_id,
content_hash: hash,
node_id: self.local_node_id.0,
generation: next_gen,
tombstone: true,
};
self.merge_content_location(entry.clone());
self.buffer.enqueue(PoolEntry::ContentLocation(entry), cluster_size);
}
/// Grant access to a node.
pub fn grant_access(&mut self, target: NodeId, cluster_size: usize) {
let next_gen = self.acl.get(&target.0).map_or(1, |e| e.generation + 1);
let entry = PoolACLEntry {
pool_id: self.pool_id,
node_id: target.0,
granted_by: self.local_node_id.0,
generation: next_gen,
revoked: false,
};
self.merge_acl(entry.clone());
self.buffer.enqueue(PoolEntry::ACL(entry), cluster_size);
}
/// Revoke access from a node.
pub fn revoke_access(&mut self, target: NodeId, cluster_size: usize) {
let next_gen = self.acl.get(&target.0).map_or(1, |e| e.generation + 1);
let entry = PoolACLEntry {
pool_id: self.pool_id,
node_id: target.0,
granted_by: self.local_node_id.0,
generation: next_gen,
revoked: true,
};
self.merge_acl(entry.clone());
self.buffer.enqueue(PoolEntry::ACL(entry), cluster_size);
}
// ─── Query API ──────────────────────────────────────────────────────
/// All active pool members.
pub fn active_members(&self) -> Vec<NodeId> {
self.members
.values()
.filter(|m| m.state == PoolMemberState::Active)
.map(|m| NodeId(m.node_id))
.collect()
}
/// Total and used capacity across the pool.
pub fn pool_capacity_summary(&self) -> (u64, u64) {
let mut total = 0u64;
let mut used = 0u64;
for cap in self.capacity.values() {
// Only count active members
if let Some(m) = self.members.get(&cap.node_id) {
if m.state == PoolMemberState::Active {
total = total.saturating_add(cap.total_bytes);
used = used.saturating_add(cap.used_bytes);
}
}
}
(total, used)
}
/// Find which nodes have a given content hash.
pub fn locate_content(&self, hash: &ContentHash) -> Vec<NodeId> {
self.content_locations
.iter()
.filter(|((h, _), entry)| h == hash && !entry.tombstone)
.map(|((_, node_id), _)| NodeId(*node_id))
.collect()
}
/// Find the node with the most free space.
pub fn node_with_most_free_space(&self) -> Option<NodeId> {
self.capacity
.values()
.filter(|cap| {
self.members
.get(&cap.node_id)
.is_some_and(|m| m.state == PoolMemberState::Active)
})
.max_by_key(|cap| cap.total_bytes.saturating_sub(cap.used_bytes))
.map(|cap| NodeId(cap.node_id))
}
/// Check if a node is authorized to join this pool.
pub fn is_node_authorized(&self, node_id: &NodeId) -> bool {
// If no ACL entries exist, the pool is open
if self.acl.is_empty() {
return true;
}
self.acl
.get(&node_id.0)
.is_some_and(|entry| !entry.revoked)
}
/// Number of active members.
pub fn member_count(&self) -> usize {
self.members
.values()
.filter(|m| m.state == PoolMemberState::Active)
.count()
}
/// Number of live content location entries (non-tombstone).
pub fn content_count(&self) -> usize {
self.content_locations
.values()
.filter(|e| !e.tombstone)
.count()
}
/// Serialize the current pool state to a JSON string for the dashboard.
pub fn snapshot_json(&self) -> String {
fn hex(bytes: &[u8; 32]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
let (total_bytes, used_bytes) = self.pool_capacity_summary();
let members: Vec<serde_json::Value> = self
.members
.values()
.filter(|m| m.state == PoolMemberState::Active)
.map(|m| {
let cap = self.capacity.get(&m.node_id);
serde_json::json!({
"node_id": hex(&m.node_id),
"state": format!("{:?}", m.state),
"generation": m.generation,
"total_bytes": cap.map_or(0, |c| c.total_bytes),
"used_bytes": cap.map_or(0, |c| c.used_bytes),
})
})
.collect();
// Group content locations by hash
let mut by_hash: HashMap<ContentHash, Vec<[u8; 32]>> = HashMap::new();
for ((hash, _), entry) in &self.content_locations {
if !entry.tombstone {
by_hash.entry(*hash).or_default().push(entry.node_id);
}
}
let content_locations: Vec<serde_json::Value> = by_hash
.iter()
.map(|(hash, nodes)| {
serde_json::json!({
"content_hash": hash.to_hex(),
"nodes": nodes.iter().map(hex).collect::<Vec<_>>(),
"replica_count": nodes.len(),
})
})
.collect();
let acl: Vec<serde_json::Value> = self
.acl
.values()
.map(|a| {
serde_json::json!({
"node_id": hex(&a.node_id),
"granted_by": hex(&a.granted_by),
"revoked": a.revoked,
})
})
.collect();
let acl_mode = if self.acl.is_empty() { "open" } else { "allow-list" };
serde_json::json!({
"pool_name": self.pool_name,
"pool_id": self.pool_id.to_hex(),
"member_count": self.member_count(),
"content_count": self.content_count(),
"total_bytes": total_bytes,
"used_bytes": used_bytes,
"members": members,
"content_locations": content_locations,
"acl": acl,
"acl_mode": acl_mode,
})
.to_string()
}
// ─── Internal merge logic ───────────────────────────────────────────
fn merge_membership(&mut self, entry: PoolMemberEntry) -> bool {
let key = entry.node_id;
if let Some(existing) = self.members.get(&key) {
if entry.generation <= existing.generation {
return false;
}
}
self.members.insert(key, entry);
true
}
fn merge_capacity(&mut self, entry: PoolCapacityEntry) -> bool {
let key = entry.node_id;
if let Some(existing) = self.capacity.get(&key) {
if entry.generation <= existing.generation {
return false;
}
}
self.capacity.insert(key, entry);
true
}
fn merge_content_location(&mut self, entry: ContentLocationEntry) -> bool {
let key = (entry.content_hash, entry.node_id);
if let Some(existing) = self.content_locations.get(&key) {
if entry.generation <= existing.generation {
return false;
}
}
self.content_locations.insert(key, entry);
true
}
fn merge_acl(&mut self, entry: PoolACLEntry) -> bool {
let key = entry.node_id;
if let Some(existing) = self.acl.get(&key) {
if entry.generation <= existing.generation {
return false;
}
}
self.acl.insert(key, entry);
true
}
/// Merge a single pool entry and return whether state changed.
fn merge_entry(&mut self, entry: PoolEntry) -> bool {
match entry {
PoolEntry::Membership(m) => self.merge_membership(m),
PoolEntry::Capacity(c) => self.merge_capacity(c),
PoolEntry::ContentLocation(cl) => self.merge_content_location(cl),
PoolEntry::ACL(a) => self.merge_acl(a),
}
}
/// Take pending entries (internal, typed).
fn take_pending_inner(&mut self, max_count: usize) -> Vec<PoolEntry> {
self.buffer.take(max_count)
}
/// Apply incoming entries (internal, typed).
fn apply_incoming_inner(&mut self, entries: Vec<PoolEntry>, cluster_size: usize) {
for entry in entries {
if self.merge_entry(entry.clone()) {
self.buffer.enqueue(entry, cluster_size);
}
}
}
/// Re-enqueue all state (internal).
fn re_disseminate_all_inner(&mut self, cluster_size: usize) {
let mut all_entries: Vec<PoolEntry> = Vec::new();
for m in self.members.values().cloned() {
all_entries.push(PoolEntry::Membership(m));
}
for c in self.capacity.values().cloned() {
all_entries.push(PoolEntry::Capacity(c));
}
for cl in self.content_locations.values().cloned() {
all_entries.push(PoolEntry::ContentLocation(cl));
}
for a in self.acl.values().cloned() {
all_entries.push(PoolEntry::ACL(a));
}
self.buffer.re_enqueue_all(all_entries, cluster_size);
}
/// GC: evict tombstones past TTL.
fn gc_tick_inner(&mut self) {
self.tick_count += 1;
if self.tick_count % self.config.gc_interval != 0 {
return;
}
let ttl = self.config.tombstone_ttl;
let tick = self.tick_count;
// GC left members
self.members.retain(|_, m| {
if m.state == PoolMemberState::Left {
m.generation + ttl > tick
} else {
true
}
});
// GC tombstoned content locations
self.content_locations.retain(|_, cl| {
if cl.tombstone {
cl.generation + ttl > tick
} else {
true
}
});
// GC revoked ACL entries
self.acl.retain(|_, a| {
if a.revoked {
a.generation + ttl > tick
} else {
true
}
});
}
}
// ─── SharedPoolChannel ─────────────────────────────────────────────────────
/// Wrapper around `Arc<Mutex<PoolDisseminator>>` that implements `GossipChannel`.
///
/// This enables shared ownership between the `PoolCoordinator` actor
/// (which needs query/lifecycle access) and `DistributedNode` (which
/// drives gossip piggyback).
pub struct SharedPoolChannel {
inner: Arc<Mutex<PoolDisseminator>>,
}
impl SharedPoolChannel {
pub fn new(disseminator: Arc<Mutex<PoolDisseminator>>) -> Self {
Self { inner: disseminator }
}
/// Consume the channel and return the underlying `Arc<Mutex<PoolDisseminator>>`.
pub fn into_inner(self) -> Arc<Mutex<PoolDisseminator>> {
self.inner
}
}
impl GossipChannel for SharedPoolChannel {
fn topic_tag(&self) -> &'static str {
"pool"
}
fn take_pending_bytes(&mut self, max_entries: usize) -> Vec<Vec<u8>> {
let entries = self.inner.lock().unwrap().take_pending_inner(max_entries);
serialize_each(&entries)
}
fn apply_incoming_bytes(&mut self, entries: &[Vec<u8>], cluster_size: usize) {
let parsed: Vec<PoolEntry> = deserialize_each(entries);
self.inner.lock().unwrap().apply_incoming_inner(parsed, cluster_size);
}
fn re_disseminate_all(&mut self, cluster_size: usize) {
self.inner.lock().unwrap().re_disseminate_all_inner(cluster_size);
}
fn on_node_death(&mut self, _node_id: &NodeId) {
// Pool membership is explicit (join/leave), not auto-removed on node death.
// Capacity becomes unreliable but we don't remove it.
}
fn gc_tick(&mut self) {
self.inner.lock().unwrap().gc_tick_inner();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node_id(b: u8) -> NodeId {
NodeId([b; 32])
}
fn make_disseminator(b: u8) -> PoolDisseminator {
PoolDisseminator::new(
PoolId::from_name("test-pool"),
"test-pool".into(),
node_id(b),
3,
)
}
#[test]
fn join_and_query_members() {
let mut d = make_disseminator(1);
d.join(2);
assert_eq!(d.member_count(), 1);
assert_eq!(d.active_members(), vec![node_id(1)]);
}
#[test]
fn leave_removes_from_active() {
let mut d = make_disseminator(1);
d.join(2);
d.leave(2);
assert_eq!(d.member_count(), 0);
assert!(d.active_members().is_empty());
}
#[test]
fn announce_and_locate_content() {
let mut d = make_disseminator(1);
d.join(2);
let hash = ContentHash::of(b"test-data");
d.announce_content(hash, 2);
let locations = d.locate_content(&hash);
assert_eq!(locations, vec![node_id(1)]);
}
#[test]
fn remove_content_tombstones() {
let mut d = make_disseminator(1);
d.join(2);
let hash = ContentHash::of(b"test-data");
d.announce_content(hash, 2);
d.remove_content(hash, 2);
assert!(d.locate_content(&hash).is_empty());
}
#[test]
fn capacity_summary() {
let mut d = make_disseminator(1);
d.join(2);
d.announce_capacity(1000, 300, 2);
let (total, used) = d.pool_capacity_summary();
assert_eq!(total, 1000);
assert_eq!(used, 300);
}
#[test]
fn acl_grant_and_check() {
let mut d = make_disseminator(1);
d.grant_access(node_id(2), 2);
assert!(d.is_node_authorized(&node_id(2)));
assert!(!d.is_node_authorized(&node_id(3)));
}
#[test]
fn acl_revoke() {
let mut d = make_disseminator(1);
d.grant_access(node_id(2), 2);
d.revoke_access(node_id(2), 2);
assert!(!d.is_node_authorized(&node_id(2)));
}
#[test]
fn empty_acl_means_open() {
let d = make_disseminator(1);
assert!(d.is_node_authorized(&node_id(99)));
}
#[test]
fn higher_generation_wins_merge() {
let mut d1 = make_disseminator(1);
let mut d2 = make_disseminator(2);
// d1 joins
d1.join(2);
// d1 leaves
d1.leave(2);
// Gossip d1's entries to d2 out of order:
// First send the Active (gen 1), then the Left (gen 2)
let active_entry = PoolEntry::Membership(PoolMemberEntry {
pool_id: PoolId::from_name("test-pool"),
node_id: [1u8; 32],
state: PoolMemberState::Active,
generation: 1,
});
let left_entry = PoolEntry::Membership(PoolMemberEntry {
pool_id: PoolId::from_name("test-pool"),
node_id: [1u8; 32],
state: PoolMemberState::Left,
generation: 2,
});
// Apply Left first (gen 2), then Active (gen 1) — Active should be rejected
d2.merge_entry(left_entry);
let changed = d2.merge_entry(active_entry);
assert!(!changed, "lower generation should not win");
// d2 should see node_id(1) as Left
assert_eq!(d2.member_count(), 0); // Active count is 0
}
#[test]
fn two_disseminators_converge_via_gossip_exchange() {
let mut d1 = make_disseminator(1);
let mut d2 = make_disseminator(2);
// d1 joins and announces content
d1.join(2);
let hash = ContentHash::of(b"shared-file");
d1.announce_content(hash, 2);
// d2 joins
d2.join(2);
// Simulate gossip: d1 → d2
let pending = d1.take_pending_inner(100);
let bytes = serialize_each(&pending);
let parsed: Vec<PoolEntry> = deserialize_each(&bytes);
d2.apply_incoming_inner(parsed, 2);
// d2 should now see d1 as a member and know about the content
assert_eq!(d2.member_count(), 2);
assert_eq!(d2.locate_content(&hash), vec![node_id(1)]);
// Simulate gossip: d2 → d1
let pending = d2.take_pending_inner(100);
let bytes = serialize_each(&pending);
let parsed: Vec<PoolEntry> = deserialize_each(&bytes);
d1.apply_incoming_inner(parsed, 2);
// d1 should now see d2 as a member
assert_eq!(d1.member_count(), 2);
}
#[test]
fn three_node_convergence_loop() {
let pool = PoolId::from_name("test-pool");
let mut nodes: Vec<PoolDisseminator> = (0..3)
.map(|i| PoolDisseminator::new(pool, "test-pool".into(), node_id(i as u8), 3))
.collect();
// Each node joins
for n in &mut nodes {
n.join(3);
}
// Node 0 announces content
let hash = ContentHash::of(b"convergence-test");
nodes[0].announce_content(hash, 3);
// Run 5 gossip rounds where each node exchanges with all others
for _ in 0..5 {
// Collect pending from each node
let pending_bytes: Vec<Vec<Vec<u8>>> = nodes
.iter_mut()
.map(|n| serialize_each(&n.take_pending_inner(100)))
.collect();
// Apply each node's pending to all other nodes
for (sender_idx, bytes) in pending_bytes.iter().enumerate() {
for (receiver_idx, node) in nodes.iter_mut().enumerate() {
if sender_idx != receiver_idx {
let parsed: Vec<PoolEntry> = deserialize_each(bytes);
node.apply_incoming_inner(parsed, 3);
}
}
}
}
// All nodes should agree on membership and content locations
for (i, node) in nodes.iter().enumerate() {
assert_eq!(node.member_count(), 3, "node {i} should see 3 members");
assert_eq!(
node.locate_content(&hash),
vec![node_id(0)],
"node {i} should know content is on node 0"
);
}
}
}

View file

@ -1,79 +0,0 @@
//! Messages for the `PoolCoordinator` actor.
use std::collections::BTreeMap;
use distribution::types::NodeId;
use shared_types::ContentHash;
/// Messages handled by the `PoolCoordinator` actor.
#[derive(Debug, Clone)]
pub enum PoolCoordinatorMsg {
// ── User-facing operations ──────────────────────────────────────────
/// Store data in the pool (placement-aware).
PoolPut {
data: Vec<u8>,
name: Option<String>,
tags: BTreeMap<String, String>,
reply_to: swactor::actor::ActorAddress,
},
/// Retrieve data from the pool (location-aware).
PoolGet {
content_hash: ContentHash,
reply_to: swactor::actor::ActorAddress,
},
/// Delete data from the pool.
PoolDelete {
content_hash: ContentHash,
reply_to: swactor::actor::ActorAddress,
},
/// List objects in the pool.
PoolList {
name_filter: Option<String>,
reply_to: swactor::actor::ActorAddress,
},
/// Pool status (members, capacity, content count).
PoolStatus {
reply_to: swactor::actor::ActorAddress,
},
// ── Pool lifecycle ──────────────────────────────────────────────────
/// Join the pool.
JoinPool {
reply_to: swactor::actor::ActorAddress,
},
/// Leave the pool.
LeavePool {
reply_to: swactor::actor::ActorAddress,
},
// ── Auth management ─────────────────────────────────────────────────
/// Grant a node access to the pool.
GrantPoolAccess {
target: NodeId,
reply_to: swactor::actor::ActorAddress,
},
/// Revoke a node's access to the pool.
RevokePoolAccess {
target: NodeId,
reply_to: swactor::actor::ActorAddress,
},
// ── Periodic ────────────────────────────────────────────────────────
/// Periodic tick: announce capacity, drive dissemination.
PoolTick,
}
/// Pool-specific response variants.
#[derive(Debug, Clone)]
pub enum PoolResponse {
/// Pool status snapshot.
PoolStatus {
pool_name: String,
pool_id_hex: String,
member_count: usize,
content_count: usize,
total_bytes: u64,
used_bytes: u64,
members: Vec<String>,
},
}

View file

@ -1,8 +0,0 @@
//! Pooled datastore protocol.
//!
//! A shared storage pool where multiple nodes contribute storage capacity
//! and converge on a shared view of what content lives where.
pub mod disseminator;
pub mod messages;
pub mod coordinator;

View file

@ -98,11 +98,10 @@ impl FilesystemBackend {
continue; continue;
}; };
for file in files.flatten() { for file in files.flatten() {
if let Some(name) = file.file_name().to_str() { if let Some(name) = file.file_name().to_str()
if let Some(hash) = ContentHash::from_hex(name) { && let Some(hash) = ContentHash::from_hex(name) {
self.chunk_index.insert(hash); self.chunk_index.insert(hash);
} }
}
} }
} }
} }

View file

@ -5,8 +5,8 @@ use iroh::endpoint::Connection;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use crate::messages::{OneShot, StreamManagerMsg}; use crate::streams::messages::{OneShot, StreamManagerMsg};
use crate::wire; use crate::streams::wire;
/// Spawn a bridge task that processes incoming stream connections and /// Spawn a bridge task that processes incoming stream connections and
/// forwards them to the StreamManager actor. /// forwards them to the StreamManager actor.

View file

@ -1,5 +1,5 @@
use crate::buffer::FrameBuf; use crate::streams::buffer::FrameBuf;
use crate::types::StreamError; use crate::streams::types::StreamError;
/// Commands sent from the actor to the send-side data-plane task. /// Commands sent from the actor to the send-side data-plane task.
pub enum SendCommand { pub enum SendCommand {

View file

@ -3,8 +3,8 @@ use std::collections::HashMap;
use iroh::endpoint::Connection; use iroh::endpoint::Connection;
use iroh::{Endpoint, PublicKey}; use iroh::{Endpoint, PublicKey};
use crate::types::StreamError; use crate::streams::types::StreamError;
use crate::wire::ALPN; use crate::streams::wire::ALPN;
/// Cache of QUIC connections used for stream data transfer. /// Cache of QUIC connections used for stream data transfer.
/// ///
@ -14,6 +14,12 @@ pub struct StreamConnectionCache {
connections: HashMap<[u8; 32], Connection>, connections: HashMap<[u8; 32], Connection>,
} }
impl Default for StreamConnectionCache {
fn default() -> Self {
Self::new()
}
}
impl StreamConnectionCache { impl StreamConnectionCache {
pub fn new() -> Self { pub fn new() -> Self {
StreamConnectionCache { StreamConnectionCache {

View file

@ -8,11 +8,11 @@
use swactor::actor::{ActorAddress, Ctx}; use swactor::actor::{ActorAddress, Ctx};
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use swactor_std::CtxNaming; use swactor::std::CtxNaming;
use swactor_std::RuntimeNaming; use swactor::std::RuntimeNaming;
use crate::messages::StreamManagerMsg; use crate::streams::messages::StreamManagerMsg;
use crate::types::{StreamConfig, StreamError, StreamId, StreamMode}; use crate::streams::types::{StreamConfig, StreamError, StreamId, StreamMode};
fn mgr_not_found() -> StreamError { fn mgr_not_found() -> StreamError {
StreamError::BrokenPipe("StreamManager not found in name registry".into()) StreamError::BrokenPipe("StreamManager not found in name registry".into())
@ -51,7 +51,7 @@ impl CtxStreams for Ctx<'_> {
config: StreamConfig, config: StreamConfig,
) -> Result<(), StreamError> { ) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send( self.send(
mgr, mgr,
@ -67,7 +67,7 @@ impl CtxStreams for Ctx<'_> {
fn stream_listen(&self, mode: StreamMode) -> Result<(), StreamError> { fn stream_listen(&self, mode: StreamMode) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send( self.send(
mgr, mgr,
@ -81,7 +81,7 @@ impl CtxStreams for Ctx<'_> {
fn stream_accept(&self, stream_id: StreamId) -> Result<(), StreamError> { fn stream_accept(&self, stream_id: StreamId) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send( self.send(
mgr, mgr,
@ -95,7 +95,7 @@ impl CtxStreams for Ctx<'_> {
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> { fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send(mgr, StreamManagerMsg::Reject { stream_id }) self.send(mgr, StreamManagerMsg::Reject { stream_id })
.map_err(|e| StreamError::BrokenPipe(e.to_string())) .map_err(|e| StreamError::BrokenPipe(e.to_string()))
@ -103,7 +103,7 @@ impl CtxStreams for Ctx<'_> {
fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> { fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send(mgr, StreamManagerMsg::Close { stream_id }) self.send(mgr, StreamManagerMsg::Close { stream_id })
.map_err(|e| StreamError::BrokenPipe(e.to_string())) .map_err(|e| StreamError::BrokenPipe(e.to_string()))
@ -149,7 +149,7 @@ impl RuntimeStreams for Runtime {
reply_to: ActorAddress, reply_to: ActorAddress,
) -> Result<(), StreamError> { ) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send_to( self.send_to(
mgr, mgr,
@ -165,7 +165,7 @@ impl RuntimeStreams for Runtime {
fn stream_listen(&self, mode: StreamMode, listener: ActorAddress) -> Result<(), StreamError> { fn stream_listen(&self, mode: StreamMode, listener: ActorAddress) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send_to(mgr, StreamManagerMsg::Listen { mode, listener }) self.send_to(mgr, StreamManagerMsg::Listen { mode, listener })
.map_err(|e| StreamError::BrokenPipe(e.to_string())) .map_err(|e| StreamError::BrokenPipe(e.to_string()))
@ -177,7 +177,7 @@ impl RuntimeStreams for Runtime {
reply_to: ActorAddress, reply_to: ActorAddress,
) -> Result<(), StreamError> { ) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send_to(mgr, StreamManagerMsg::Accept { stream_id, reply_to }) self.send_to(mgr, StreamManagerMsg::Accept { stream_id, reply_to })
.map_err(|e| StreamError::BrokenPipe(e.to_string())) .map_err(|e| StreamError::BrokenPipe(e.to_string()))
@ -185,7 +185,7 @@ impl RuntimeStreams for Runtime {
fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> { fn stream_reject(&self, stream_id: StreamId) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send_to(mgr, StreamManagerMsg::Reject { stream_id }) self.send_to(mgr, StreamManagerMsg::Reject { stream_id })
.map_err(|e| StreamError::BrokenPipe(e.to_string())) .map_err(|e| StreamError::BrokenPipe(e.to_string()))
@ -193,7 +193,7 @@ impl RuntimeStreams for Runtime {
fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> { fn stream_close(&self, stream_id: StreamId) -> Result<(), StreamError> {
let mgr = self let mgr = self
.where_is(crate::manager::STREAM_MANAGER_NAME) .where_is(crate::streams::manager::STREAM_MANAGER_NAME)
.ok_or_else(mgr_not_found)?; .ok_or_else(mgr_not_found)?;
self.send_to(mgr, StreamManagerMsg::Close { stream_id }) self.send_to(mgr, StreamManagerMsg::Close { stream_id })
.map_err(|e| StreamError::BrokenPipe(e.to_string())) .map_err(|e| StreamError::BrokenPipe(e.to_string()))

View file

@ -1,11 +1,11 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::buffer::BufferPool; use crate::streams::buffer::BufferPool;
use crate::channel::{RecvEvent, SendCommand}; use crate::streams::channel::{RecvEvent, SendCommand};
use crate::notify::NotifySink; use crate::streams::notify::NotifySink;
use crate::types::StreamError; use crate::streams::types::StreamError;
use crate::wire; use crate::streams::wire;
/// A send-side data-plane task for a single stripe. /// A send-side data-plane task for a single stripe.
/// ///
@ -215,8 +215,8 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::buffer::BufferPool; use crate::streams::buffer::BufferPool;
use crate::types::StreamId; use crate::streams::types::StreamId;
fn make_test_pool(count: usize, capacity: usize) -> BufferPool { fn make_test_pool(count: usize, capacity: usize) -> BufferPool {
BufferPool::new(count, capacity) BufferPool::new(count, capacity)
@ -425,7 +425,7 @@ mod tests {
/// Notification coalescing through NotifySink. /// Notification coalescing through NotifySink.
#[tokio::test] #[tokio::test]
async fn notification_coalescing() { async fn notification_coalescing() {
use crate::notify::*; use crate::streams::notify::*;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
@ -459,8 +459,8 @@ mod tests {
/// Backpressure: when channel and active buffer are saturated, try_write returns 0. /// Backpressure: when channel and active buffer are saturated, try_write returns 0.
#[tokio::test] #[tokio::test]
async fn send_backpressure() { async fn send_backpressure() {
use crate::handle::create_stream_handle; use crate::streams::handle::create_stream_handle;
use crate::types::StreamConfig; use crate::streams::types::StreamConfig;
let config = StreamConfig { let config = StreamConfig {
stripe_count: 1, stripe_count: 1,

Some files were not shown because too many files have changed in this diff Show more