diff --git a/Cargo.lock b/Cargo.lock index 7c42c71..41d03ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4653,7 +4653,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rampage-agent" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "rampage-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4699,7 +4699,7 @@ dependencies = [ [[package]] name = "rampage-controller" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "rampage-desktop" -version = "0.3.0" +version = "0.3.1" dependencies = [ "aes-gcm 0.11.0", "base64 0.22.1", @@ -4757,7 +4757,7 @@ dependencies = [ [[package]] name = "rampage-edge" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -4779,7 +4779,7 @@ dependencies = [ [[package]] name = "rampage-edge-app" -version = "0.3.0" +version = "0.3.1" dependencies = [ "rampage-edge", "serde", @@ -4791,7 +4791,7 @@ dependencies = [ [[package]] name = "rampage-ledger" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "hex", @@ -4804,7 +4804,7 @@ dependencies = [ [[package]] name = "rampage-mesh" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -4823,7 +4823,7 @@ dependencies = [ [[package]] name = "rampage-policy" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "ed25519-dalek 2.2.0", @@ -4839,7 +4839,7 @@ dependencies = [ [[package]] name = "rampage-project" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -4852,7 +4852,7 @@ dependencies = [ [[package]] name = "rampage-protocol" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "serde", @@ -4863,7 +4863,7 @@ dependencies = [ [[package]] name = "rampage-relay" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -4887,7 +4887,7 @@ dependencies = [ [[package]] name = "rampage-sdk" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "rampage-storage" -version = "0.3.0" +version = "0.3.1" dependencies = [ "aes-gcm 0.11.0", "chrono", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "tauri-plugin-rampage-edge" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde", "tauri", diff --git a/Cargo.toml b/Cargo.toml index 82e7f81..b778ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" rust-version = "1.91" authors = ["ObtuseAI"] diff --git a/README.md b/README.md index e837dcb..1ee48cb 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,18 @@ the authority to run wild. [![Rust 1.91](https://img.shields.io/badge/Rust-1.91%2B-0b0f17?logo=rust&logoColor=67f5c5)](Cargo.toml) [![AI authority](https://img.shields.io/badge/AI%20authority-proposals%20only-0b0f17?logo=probot&logoColor=67f5c5)](docs/ARCHITECTURE.md#recursive-improvement) -## [⬇ Download Rampage 0.3.0 for Windows](https://obtuseai.github.io/rampage/download/) +## [⬇ Download Rampage 0.3.1 for Windows](https://obtuseai.github.io/rampage/download/) **One installer. Desktop shortcut included.** -[Release notes and SHA-256](https://github.com/ObtuseAI/rampage/releases/tag/v0.3.0-remote-assist.1) · +[Release notes and SHA-256](docs/RELEASE_NOTES_0.3.1.md) · [All releases](https://github.com/ObtuseAI/rampage/releases) [**Launch the showcase**](https://obtuseai.github.io/rampage/) · [Architecture](docs/ARCHITECTURE.md) · +[Universal fabric blueprint](docs/UNIVERSAL_FABRIC_BLUEPRINT.md) · [Easy pairing](docs/PAIRING.md) · [Security](SECURITY.md) · -[0.3.0 release evidence](docs/RELEASE_EVIDENCE_0.3.0.md) · +[0.3.1 release evidence](docs/RELEASE_EVIDENCE_0.3.1.md) · [Owner-relay evidence](docs/OWNER_RELAY_EVIDENCE.md) · [Resumable-storage evidence](docs/RESUMABLE_STORAGE_EVIDENCE.md) · [Native distribution](docs/DISTRIBUTION.md) · @@ -62,12 +63,11 @@ machine that can actually perform it. ## Built—not imagined -Rampage 0.3.0 is the current Windows x64 Remote Assist candidate. It keeps the complete 0.2.3 -fabric-proof foundation, then gives the paired owner an optional live view/control lane for an -unlocked Windows worker. Access is off by default on the worker, visible in the app and tray while -active, bounded by renewable 30-second signed leases, authenticated to the pinned controller, and -revocable from either machine. It does not grant a shell, elevation, UAC, lock-screen, or Windows -secure-desktop authority. +Rampage 0.3.1 is the current Windows x64 recovery and automatic-role release. It preserves the +Remote Assist and fabric-proof foundation, then closes the complete device lifecycle: repair in +place, return a worker to pairing, revoke a stale enrolled identity from the owner, or factory-reset +the local Rampage runtime without uninstalling external model stores. A failed native status bridge +now produces an actionable recovery state instead of an infinite loading surface. | Proof surface | Validated result | | --- | --- | @@ -80,13 +80,15 @@ secure-desktop authority. | Signed fabric benchmark | One bounded CPU grant per capable live node; deterministic SHA-256 chains; node-pinned placement; signed receipts; aggregate and scale-over-fastest results in the desktop | | Universal capability contract | Signed offers advertise exact workload domain, adapter, operation, execution pattern, isolation, runtime digest, and qualification status; candidate profiles grant no authority | | Autonomous self-scan | Stable evidence digests cover routes, links, failures, denials, thermal/battery pressure, capability gaps, idle capacity, and protected-artifact replication | -| Compute Strategy | Read-only Maximum Model, Speed Boost, Throughput, Efficiency, and Autonomous placement previews with exact capacity and qualification blockers | +| Compute Strategy | Outcome-first Automatic, Biggest AI, Fastest AI, More Work, and Protect This PC placement previews with exact capacity and qualification blockers | +| Lifecycle recovery | One-screen Fix Rampage, Pair again, enrolled-device Forget, redacted receipt, and typed local factory reset; restart replay keeps revoked nodes and offers gone | | Remote Assist | Worker opt-in; paired-owner view/control; dedicated authenticated QUIC protocol; request-, node-, controller-, epoch-, size-, frame-rate-, and input-sequence bounds; visible active state; STOP/revoke | | Packaged product | Native Tauri shell, role-aware system tray, close-to-tray, start-at-login, governed sidecars, cold-start tolerance, controller-restart recovery, clean explicit shutdown, installer, and automatic desktop launcher | | Verification | Rust, desktop, TypeScript SDK, Python intelligence, Python SDK, deterministic universal-gateway, forced-relay mesh, packaging, and lifecycle gates | -The 0.3.0 source, package, and physical-device boundary is recorded in -[the current release evidence](docs/RELEASE_EVIDENCE_0.3.0.md). The immutable packaged 0.2.3 +The 0.3.1 source, package, and physical-device boundary is recorded in +[the current release evidence](docs/RELEASE_EVIDENCE_0.3.1.md). The previous 0.3.0 Remote Assist +record remains immutable in [its release evidence](docs/RELEASE_EVIDENCE_0.3.0.md). The packaged 0.2.3 qualification record—including artifact hashes, live Qwen proof, and its unsigned-release boundary—remains in [the 0.2.3 evidence](docs/RELEASE_EVIDENCE_0.2.3.md). The 0.2.2 pairing record remains in @@ -160,6 +162,20 @@ Once an opted-in worker is live, the owner can produce a fail-closed physical fr requires a fresh shipped Remote Assist offer, verifies the signed lease bounds and JPEG SHA-256, closes the session, and proves the controller returns `404` for the revoked session. It never injects input. +## Recovery Center: nobody gets trapped in a broken pairing + +The wrench in the app header—and **Already paired or stuck? Fix Rampage** on first-run setup—opens a +single recovery surface. **Fix Rampage** safely restarts a consistent installation. **Pair again** +removes only the worker's old fabric identity and returns it to nearby pairing. On the owner PC, +**Forget** revokes the selected identity, live offer, outstanding work, Remote Assist sessions, +artifact locations, and replay state. **Factory reset** is deliberately separate and requires the +full `RESET RAMPAGE` phrase. + +![Rampage 0.3.1 Recovery Center showing one-click repair, enrolled machines, stale-device Forget, and factory reset](docs/assets/rampage-recovery-center.png) + +This image is a source-current browser showcase of the real Recovery Center. Native deletion, +restart, auto-start, and sidecar behavior is verified separately in the packaged Windows campaign. + ## OnePool: pool the work, not the address space Remote memory and VRAM do not become magically coherent across commodity networks. Rampage instead @@ -194,20 +210,21 @@ Apple-silicon iOS simulator build proof, artifact identities, checksums, and rel ## Model Fabric: biggest model and fastest chat are different lanes -The desktop now defaults to **Maximum Model** and exposes five explicit ways to use added compute: +The desktop defaults to **Automatic** and exposes five outcome choices while assigning technical +device roles itself: | Toggle | What Rampage optimizes | | --- | --- | -| Maximum Model | Largest compatible aggregate model-memory placement | -| Speed Boost | Fastest evidence-supported single chat; slow distributed links are rejected | -| Throughput | Independent replicas for many concurrent users or agents | -| Efficiency | Smallest qualified placement that fits | -| Autonomous | Proposal-only strategy adaptation behind Governor gates | +| Automatic | Measure the work, devices, and paths; continuously choose the safest high-value role | +| Biggest AI | Largest compatible model placement; engine-native cross-node layouts only after qualification | +| Fastest AI | Fastest evidence-supported chat; slow distributed links are rejected | +| More Work | Independent replicas and shards for concurrent users, agents, builds, renders, and batches | +| Protect This PC | Preserve the foreground game, call, or production session by spending remote capacity first | On Windows, Local AI Autopilot now qualifies a pinned Ollama runtime and exact starter model in the -background. **Prove my speed** runs sustained, lease-bounded work on every capable live 0.3.0 node +background. **Prove my speed** runs sustained, lease-bounded work on every capable live 0.3.1 node and accepts a node only after its signed execution receipt arrives. This measures useful distributed -work; it does not turn network latency into fictional local VRAM. +work; it does not imply remotely addressable local VRAM. The planner reports visible versus compatible memory, requested weights plus KV cache, selected ranks, parallelism, predicted speedup, and the exact missing qualification. Planning remains @@ -257,7 +274,7 @@ never displayed or copied. Capability discovery does the rest. | Platform lane | Native deliverables | Current qualification boundary | | --- | --- | --- | -| Windows x64 | MSI and NSIS installers; automatic desktop shortcut; Start-menu entry; role-aware tray; bundled sidecars | 0.3.0 carries forward the packaged 0.2.3 foundation and adds source-qualified Windows Remote Assist; exact package hashes and physical-worker status are in the current release evidence | +| Windows x64 | MSI and NSIS installers; automatic desktop shortcut; Start-menu entry; role-aware tray; bundled sidecars | 0.3.1 adds complete recovery and enrolled-device revocation to the Remote Assist foundation; exact package hashes and physical-worker status are in the current release evidence | | Linux x64 | Debian package and AppImage with the same Tauri shell and native sidecars | Fresh Ubuntu 24.04 candidate gate; stable repository/AppImage signing remains channel-specific | | macOS Apple Silicon | Native app bundle and DMG | Fresh macOS 15/M1 candidate gate; stable publication requires Developer ID signing, Gatekeeper acceptance, notarization, and a stapled ticket | | Windows 10 x64 | MSI/NSIS lifecycle, desktop shortcut, tray, sidecars, restart, STOP, and uninstall campaign | Deliberately unqualified until a real self-hosted Windows 10 runner completes the fail-closed workflow | @@ -306,6 +323,9 @@ ambiguous evidence fails closed. 5. On the main PC, choose **Add machine**. Confirm the same four digits and press **Codes match—approve**. 6. Rampage securely enrolls and restarts the laptop automatically. Leave contribution limits on automatic or tune them. Press **STOP** whenever you want the node back. +If setup is ever interrupted, open **Fix Rampage**. A worker can return to pairing in two clicks; +the owner can forget the stale identity before approving the replacement. + The nearby flow uses only the private LAN. If Windows asks, allow Rampage on **private networks**. The complete invite remains available under **Advanced** for segmented networks where local discovery is intentionally blocked. See [Easy and secure pairing](docs/PAIRING.md). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ef6b949..9d751cd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@rampage/desktop", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 122a5f7..510524b 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rampage-desktop" -version = "0.3.0" +version = "0.3.1" description = "Rampage personal compute fabric" authors = ["ObtuseAI"] edition = "2024" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4e28804..11ea8c3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -7,7 +7,7 @@ use rand::{TryRng as _, rngs::SysRng}; use std::{ io::{Read, Write}, net::SocketAddr, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; @@ -54,6 +54,19 @@ struct RemoteAssistStatusView { expires_at: Option, } +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct RecoveryStatusView { + schema: &'static str, + version: &'static str, + role: &'static str, + state: &'static str, + healthy: bool, + issues: Vec, + can_leave_fabric: bool, + can_factory_reset: bool, +} + #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] struct RemoteAssistPolicyDisk { @@ -88,8 +101,11 @@ impl Default for WorkerRuntime { const BACKGROUND_ARG: &str = "--background"; const AUTOSTART_NAME: &str = "Rampage"; const OWNER_FABRIC_MARKER: &str = "owner-fabric-v1.ready"; +const SETUP_REQUIRED_MARKER: &str = "setup-required-v1.ready"; const REMOTE_ASSIST_POLICY_FILE: &str = "remote-assist-policy.json"; const REMOTE_ASSIST_ACTIVE_FILE: &str = "remote-assist-active.json"; +const LEAVE_FABRIC_CONFIRMATION: &str = "LEAVE FABRIC"; +const FACTORY_RESET_CONFIRMATION: &str = "RESET RAMPAGE"; #[cfg(target_os = "windows")] const AUTOSTART_RUN_KEY: &str = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run"; #[cfg(target_os = "windows")] @@ -181,6 +197,22 @@ fn kill_process_tree(child: CommandChild) { let _ = child.kill(); } +fn stop_all_sidecars(app: &AppHandle) -> Result { + let children = { + let state = app.state::(); + let mut sidecars = state + .0 + .lock() + .map_err(|_| "sidecar state lock poisoned".to_string())?; + sidecars.drain(..).collect::>() + }; + let count = children.len(); + for child in children { + kill_process_tree(child); + } + Ok(count) +} + fn runtime_dir(app: &AppHandle) -> Result { if let Some(path) = std::env::var_os("RAMPAGE_DATA_DIR") { return Ok(PathBuf::from(path)); @@ -206,6 +238,188 @@ fn controller_origin() -> Result { Ok(format!("http://{}", controller_bind()?)) } +fn setup_required(data_dir: &Path) -> bool { + data_dir.join(SETUP_REQUIRED_MARKER).is_file() +} + +fn fabric_role_at(data_dir: &Path) -> &'static str { + if setup_required(data_dir) { + "setup" + } else if worker_enrollment_exists(data_dir) { + "worker" + } else { + "owner" + } +} + +fn enrollment_file_state(data_dir: &Path) -> (&'static str, Vec) { + let setup = setup_required(data_dir); + let owner = data_dir.join(OWNER_FABRIC_MARKER).is_file(); + let invite = data_dir.join("remote-invite.json").is_file(); + let pin = data_dir.join("agent.controller-pin.json").is_file(); + let enrolled = data_dir.join("agent.enrolled").is_file(); + let identity = data_dir.join("agent.identity.json").is_file(); + let key = data_dir.join("agent.key").is_file(); + let mut issues = Vec::new(); + + if setup { + if owner || invite || pin || enrolled || identity || key { + issues.push( + "Setup mode still contains fabric credentials; run Reset Rampage again.".into(), + ); + return ("cleanup_required", issues); + } + return ("ready_to_configure", issues); + } + if owner { + if invite || pin { + issues.push("Owner and worker enrollment markers conflict on this device.".into()); + return ("repair_required", issues); + } + return ("owner_active", issues); + } + if pin { + if !enrolled || !identity || !key { + issues.push( + "The paired-worker identity is incomplete or was interrupted during an update." + .into(), + ); + return ("repair_required", issues); + } + return ("worker_paired", issues); + } + if invite { + return ("enrollment_pending", issues); + } + if enrolled || identity || key { + issues.push("Local worker identity exists without a pinned owner.".into()); + return ("repair_required", issues); + } + ("owner_starting", issues) +} + +fn recovery_status_at(data_dir: &Path) -> RecoveryStatusView { + let role = fabric_role_at(data_dir); + let (state, issues) = enrollment_file_state(data_dir); + RecoveryStatusView { + schema: "rampage.recovery-status.v1", + version: env!("CARGO_PKG_VERSION"), + role, + state, + healthy: issues.is_empty(), + issues, + can_leave_fabric: role == "worker", + can_factory_reset: true, + } +} + +fn validate_runtime_reset_target(data_dir: &Path) -> Result<(), String> { + if !data_dir.is_absolute() + || data_dir.file_name().and_then(|value| value.to_str()) != Some("runtime") + || data_dir + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .is_none_or(|value| !value.eq_ignore_ascii_case("ai.obtuse.rampage")) + { + return Err("refusing to reset a path outside the Rampage runtime directory".into()); + } + if data_dir.exists() { + let metadata = std::fs::symlink_metadata(data_dir).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("Rampage runtime path is not a regular directory".into()); + } + } + Ok(()) +} + +fn remove_runtime_entry(path: &Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || metadata.is_file() { + std::fs::remove_file(path).map_err(|error| error.to_string()) + } else if metadata.is_dir() { + std::fs::remove_dir_all(path).map_err(|error| error.to_string()) + } else { + Err(format!( + "refusing to reset unsupported runtime entry {}", + path.display() + )) + } +} + +fn reset_runtime_to_setup(data_dir: &Path) -> Result<(), String> { + validate_runtime_reset_target(data_dir)?; + std::fs::create_dir_all(data_dir).map_err(|error| error.to_string())?; + for entry in std::fs::read_dir(data_dir).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + remove_runtime_entry(&entry.path())?; + } + write_new_marker( + &data_dir.join(SETUP_REQUIRED_MARKER), + b"rampage.setup-required.v1\n", + ) +} + +#[tauri::command] +fn recovery_status(app: AppHandle) -> Result { + Ok(recovery_status_at(&runtime_dir(&app)?)) +} + +#[tauri::command] +fn repair_connection(app: AppHandle) -> Result<(), String> { + let data_dir = runtime_dir(&app)?; + let (_, issues) = enrollment_file_state(&data_dir); + if !issues.is_empty() { + return Err("Enrollment files are inconsistent; use Leave fabric or Reset Rampage.".into()); + } + app.restart() +} + +#[tauri::command] +fn leave_fabric(app: AppHandle, confirmation: String) -> Result<(), String> { + if confirmation != LEAVE_FABRIC_CONFIRMATION { + return Err("exact LEAVE FABRIC confirmation is required".into()); + } + let data_dir = runtime_dir(&app)?; + if fabric_role_at(&data_dir) != "worker" { + return Err("Leave fabric is available only on a paired worker".into()); + } + stop_all_sidecars(&app)?; + reset_runtime_to_setup(&data_dir)?; + app.restart() +} + +#[tauri::command] +fn factory_reset(app: AppHandle, confirmation: String) -> Result<(), String> { + if confirmation != FACTORY_RESET_CONFIRMATION { + return Err("exact RESET RAMPAGE confirmation is required".into()); + } + let data_dir = runtime_dir(&app)?; + stop_all_sidecars(&app)?; + platform_set_autostart(&app, false)?; + reset_runtime_to_setup(&data_dir)?; + app.restart() +} + +#[tauri::command] +fn activate_owner_fabric(app: AppHandle) -> Result<(), String> { + let data_dir = runtime_dir(&app)?; + if worker_enrollment_exists(&data_dir) { + return Err("this device is still paired as a worker".into()); + } + let setup_marker = data_dir.join(SETUP_REQUIRED_MARKER); + if setup_marker.is_file() { + let owner_marker = data_dir.join(OWNER_FABRIC_MARKER); + write_new_marker(&owner_marker, b"rampage.owner-fabric.v1\n")?; + if let Err(error) = std::fs::remove_file(setup_marker) { + let _ = std::fs::remove_file(owner_marker); + return Err(format!("could not activate the owner fabric: {error}")); + } + app.restart(); + } + Ok(()) +} + #[tauri::command] fn local_stop(app: AppHandle) -> Result<(), String> { let data_dir = runtime_dir(&app)?; @@ -258,11 +472,7 @@ fn propagate_controller_stop(data_dir: PathBuf) { #[tauri::command] fn fabric_mode(app: AppHandle) -> Result<&'static str, String> { - Ok(if worker_enrollment_exists(&runtime_dir(&app)?) { - "worker" - } else { - "owner" - }) + Ok(fabric_role_at(&runtime_dir(&app)?)) } fn remote_assist_enabled_at(data_dir: &std::path::Path) -> bool { @@ -584,6 +794,15 @@ fn persist_remote_invite(app: &AppHandle, invitation: &str) -> Result<(), String let _ = std::fs::remove_file(&temporary); } write_result?; + let setup_marker = data_dir.join(SETUP_REQUIRED_MARKER); + if setup_marker.is_file() + && let Err(error) = std::fs::remove_file(&setup_marker) + { + let _ = std::fs::remove_file(&destination); + return Err(format!( + "could not leave setup mode after enrollment: {error}" + )); + } Ok(()) } @@ -816,7 +1035,8 @@ async fn run_fabric_benchmark(app: AppHandle) -> Result bool { - !data_dir.join(OWNER_FABRIC_MARKER).is_file() + !data_dir.join(SETUP_REQUIRED_MARKER).is_file() + && !data_dir.join(OWNER_FABRIC_MARKER).is_file() && (data_dir.join("remote-invite.json").is_file() || data_dir.join("agent.controller-pin.json").is_file()) } @@ -908,6 +1128,9 @@ fn configured_private_relay(data_dir: &std::path::Path) -> Result fn launch_fabric(app: &AppHandle) -> Result<(), String> { let data_dir = runtime_dir(app)?; std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?; + if setup_required(&data_dir) { + return Ok(()); + } if worker_enrollment_exists(&data_dir) { return launch_remote_worker(app, &data_dir); } @@ -1120,7 +1343,10 @@ fn schedule_remote_assist_indicator( } fn install_desktop_lifecycle(app: &tauri::App) -> Result<(), Box> { - let role = if worker_enrollment_exists(&runtime_dir(app.handle())?) { + let data_dir = runtime_dir(app.handle())?; + let role = if setup_required(&data_dir) { + "Setup required" + } else if worker_enrollment_exists(&data_dir) { "Worker active" } else { "Owner fabric active" @@ -1234,11 +1460,14 @@ pub fn run() { .manage(LocalAiRuntime::default()) .manage(PairingManager::default()) .setup(|app| { + let setup_required = setup_required(&runtime_dir(app.handle())?); launch_fabric(app.handle()).map_err(std::io::Error::other)?; - local_ai::schedule( - app.state::().inner().clone(), - diagnostic_instance_is_bounded(), - ); + if !setup_required { + local_ai::schedule( + app.state::().inner().clone(), + diagnostic_instance_is_bounded(), + ); + } install_desktop_lifecycle(app)?; schedule_diagnostic_exit(app.handle()); Ok(()) @@ -1260,6 +1489,11 @@ pub fn run() { reject_pairing, autostart_enabled, set_autostart, + recovery_status, + repair_connection, + leave_fabric, + factory_reset, + activate_owner_fabric, remote_assist_status, set_remote_assist_enabled ]) @@ -1373,4 +1607,51 @@ mod tests { .unwrap(); assert!(!worker_enrollment_exists(temp.path())); } + + fn recovery_runtime() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("ai.obtuse.rampage/runtime")).unwrap(); + root + } + + #[test] + fn recovery_detects_incomplete_worker_identity() { + let root = recovery_runtime(); + let runtime = root.path().join("ai.obtuse.rampage/runtime"); + std::fs::write(runtime.join("agent.controller-pin.json"), b"pinned").unwrap(); + let status = recovery_status_at(&runtime); + assert_eq!(status.role, "worker"); + assert_eq!(status.state, "repair_required"); + assert!(!status.healthy); + assert!(status.can_leave_fabric); + } + + #[test] + fn reset_rotates_to_clean_setup_without_following_unrelated_paths() { + let root = recovery_runtime(); + let runtime = root.path().join("ai.obtuse.rampage/runtime"); + std::fs::write(runtime.join("agent.key"), b"secret").unwrap(); + std::fs::write(runtime.join("agent.controller-pin.json"), b"pinned").unwrap(); + std::fs::create_dir(runtime.join("cas")).unwrap(); + std::fs::write(runtime.join("cas/object"), b"encrypted").unwrap(); + reset_runtime_to_setup(&runtime).unwrap(); + assert_eq!( + std::fs::read_to_string(runtime.join(SETUP_REQUIRED_MARKER)).unwrap(), + "rampage.setup-required.v1\n" + ); + assert_eq!(std::fs::read_dir(&runtime).unwrap().count(), 1); + let status = recovery_status_at(&runtime); + assert_eq!(status.role, "setup"); + assert_eq!(status.state, "ready_to_configure"); + assert!(status.healthy); + } + + #[test] + fn reset_refuses_broad_or_renamed_targets() { + let root = tempfile::tempdir().unwrap(); + assert!(reset_runtime_to_setup(root.path()).is_err()); + let wrong = root.path().join("not-rampage/runtime"); + std::fs::create_dir_all(&wrong).unwrap(); + assert!(reset_runtime_to_setup(&wrong).is_err()); + } } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a97f880..acbb6c4 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Rampage", - "version": "0.3.0", + "version": "0.3.1", "identifier": "ai.obtuse.rampage", "build": { "beforeDevCommand": "pnpm dev", diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index b70cfa2..fe4af0f 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -63,10 +63,10 @@ test("local stop does not depend on controller", () => { expect(useRampage.getState().capability).toBe("read_only"); }); -test("separates maximum model size from measured speed boost", () => { +test("separates the biggest AI outcome from the fastest AI outcome", () => { render(); - expect(screen.getByRole("button", { name: /maximum model/i })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: /speed boost/i })); + expect(screen.getByRole("button", { name: /biggest ai/i })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: /fastest ai/i })); expect(useRampage.getState().computeStrategy).toBe("speed_boost"); expect(screen.getByText(/use tensor peers only when measured links predict faster tokens/i)).toBeInTheDocument(); expect(localStorage.getItem("rampage.compute-strategy")).toBe("speed_boost"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index baf7186..fb1cbeb 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { Activity, Boxes, BrainCircuit, CircleStop, Command, Eye, Grid2X2, MonitorUp, MousePointer2, Orbit, Play, RefreshCw, Rocket, ShieldCheck, UserPlus } from "lucide-react"; +import { Activity, Boxes, BrainCircuit, CircleStop, Command, Eye, Grid2X2, MonitorUp, MousePointer2, Orbit, Play, RefreshCw, Rocket, ShieldCheck, UserPlus, Wrench } from "lucide-react"; import { lazy, Suspense, useEffect } from "react"; import { CommandPalette } from "./components/CommandPalette"; import { ComputeStrategyPanel } from "./components/ComputeStrategyPanel"; @@ -7,6 +7,7 @@ import { PairingPanel } from "./components/PairingPanel"; import { Onboarding } from "./components/Onboarding"; import { OpsGrid } from "./components/OpsGrid"; import { RemoteAssistPanel } from "./components/RemoteAssistPanel"; +import { RecoveryCenter } from "./components/RecoveryCenter"; import { useRampage } from "./store"; const Arena = lazy(() => import("./components/Arena").then((module) => ({ default: module.Arena }))); @@ -37,11 +38,13 @@ export default function App() { {state.onboarding && } +
R
RAMPAGEPERSONAL COMPUTE FABRIC
{state.killLatch ? "OWNER STOPPED" : state.remoteAssistStatus.active ? "REMOTE CONTROL ACTIVE" : state.fabricRole === "worker" ? state.workerRuntime.state === "active" ? "WORKER ACTIVE" : state.workerRuntime.state === "starting" ? "WORKER CONNECTING" : "WORKER ATTENTION" : state.connected ? "FABRIC LIVE" : "LOCAL REDUCED"}{state.nodes.length} nodes{state.meshMode.replace("_", " ")}{state.diagnostic ? `self-scan ${state.diagnostic.health_score}/100` : state.capability.replaceAll("_", " ")}
+ {state.fabricRole === "owner" && } diff --git a/apps/desktop/src/RecoveryCenter.test.tsx b/apps/desktop/src/RecoveryCenter.test.tsx new file mode 100644 index 0000000..f27d919 --- /dev/null +++ b/apps/desktop/src/RecoveryCenter.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { RecoveryCenter } from "./components/RecoveryCenter"; +import { type RecoveryStatus, useRampage } from "./store"; + +const workerStatus: RecoveryStatus = { + schema: "rampage.recovery-status.v1", + version: "0.3.1", + role: "worker", + state: "enrolled_worker", + healthy: true, + issues: [], + canLeaveFabric: true, + canFactoryReset: true, + nodes: [], +}; + +afterEach(cleanup); + +beforeEach(() => { + useRampage.setState({ + recoveryOpen: true, + recoveryStatus: workerStatus, + repairConnection: vi.fn().mockResolvedValue(undefined), + leaveFabric: vi.fn().mockResolvedValue(undefined), + factoryReset: vi.fn().mockResolvedValue(undefined), + forgetNode: vi.fn().mockResolvedValue(undefined), + refreshRecovery: vi.fn().mockResolvedValue(undefined), + }); +}); + +test("lets a worker repair or return to pairing without copying an identity", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /fix rampage/i })); + expect(useRampage.getState().repairConnection).toHaveBeenCalledOnce(); + + fireEvent.click(screen.getByRole("button", { name: /pair again/i })); + expect(screen.getByRole("heading", { name: /start pairing over/i })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: /leave fabric/i })); + + await waitFor(() => expect(useRampage.getState().leaveFabric).toHaveBeenCalledWith("LEAVE FABRIC")); +}); + +test("keeps enrolled-machine revocation in advanced recovery with a clear second confirmation", async () => { + const nodeId = "0198f1aa-9f18-7dc3-81a3-d78f22efb662"; + useRampage.setState({ + recoveryStatus: { + ...workerStatus, + role: "owner", + state: "owner_fabric", + canLeaveFabric: false, + nodes: [{ + nodeId, + displayName: "Studio Laptop", + platform: "windows-x86_64", + deviceKind: "desktop", + live: false, + local: false, + }], + }, + }); + render(); + + fireEvent.click(screen.getByText(/advanced recovery and enrolled devices/i)); + fireEvent.click(screen.getByRole("button", { name: /forget/i })); + expect(screen.getByRole("heading", { name: /forget studio laptop/i })).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: /forget machine/i })); + + await waitFor(() => expect(useRampage.getState().forgetNode).toHaveBeenCalledWith(nodeId, `FORGET ${nodeId}`)); +}); + +test("requires the full factory-reset phrase before destructive reset is enabled", () => { + render(); + + fireEvent.click(screen.getByText(/advanced recovery and enrolled devices/i)); + fireEvent.click(screen.getByRole("button", { name: /factory reset/i })); + const reset = screen.getByRole("button", { name: /reset rampage/i }); + expect(reset).toBeDisabled(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "RESET RAMPAGE" } }); + expect(reset).toBeEnabled(); +}); diff --git a/apps/desktop/src/components/ComputeStrategyPanel.tsx b/apps/desktop/src/components/ComputeStrategyPanel.tsx index a7e4db4..48ed772 100644 --- a/apps/desktop/src/components/ComputeStrategyPanel.tsx +++ b/apps/desktop/src/components/ComputeStrategyPanel.tsx @@ -1,4 +1,4 @@ -import { Activity, BatteryCharging, Bot, Gauge, Layers3, Network, Sparkles, Zap } from "lucide-react"; +import { Activity, Bot, Gamepad2, Gauge, Layers3, Network, Sparkles, Zap } from "lucide-react"; import { useRampage } from "../store"; import type { ComputeStrategy } from "../types"; @@ -9,40 +9,40 @@ const strategies: Array<{ description: string; icon: typeof Layers3; }> = [ + { + id: "autonomous_balanced", + label: "Automatic", + short: "RAMPAGE CHOOSES", + description: "Measure every device and path, then continuously choose the safest high-value role without technical setup.", + icon: Sparkles, + }, { id: "maximum_model_size", - label: "Maximum Model", - short: "BIGGEST LLM", + label: "Biggest AI", + short: "BIGGEST AI", description: "Combine only compatible, qualified model memory to fit the largest possible local model.", icon: Layers3, }, { id: "speed_boost", - label: "Speed Boost", - short: "FASTEST CHAT", + label: "Fastest AI", + short: "FASTEST AI", description: "Use tensor peers only when measured links predict faster tokens; otherwise select the fastest whole-model node.", icon: Zap, }, { id: "maximum_throughput", - label: "Throughput", - short: "MOST REQUESTS", + label: "More Work", + short: "MOST WORK", description: "Replicate the model across capable nodes for more simultaneous requests and agent work.", icon: Network, }, { id: "efficiency", - label: "Efficiency", - short: "LESS ENERGY", - description: "Choose the smallest qualified placement that fits while preserving owner reserves.", - icon: BatteryCharging, - }, - { - id: "autonomous_balanced", - label: "Autonomous", - short: "EVIDENCE ADAPTS", - description: "Let proposal-only intelligence recommend a strategy; the Governor still enforces every promotion gate.", - icon: Sparkles, + label: "Protect This PC", + short: "GAMING-SAFE", + description: "Keep foreground CPU, memory, GPU, and network reserves on this PC while moving eligible background work elsewhere.", + icon: Gamepad2, }, ]; @@ -76,7 +76,7 @@ export function ComputeStrategyPanel() { > {strategy.label} - {strategy.id === "maximum_model_size" && FOCUS} + {strategy.id === "autonomous_balanced" && RECOMMENDED} ); })} @@ -86,26 +86,29 @@ export function ComputeStrategyPanel() {

{selected.description}

{plan?.reason ?? "Connect the controller to calculate a signed-resource placement preview."}
-
- - - - -
+
+ Advanced model target +
+ + + + +
+
Requested{formatGiB((state.targetModelGiB + state.kvCacheGiB) * gib)}
Visible memory{plan ? formatGiB(plan.observed_fabric_bytes) : "—"}
diff --git a/apps/desktop/src/components/Onboarding.tsx b/apps/desktop/src/components/Onboarding.tsx index 89ecf52..7d45d3e 100644 --- a/apps/desktop/src/components/Onboarding.tsx +++ b/apps/desktop/src/components/Onboarding.tsx @@ -13,6 +13,7 @@ export function Onboarding() { const joinFabric = useRampage((state) => state.joinFabric); const beginPairing = useRampage((state) => state.beginPairing); const cancelPairing = useRampage((state) => state.cancelPairing); + const openRecovery = useRampage((state) => state.setRecoveryOpen); const pairing = useRampage((state) => state.workerPairing); const [step, setStep] = useState(0); const [choice, setChoice] = useState<"owner" | "worker">("owner"); @@ -66,12 +67,13 @@ export function Onboarding() { } return; } - step < steps.length - 1 ? setStep(step + 1) : finish(); + step < steps.length - 1 ? setStep(step + 1) : void finish().catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Could not start this fabric.")); }}> {step === 0 && choice === "worker" ? pairing.state === "idle" || pairing.state === "failed" ? "Find my fabric" : pairing.state === "approved" ? "Finishing securely…" : "Waiting for main PC…" : step < steps.length - 1 ? "Continue" : "Enter Rampage"} +
); diff --git a/apps/desktop/src/components/RecoveryCenter.tsx b/apps/desktop/src/components/RecoveryCenter.tsx new file mode 100644 index 0000000..234c238 --- /dev/null +++ b/apps/desktop/src/components/RecoveryCenter.tsx @@ -0,0 +1,97 @@ +import { AlertTriangle, Check, Clipboard, HardDrive, RefreshCw, RotateCcw, ShieldX, Trash2, Unplug, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useRampage } from "../store"; + +type DestructiveAction = + | { kind: "leave"; phrase: "LEAVE FABRIC"; requiresTyping: false; title: string; detail: string } + | { kind: "reset"; phrase: "RESET RAMPAGE"; requiresTyping: true; title: string; detail: string } + | { kind: "forget"; phrase: string; requiresTyping: false; title: string; detail: string; nodeId: string }; + +export function RecoveryCenter() { + const open = useRampage((state) => state.recoveryOpen); + const status = useRampage((state) => state.recoveryStatus); + const setOpen = useRampage((state) => state.setRecoveryOpen); + const refresh = useRampage((state) => state.refreshRecovery); + const repair = useRampage((state) => state.repairConnection); + const leave = useRampage((state) => state.leaveFabric); + const reset = useRampage((state) => state.factoryReset); + const forget = useRampage((state) => state.forgetNode); + const [action, setAction] = useState(null); + const [confirmation, setConfirmation] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const canConfirm = action !== null && (!action.requiresTyping || confirmation === action.phrase); + const diagnostics = useMemo(() => status ? JSON.stringify(status, null, 2) : "", [status]); + + if (!open) return null; + const begin = (next: DestructiveAction) => { + setAction(next); + setConfirmation(""); + setError(null); + }; + const execute = async () => { + if (!action || (action.requiresTyping && confirmation !== action.phrase)) return; + setPending(true); + setError(null); + try { + if (action.kind === "leave") await leave(action.phrase); + if (action.kind === "reset") await reset(confirmation); + if (action.kind === "forget") { + await forget(action.nodeId, action.phrase); + setAction(null); + setConfirmation(""); + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Recovery action failed."); + } finally { + setPending(false); + } + }; + + return
+
+
+

RECOVERY CENTER

Repair, leave, or start clean.

+ +
+ {!status ?
Inspecting local identity, sidecars, and enrollment…
: <> +
+ {status.healthy ? : } +
{status.healthy ? "Local lifecycle is consistent" : "Recovery attention required"}Rampage {status.version} · {status.role} · {status.state.replaceAll("_", " ")}
+ +
+ {status.issues.length > 0 &&
    {status.issues.map((issue) =>
  • {issue}
  • )}
} + +
+ + {status.canLeaveFabric && } +
+ +
+ Advanced recovery and enrolled devices + {status.role === "owner" &&
+
Enrolled machinesForget stale devices so old identities cannot reconnect.
+ {status.nodes.map((node) =>
+ +
{node.displayName}{node.platform} · {node.live ? "live signed offer" : "offline"}
+ {node.local ? OWNER LOCAL : } +
)} +
} +
+
Factory reset

Erase this device’s Rampage runtime, identities, local evidence, encrypted caches, and auto-start setting. Installed models outside Rampage are untouched.

+
+ +
+ } + {action &&
+ +

{action.title}

+

{action.detail}

+ {action.requiresTyping && } + {error &&

{error}

} +
+
} + {!action && error &&

{error}

} +
+
; +} diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index d96e3b5..6b6f659 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -17,7 +17,7 @@ const computeStrategies: ComputeStrategy[] = [ function storedComputeStrategy(): ComputeStrategy { const stored = localStorage.getItem("rampage.compute-strategy") as ComputeStrategy | null; - return stored && computeStrategies.includes(stored) ? stored : "maximum_model_size"; + return stored && computeStrategies.includes(stored) ? stored : "autonomous_balanced"; } function controllerHeaders(json = false): HeadersInit { @@ -97,6 +97,27 @@ export interface FabricBenchmarkResult { all_results_signed: true; } +export interface RecoveryNode { + nodeId: string; + displayName: string; + platform: string; + deviceKind: string; + live: boolean; + local: boolean; +} + +export interface RecoveryStatus { + schema: "rampage.recovery-status.v1"; + version: string; + role: "owner" | "worker" | "setup"; + state: string; + healthy: boolean; + issues: string[]; + canLeaveFabric: boolean; + canFactoryReset: boolean; + nodes: RecoveryNode[]; +} + const initialLocalAiRuntime: LocalAiRuntime = { state: "detecting", modelId: "qwen3:4b", @@ -130,6 +151,20 @@ const demoDiagnostic: FabricDiagnosticReport = { evidence: "Fresh signed offers show useful idle capacity inside every owner reserve.", }], }; +const demoRecoveryStatus: RecoveryStatus = { + schema: "rampage.recovery-status.v1", + version: "0.3.1", + role: "owner", + state: "owner_active", + healthy: true, + issues: [], + canLeaveFabric: false, + canFactoryReset: true, + nodes: [ + { nodeId: "0198f1aa-9f18-7dc3-81a3-d78f22efb660", displayName: "This Device", platform: "windows-x86_64", deviceKind: "desktop", live: true, local: true }, + { nodeId: "0198f1aa-9f18-7dc3-81a3-d78f22efb662", displayName: "Studio Laptop", platform: "windows-x86_64", deviceKind: "laptop", live: false, local: false }, + ], +}; interface RampageState { mode: "arena" | "grid"; @@ -146,7 +181,7 @@ interface RampageState { meshEndpointId: string | null; inviteCode: string | null; inviteBundle: string | null; - fabricRole: "owner" | "worker"; + fabricRole: "owner" | "worker" | "setup"; lastAction: string | null; runAtLogin: boolean; killLatch: boolean; @@ -169,6 +204,8 @@ interface RampageState { remoteDesktopFrame: RemoteDesktopFramePayload | null; remoteDesktopPending: boolean; remoteDesktopInputSequence: number; + recoveryOpen: boolean; + recoveryStatus: RecoveryStatus | null; setMode: (mode: "arena" | "grid") => void; setSelectedNode: (id: string) => void; setCommandOpen: (open: boolean) => void; @@ -179,7 +216,7 @@ interface RampageState { setKvCacheGiB: (gib: number) => void; planModelSession: () => Promise; copyGatewayConfig: () => Promise; - finishOnboarding: () => void; + finishOnboarding: () => Promise; refresh: () => Promise; createInvite: () => Promise; joinFabric: (invitation: string) => Promise; @@ -202,6 +239,12 @@ interface RampageState { refreshRemoteDesktopFrame: () => Promise; sendRemoteDesktopInput: (events: RemoteInputEvent[]) => Promise; closeRemoteDesktop: () => Promise; + setRecoveryOpen: (open: boolean) => void; + refreshRecovery: () => Promise; + repairConnection: () => Promise; + leaveFabric: (confirmation: string) => Promise; + factoryReset: (confirmation: string) => Promise; + forgetNode: (nodeId: string, confirmation: string) => Promise; } function offersToNodes(offers: ResourceOffer[]): FabricNode[] { @@ -295,6 +338,8 @@ export const useRampage = create((set, get) => ({ remoteDesktopFrame: null, remoteDesktopPending: false, remoteDesktopInputSequence: 0, + recoveryOpen: false, + recoveryStatus: null, setMode: (mode) => set({ mode }), setSelectedNode: (selectedNode) => set({ selectedNode }), setCommandOpen: (commandOpen) => set({ commandOpen }), @@ -367,18 +412,45 @@ export const useRampage = create((set, get) => ({ lastAction: "Universal AI gateway settings copied. Treat the shared local API key as a secret.", }); }, - finishOnboarding: () => { + finishOnboarding: async () => { localStorage.setItem("rampage.onboarded", "true"); - set({ onboarding: false }); + try { + await invoke("activate_owner_fabric"); + set({ onboarding: false, fabricRole: "owner" }); + } catch (error) { + localStorage.removeItem("rampage.onboarded"); + throw error; + } }, refresh: async () => { try { const [fabricRole, runAtLogin, localAiRuntime, remoteAssistStatus] = await Promise.all([ - invoke<"owner" | "worker">("fabric_mode").catch(() => "owner" as const), + invoke<"owner" | "worker" | "setup">("fabric_mode").catch(() => "owner" as const), invoke("autostart_enabled").catch(() => get().runAtLogin), invoke("local_ai_runtime_status").catch(() => get().localAiRuntime), invoke("remote_assist_status").catch(() => get().remoteAssistStatus), ]); + if (fabricRole === "setup") { + localStorage.removeItem("rampage.onboarded"); + set({ + onboarding: true, + fabricRole, + connected: false, + capability: "blocked", + nodes: [], + selectedNode: "", + workerRuntime: { state: "inactive", nodeId: null, message: null }, + localAiRuntime, + remoteAssistStatus, + runAtLogin, + killLatch: false, + gatewayModels: [], + diagnostic: null, + lastAction: "This device is clean and ready to create or join a fabric.", + lastSync: new Date(), + }); + return; + } if (fabricRole === "worker") { const workerRuntime = await invoke("worker_runtime_status").catch(() => ({ state: "failed" as const, @@ -835,6 +907,103 @@ export const useRampage = create((set, get) => ({ set({ lastAction: "Remote Assist viewer closed. The worker lease will expire within 30 seconds." }); } }, + setRecoveryOpen: (recoveryOpen) => { + set({ recoveryOpen }); + if (recoveryOpen) void get().refreshRecovery(); + }, + refreshRecovery: async () => { + let native: Omit; + try { + native = await invoke>("recovery_status"); + } catch (error) { + if (import.meta.env.DEV) { + set({ recoveryStatus: demoRecoveryStatus }); + return; + } + const role = get().fabricRole; + set({ + recoveryStatus: { + schema: "rampage.recovery-status.v1", + version: "unavailable", + role, + state: "status_unavailable", + healthy: false, + issues: [error instanceof Error ? error.message : "The native recovery bridge did not respond."], + canLeaveFabric: role === "worker", + canFactoryReset: true, + nodes: [], + }, + }); + return; + } + let nodes: RecoveryNode[] = []; + if (native.role === "owner") { + try { + localControllerToken ??= await invoke("controller_token"); + const [nodesResponse, offersResponse] = await Promise.all([ + fetch(`${controller}/v1/nodes`, { headers: controllerHeaders() }), + fetch(`${controller}/v1/offers`, { headers: controllerHeaders() }), + ]); + if (nodesResponse.ok && offersResponse.ok) { + const enrolled = await nodesResponse.json() as Array<{ + node_id: string; + display_name: string; + platform: string; + device_kind: string; + }>; + const offers = await offersResponse.json() as ResourceOffer[]; + const liveIds = new Set(offers.map((offer) => offer.node_id)); + nodes = enrolled.map((node) => ({ + nodeId: node.node_id, + displayName: node.display_name, + platform: node.platform, + deviceKind: node.device_kind, + live: liveIds.has(node.node_id), + local: node.display_name === "This Device", + })); + } + } catch { + // Native recovery remains available even if the owner controller is unhealthy. + } + } + set({ recoveryStatus: { ...native, nodes } }); + }, + repairConnection: async () => { + set({ lastAction: "Restarting Rampage and rebuilding the local connection path…" }); + await invoke("repair_connection"); + }, + leaveFabric: async (confirmation) => { + const prior = localStorage.getItem("rampage.onboarded"); + localStorage.removeItem("rampage.onboarded"); + try { + await invoke("leave_fabric", { confirmation }); + } catch (error) { + if (prior !== null) localStorage.setItem("rampage.onboarded", prior); + throw error; + } + }, + factoryReset: async (confirmation) => { + const keys = ["rampage.onboarded", "rampage.compute-strategy", "rampage.target-model", "rampage.target-model-gib", "rampage.kv-cache-gib"]; + const prior = new Map(keys.map((key) => [key, localStorage.getItem(key)])); + keys.forEach((key) => localStorage.removeItem(key)); + try { + await invoke("factory_reset", { confirmation }); + } catch (error) { + prior.forEach((value, key) => { if (value !== null) localStorage.setItem(key, value); }); + throw error; + } + }, + forgetNode: async (nodeId, confirmation) => { + localControllerToken ??= await invoke("controller_token"); + const response = await fetch(`${controller}/v1/nodes/${nodeId}/revoke`, { + method: "POST", + headers: controllerHeaders(true), + body: JSON.stringify({ confirmation }), + }); + if (!response.ok) throw new Error(await response.text()); + set({ lastAction: "Machine forgotten. Its offers, leases, sessions, and future access were revoked." }); + await Promise.all([get().refreshRecovery(), get().refresh()]); + }, toggleAutostart: async () => { const requested = !get().runAtLogin; try { diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 2886243..e292e53 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -32,3 +32,7 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 280px;min-height:0}.worksp .gateway-ready{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 13px 8px;padding:7px 8px;border:1px solid #2b3547;background:#0b1018;color:#748096;font-size:.57rem}.gateway-ready.online{border-color:#2d6654;background:#0d1d18;color:#a4bdb5}.gateway-ready span{display:flex;gap:8px;align-items:center}.gateway-ready strong{font:750 .52rem var(--mono);letter-spacing:.08em;color:#6e7d93}.gateway-ready.online strong{color:var(--mint)}.gateway-ready button{border:1px solid #385164;border-radius:5px;background:#101a24;color:#b8c5d7;padding:4px 8px;font:700 .52rem var(--mono);cursor:pointer}.gateway-ready button:disabled{opacity:.4;cursor:not-allowed}.gateway-ready.online button{border-color:#3f806b;color:var(--mint);background:#122a22} .local-ai-autopilot,.fabric-proof{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;margin:0 13px 8px;padding:7px 8px;border:1px solid #32405a;background:#0d131d;color:#8491a5;font-size:.57rem}.local-ai-autopilot>svg,.fabric-proof>svg{color:#8fa9cc}.local-ai-autopilot span,.fabric-proof span{min-width:0;display:flex;gap:8px;align-items:center}.local-ai-autopilot strong,.fabric-proof strong{flex:none;font:750 .52rem var(--mono);letter-spacing:.08em;color:#8fa9cc}.local-ai-autopilot small{font:700 .49rem var(--mono);letter-spacing:.05em;color:#687991}.local-ai-autopilot.ready{border-color:#2d6654;background:#0d1d18;color:#a4bdb5}.local-ai-autopilot.ready>svg,.local-ai-autopilot.ready strong{color:var(--mint)}.local-ai-autopilot.failed{border-color:#63333a;background:#1b1115;color:#c59ca1}.local-ai-autopilot.failed>svg,.local-ai-autopilot.failed strong{color:#ff9a9a}.local-ai-autopilot.installing,.local-ai-autopilot.pulling_model{border-color:#674c2b;background:#211911;color:#bfa580}.local-ai-autopilot.installing>svg,.local-ai-autopilot.pulling_model>svg,.local-ai-autopilot.installing strong,.local-ai-autopilot.pulling_model strong{color:#efb870}.fabric-proof button{border:1px solid #4a7088;border-radius:5px;background:#12202b;color:#b8d7e8;padding:4px 8px;font:700 .52rem var(--mono);cursor:pointer;white-space:nowrap}.fabric-proof button:disabled{opacity:.42;cursor:not-allowed}.fabric-proof.complete{border-color:#435b80;background:#0e1623;color:#aab9cf}.fabric-proof.complete>svg,.fabric-proof.complete strong{color:#9ccaff} .join-flow{display:grid;gap:10px}.join-state{position:relative;display:grid;justify-items:center;gap:7px;padding:18px;border:1px solid #2b3548;border-radius:11px;background:#0a0f17;color:#8794a8}.join-state>svg{color:#8a9ab1}.join-state strong{color:#e8effb;font-size:.8rem}.join-state span{font-size:.68rem;line-height:1.5}.join-state ul{margin:3px 0 0;padding-left:18px;text-align:left;font-size:.62rem;line-height:1.55}.join-state.active{border-color:#315849;background:#0d1b17}.join-state.active>svg,.join-state.success>svg{color:var(--mint)}.join-state.failed{border-color:#63333a;background:#1b1115}.join-state.failed>svg{color:#ff9a9a}.join-state output{font:800 2.25rem var(--mono);letter-spacing:.3em;color:var(--mint);padding-left:.3em;text-shadow:0 0 24px #76f7c555}.pairing-pulse{width:10px;height:10px;border-radius:50%;background:var(--mint);box-shadow:0 0 0 0 #76f7c566;animation:pairing-pulse 1.5s infinite}.cancel-join{justify-self:center;border:0;background:transparent;color:#8f9bad;text-decoration:underline;cursor:pointer;font-size:.66rem}.manual-invite{border-top:1px solid #252d3c;padding-top:9px;color:#78869a;font-size:.65rem}.manual-invite summary{cursor:pointer}.manual-invite textarea{margin-top:10px}.manual-invite button{margin-top:7px;border:1px solid #354157;border-radius:6px;background:#111925;color:#b8c4d5;padding:7px 10px;cursor:pointer}.pairing-panel{display:grid;gap:10px;margin-bottom:18px;padding-bottom:16px;border-bottom:1px solid var(--line)}.pairing-panel>header{display:flex;align-items:center;gap:9px;color:var(--mint)}.pairing-panel>header div{display:flex;flex-direction:column}.pairing-panel>header strong{font:750 .62rem var(--mono);letter-spacing:.08em}.pairing-panel>header span{font-size:.58rem;color:#7d899d;margin-top:2px}.pairing-searching{display:flex;gap:11px;align-items:center;padding:12px;border:1px solid #293649;border-radius:9px;background:#0d131d}.pairing-searching strong{font-size:.7rem}.pairing-searching p{margin:4px 0 0;color:#8491a5;font-size:.62rem;line-height:1.4}.pairing-searching small{display:block;margin-top:5px;color:#6f7e92;font-size:.56rem;line-height:1.4}.pairing-request{display:grid;gap:9px;padding:12px;border:1px solid #315849;border-radius:10px;background:#0d1b17}.pairing-device{display:flex;align-items:center;gap:8px}.pairing-device>svg{color:var(--mint)}.pairing-device div{display:flex;flex-direction:column}.pairing-device strong{font-size:.74rem}.pairing-device span{font-size:.57rem;color:#799187;text-transform:capitalize}.pairing-code{text-align:center;font:850 1.75rem var(--mono);letter-spacing:.28em;color:var(--mint);padding-left:.28em;text-shadow:0 0 20px #76f7c544}.pairing-request>p{display:flex;gap:6px;align-items:flex-start;margin:0;color:#8da39b;font-size:.59rem;line-height:1.45}.pairing-request>p svg{flex:none;color:var(--mint)}.pairing-actions{display:grid;grid-template-columns:.75fr 1.5fr;gap:6px}.pairing-actions button{display:flex;align-items:center;justify-content:center;gap:5px;border-radius:6px;padding:7px;border:1px solid #334052;background:#111925;color:#aab6c8;font-size:.58rem;cursor:pointer}.pairing-actions .pairing-approve{border-color:#3e806a;background:#153127;color:var(--mint)}.pairing-approved{display:flex;align-items:center;justify-content:center;gap:6px;color:var(--mint);font-size:.61rem}@keyframes pairing-pulse{70%{box-shadow:0 0 0 12px #76f7c500}100%{box-shadow:0 0 0 0 #76f7c500}} +.model-advanced{min-width:0;border:1px solid #273246;border-radius:7px;background:#0a0f17;padding:7px}.model-advanced>summary{cursor:pointer;color:#8f9db0;font:650 .58rem var(--mono);text-align:center;list-style-position:inside}.model-advanced[open]>summary{margin-bottom:7px;color:var(--mint)} +.onboarding-recovery{margin-top:12px;border:0;background:transparent;color:#8290a4;text-decoration:underline;cursor:pointer;font-size:.66rem} +.recovery-backdrop{position:fixed;inset:0;z-index:110;display:grid;place-items:center;padding:20px;background:#020408e8;backdrop-filter:blur(18px)}.recovery-center{position:relative;width:min(760px,calc(100vw - 40px));max-height:calc(100vh - 40px);overflow:auto;border:1px solid #344158;border-radius:16px;background:linear-gradient(145deg,#111925,#080c12);box-shadow:0 35px 130px #000;padding:20px}.recovery-center>header{display:flex;align-items:flex-start;justify-content:space-between;border-bottom:1px solid #283246;padding-bottom:14px}.recovery-center>header>div{display:flex;gap:11px;align-items:center}.recovery-center>header>div>svg{color:var(--mint)}.recovery-center>header .eyebrow{margin:0 0 4px}.recovery-center h1{margin:0;font-size:1.25rem}.recovery-center>header>button{width:32px;height:32px;display:grid;place-items:center;border:1px solid #344158;border-radius:7px;background:#111823;cursor:pointer}.recovery-loading{display:grid;justify-items:center;gap:10px;padding:42px;color:#8693a7;font-size:.72rem}.recovery-health{display:flex;align-items:center;gap:10px;margin:15px 0;padding:12px;border:1px solid #315849;border-radius:10px;background:#0d1b17}.recovery-health.attention{border-color:#674c2b;background:#211911}.recovery-health>svg{color:var(--mint);flex:none}.recovery-health.attention>svg{color:#efb870}.recovery-health>div{display:flex;flex-direction:column;gap:3px;flex:1}.recovery-health strong{font-size:.76rem}.recovery-health span{color:#8290a4;font-size:.62rem;text-transform:capitalize}.recovery-health button,.copy-diagnostics{display:flex;align-items:center;gap:5px;border:1px solid #3a4b63;border-radius:6px;background:#111a25;color:#b4c2d5;padding:7px 9px;cursor:pointer;font-size:.6rem}.recovery-issues{margin:0 0 14px;padding:10px 10px 10px 28px;border-left:2px solid #e2a85f;background:#1a150f;color:#d5b385;font-size:.67rem;line-height:1.5}.recovery-simple-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:12px 0}.recovery-simple-actions>button{display:flex;align-items:center;gap:11px;text-align:left;border:1px solid #344158;border-radius:11px;background:#111923;color:#dce6f4;padding:14px;cursor:pointer}.recovery-simple-actions>button.recovery-primary{border-color:#3e806a;background:#123027;color:var(--mint)}.recovery-simple-actions span{display:flex;flex-direction:column;gap:3px}.recovery-simple-actions strong{font-size:.77rem}.recovery-simple-actions small{color:#8290a4;font-size:.59rem}.recovery-advanced{margin-top:14px;border-top:1px solid #283246;padding-top:12px}.recovery-advanced>summary{cursor:pointer;color:#8491a5;font-size:.66rem}.recovery-advanced[open]>summary{color:#b7c5d8;margin-bottom:12px}.recovery-actions{display:grid;gap:8px;margin:10px 0}.recovery-actions article{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;padding:11px;border:1px solid #2d384b;border-radius:9px;background:#0c121b}.recovery-actions article>svg{color:#93a6c1}.recovery-actions strong{font-size:.7rem}.recovery-actions p{margin:3px 0 0;color:#7f8da2;font-size:.59rem;line-height:1.45}.recovery-actions button,.recovery-devices button{border:1px solid #43526a;border-radius:6px;background:#121b27;color:#b7c4d6;padding:7px 9px;cursor:pointer;font-size:.59rem}.recovery-actions button.danger,.recovery-confirm button.danger{border-color:#733b46;background:#28151b;color:#ff9fa9}.recovery-devices{display:grid;gap:7px}.recovery-devices>div{display:flex;align-items:center;gap:8px;margin-bottom:2px}.recovery-devices>div>svg{color:var(--mint)}.recovery-devices>div>div{display:flex;flex-direction:column}.recovery-devices>div strong{font-size:.7rem}.recovery-devices>div span{font-size:.58rem;color:#7d8a9e}.recovery-devices article{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:9px;padding:9px;border:1px solid #293448;border-radius:8px;background:#0b1119}.recovery-devices article>i{width:8px;height:8px;border-radius:50%;background:#646f80}.recovery-devices article>i.live{background:var(--mint);box-shadow:0 0 8px var(--mint)}.recovery-devices article>div{display:flex;flex-direction:column;gap:2px}.recovery-devices article strong{font-size:.67rem}.recovery-devices article span{color:#78869a;font-size:.56rem}.recovery-devices article small{color:#6f7c90;font:700 .5rem var(--mono)}.copy-diagnostics{margin-top:10px}.recovery-confirm{position:absolute;inset:0;z-index:2;display:grid;align-content:center;justify-items:center;text-align:center;padding:44px;background:#090d14f5;border-radius:16px}.recovery-confirm>svg{color:#ff9fa9}.recovery-confirm h2{margin:11px 0 5px}.recovery-confirm>p{max-width:520px;color:#8f9caf;font-size:.7rem;line-height:1.55}.recovery-confirm label{width:min(500px,100%);display:grid;gap:7px;margin:13px 0;color:#9aa7b9;font-size:.65rem}.recovery-confirm label strong{color:#ffabb4;font-family:var(--mono)}.recovery-confirm input{border:1px solid #583843;border-radius:7px;background:#100b0e;color:#fff;padding:10px;text-align:center;font-family:var(--mono)}.recovery-confirm>div{display:flex;gap:8px}.recovery-confirm button{border:1px solid #39475d;border-radius:7px;background:#121a25;padding:8px 13px;cursor:pointer}.recovery-confirm button:disabled{opacity:.45;cursor:not-allowed}.recovery-error{color:#ff9a9a;font-size:.66rem}.spin{animation:pairing-pulse 1.5s infinite} +.recovery-center{width:min(840px,calc(100vw - 40px));padding:24px}.recovery-center h1{font-size:1.45rem}.recovery-health strong,.recovery-simple-actions strong{font-size:.9rem}.recovery-health span,.recovery-simple-actions small,.recovery-issues,.recovery-advanced>summary{font-size:.75rem}.recovery-health button,.copy-diagnostics,.recovery-actions button,.recovery-devices button,.recovery-confirm button{min-height:40px;font-size:.72rem}.recovery-actions strong,.recovery-devices>div strong,.recovery-devices article strong{font-size:.82rem}.recovery-actions p,.recovery-devices>div span,.recovery-devices article span,.recovery-confirm>p,.recovery-confirm label,.recovery-error{font-size:.72rem}.recovery-devices article small{font-size:.62rem}.recovery-center button:focus-visible,.recovery-center summary:focus-visible,.recovery-center input:focus-visible{outline:2px solid var(--mint);outline-offset:3px}.onboarding-recovery{min-height:40px;font-size:.75rem}@media(max-width:650px){.recovery-simple-actions{grid-template-columns:1fr}.recovery-actions article{grid-template-columns:auto 1fr}.recovery-actions article>button{grid-column:1/3}.recovery-center{padding:18px}} diff --git a/apps/edge/package.json b/apps/edge/package.json index a7c3613..216fe2c 100644 --- a/apps/edge/package.json +++ b/apps/edge/package.json @@ -1,6 +1,6 @@ { "name": "@rampage/edge", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "scripts": { diff --git a/apps/edge/src-tauri/tauri.conf.json b/apps/edge/src-tauri/tauri.conf.json index 7bda58b..1c16b01 100644 --- a/apps/edge/src-tauri/tauri.conf.json +++ b/apps/edge/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Rampage Edge", - "version": "0.3.0", + "version": "0.3.1", "identifier": "ai.obtuse.rampage.edge", "build": { "beforeDevCommand": "pnpm dev", diff --git a/crates/rampage-controller/src/main.rs b/crates/rampage-controller/src/main.rs index eb9dddb..07a2801 100644 --- a/crates/rampage-controller/src/main.rs +++ b/crates/rampage-controller/src/main.rs @@ -265,6 +265,12 @@ struct RemoteDesktopInputRequest { events: Vec, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RevokeNodeRequest { + confirmation: String, +} + const DEFAULT_MESH_UDP_PORT: u16 = 47_838; #[tokio::main] @@ -389,6 +395,7 @@ async fn main() -> anyhow::Result<()> { .route("/v1/resume", post(local_resume)) .route("/v1/enrollment/invites", post(create_invite)) .route("/v1/nodes", get(list_nodes)) + .route("/v1/nodes/{node_id}/revoke", post(revoke_node)) .route("/v1/nodes/enroll", post(enroll_node)) .route("/v1/offers", get(list_offers).post(register_offer)) .route("/v1/workload-capabilities", get(list_workload_capabilities)) @@ -617,6 +624,103 @@ async fn list_nodes( )) } +async fn revoke_node( + State(state): State, + Path(node_id): Path, + Json(request): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + if request.confirmation != format!("FORGET {node_id}") { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "exact device revocation confirmation is required"})), + )); + } + let _admission_guard = state.admission_gate.lock().await; + let identity = state + .nodes + .read() + .map_err(lock_error)? + .get(&node_id) + .cloned() + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": "node is not enrolled"})), + ) + })?; + let remote_sessions_closed = state + .remote_desktop_sessions + .read() + .map_err(lock_error)? + .values() + .filter(|session| session.lease.node_id == node_id) + .count(); + state + .ledger + .append( + "node.revoked", + &node_id.to_string(), + &json!({ + "node_id": node_id, + "display_name": identity.display_name, + "public_key": identity.public_key, + "remote_assist_sessions_closed": remote_sessions_closed, + "revoked_at": chrono::Utc::now() + }), + ) + .map_err(internal_error)?; + state.nodes.write().map_err(lock_error)?.remove(&node_id); + state.offers.write().map_err(lock_error)?.remove(&node_id); + state + .assignments + .write() + .map_err(lock_error)? + .retain(|_, assignment| assignment.lease.node_id != node_id); + state + .reservations + .write() + .map_err(lock_error)? + .retain(|reservation| reservation.node_id != node_id); + state + .shard_sets + .write() + .map_err(lock_error)? + .retain(|_, shard| shard.leases.iter().all(|lease| lease.node_id != node_id)); + state + .remote_desktop_sessions + .write() + .map_err(lock_error)? + .retain(|_, session| session.lease.node_id != node_id); + state + .artifact_replicas + .write() + .map_err(lock_error)? + .retain(|(_, replica_node), _| *replica_node != node_id); + state + .replica_evidence + .write() + .map_err(lock_error)? + .retain(|(_, replica_node), _| *replica_node != node_id); + for cancellation in state.model_cancellations.lock().await.values() { + let _ = cancellation.send(true); + } + refresh_diagnostics(&state).map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": error})), + ) + })?; + Ok(( + StatusCode::OK, + Json(json!({ + "schema": "rampage.node-revocation-receipt.v1", + "node_id": node_id, + "revoked": true, + "remote_assist_sessions_closed": remote_sessions_closed + })), + )) +} + async fn health(State(state): State) -> Json { let killed = state.kill_latch_path.is_file(); Json(Health { @@ -5335,7 +5439,7 @@ fn restore_state(ledger: &Ledger, fencing_epoch: u64) -> anyhow::Result = HashMap::new(); let mut assignments: HashMap = HashMap::new(); let mut completed_receipts = HashMap::new(); - let mut shard_sets = HashMap::new(); + let mut shard_sets: HashMap = HashMap::new(); let mut artifact_replicas = HashMap::new(); let mut replica_evidence = HashMap::new(); let now = chrono::Utc::now(); @@ -5352,6 +5456,19 @@ fn restore_state(ledger: &Ledger, fencing_epoch: u64) -> anyhow::Result { + let Ok(node_id) = Uuid::parse_str(&event.subject_id) else { + continue; + }; + nodes.remove(&node_id); + offers.remove(&node_id); + assignments.retain(|_, assignment| assignment.lease.node_id != node_id); + shard_sets.retain(|_, shard| { + shard.leases.iter().all(|lease| lease.node_id != node_id) + }); + artifact_replicas.retain(|(_, replica_node), _| *replica_node != node_id); + replica_evidence.retain(|(_, replica_node), _| *replica_node != node_id); + } "resource.offer.registered" => { let offer: ResourceOfferV1 = serde_json::from_value(event.payload)?; if offer.expires_at > now { @@ -5879,6 +5996,44 @@ mod tests { assert!(validate_model_runtime_contracts(&offer).is_err()); } + #[test] + fn revoked_node_and_its_live_offer_do_not_return_after_restart() { + let ledger = Ledger::in_memory().unwrap(); + let offer = valid_model_offer(); + let identity = NodeIdentityV1 { + schema: NodeIdentityV1::SCHEMA.into(), + node_id: offer.node_id, + owner_id: Uuid::now_v7(), + display_name: "retired laptop".into(), + device_kind: DeviceKind::Desktop, + platform: "windows-x86_64".into(), + public_key: "b".repeat(64), + enrolled_at: chrono::Utc::now(), + fencing_epoch: 0, + }; + ledger + .append("node.enrolled", &identity.node_id.to_string(), &identity) + .unwrap(); + ledger + .append( + "resource.offer.registered", + &identity.node_id.to_string(), + &offer, + ) + .unwrap(); + ledger + .append( + "node.revoked", + &identity.node_id.to_string(), + &json!({"node_id": identity.node_id}), + ) + .unwrap(); + + let (nodes, offers, ..) = restore_state(&ledger, 0).unwrap(); + assert!(!nodes.contains_key(&identity.node_id)); + assert!(!offers.contains_key(&identity.node_id)); + } + #[test] fn edge_offer_is_bound_to_enrolled_device_class_and_battery_telemetry() { let mut offer = valid_model_offer(); diff --git a/crates/rampage-sdk/src/lib.rs b/crates/rampage-sdk/src/lib.rs index d82e183..1c7036b 100644 --- a/crates/rampage-sdk/src/lib.rs +++ b/crates/rampage-sdk/src/lib.rs @@ -4,7 +4,7 @@ use anyhow::Context; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use rampage_protocol::{ ArtifactRefV1, CapabilityLeaseV1, EnrollmentInviteV1, ExecutionReceiptV1, JobSpecV1, - ModelSessionRequestV1, ResourceOfferV1, StorageClass, + ModelSessionRequestV1, NodeIdentityV1, ResourceOfferV1, StorageClass, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -53,6 +53,18 @@ impl RampageClient { .await } + pub async fn nodes(&self) -> anyhow::Result> { + self.get("/v1/nodes").await + } + + pub async fn revoke_node(&self, node_id: uuid::Uuid) -> anyhow::Result { + self.post( + &format!("/v1/nodes/{node_id}/revoke"), + &serde_json::json!({"confirmation": format!("FORGET {node_id}")}), + ) + .await + } + pub async fn plan(&self, job: &JobSpecV1) -> anyhow::Result { self.post("/v1/jobs/plan", job).await } diff --git a/docs/PLATFORM_MATRIX.md b/docs/PLATFORM_MATRIX.md index 1c913aa..67ae982 100644 --- a/docs/PLATFORM_MATRIX.md +++ b/docs/PLATFORM_MATRIX.md @@ -5,7 +5,7 @@ binary release claim. | Platform | Installable package | Controller | Worker | Intended donation profile | Evidence | | --- | --- | --- | --- | --- | --- | -| Windows 11 x64 | MSI and NSIS 0.3.0 Remote Assist candidate | Shipped | Shipped | CPU, RAM cache, NVIDIA GPU/VRAM, disk, exact local Ollama models; optional paired-owner desktop view/control | 0.2.3 packaged owner/worker lifecycle proof plus source-qualified 0.3.0 Remote Assist contracts, authenticated transport, worker opt-in, visible active state, short signed leases, replay fencing, and STOP/revoke; exact 0.3.0 package hashes and physical laptop control remain gated by the release evidence | +| Windows 11 x64 | MSI and NSIS 0.3.1 recovery candidate | Shipped | Shipped | CPU, RAM cache, NVIDIA GPU/VRAM, disk, exact local Ollama models; optional paired-owner desktop view/control | 0.3.1 adds repair, pair-again, owner Forget, factory reset, restart-safe revocation replay, and an outcome-first automatic strategy UI to the qualified 0.3.0 Remote Assist contracts; exact package hashes and physical laptop control remain gated by the release evidence | | Windows 10 x64 | Not qualified | Source likely portable | Source likely portable | Same as Windows 11, hardware-dependent | Unexecuted | | macOS Apple Silicon | Not shipped | Rust source portable | Rust source portable | CPU/GPU whole jobs, RAM cache, disk | Unexecuted; native packaging and discovery adapters required | | Linux x64 | Not shipped | Rust source portable | Rust source portable | CPU, vendor GPU/VRAM, RAM cache, disk | Unexecuted; GTK dependency warning must be resolved or accepted before release | @@ -20,7 +20,7 @@ packaging on its named GitHub-hosted runner; it does not convert an unsigned can release. Stable Windows and macOS publication additionally requires real platform credentials and independent signature/notarization verification. -The installable column describes the current 0.3.0 candidate lane. Later source-qualified capabilities +The installable column describes the current 0.3.1 candidate lane. Later source-qualified capabilities do not become packaged or signed merely because they pass a source campaign; each evidence page states that boundary explicitly. diff --git a/docs/RELEASE_EVIDENCE_0.3.1.md b/docs/RELEASE_EVIDENCE_0.3.1.md new file mode 100644 index 0000000..56ef78c --- /dev/null +++ b/docs/RELEASE_EVIDENCE_0.3.1.md @@ -0,0 +1,46 @@ +# Rampage 0.3.1 release evidence + +This is the qualification ledger for the Rampage 0.3.1 recovery release. Source tests, native +packages, public artifacts, and physical two-machine behavior are separate claims. + +## Qualification status + +| Gate | Command or artifact | Result | +| --- | --- | --- | +| Nine release versions | `scripts/Assert-RampageVersion.ps1 -Tag v0.3.1` | PASS — nine surfaces report 0.3.1 | +| Rust workspace compile | `cargo check --workspace --all-targets` | PASS | +| Controller lifecycle | `cargo test -p rampage-controller --bin rampage-controller` | PASS — 20 tests, including restart-safe node revocation | +| Native desktop recovery | `cargo test --workspace --no-fail-fast` | PASS — 19 desktop tests inside the full workspace campaign | +| Desktop UI and recovery | `pnpm --dir apps/desktop test -- --run` | PASS — 18 tests | +| TypeScript SDK | `pnpm --dir packages/sdk-ts test -- --run` | PASS — 12 tests | +| Python SDK | `uv run --project packages/sdk-python --with pytest --with httpx python -m pytest packages/sdk-python/tests -q` | PASS — 11 tests | +| Full workspace tests and policy | `cargo test --workspace --no-fail-fast`; `cargo clippy --workspace --all-targets -- -D warnings`; `scripts/Assert-RustSecBaseline.ps1` | PASS — all tests; no clippy warnings; 0 RustSec vulnerabilities and 18 target-reviewed warnings through 2026-10-31 | +| Desktop, edge, and TypeScript builds | `pnpm check` | PASS — desktop 18, edge 2, SDK 12 tests plus all production builds | +| Proposal-only intelligence | Ruff, mypy, and pytest | PASS — Ruff clean, mypy clean across 9 files, 17 tests | +| NSIS installer and desktop shortcut | `scripts/Smoke-RampageInstaller.ps1` | PASS — install 0, uninstall 0, six payloads, controller/intelligence ready, one signed node and offer, shortcut created then removed, no leaked sidecars | +| Public release assets | GitHub release | PENDING publication | +| Physical owner/laptop re-pair | Fresh 0.3.1 installs | PENDING physical laptop action | +| Physical owner-to-laptop view | `scripts/Qualify-RampageRemoteAssist.ps1 -ExpectedVersion 0.3.1` | PENDING live opted-in worker | + +## Locally qualified artifacts + +These packages came from the verified local source tree and passed the independent installer smoke +test. GitHub Actions builds the public release artifacts again from the tagged commit, so the public +asset hashes will be recorded separately after publication. + +| Package | Bytes | SHA-256 | +| --- | ---: | --- | +| `Rampage_0.3.1_x64_en-US.msi` | 79,290,368 | `a02b5995e082eb7be371f675ed80d5b0640a16499eb9de1b8e0f093ac7cd06ee` | +| `Rampage_0.3.1_x64-setup.exe` | 69,374,688 | `e2fffa0326e6a6e3322b294433911d94f47ef9bbd40e0647857ce52bc899a5a5` | + +The source-current Recovery Center capture is +`docs/assets/rampage-recovery-center.png`, SHA-256 +`f5503739ce0f1a069ea067a9bb807639d541872f7f6487a12efcb46fa414366d`. + +## Honest boundary + +The Recovery Center screenshot is a browser-rendered view of the real React component using labeled +showcase topology. Rust tests cover local reset target validation and identity cleanup; controller +tests cover revocation replay. The final physical line requires the owner and laptop to install the +same 0.3.1 package, pair over the real network, advertise a fresh signed worker offer, and complete +the fail-closed Remote Assist qualifier. diff --git a/docs/RELEASE_NOTES_0.3.1.md b/docs/RELEASE_NOTES_0.3.1.md new file mode 100644 index 0000000..54ea269 --- /dev/null +++ b/docs/RELEASE_NOTES_0.3.1.md @@ -0,0 +1,56 @@ +# Rampage 0.3.1 — Recovery without rituals + +Rampage 0.3.1 closes the device lifecycle that a real multi-machine desktop product needs. A broken, +stale, or interrupted pairing no longer requires uninstalling the app or finding hidden runtime +files. Recovery is available from both first-run setup and the main header. + +## What changed + +- **Fix Rampage** safely restarts a consistent native installation without erasing identity or work. +- **Pair again** stops the worker sidecars, removes its old fabric runtime, and returns the device to + the two-button nearby-pairing screen. +- Owner **Forget** revokes one exact enrolled identity and removes its offer, outstanding assignments, + reservations, shard sets, Remote Assist sessions, artifact locations, and possession evidence. +- Revocation is appended to the hash-chained ledger and replayed after controller restart, so a stale + node and offer do not reappear. +- **Factory reset** disables Rampage auto-start and clears only the bounded Rampage runtime after the + user types `RESET RAMPAGE`. External Ollama model storage is not deleted. +- A redacted recovery receipt makes support diagnosis copyable without exposing controller secrets or + private keys. +- SDK lifecycle methods are available in Rust, TypeScript, and Python for loopback owner automation. + +## Simpler compute outcomes + +Rampage now defaults to **Automatic** and auto-assigns technical roles from signed hardware, runtime, +link, power, thermal, and workload facts. Users can override the outcome with four plain choices: +**Biggest AI**, **Fastest AI**, **More Work**, or **Protect This PC**. Detailed model sizing remains in +the advanced disclosure instead of occupying the default surface. + +The strategies do not claim that commodity networks create physically shared RAM or VRAM. Whole +models, replicas, independent shards, caches, relays, and engine-native distributed layouts remain +separate lanes with separate qualification gates. The research and delivery sequence is in the +[universal fabric blueprint](UNIVERSAL_FABRIC_BLUEPRINT.md). + +## Security and lifecycle boundaries + +- Exact destructive confirmations are checked again by native Rust/controller code; the React dialog + is not the security boundary. +- Reset refuses paths outside the exact Rampage app-runtime shape and refuses a redirected runtime + root. +- Setup mode launches no fabric sidecars until the device creates or joins a fabric. +- Worker Remote Assist is still off by default, visible when active, limited to a paired owner, and + bounded by signed 30-second leases plus STOP/revoke. +- Intelligence remains proposal-only. Automatic improvements can act inside the owner's standing + envelope; authority-expanding proposals are automatically denied. + +## Install and recover + +1. Install Rampage on the owner PC and laptop with the Windows installer. The installer creates the + desktop shortcut. +2. Create the fabric on the owner, then choose **Join my fabric** and **Find my fabric** on the laptop. +3. Approve the matching four digits on the owner. +4. If the laptop ever retains a stale identity, choose **Fix Rampage → Pair again**, then approve it + again. On the owner, use **Advanced recovery → Forget** for the stale enrolled entry. + +Package hashes, verification commands, and the remaining physical two-machine gate are recorded in +[the 0.3.1 release evidence](RELEASE_EVIDENCE_0.3.1.md). diff --git a/docs/SHA256SUMS-0.3.1.txt b/docs/SHA256SUMS-0.3.1.txt new file mode 100644 index 0000000..c4c13e3 --- /dev/null +++ b/docs/SHA256SUMS-0.3.1.txt @@ -0,0 +1,3 @@ +a02b5995e082eb7be371f675ed80d5b0640a16499eb9de1b8e0f093ac7cd06ee Rampage_0.3.1_x64_en-US.msi +e2fffa0326e6a6e3322b294433911d94f47ef9bbd40e0647857ce52bc899a5a5 Rampage_0.3.1_x64-setup.exe +f5503739ce0f1a069ea067a9bb807639d541872f7f6487a12efcb46fa414366d rampage-recovery-center.png diff --git a/docs/UNIVERSAL_FABRIC_BLUEPRINT.md b/docs/UNIVERSAL_FABRIC_BLUEPRINT.md new file mode 100644 index 0000000..1630d83 --- /dev/null +++ b/docs/UNIVERSAL_FABRIC_BLUEPRINT.md @@ -0,0 +1,192 @@ +# Rampage universal fabric blueprint + +This is the engineering map for making Rampage broadly useful without confusing networked machines +with one physically coherent motherboard. It separates what is **shipped**, what can be **qualified by +an adapter**, and what remains **experimental**. A capability does not gain execution authority merely +because it appears in this document. + +## The one-screen product + +Rampage should make the technical decisions and expose outcomes: + +| Choice | User intent | Automatic fabric behavior | +| --- | --- | --- | +| **Automatic** | Make this machine and its work feel better | Continuously choose the highest measured benefit within the owner's standing limits | +| **Biggest AI** | Run the largest model that can be served correctly | Prefer one complete-model host, then qualified pipeline/tensor topologies only when memory and link gates pass | +| **Fastest AI** | Minimize time to first and next token | Prefer the fastest whole-model host, replicas, prefix locality, speculative work, and disaggregated stages only when measured faster | +| **More Work** | Maximize total completed work | Replicate services and spread independent agents, batches, builds, renders, evaluations, and transforms | +| **Protect This PC** | Keep the foreground game, call, render, or creative session smooth | Evacuate background work, cap local pressure, and spend remote capacity first | + +There is no manual CPU/GPU/cache/relay role picker in the normal path. Every device advertises +facts; the controller assigns short-lived roles per workload. Advanced controls remain available for +diagnosis, but a new user sees one recommendation and four understandable alternatives. + +## The automatic role engine + +```mermaid +flowchart LR + O["Observe\nhardware · power · thermals · runtimes"] --> B["Benchmark\ncompute · storage · path · codec"] + B --> D["Describe the work\nlatency · memory · locality · checkpointing"] + D --> P["Place roles\nmodel · shard · cache · relay · standby"] + P --> L["Lease\nexact operation · budget · expiry · fencing"] + L --> M["Measure\nlatency · throughput · failure · foreground impact"] + M --> A["Adapt\nkeep winner · rollback loser · quarantine failure"] + A --> D +``` + +A laptop can be a whole-model server for one request, a build worker for the next, and a cache or +relay later. A phone normally becomes a control surface, sensor source, foreground-safe evaluator, +or small-model worker—not fake shared VRAM. A GPU server can advertise several independently +qualified engines without giving Rampage shell access to the host. + +The owner defines the standing envelope once. Inside it, deterministic thresholds can adapt without +per-change prompts. A proposed change that tries to widen authority is denied rather than waiting for +the AI to approve itself. Pairing, worker Remote Assist opt-in, and STOP remain independent owner +security boundaries. + +## One capability market, several execution lanes + +| Lane | Best use | Admission rule | State | +| --- | --- | --- | --- | +| Whole-model placement | One interactive local LLM, game server, compiler, renderer, or tool on the strongest node | Exact installed runtime/model or application adapter; signed offer and result | Shipped for bounded Ollama text and independent jobs | +| Replicated service | Higher AI throughput, many agents, failover, render/build farms | Compatible artifacts and runtime digest; health and saturation routing | Shipped contract; adapters expand independently | +| Independent shards | Search, evaluation, tests, simulation, preprocessing, transcode, compilation units | Deterministic partition, deadline, minimum-success threshold, signed receipts | Shipped | +| Cross-node model | A model that cannot fit one qualified GPU/node | Engine-native tensor or pipeline plan; compatible devices; topology and failure proof | Planner shipped; execution gated | +| Prefill/decode split | Long prompts or high concurrency on differently shaped GPU pools | KV format match and transfer benchmark must beat aggregated serving | Experimental adapter | +| Speculative lane | Faster decode using spare smaller-model capacity | Tokenizer/model compatibility and measured acceptance/speed gain | Experimental adapter | +| Cache and storage | Model layers, build outputs, datasets, checkpoints, prefix/KV state where supported | Encrypted content addressing, quota, possession evidence, expiration | Artifact fabric shipped; engine caches gated | +| Interactive desktop | Help, administration, remote creative work, and game streaming | Paired identity, explicit worker opt-in, visible bounded session, codec/path qualification | Remote Assist shipped; low-latency media lane next | +| Network utility | Direct-path probe, private relay, store-and-forward, cache prepositioning | Owner-signed membership, rate caps, no arbitrary forwarding | Direct QUIC and owner relay shipped | + +Ray Serve LLM documents tensor, pipeline, expert, replica, data-parallel attention, prefix-aware +routing, and prefill/decode patterns across nodes. NVIDIA Dynamo likewise separates prefill and +decode, but explicitly treats KV transfer as the critical path and warns that TCP fallback can make +the split slower. Rampage should therefore qualify these as engine adapters, never as a universal +promise. See the [Ray Serve LLM architecture](https://docs.ray.io/en/latest/serve/llm/architecture/overview.html), +[Ray cross-node parallelism](https://docs.ray.io/en/latest/serve/llm/user-guides/cross-node-parallelism.html), +and [NVIDIA Dynamo disaggregated serving guide](https://docs.nvidia.com/dynamo/latest/user-guides/disaggregated-serving). + +### The large-model decision tree + +1. If the model fits one live qualified node, place the complete model there. This normally gives the + most predictable interactive latency. +2. If several nodes hold the complete model and traffic is concurrent, replicate and route by load, + prefix locality, health, and energy/foreground cost. +3. If the model does not fit one node, search only compatible engine-native tensor/pipeline layouts. +4. Reject a layout when collective traffic, weakest-rank compute, transfer time, or failure recovery + predicts a loss. Aggregate memory is a capacity ceiling, not proof of useful serving. +5. Evaluate prefill/decode separation and speculative decoding as measured alternatives, not assumed + upgrades. + +The llama.cpp RPC backend is not an acceptable shortcut today. Its own documentation calls it a +fragile, insecure proof of concept, and the project's current security guidance says not to expose +the RPC backend on untrusted networks. A critical unauthenticated RCE advisory lists no patched +version. Rampage must not launch or expose raw RPC until an independently reviewed safe upstream +version exists and passes the mesh adapter campaign. Sources: +[llama.cpp security guidance](https://github.com/ggml-org/llama.cpp/security), +[RPC proof-of-concept warning](https://github.com/ggml-org/llama.cpp/blob/master/tools/rpc/README.md), +and [GHSA-j8rj-fmpv-wcxw](https://github.com/ggml-org/llama.cpp/security/advisories/GHSA-j8rj-fmpv-wcxw). + +## Network Autopilot + +Rampage should optimize the path as carefully as the placement: + +1. Probe direct IPv4/IPv6 and owner-relay candidates in parallel; retain authenticated path identity. +2. Measure RTT distribution, jitter, loss, reordering, goodput, MTU behavior, and sustained—not burst— + transfer speed per direction. +3. Classify traffic: interactive input, video/audio, tokens, collectives, artifact transfer, evidence, + and background repair do not share one congestion goal. +4. Choose direct or relay per session, then migrate only with continuity and replay fencing. +5. Apply adaptive bitrate, resolution, frame rate, codec, keyframe cadence, and bounded forward error + correction to media; do not retransmit stale interactive frames. +6. Preposition immutable model/artifact chunks and resume by verified chunk digest rather than moving + the same bytes for every job. +7. Reserve foreground headroom. **Protect This PC** lowers or evacuates background traffic when game, + call, production, battery, thermal, or input-latency signals cross thresholds. +8. Continuously compare the observed result with the predicted result. Roll back routes or placements + whose gain disappears. + +These ideas follow observable industry patterns without copying proprietary implementations. +Parsec exposes separate network, encode, and decode latency and adjusts bitrate against congestion; +Sunshine exposes bitrate, hardware encoder, low-latency, and FEC controls; Microsoft notes that +remote-session quality depends strongly on available network capacity. Sources: +[Parsec latency guidance](https://support.parsec.app/hc/en-us/articles/32381352822804-Troubleshooting-Lag-Latency-and-Quality-Issues), +[Sunshine configuration](https://docs.lizardbyte.dev/projects/sunshine/latest/md_docs_2configuration.html), +and [Microsoft RDS network guidance](https://learn.microsoft.com/en-us/windows-server/remote/remote-desktop-services/network-guidance). + +## Remote experience plane + +Remote access products reveal three separate contracts that Rampage should keep separate: + +| Contract | Rampage meaning | +| --- | --- | +| Connection eligibility | Only an identity paired into this owner fabric can request a session | +| In-session permission | View, keyboard/mouse, clipboard, file transfer, audio, controller, and administration are separate capabilities | +| Lifecycle and revocation | Active indication, expiry, disconnect, worker opt-out, owner forget, STOP, leave fabric, and factory reset | + +RustDesk documents the same useful separation between access eligibility and in-session control +roles. Windows RDS demonstrates full desktop versus individually published applications. Rampage's +next remote adapter should add permission-scoped clipboard/file/audio/gamepad and application-window +streaming, while defaulting to the smallest capability that satisfies the task. Sources: +[RustDesk access control](https://rustdesk.com/docs/en/self-host/rustdesk-server-pro/permissions/), +[RustDesk control roles](https://rustdesk.com/docs/en/self-host/rustdesk-server-pro/control-role/), +and [Microsoft RDS overview](https://learn.microsoft.com/en-us/windows-server/remote/remote-desktop-services/overview). + +The low-latency lane should use native capture, hardware encode/decode when available, a zero-copy +GPU path where proven, cursor/input prioritization, multi-monitor/window selection, audio and +gamepad channels, quality adaptation, and a visible latency breakdown. It must retain Rampage's +paired identity, short lease, worker indicator, opt-out, and STOP semantics. + +## Self-improvement loop + +Rampage can improve automatically without giving an AI administrator authority: + +1. **Detect:** compare predictions to signed receipts, path telemetry, foreground impact, crashes, + thermal throttling, retry storms, cache misses, and unused capacity. +2. **Diagnose:** produce a typed hypothesis with affected adapter, expected gain, rollback trigger, + and evidence digest. +3. **Experiment:** replay or shadow first; then use a small traffic-, resource-, time-, and node-capped + canary lease for allowlisted low-risk changes. +4. **Decide:** the Rust Governor checks immutable thresholds. All required gates pass or nothing is + promoted. +5. **Watch:** compare canary and baseline with confidence and minimum sample requirements. Roll back + automatically on any guardrail breach. +6. **Remember:** store proposal, environment, artifacts, outcomes, and failure class by content digest + so the fabric does not repeat losing experiments. + +No AI-issued peer enrollment, signing key access, lease minting, policy-envelope edits, STOP bypass, +or destructive machine access is part of this loop. Within the existing envelope, the system acts +without per-change approval; outside it, it fails closed. + +## Qualification campaigns + +Every new adapter must publish a machine-readable campaign covering: + +- correctness against a single-node baseline; +- cold and warm latency, sustained throughput, and time-to-first-token/frame; +- weakest-link and heterogeneous-device behavior; +- restart, disconnect, packet loss, stale lease, duplicate request, and partial-result recovery; +- resource ceilings, foreground impact, thermal/battery response, and storage wear limits; +- artifact/model integrity, secret isolation, authenticated transport, and replay fencing; +- rollback and STOP behavior; +- a measured break-even surface that tells Autopilot when **not** to distribute. + +The promotion unit is not “backend installed.” It is `(backend digest, operation, hardware class, +driver/runtime compatibility, topology class, security profile, campaign digest)`. Unknown +combinations remain candidate-only. + +## Delivery sequence + +| Stage | Deliverable | Completion evidence | +| --- | --- | --- | +| 0.3.1 | Recovery Center, Pair again, owner Forget, factory reset, automatic outcome-first UI, SDK lifecycle API | Native package, restart-safe tests, two-machine re-pair and Remote Assist receipt | +| Next | Network Autopilot v1 and remote media telemetry | Direct/relay path race, adaptive media proof, visible encode/network/decode latency | +| Next | Qualified multi-GPU server adapter | Homogeneous CUDA tensor/pipeline campaign that beats or enables the single-node baseline | +| Next | Replicated AI service and prefix-aware routing | Concurrent-load proof, failover, cache-hit evidence, bounded autoscaling | +| Experimental | Prefill/decode and speculative lanes | KV/token compatibility, transfer break-even, quality equality, rollback proof | +| Experimental | Production/gaming adapters | Per-application operation contract and foreground-impact proof; no generic FPS promises | +| Experimental | Expanded mobile roles | Physical battery, thermal, lifecycle, network, and store-signing qualification | + +This ordering makes Rampage immediately easier to recover, then progressively faster and broader. +The interface can stay simple because complexity lives in signed capability discovery, measurements, +and adapter qualification—not in a settings maze. diff --git a/docs/assets/rampage-recovery-center.png b/docs/assets/rampage-recovery-center.png new file mode 100644 index 0000000..0e18a3b Binary files /dev/null and b/docs/assets/rampage-recovery-center.png differ diff --git a/docs/download/index.html b/docs/download/index.html index ac43cef..136f314 100644 --- a/docs/download/index.html +++ b/docs/download/index.html @@ -3,16 +3,16 @@ - - + + Download Rampage for Windows

Rampage for Windows

Your download should begin automatically.

-

Download Rampage 0.3.0 for Windows x64

-

Release notes and SHA-256

+

Download Rampage 0.3.1 for Windows x64

+

Release notes and SHA-256

diff --git a/docs/index.html b/docs/index.html index 2c23df4..ea22da2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -44,7 +44,7 @@

FABRIC LIVE · OWNER CONTROLLED

Your machines.
Acting as one.

-

Rampage turns the computers and edge devices you already own into a private compute fabric, qualifies local AI automatically, serves it through OpenAI, Anthropic, or OpenRouter-style clients, proves useful capacity with signed execution receipts, and can visibly assist an opted-in paired Windows worker from the owner PC.

+

Rampage turns the computers and edge devices you already own into a private compute fabric, qualifies local AI automatically, serves it through OpenAI, Anthropic, or OpenRouter-style clients, proves useful capacity with signed execution receipts, visibly assists an opted-in paired Windows worker, and makes broken pairing recoverable in two clicks.

Download Rampage for Windows See how it works @@ -99,7 +99,7 @@

The hardware is already there.
The missing piece was trust.<

- WINDOWS X64 · 0.3.0 REMOTE ASSIST CANDIDATE

MSI + NSIS

+ WINDOWS X64 · 0.3.1 RECOVERY CANDIDATE

MSI + NSIS

The installer creates the desktop launcher, bundles the governed services, qualifies a pinned local AI runtime, and hands ongoing control to the role-aware system tray.

  • Signed sustained-compute receipts
  • Optional paired-owner Remote Assist
  • Unsigned until Authenticode credentials exist
@@ -130,6 +130,11 @@

The hardware is already there.
The missing piece was trust.< Rampage owner grid with a paired Studio Laptop selected and explicit View desktop and Control desktop actions
One visible session per worker · 30-second signed lease · pinned paired owner · worker toggle + STOP revoke
+
+
RECOVERY CENTER · COMPLETE LIFECYCLE
+ Rampage Recovery Center with Fix Rampage, enrolled machines, stale-device Forget, and factory reset +
Repair in place · pair again · revoke stale identity · typed bounded factory reset
+

This Remote Assist image is a source-current showcase with labeled demonstration topology, not a physical two-machine receipt. Windows capture/input, lease, digest, replay-fence, and UI contracts are tested separately. Rampage cannot cross UAC, the lock screen, the secure desktop, or higher-integrity applications.

Windows 10 remains an explicit real-machine gate: the dedicated self-hosted workflow must prove the operating-system caption, installer, desktop launcher, tray lifecycle, sidecars, restart behavior, STOP, and uninstall. A Windows Server build is useful candidate evidence; it is not relabeled as Windows 10 proof.

@@ -193,18 +198,18 @@

Useful compute.
No pretending.

-

COMPUTE STRATEGY

Biggest model.
Fastest chat. Different proof.

-

The new strategy plane previews exactly how compatible memory and measured links should be used—without turning a placement guess into execution authority.

+

COMPUTE STRATEGY

Automatic first.
Four clear outcomes.

+

Rampage assigns technical roles from signed device, runtime, link, power, thermal, and workload facts. People choose the outcome, not a settings maze.

-
DEFAULT FOCUS01

Maximum Model

Combine qualified compatible model memory to fit an LLM no single machine can hold.

-
MEASURED SPEED02

Speed Boost

Use tensor peers only when topology predicts faster tokens. Otherwise, stay on the fastest node.

-
CONCURRENCY03

Throughput

Place whole-model replicas for simultaneous users, agents, and batched work.

-
OWNER FIRST04

Efficiency

Choose the smallest qualified fit and preserve energy, thermals, and responsiveness.

-
NO PER-CHANGE PROMPT05

Autonomous

Adapt from signed evidence inside an owner envelope; reject authority expansion automatically.

+
RECOMMENDED01

Automatic

Measure every device and path, then continuously choose the safest high-value role.

+
CAPACITY02

Biggest AI

Fit the largest qualified model; use cross-node layouts only when an engine-specific campaign passes.

+
INTERACTIVE03

Fastest AI

Reject slow peers and optimize whole-model, replica, prefix, speculative, or split-phase lanes by proof.

+
CONCURRENCY04

More Work

Spread independent users, agents, builds, renders, evaluations, and batch work.

+
FOREGROUND FIRST05

Protect This PC

Evacuate background work and preserve the game, call, or production session.

Weights + KV cacheSigned runtime offersCompatibility groupsTopology gateRead-only plan
-
Rampage desktop showing the universal AI gateway, Maximum Model strategy, five-machine fabric grid, and a healthy autonomous self-scan
The real desktop: one strategy deck, an exact universal API surface, and an evidence-driven self-scan that acts inside the owner envelope without per-change approval.
+
Rampage desktop showing the universal AI gateway, compute strategy, five-machine fabric grid, and a healthy autonomous self-scan
The real desktop: one strategy deck, an exact universal API surface, and an evidence-driven self-scan that acts inside the owner envelope without per-change approval.

The planner stays read-only. The separate whole-model lane serves OpenAI, Anthropic Messages, and OpenRouter-style text requests through an exact one-shot lease, authenticated QUIC, local Ollama, and a signed transcript receipt. Cross-host tensor and pipeline launch remain fenced.

@@ -305,7 +310,7 @@

Stop wasting the machines you already own.

diff --git a/packages/sdk-python/pyproject.toml b/packages/sdk-python/pyproject.toml index b436da2..0eaee5c 100644 --- a/packages/sdk-python/pyproject.toml +++ b/packages/sdk-python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rampage-sdk" -version = "0.3.0" +version = "0.3.1" requires-python = ">=3.12" dependencies = ["httpx>=0.28"] diff --git a/packages/sdk-python/src/rampage_sdk/__init__.py b/packages/sdk-python/src/rampage_sdk/__init__.py index 5b1ae6f..1bfde53 100644 --- a/packages/sdk-python/src/rampage_sdk/__init__.py +++ b/packages/sdk-python/src/rampage_sdk/__init__.py @@ -3,7 +3,7 @@ import base64 import time from typing import Any -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx @@ -26,6 +26,19 @@ def health(self) -> dict[str, Any]: def invite(self) -> dict[str, Any]: return self._post("/v1/enrollment/invites", {}) + def nodes(self) -> list[dict[str, Any]]: + response = self._client.get("/v1/nodes") + response.raise_for_status() + payload: list[dict[str, Any]] = response.json() + return payload + + def revoke_node(self, node_id: str) -> dict[str, Any]: + """Revoke one exact enrolled identity and all authority derived from it.""" + return self._post( + f"/v1/nodes/{quote(node_id, safe='')}/revoke", + {"confirmation": f"FORGET {node_id}"}, + ) + def discover(self, path: str) -> dict[str, Any]: return self._post("/v1/projects/discover", {"path": path}) diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index 167d18a..51a79be 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -81,6 +81,33 @@ def handler(request: httpx.Request) -> httpx.Response: assert client.topology() == [] +def test_node_revocation_is_exact_and_token_protected() -> None: + node_id = "0198f1aa-9f18-7dc3-81a3-d78f22efb662" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["x-rampage-token"] == "local-token" + assert request.url.path == f"/v1/nodes/{node_id}/revoke" + assert request.content == f'{{"confirmation":"FORGET {node_id}"}}'.encode() + return httpx.Response( + 200, + json={ + "schema": "rampage.node-revocation-receipt.v1", + "node_id": node_id, + "revoked": True, + "remote_assist_sessions_closed": 0, + }, + ) + + client = RampageClient(token="local-token") + client._client.close() + client._client = httpx.Client( + base_url="http://127.0.0.1:47831", + headers={"x-rampage-token": "local-token"}, + transport=httpx.MockTransport(handler), + ) + assert client.revoke_node(node_id)["revoked"] is True + + def test_capability_inventory_and_self_scan_are_token_protected() -> None: requests: list[str] = [] diff --git a/packages/sdk-python/uv.lock b/packages/sdk-python/uv.lock index 5ef9ea2..b9206c2 100644 --- a/packages/sdk-python/uv.lock +++ b/packages/sdk-python/uv.lock @@ -72,7 +72,7 @@ wheels = [ [[package]] name = "rampage-sdk" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/sdk-ts/package.json b/packages/sdk-ts/package.json index abd268b..dc408d6 100644 --- a/packages/sdk-ts/package.json +++ b/packages/sdk-ts/package.json @@ -1,6 +1,6 @@ { "name": "@rampage/sdk", - "version": "0.3.0", + "version": "0.3.1", "private": true, "type": "module", "exports": "./dist/index.js", diff --git a/packages/sdk-ts/src/index.test.ts b/packages/sdk-ts/src/index.test.ts index d0eb8c2..8e6b7a9 100644 --- a/packages/sdk-ts/src/index.test.ts +++ b/packages/sdk-ts/src/index.test.ts @@ -39,6 +39,23 @@ describe("RampageClient", () => { vi.unstubAllGlobals(); }); + it("revokes one exact enrolled identity with the required confirmation", async () => { + const nodeId = "0198f1aa-9f18-7dc3-81a3-d78f22efb662"; + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + schema: "rampage.node-revocation-receipt.v1", + node_id: nodeId, + revoked: true, + remote_assist_sessions_closed: 1, + }), { status: 200, headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + const client = new RampageClient(undefined, "local-token"); + expect((await client.revokeNode(nodeId)).revoked).toBe(true); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`http://127.0.0.1:47831/v1/nodes/${nodeId}/revoke`); + expect(JSON.parse(init.body as string)).toEqual({ confirmation: `FORGET ${nodeId}` }); + vi.unstubAllGlobals(); + }); + it("previews model strategy without implying execution authority", async () => { const payload = { schema: "rampage.model-session-plan.v1", diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index 2a6073a..a9ae38c 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -25,6 +25,25 @@ export interface EnrollmentInvite { governor_public_key: string; } +export interface NodeIdentity { + schema: "rampage.node-identity.v1"; + node_id: string; + owner_id: string; + display_name: string; + device_kind: "desktop" | "laptop" | "server" | "steam_deck" | "phone" | "tablet" | "console"; + platform: string; + public_key: string; + enrolled_at: string; + fencing_epoch: number; +} + +export interface NodeRevocationReceipt { + schema: "rampage.node-revocation-receipt.v1"; + node_id: string; + revoked: true; + remote_assist_sessions_closed: number; +} + export interface ExecutionReceipt { schema: "rampage.execution-receipt.v1"; receipt_id: string; @@ -422,6 +441,18 @@ export class RampageClient { return this.request("/v1/enrollment/invites", { method: "POST", body: "{}" }); } + nodes(): Promise { + return this.request("/v1/nodes"); + } + + revokeNode(nodeId: string): Promise { + const encodedNodeId = encodeURIComponent(nodeId); + return this.request( + `/v1/nodes/${encodedNodeId}/revoke`, + this.json({ confirmation: `FORGET ${nodeId}` }), + ); + } + discover(path: string): Promise { return this.request("/v1/projects/discover", this.json({ path })); } diff --git a/scripts/Qualify-RampageRemoteAssist.ps1 b/scripts/Qualify-RampageRemoteAssist.ps1 index 36b19ca..b6dac8a 100644 --- a/scripts/Qualify-RampageRemoteAssist.ps1 +++ b/scripts/Qualify-RampageRemoteAssist.ps1 @@ -3,6 +3,8 @@ param( [string]$NodeId, [string]$ControllerBase = 'http://127.0.0.1:47831', [string]$DataDir = (Join-Path $env:APPDATA 'ai.obtuse.rampage\runtime'), + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$ExpectedVersion = '0.3.1', [ValidateRange(5, 60)] [int]$TimeoutSeconds = 15 ) @@ -79,8 +81,8 @@ $health = (Invoke-RampageRequest -Method GET -Path '/health').Body if ($health.status -ne 'ready' -or $health.kill_latch -eq $true) { throw 'The owner controller is not ready or its STOP latch is active' } -if ($health.version -ne '0.3.0') { - throw "Remote Assist qualification requires controller 0.3.0, found $($health.version)" +if ($health.version -ne $ExpectedVersion) { + throw "Remote Assist qualification requires controller $ExpectedVersion, found $($health.version)" } $nodes = @((Invoke-RampageRequest -Method GET -Path '/v1/nodes').Body) @@ -113,7 +115,7 @@ if ($NodeId) { throw "Cannot qualify worker ${NodeId}: $reason" } } elseif ($eligible.Count -eq 0) { - throw 'No live paired worker is advertising opted-in Rampage 0.3.0 Remote Assist' + throw "No live paired worker is advertising opted-in Rampage $ExpectedVersion Remote Assist" } elseif ($eligible.Count -gt 1) { $choices = ($eligible.node_id | Sort-Object) -join ', ' throw "More than one Remote Assist worker is eligible; rerun with -NodeId. Choices: $choices" diff --git a/scripts/Smoke-RampageInstaller.ps1 b/scripts/Smoke-RampageInstaller.ps1 index 9b7adec..802ca89 100644 --- a/scripts/Smoke-RampageInstaller.ps1 +++ b/scripts/Smoke-RampageInstaller.ps1 @@ -1,5 +1,5 @@ param( - [string]$Installer = 'target\release\bundle\nsis\Rampage_0.3.0_x64-setup.exe' + [string]$Installer = 'target\release\bundle\nsis\Rampage_0.3.1_x64-setup.exe' ) $ErrorActionPreference = 'Stop' diff --git a/scripts/Stage-RampageDistribution.ps1 b/scripts/Stage-RampageDistribution.ps1 index 287bc1f..8079d26 100644 --- a/scripts/Stage-RampageDistribution.ps1 +++ b/scripts/Stage-RampageDistribution.ps1 @@ -7,6 +7,7 @@ param( ) $ErrorActionPreference = 'Stop' +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) $root = Split-Path -Parent $PSScriptRoot $bundleRoot = Join-Path $root 'target/release/bundle' $sourceChanges = @(& git -C $root status --porcelain=v1 --untracked-files=all) @@ -113,7 +114,11 @@ $assets = Get-ChildItem -LiteralPath $stageRoot -File | Sort-Object Name | ForEa } } $checksumLines = $assets | ForEach-Object { "$($_.sha256) $($_.name)" } -Set-Content -LiteralPath (Join-Path $stageRoot "SHA256SUMS-$Platform") -Value $checksumLines -Encoding utf8NoBOM +[IO.File]::WriteAllLines( + (Join-Path $stageRoot "SHA256SUMS-$Platform"), + [string[]]$checksumLines, + $utf8NoBom +) $sourceCommit = (& git -C $root rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0) { throw 'could not resolve source commit' } @@ -127,7 +132,11 @@ $manifest = [ordered]@{ platform_signature_verified = $signatureVerified assets = @($assets) } -$manifest | ConvertTo-Json -Depth 6 | - Set-Content -LiteralPath (Join-Path $stageRoot "distribution-manifest-$Platform.json") -Encoding utf8NoBOM +$manifestJson = $manifest | ConvertTo-Json -Depth 6 +[IO.File]::WriteAllText( + (Join-Path $stageRoot "distribution-manifest-$Platform.json"), + $manifestJson + [Environment]::NewLine, + $utf8NoBom +) -$manifest | ConvertTo-Json -Depth 6 +$manifestJson diff --git a/services/intelligence/pyproject.toml b/services/intelligence/pyproject.toml index 39e7809..d113e61 100644 --- a/services/intelligence/pyproject.toml +++ b/services/intelligence/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rampage-intelligence" -version = "0.3.0" +version = "0.3.1" description = "Durable, proposal-only intelligence plane for Rampage" requires-python = ">=3.12,<3.15" dependencies = [ diff --git a/services/intelligence/uv.lock b/services/intelligence/uv.lock index 21ddccf..7706ea0 100644 --- a/services/intelligence/uv.lock +++ b/services/intelligence/uv.lock @@ -1816,7 +1816,7 @@ wheels = [ [[package]] name = "rampage-intelligence" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "fastapi" },