diff --git a/.gitignore b/.gitignore
index 1b2ae933..a79024a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -227,6 +227,8 @@ __marimo__/
# otherwise record as gitlinks to commits no clone of this one can resolve.
.claude/
-# The macOS app: what pnpm installs, and the schemas tauri-build writes on every build.
+# The macOS app: what pnpm installs, and the schemas and the permissions of its own commands
+# tauri-build writes on every build.
desktop/node_modules/
desktop/src-tauri/gen/
+desktop/src-tauri/permissions/autogenerated/
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 10e7203b..b485f2df 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -1477,10 +1477,14 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
name = "humanize"
version = "0.0.0"
dependencies = [
+ "block2",
"flate2",
"libc",
"log",
+ "objc2",
"objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-user-notifications",
"semver",
"serde",
"serde_json",
@@ -2366,6 +2370,8 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
+ "bitflags 2.13.2",
+ "block2",
"objc2",
"objc2-foundation",
]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 93aedca7..0eb718c8 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -27,6 +27,11 @@ libc = "0.2"
log = "0.4"
# The update's alerts: CFUserNotification, which never holds up a quit as a sheet would.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFDate", "CFDictionary", "CFString", "CFURL", "CFUserNotification"] }
+# Notifications the page asks for: UNUserNotificationCenter, and a delegate told of a click.
+block2 = { version = "0.6", default-features = false, features = ["std"] }
+objc2 = { version = "0.6", default-features = false, features = ["std"] }
+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSError", "NSObject", "NSString"] }
+objc2-user-notifications = { version = "0.3", default-features = false, features = ["std", "bitflags", "block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSound", "UNNotificationTrigger", "UNUserNotificationCenter"] }
semver = "1"
serde = { version = "1", features = ["derive"] }
# What generate_context! writes the updater's configuration out with.
diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs
index 261851f6..235af997 100644
--- a/desktop/src-tauri/build.rs
+++ b/desktop/src-tauri/build.rs
@@ -1,3 +1,9 @@
+/// The app's commands, each granted by a capability: `notify` to `hmz web`'s page, the rest to
+/// the app's own.
+const COMMANDS: &[&str] = &["status", "retry", "keep", "update_now", "notify"];
+
fn main() {
- tauri_build::build();
+ let manifest = tauri_build::AppManifest::new().commands(COMMANDS);
+ tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
+ .expect("failed to run the Tauri build script");
}
diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json
index 818e7b8b..02e0605e 100644
--- a/desktop/src-tauri/capabilities/default.json
+++ b/desktop/src-tauri/capabilities/default.json
@@ -1,7 +1,14 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
- "description": "The app's own page: its own commands need no permission, and it is granted only to drag and zoom the window.",
+ "description": "The app's own page: granted its own commands, which say and change how hmz web starts, and to drag and zoom the window.",
"windows": ["main"],
- "permissions": ["core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"]
+ "permissions": [
+ "allow-status",
+ "allow-retry",
+ "allow-keep",
+ "allow-update-now",
+ "core:window:allow-start-dragging",
+ "core:window:allow-internal-toggle-maximize"
+ ]
}
diff --git a/desktop/src-tauri/capabilities/page.json b/desktop/src-tauri/capabilities/page.json
index ef5c64cf..14239428 100644
--- a/desktop/src-tauri/capabilities/page.json
+++ b/desktop/src-tauri/capabilities/page.json
@@ -1,8 +1,8 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "page",
- "description": "hmz web's page, on whichever port of this machine the app started it, the window loading no other: granted only to drag and zoom the window by its top row, and none of the app's commands.",
+ "description": "hmz web's page, on whichever port of this machine the app started it, the window loading no other: granted only to post a notification, and to drag and zoom the window by its top row.",
"windows": ["main"],
"remote": { "urls": ["http://127.0.0.1:*"] },
- "permissions": ["core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"]
+ "permissions": ["allow-notify", "core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize"]
}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index ba491b4b..3fecf5da 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -9,6 +9,7 @@ mod child;
mod chrome;
mod install;
mod menu;
+mod notify;
mod server;
mod shell_env;
mod update;
@@ -92,6 +93,8 @@ struct Shell {
page: Mutex>,
/// An epic's archive opened before then, imported once it is set.
opening: Mutex >,
+ /// The address of a notification clicked before then, opened once it is set.
+ notified: Mutex >,
}
/// Where `hmz web` stands, once it is anything but starting, and other than the page drew last.
@@ -106,6 +109,13 @@ async fn status(after: Option, shell: State<'_, Shell>) -> Result) {
+ notify::post(&title, &body, address.as_deref().unwrap_or_default());
+}
+
/// Starts again where it stopped short: installing `hmz`, then `hmz web`.
#[tauri::command]
fn retry(app: AppHandle, shell: State<'_, Shell>) {
@@ -205,7 +215,9 @@ fn start(app: AppHandle, how: How) {
*lock(&shell.page) = Some(page.clone());
let opening = lock(&shell.opening).take();
let opening = opening.and_then(|path| importing(&page, &path));
- show(app, opening.unwrap_or(page));
+ let notified = lock(&shell.notified).take();
+ let notified = notified.and_then(|address| notify::target(&page, &address));
+ show(app, opening.or(notified).unwrap_or(page));
});
// Told once `hmz web` stops on its own; nothing where the app stopped it.
if let Some(said) = server.exited() {
@@ -454,12 +466,16 @@ pub fn run() {
downloads: Mutex::default(),
page: Mutex::default(),
opening: Mutex::default(),
+ notified: Mutex::default(),
})
.menu(menu::build)
.on_menu_event(menu::handle)
- .invoke_handler(tauri::generate_handler![status, retry, keep, update_now])
+ .invoke_handler(tauri::generate_handler![
+ status, retry, keep, update_now, notify
+ ])
.setup(|app| {
window(app.handle())?;
+ notify::init(app.handle());
start(app.handle().clone(), How::Usual);
update::init(app.handle())?;
Ok(())
diff --git a/desktop/src-tauri/src/notify.rs b/desktop/src-tauri/src/notify.rs
new file mode 100644
index 00000000..83723686
--- /dev/null
+++ b/desktop/src-tauri/src/notify.rs
@@ -0,0 +1,262 @@
+//! A notification as macOS posts one for an app, through `UNUserNotificationCenter`: what
+//! `hmz web`'s page asks for when an epic ends, a flow asks, or a chat turn is done while the
+//! window is not looked at. Clicking one brings the window forward, and opens on `hmz web`'s
+//! page the address it was given -- a hash of that page, and never anywhere else.
+
+use std::sync::OnceLock;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use block2::{DynBlock, RcBlock};
+use objc2::rc::Retained;
+use objc2::runtime::{Bool, NSObject, NSObjectProtocol, ProtocolObject};
+use objc2::{AnyThread, define_class, msg_send};
+use objc2_foundation::{NSError, NSString};
+use objc2_user_notifications::{
+ UNAuthorizationOptions, UNMutableNotificationContent, UNNotification,
+ UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse,
+ UNNotificationSound, UNUserNotificationCenter, UNUserNotificationCenterDelegate,
+};
+use tauri::AppHandle;
+use url::Url;
+
+use crate::{lock, on_main, reveal, show};
+
+/// The longest address a notification opens: one of the page's own is far shorter.
+const LONGEST: usize = 2048;
+
+/// The app, for a notification clicked.
+static APP: OnceLock = OnceLock::new();
+
+/// Notifications posted this launch, numbering each apart from the others.
+static POSTED: AtomicU64 = AtomicU64::new(0);
+
+define_class!(
+ // SAFETY: NSObject has no subclassing requirements, and the delegate keeps no state.
+ #[unsafe(super(NSObject))]
+ #[name = "HumanizeNotifications"]
+ struct Delegate;
+
+ unsafe impl NSObjectProtocol for Delegate {}
+
+ unsafe impl UNUserNotificationCenterDelegate for Delegate {
+ /// Shown even while the app is in front: the page asks only while its window is not
+ /// looked at.
+ #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
+ fn will_present(
+ &self,
+ _center: &UNUserNotificationCenter,
+ _notification: &UNNotification,
+ handler: &DynBlock,
+ ) {
+ handler.call((UNNotificationPresentationOptions::Banner
+ | UNNotificationPresentationOptions::List
+ | UNNotificationPresentationOptions::Sound,));
+ }
+
+ #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
+ fn did_receive(
+ &self,
+ _center: &UNUserNotificationCenter,
+ response: &UNNotificationResponse,
+ handler: &DynBlock,
+ ) {
+ let identifier = response.notification().request().identifier().to_string();
+ clicked(address(&identifier).to_owned());
+ handler.call(());
+ }
+ }
+);
+
+/// Listens for notifications clicked, those of an earlier launch too. On the main thread, as
+/// the app sets up.
+pub fn init(app: &AppHandle) {
+ let _ = APP.set(app.clone());
+ if !bundled() {
+ return;
+ }
+ // SAFETY: NSObject's own init, on an instance just allocated.
+ let delegate: Retained = unsafe { msg_send![Delegate::alloc(), init] };
+ UNUserNotificationCenter::currentNotificationCenter()
+ .setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
+ // The centre holds its delegate weakly: this one is kept for as long as the app runs.
+ std::mem::forget(delegate);
+}
+
+/// Posts a notification, asking the first time whether the app may.
+pub fn post(title: &str, body: &str, address: &str) {
+ // Kept with the notification for as long as macOS keeps it: only what a click may open.
+ let address = hash(address).map_or("", |_| address);
+ if !bundled() {
+ log::info!("a notification is not posted outside the app's bundle");
+ return;
+ }
+ let content = UNMutableNotificationContent::new();
+ content.setTitle(&NSString::from_str(title));
+ content.setBody(&NSString::from_str(body));
+ content.setSound(Some(&UNNotificationSound::defaultSound()));
+ let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
+ &NSString::from_str(&identifier(address)),
+ &content,
+ None,
+ );
+ let center = UNUserNotificationCenter::currentNotificationCenter();
+ let posting = center.clone();
+ // Asked once: macOS answers at once from then on, as the user last said.
+ let posted = RcBlock::new(|error: *mut NSError| {
+ // SAFETY: an NSError the centre made and holds through the call, or none.
+ if let Some(error) = unsafe { error.as_ref() } {
+ log::warn!("a notification could not be posted: {error}");
+ }
+ });
+ let granted = RcBlock::new(move |granted: Bool, error: *mut NSError| {
+ // SAFETY: as above.
+ if let Some(error) = unsafe { error.as_ref() } {
+ log::warn!("notifications could not be asked for: {error}");
+ } else if granted.as_bool() {
+ posting.addNotificationRequest_withCompletionHandler(&request, Some(&posted));
+ } else {
+ log::info!("notifications are not allowed");
+ }
+ });
+ center.requestAuthorizationWithOptions_completionHandler(
+ UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound,
+ &granted,
+ );
+}
+
+/// Brings the window forward, on `hmz web`'s page at the address clicked where it is one: on
+/// the page it shows, by its hash alone; or, where it shows none yet -- a click that launched
+/// the app -- on the page it opens once there is one.
+fn clicked(address: String) {
+ let Some(app) = APP.get() else { return };
+ on_main(app, move |app, shell| {
+ let page = lock(&shell.page).clone();
+ match page {
+ Some(mut page) => {
+ // Signed in already: the hash changes, and the page is not loaded again.
+ page.set_query(None);
+ if let Some(url) = target(&page, &address) {
+ show(app, url);
+ }
+ }
+ None => *lock(&shell.notified) = Some(address),
+ }
+ reveal(app);
+ });
+}
+
+/// Whether the app runs from its bundle, `….app/Contents/MacOS/…`: the notification centre
+/// throws for a process that is not, as one `cargo` runs.
+fn bundled() -> bool {
+ std::env::current_exe().is_ok_and(|exe| in_bundle(&exe))
+}
+
+fn in_bundle(exe: &std::path::Path) -> bool {
+ let mut up = exe.ancestors().skip(1);
+ up.next().and_then(|dir| dir.file_name()) == Some("MacOS".as_ref())
+ && up.next().and_then(|dir| dir.file_name()) == Some("Contents".as_ref())
+ && up
+ .next()
+ .and_then(|dir| dir.extension())
+ .is_some_and(|ext| ext == "app")
+}
+
+/// A notification's identifier: unique, so that none replaces another, and carrying the address
+/// it opens, so that one of an earlier launch opens it too.
+fn identifier(address: &str) -> String {
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis();
+ let n = POSTED.fetch_add(1, Ordering::Relaxed);
+ format!("{now}.{n} {address}")
+}
+
+/// The address a notification's identifier carries.
+fn address(identifier: &str) -> &str {
+ identifier
+ .split_once(' ')
+ .map_or("", |(_, address)| address)
+}
+
+/// The hash a notification may open, `#/…`, without its `#`; nothing for anything else.
+fn hash(address: &str) -> Option<&str> {
+ address.strip_prefix('#').filter(|hash| {
+ hash.starts_with('/') && address.len() <= LONGEST && !hash.chars().any(char::is_control)
+ })
+}
+
+/// Where a notification clicked opens: the hash it was given, `#/…`, at the root of `hmz web`'s
+/// page -- its origin, and its key where it is given one -- and nowhere else; nothing for
+/// anything but such a hash.
+pub fn target(page: &Url, address: &str) -> Option {
+ let hash = hash(address)?;
+ let mut url = page.clone();
+ url.set_path("/");
+ url.set_fragment(Some(hash));
+ Some(url)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn opens_a_hash_of_the_servers_page_alone() {
+ let server = Url::parse("http://127.0.0.1:8765/").unwrap();
+ let at = |address: &str| target(&server, address).map(String::from);
+ assert_eq!(
+ at("#/@gpu/live?workspace=%2Fhome%2Fme").as_deref(),
+ Some("http://127.0.0.1:8765/#/@gpu/live?workspace=%2Fhome%2Fme")
+ );
+ assert_eq!(at("#/").as_deref(), Some("http://127.0.0.1:8765/#/"));
+ // From the address with the key, as the window first opens on, keeping it to sign in.
+ let first = Url::parse("http://127.0.0.1:8765/?key=secret#/epics").unwrap();
+ assert_eq!(
+ target(&first, "#/@local/live").map(String::from).as_deref(),
+ Some("http://127.0.0.1:8765/?key=secret#/@local/live")
+ );
+ // Never another origin, path or scheme.
+ for address in [
+ "",
+ "#",
+ "#epics",
+ "/#/epics",
+ "epics",
+ "http://evil.example/#/epics",
+ "//evil.example/#/epics",
+ "javascript:alert(1)",
+ "#/epics\n",
+ "#/\u{7f}",
+ ] {
+ assert_eq!(at(address), None, "{address:?}");
+ }
+ assert_eq!(at(&format!("#/{}", "a".repeat(LONGEST))), None);
+ // Whatever the hash says, the origin stays the server's.
+ let odd = target(&server, "#/x#@evil.example:80/ y").unwrap();
+ assert_eq!(odd.origin(), server.origin());
+ assert_eq!(odd.path(), "/");
+ assert_eq!(odd.query(), None);
+ }
+
+ #[test]
+ fn carries_its_address_in_a_unique_identifier() {
+ let one = identifier("#/@local/live?workspace=a b");
+ let two = identifier("#/@local/live?workspace=a b");
+ assert_ne!(one, two);
+ assert_eq!(address(&one), "#/@local/live?workspace=a b");
+ assert_eq!(address(&identifier("")), "");
+ assert_eq!(address("elsewhere"), "");
+ }
+
+ #[test]
+ fn posts_only_from_its_bundle() {
+ let at = |path: &str| in_bundle(std::path::Path::new(path));
+ assert!(at("/Applications/humanize.app/Contents/MacOS/humanize"));
+ assert!(!at("/repo/desktop/src-tauri/target/debug/humanize"));
+ assert!(!at("/x/Contents/MacOS/humanize"));
+ assert!(!at("/x.app/MacOS/humanize"));
+ assert!(!at("humanize"));
+ }
+}
diff --git a/docs/features/surfaces.md b/docs/features/surfaces.md
index 0db52832..516032d6 100644
--- a/docs/features/surfaces.md
+++ b/docs/features/surfaces.md
@@ -21,6 +21,11 @@ import { withBase } from 'vitepress'
- **Walk away.** [The terminal can leave](/features/daemon), and the run keeps going.
- **Two windows, one run.** A browser on [`hmz web`](/user/web) and a terminal on `hmz` read and
drive the same run, and each says who did what.
+- **Many machines, one page.** The page reaches the [daemons](/user/web#daemons) of your ssh
+ hosts as it does this machine's, one at a time, and installs the same humanize where one is
+ missing.
+- **Told when it matters.** An epic ending, a question for you or a `chat` waiting, on any of
+ them, is a [notification](/user/web#notifications): in the page, the browser or macOS.
- **Settings become a form.** The terminal interface draws one from a flow's
[settings](/weaver/flow-settings). Its author writes none of it.
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index f3d0230e..f2f5aafb 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -585,7 +585,7 @@ to it, as `uv tool upgrade` holds it; uv says how to lift the pin. Epics a [daem
under the release it started with until it stops; where one is running, `hmz update` says so
and leaves it alone. The interfaces say when a newer release is out: the [terminal
interface](/reference/tui) in a line as it opens, the [web interface](/reference/web) with
-`Update` in the machine menu.
+`Update` in the daemon picker.
| Condition | Output | Exit |
| --- | --- | --- |
diff --git a/docs/reference/daemon.md b/docs/reference/daemon.md
index fd903fb0..643c8c83 100644
--- a/docs/reference/daemon.md
+++ b/docs/reference/daemon.md
@@ -247,6 +247,9 @@ the daemon answers it ([Routing](#routing)). `Daemon.asked(said)` sends one with
| `state` | string | `running`, `stopping` or `idle`. |
| `run` | int | The run going, else the one stopping, else the last; `0` for none. |
| `flow` | string | That run's flow, or `""`. |
+| `epic`, `task` | string | That run's epic (its directory's name, `""` before it has one) and task, or `""`. |
+| `pending` | list | `[{question, role, text, mode}]`: the questions waited on, as the [`pending` snapshot](#snapshots) says them. |
+| `ended` | object \| null | How the last run to end here ended: `how` (`done`, `stopped`, `refused`, `budget`, `failed` or `crashed`), `why`, `epic`, `at` (Unix seconds), `run`, `flow`, `task`; `null` before one has. |
| `budget`, `usage` | object \| null | As JSON, for the run going; `null` while stopping or idle. An infinite cost is the JSON string `"Infinity"`. |
| `flows` † | list[string] | Canonical refs of the flow calls in progress. |
| `calls` † | list | `[{ref, name, depth, seconds, id, parent}]`, oldest-started first across every run in the process; `seconds` rounded to 0.001; `parent` the index of the calling entry, or `null`. |
diff --git a/docs/reference/settings.md b/docs/reference/settings.md
index 2c7b1d6c..0626d5b4 100644
--- a/docs/reference/settings.md
+++ b/docs/reference/settings.md
@@ -46,6 +46,17 @@ enable_sentry: false # this machine
details: true # this machine
btw: claude/claude-opus-5:high # this machine
every_epic: true # this machine
+daemons: # this machine; Settings › Daemons in the web interface
+- name: gpu
+ host: gpu.example
+ user: me
+ port: 22
+ identity_file: ~/.ssh/id_ed25519
+ proxy_jump: ""
+ options: [[ServerAliveInterval, "30"]]
+notifications: # this machine; Settings › Notifications in the web interface
+ kinds: {done: true, failed: true, stopped: true, asked: true, listening: true}
+ system: true
spelling: 2 # how envs are spelled; written with every change
fallbacks: # this machine; the Fallback page
- spec: claude@work/claude-opus-5
@@ -76,6 +87,8 @@ backup: # this machine
| `details` | `bool` (only `true` is on) | `false` | `/settings` › General › **Details** | the TUI | immediately: turns show every tool call and all thinking instead of responses only |
| `btw` | `str`: `cli[@account]/model:effort` | `""`: the running flow's first agent | `/settings` › General › **/btw agent** | the TUI | the next time `/btw` is entered |
| `every_epic` | `bool` (only `true` is on) | `false`: this directory's | `ctrl+t` on [`/epics`](/reference/tui#the-runs-that-have-already-happened) | the TUI | the next time `/epics` is opened |
+| `daemons` | list of `{name, host, user, port, identity_file, proxy_jump, options: [[keyword, value]], alias, config}` | `[]`: Local alone | the web interface's Settings › Daemons, [`POST /api/daemons`](/reference/web#api-settings) and its `import` | `hmz web` | at once: a daemon added is reached the first time the page asks for it, one removed is let go of |
+| `notifications` | `{kinds: {done, failed, stopped, asked, listening}: bool, system: bool}` | every kind told, and shown by the system too; only `false` turns one off | the web interface's Settings › Notifications, [`POST /api/notifications/settings`](/reference/web#api-notifications) | `hmz web` | immediately: the next notice of a kind is told or not |
| `spelling` | `int` | absent: written before `-e` wrote `@` only before a provider | every write of the file, as `2` | every process, as it reads the file | absent, `envs` are read as their old spelling meant and rewritten once; `2`, as written |
What error reports contain and exclude is listed on `/settings` › General › **What is sent**.
diff --git a/docs/reference/web.md b/docs/reference/web.md
index a7d731f4..a9ddf1f1 100644
--- a/docs/reference/web.md
+++ b/docs/reference/web.md
@@ -143,7 +143,16 @@ Each view is an address after `#`, so it can be kept and opened again:
| `#/flows?tab=verses[&verse=]` | The flowverses, one opened. |
| `#/search[?status=&flow=&q=&offset=]` | A dialog finding epics. `status`: how it went, `running` or `unfinished`. |
| `#/download[?target=]` | A dialog downloading an epic out of a backup target: the first in settings without `target`. |
-| `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace` or `usage`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. |
+| `#/settings/[?days=]` | A dialog: `general`, `themes`, `accounts`, `fallback`, `runtimes`, `backup`, `workspace`, `usage`, `daemons` or `notifications`; `days` is how far `usage` looks back (7, 30, 90 or 365; default 30), by UTC day. |
+
+Every address above is of one daemon. `#/@/` -- `#/@local/new`, `#/@gpu/epics/`
+-- is that view of the daemon named; an address naming none is Local's, as every address
+written before daemons were is. The page shows only the daemon its address names: every link
+it draws keeps the `@` the address carries, and going to another daemon's address draws
+the view and the sidebar again, of that daemon, closing what the page streamed of the last.
+Everything the page asks of a daemon is under `/d//api/…`, Local's `/d/local/api/…`
+being the same as `/api/…`; what is the same whichever daemon the page is on --
+`/api/daemons…` and `/api/notifications…` -- is asked of Local, unscoped.
The page keeps a few conveniences in the browser's `localStorage`, each forgotten without
harm: the colours chosen as `hmz-theme` (`light`, `dark`, or absent for the system's), a sidebar
@@ -275,9 +284,62 @@ Every `error` but the last is the runtime's own sentence, e.g.
| `POST /api/runtimes/check` | `backend`, `name` | What it said: `reached`, `said`, `home`, `cpus`, `memory`, `gpus`, `usable`, `gpu_memory`, `runtimes`, `version`, `short`, `nodes` |
| `GET /api/runtimes/hosts` | | `hosts` your ssh config names (`alias`, `host`, `user`, `port`, `identity_files`, `proxy_jump`, `saved`) |
| `POST /api/runtimes/import` | `names`; `update` (bool) | As `GET /api/runtimes` |
+| `GET /api/daemons` | | `daemons`: `Local` first, then each ssh daemon (`name`, `label`, `host`, `user`, `port`, `identity_file`, `proxy_jump`, `options` as `[keyword, value]` pairs, `alias`, `config`) with `state`, `detail` and `version` |
+| `POST /api/daemons` | `name`, `fields`; `replace` (bool) | As `GET` |
+| `POST /api/daemons/remove` | `name` | As `GET` |
+| `GET /api/daemons/hosts` | | `hosts` your ssh config names that are not daemons yet (`alias`, `name`, `host`, `user`, `port`, `identity_files`, `proxy_jump`) |
+| `POST /api/daemons/import` | `aliases` | As `GET`, and `added`: the daemons made, skipping any already there |
+| `GET /api/daemons/{name}/state` | | `state` (`idle`, `connecting`, `installing`, `up` or `failed`), `detail` (what was said last, such as uv's last line) and `version` (its `hmz`'s) |
+| `POST /api/daemons/{name}/connect` | | As `GET …/state`, once it is being reached: its `hmz` installed at this version where none or another is and it holds no running epics, `hmz web --no-open --attached` started there over ssh, and a port of this machine forwarded to it |
+| any `/d/{name}/api/…` | as `/api/…` there | What that daemon's own interface answers, passed through as it comes -- a stream included -- signed in with its key, which no page sees; `503` while it is not up, `404` for a daemon not written down |
An account is listed by the names of the variables it sets (`sets`), never by a value.
+### Notices {#api-notices}
+
+What happened to this machine's runs worth telling somebody about. They are worked out of each
+workspace's host [`status`](/reference/daemon#status), asked every **2 s** without attaching to
+any host, so a host goes as it would with nobody reading. Anything already standing when
+`hmz web` starts is not news.
+
+| Method and path | Takes | Answers |
+| --- | --- | --- |
+| `GET /api/notices` | `after` (an `id`; `0`, the default, for every one kept) | `notices` (those kept after it, oldest first; the last **200**), `last` (the newest `id`, `0` for none); `400` where `after` is not a whole number |
+| `GET /api/notices/stream` | `Last-Event-ID` header, or `?last=` | `text/event-stream`: each `notice` event's `id` is its `id` and its data the notice, from after the one named (without one, from now on), then each as it comes; `: quiet` after **15 s** of nothing |
+
+A notice is `id` (a whole number, larger than any before it, on this machine too after
+`hmz web` starts again), `kind`, `workspace`, `epic`, `flow`, `task`, `role`, `text` and `at`
+(Unix seconds):
+
+| `kind` | When | `role`, `text` |
+| --- | --- | --- |
+| `done` | An epic ended done | `""`, `""` |
+| `failed` | An epic ended failed, crashed, refused or over its budget | `""`, why |
+| `stopped` | An epic was stopped | `""`, `""` |
+| `asked` | A flow asks a person a question (mode `ask`) | The role asking, the question |
+| `listening` | A flow waits for what to say next: a chat between turns (mode `listen`) | The role asking, what it asks |
+
+### Notifications {#api-notifications}
+
+What every daemon's notices come to, served by the interface a page is open on alone: its own
+machine's as they are heard, and each SSH daemon's while it is reached, off the
+`/api/notices/stream` that daemon's own interface serves -- from what it says once first
+reached, and from where it was last heard after that. A notification is the notice, with its own
+`id` (counted from `1` each time `hmz web` starts), `daemon` (`local` or the daemon's name) and
+`read`. The last **200** are kept, in memory.
+
+| Method and path | Takes | Answers |
+| --- | --- | --- |
+| `GET /api/notifications` | | `items` (newest first), `unread` (how many are not read) |
+| `GET /api/notifications/stream` | `Last-Event-ID` header, or `?last=` | `text/event-stream`: a `notification` event, `id` its `id`, for each kept after the one named -- every one kept where it names one later than any here -- then each as it comes; with none, from now. `: quiet` after **15 s** of nothing |
+| `POST /api/notifications/read` | `ids` (a list), or `all: true` | As `GET` |
+| `POST /api/notifications/clear` | | As `GET`, with nothing kept |
+| `GET /api/notifications/settings` | | `kinds` (`{done, failed, stopped, asked, listening}`: whether each is told) and `system` (whether the page asks the system to show them too), as [`notifications`](/reference/settings#machine-settings) holds them |
+| `POST /api/notifications/settings` | any of `kinds` (any of those five, each a bool), `system` (bool) | As `GET`; `400`, and nothing written, for anything else |
+
+A notice of a kind turned off is never kept nor sent. Which kinds are told is read afresh for
+every notice, so a change takes effect at once.
+
## The stream {#stream}
`GET /api/held/stream` answers `text/event-stream`, starting with `retry: 2000`. Each event is
diff --git a/docs/user/backups.md b/docs/user/backups.md
index 48aaf2cf..ed8aa9ef 100644
--- a/docs/user/backups.md
+++ b/docs/user/backups.md
@@ -122,7 +122,7 @@ same path, on the commit it was on. An epic you already have -- one this machine
still keeps, or one downloaded before -- is refused. Without `--to`, every target is looked in;
where two machines' epics share a name, name it as `//`.
-In [`hmz web`](/user/web), **Download** in the machine's menu lists what a
+In [`hmz web`](/user/web), **Download** in the daemon picker lists what a
target holds by machine and then by directory, the ones already here marked **Here**; pick the
target where there is more than one. **Download** on an epic brings it here and opens it.
diff --git a/docs/user/desktop.md b/docs/user/desktop.md
index 75f28a5d..a8e8ce1b 100644
--- a/docs/user/desktop.md
+++ b/docs/user/desktop.md
@@ -57,7 +57,13 @@ The app is a window, not a second humanize. It runs `hmz` as
sidebar's top. Drag the window by any empty part of the top row, and double-click it to zoom.
- **Links to elsewhere open in your browser**, and an export is saved to **Downloads** and shown
in the Finder.
+- **Other machines too.** The [daemons](/user/web#daemons) the page reaches -- your ssh hosts
+ -- are reached from the app as from a browser, with your ssh config, and switched between at
+ the foot of the sidebar.
- **It updates itself.** See [Updates](#updates).
+- **It notifies you as a Mac app does.** When an epic ends, a flow asks you something, or a chat
+ turn is done while you are not looking at the window, the page's notification shows in the
+ corner of the screen and in Notification Center. See [Notifications](#notifications).
## Opening a `.hmzepic` {#opening-a-hmzepic}
@@ -70,6 +76,18 @@ at once, the last is imported; anything but a `.hmzepic` is left alone.
One you already have is refused, with **Open** to go to it; one that cannot be picked up on this
Mac says why on its own page, as any [imported epic](/user/web#importing-an-epic) does.
+## Notifications {#notifications}
+
+The page's [notifications](/user/web#notifications) -- an epic done, failed or stopped, a
+question for you, a `chat` waiting for your next line, on any daemon -- show in the window's bell
+and toasts as in a browser, and, while the window is hidden or not in front, as macOS
+notifications from humanize. Click one: the window comes to the front, on the epic it is about.
+
+The kinds that appear, and whether they are macOS notifications at all, are set in
+**Settings → Notifications**; the app needs no permission asked there, as a browser does. The
+first one asks whether humanize may send them: choose **Allow**. After that, macOS's own
+**System Settings → Notifications → humanize** decides how they are shown, or silences them.
+
## Updates {#updates}
The app looks for a new release a few seconds after it opens, and every 6 hours after. It
@@ -143,6 +161,12 @@ open ~/Library/Logs/ai.humanfia.humanize/humanize.log
`hmz: the runs … are held by an older humanize` is the most common: an older `hmz` holds them.
See [`hmz web`'s troubleshooting](/user/web#troubleshooting).
+### No macOS notifications
+
+They come only while the window is hidden or not in front. Check that **Settings →
+Notifications** in the app has them switched on, and that **System Settings → Notifications →
+humanize** allows them.
+
### The window opened on a page that forgot what I chose
The page keeps your choices per port, and the port it had was taken by something else this
diff --git a/docs/user/settings.md b/docs/user/settings.md
index 623e23e4..8903b270 100644
--- a/docs/user/settings.md
+++ b/docs/user/settings.md
@@ -121,6 +121,10 @@ opens Workspace, and `environments` opens Runtimes. The [flowverses](#flowverses
`/flow` now, and `/settings flowverses` opens them there. A card marked `● unsaved` holds a
change you have not saved yet.
+The [web page](/user/web#settings) has the same pages, under **Daemon**, for whichever daemon
+it reaches -- this machine or an ssh host -- with **Batch apply** to copy one to several; its
+**App** pages, themes, daemons and notifications, belong to the page and this machine alone.
+
### How a page is laid out {#layout}
Inside a page, the line across the top says where you are (`hmz › /settings › Accounts`),
diff --git a/docs/user/web.md b/docs/user/web.md
index ba8caa79..b9091ba5 100644
--- a/docs/user/web.md
+++ b/docs/user/web.md
@@ -4,11 +4,13 @@
[daemon](/features/daemon) holds every directory's runs: each epic going as it happens, every
epic written down, starting one in any directory, what they spent, and what humanize remembers.
It is one more window onto the runs `hmz` shows, not a copy of them. Start an epic in the
-terminal and answer it in the browser, or the other way round.
+terminal and answer it in the browser, or the other way round. The same page reaches the epics of
+[other machines](#daemons) over ssh, one at a time, and [tells you](#notifications) when an epic
+on any of them ends or asks you something.
::: info At a glance
-- **You will** watch, steer and start epics in any directory from a browser, read an epic
- written down, and see what they spent.
+- **You will** watch, steer and start epics in any directory from a browser, on this machine or
+ an ssh host, read an epic written down, see what they spent, and be told when one needs you.
- **Use it when** a picture of a run reads better than a transcript: its agents and who handed
to whom, each session's turns laid out over time, a week's spend by flow.
- **You need** humanize installed as in [Installation](/user/installation), and a browser on the
@@ -65,13 +67,17 @@ lists its chats, and an epic opens out into its agents and the sessions each ope
| **Search epics** (⌘K ) | Every epic, with its directory, agents, what it spent and how long it took | Find epics by how they went, by directory, by flow, or by a word of their task; **Import** one. See [Importing an epic](#importing-an-epic) |
| **Settings** | What humanize remembers, and what the epics here spent | Change it, as [`/settings`](/user/settings) does |
-The foot of the sidebar names this machine and says how many epics are going on it;
-its menu opens **Import**, **Settings**, **Usage** and **Download**, which downloads an epic out of a
-[backup target](/user/backups#downloading-an-epic). Where a newer humanize is out, the machine is marked ↑ and its menu offers
-**Update**, which copies `hmz update` to run in a terminal. ⇧⌘O opens a new epic and ⌘. folds the sidebar away. On a phone
+The foot of the sidebar is the [daemon bar](#daemon-bar): it names the daemon the page shows,
+its host and whether it is reached, and says how many epics are going on it. Its menu picks
+another daemon, and opens **Import**, **Settings**, **Usage** and **Download**, which downloads an epic out of a
+[backup target](/user/backups#downloading-an-epic). Where a newer humanize is out, the bar is marked ↑ and its menu offers
+**Update**, which copies `hmz update` to run in a terminal. The bell at the top of the sidebar
+opens your [notifications](#notifications). ⇧⌘O opens a new epic and ⌘. folds the sidebar away. On a phone
the sidebar is a drawer behind the button in the corner.
-Every view is an address: copy it, keep it, or open it in another tab.
+Every view is an address: copy it, keep it, or open it in another tab. The address names the
+daemon it shows, `#/@devbox/epics/…`, so a kept address opens on the same machine; one with no
+`@` is Local's.
**Right-click** anything for what can be done to it: a folder in the sidebar (new epic here, its
flows, search it, copy its path, settings), an epic (open, its log, resume, copy its link or task,
@@ -212,9 +218,10 @@ tokens, and every process -- click one to pick it.
### Importing an epic {#importing-an-epic}
-Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the machine's menu at
-the foot of the sidebar, or right-click between the epics in the sidebar, and pick the file --
-or drop it anywhere on the window. It is read back in under the directory it ran in, and opens
+Somebody sent you a `.hmzepic`? Press **Import** in **Search epics**, or in the daemon bar's
+menu at the foot of the sidebar, or right-click between the epics in the sidebar, and pick the file --
+or drop it anywhere on the window. It is read back in on the daemon the page shows, under the
+directory it ran in, and opens
once it is in. One you already have is refused, with **Open** to go to it.
An imported epic is read as any other. It is picked up only where the directory it ran in is
@@ -245,20 +252,39 @@ directory last set it up:
needs at least one limit. The page goes to the epic once it starts -- a `chat` to its
conversation.
-### Settings
+### Settings {#settings}
-The same pages as [`/settings`](/user/settings), in the same order, and what the epics spent:
+Settings has two groups. **App** is the page's own: how it is drawn, the daemons it reaches and
+what it tells you of them. It is the same whichever daemon is on screen. **Daemon** is the same
+pages as [`/settings`](/user/settings), in the same order, and what the epics spent, all of one
+daemon:
| Page | What is there |
| --- | --- |
-| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) |
| **Themes** | The [themes](#themes) of this browser, and which two **Auto** follows your system with |
+| **General** | Whether a turn's working is shown, the [`/btw` agent](/user/btw), and [error reports](/user/reporting) |
| **Accounts** | Every [account](/user/settings#accounts) by its CLI, way in and the names of what it sets: ask what it runs again, **Edit** it (what you leave blank stays as it was), copy it to another CLI, remove it, or make one |
| **Fallback** | Each [step](/user/settings#fallback): where a turn goes when a place cannot take it, and how it is tried again |
| **Runtimes** | Every runtime: add one from its backend's fields, change, check or remove it, or bring hosts in from your ssh config |
| **Backup** | Every [backup target](/user/backups): its repository and branch, when an epic goes to it (**On session close**, **Every N minutes**, **On epic end**, or none: by hand only), and whether it is a **Default**; add, edit or remove one. A URL is shown with nothing signed into it |
| **Workspace** | What a directory was last set up with, and forgetting it -- which also takes it out of the sidebar where no epic ran there |
| **Usage** | What the epics spent, of every directory or of one, by day and by flow, looking back 7, 30, 90 or 365 days |
+| **Daemons** | **Local**, always there, and every SSH daemon with its state (up, connecting, installing, failed or idle): add or **Edit** one a part at a time (name, host, user, port, identity file, proxy jump, SSH options), **Import** hosts from your SSH config, **Connect** or **Remove** one, asked first |
+| **Notifications** | Which notifications appear, of every daemon: **Done**, **Failed**, **Stopped**, **Questions** and **Chat turns**, and whether the **System** shows them too. In a browser, **Allow** asks it for permission; the desktop app needs nothing more. Kept on this machine, never per daemon |
+
+Above the **Daemon** pages, **Daemon** picks which daemon they read and write: the one on screen
+until another is picked. It goes into the address as `?daemon=`, so a daemon's page can be
+kept and opened again. Picking one changes nothing else on screen.
+
+**Batch apply**, on **General**, **Fallback**, **Runtimes** and **Backup**, copies what the page
+holds to other daemons at once. Tick the daemons, or **All**, then **Apply**. Each daemon then
+says **Applied** or why it refused. **General** sends the form as it stands, saved or not.
+**Backup** sends every target and default, in place of the daemon's own. A target signed into its URL
+is refused there, since the URL is never shown whole. **Fallback** and
+**Runtimes** send each step or runtime, written over the one of the same name there, or added.
+Nothing a daemon already has is taken away. **Accounts** cannot be copied, since their secrets
+are never shown (**Secrets stay put**). **Workspace** and **Usage** are of one daemon's
+directories, and have no **Batch apply**.
An account is never shown by its values: a page lists the names of the variables it sets, and
nothing else. A way in that runs a CLI's own sign-in, such as `login`, needs a terminal: it is
@@ -346,10 +372,112 @@ Start `hmz web` in a project, and open **New epic**:
- The terminal's transcript shows the task you typed in the browser, marked `· by browser`.
- The sidebar lists the epic, `stopped`; **Search epics** shows what it spent.
+## Daemons {#daemons}
+
+Each machine whose epics the page reaches is a **daemon**: the [daemon](/features/daemon) there
+holds its runs. **Local** is this machine, always there and never removed. Every other is an ssh
+host, reached the way `ssh` reaches it, with your keys and your ssh config.
+
+The page shows one daemon at a time: the sidebar's folders and epics, a new epic, search,
+flows and the Daemon pages of Settings are all that daemon's. To see another's, switch to it.
+The daemon is part of every address, `#/@/…`, so a tab can stay on one while another tab
+shows a second.
+
+### The daemon bar {#daemon-bar}
+
+The foot of the sidebar names the daemon showing, its host, a dot for whether it is reached, and
+how many epics are going on it. Click it for its menu:
+
+- every daemon, with whether it is reached; pick one to switch to it. An ssh host is connected
+ to when you pick it.
+- **Connect** on one that failed, to try it again.
+- **Manage**, which opens **Settings → Daemons**.
+- **Update**, **Import**, **Settings**, **Usage** and **Download**, as before.
+
+A daemon is in one of these states:
+
+| State | What it means |
+| --- | --- |
+| `idle` | Not connected to yet. |
+| `connecting` | `ssh` is reaching the host and starting `hmz web` there. |
+| `installing` | The host has no `hmz`, or another version, and it is being installed. See [What happens on connect](#on-connect). |
+| `up` | Reached: its epics are on the page. |
+| `failed` | It could not be reached; the menu says why, and **Connect** tries again. |
+
+### Adding one {#adding-a-daemon}
+
+**Settings → Daemons** lists Local and every ssh host, each with its state. **Add** opens a form,
+the same as an [ssh runtime's](/user/settings#an-ssh-host): the **name** the page calls it, then
+the host, user, port, key, proxy jump and options, a part at a time. A name is letters, digits,
+`.`, `_` and `-`; `local` is Local's and cannot be taken.
+
+**Import** reads your `~/.ssh/config` and lists every host in it not yet added, with a switch
+each: switch on the ones you want, and each is added under its `Host`, resolved through your
+config every time, so editing the config edits the daemon. Nothing here writes to the config.
+
+Edit a daemon, connect to it to check it, or remove it, which asks first. Removing one forgets
+how to reach it, and nothing on the host: its epics, and its `hmz`, stay there.
+
+The daemons are kept in this machine's `~/.hmz/settings.yaml`, under `daemons`.
+
+### What happens on connect {#on-connect}
+
+Connecting runs `hmz web --no-open --attached --port 0` on the host over `ssh`, forwards a free
+port here to the one it printed with `ssh -N -L`, and passes the page's calls for that daemon
+through Local's `hmz web` to it. Nothing on the host listens beyond its own loopback, and your
+browser only ever talks to Local.
+
+- **It brings the host the same humanize.** Before it starts `hmz web` there, it asks the host's
+ `hmz --version`, through a login shell with `~/.local/bin` on its `PATH`. Where there is none,
+ or one of another version, it runs `uv tool install --force --python '>=3.12' 'hmz=='`
+ on the host, at this machine's version, installing uv there first where the host has none.
+ The daemon says `installing` meanwhile.
+- **It never replaces an `hmz` holding running epics.** Where the host's daemon has epics
+ running, or it cannot tell, the `hmz` there is left as it is, and the daemon says why.
+- **It keeps reaching it.** A connection that drops is tried again, waiting longer each time.
+- **The epics outlive it.** Stopping `hmz web` here closes every ssh connection, and the
+ `hmz web` on each host stops with it; the epics there keep going, held by that host's
+ daemon. See [Leaving it running](/user/leaving).
+
+## Notifications {#notifications}
+
+The page tells you when something happens that you may want to look at, on **every** daemon,
+not only the one showing:
+
+| Kind | When |
+| --- | --- |
+| **done** | An epic ended, done. |
+| **failed** | An epic failed, crashed, ran out of its budget, or was refused. |
+| **stopped** | An epic was stopped. |
+| **asked** | A flow put a question to a person: the outworlder. See [Questions](/user/questions). |
+| **listening** | A `chat` finished a turn and waits for your next line. |
+
+Each says which daemon, which directory and which epic it came from, and what was asked or how
+it ended.
+
+- **The bell**, at the top of the sidebar, counts the ones you have not read. Click it for the
+ **menu**: the latest notifications of every daemon, newest first, each labelled with its
+ daemon. Click one to open its epic, switching to its daemon; mark them all read, or clear
+ them. The last 200 or so are kept.
+- **Toasts.** A new notification also shows as a card in the corner of the page for a few
+ seconds.
+- **System notifications.** While the page is hidden or not focused, a notification is also
+ one of your system's: the browser's, or, in [the macOS app](/user/desktop#notifications), a
+ macOS one. Clicking it brings the page back on what it is about.
+
+A daemon is heard from while it is reached: Local always, an ssh host once it is `up`.
+
+**Settings → Notifications** switches each kind on or off, and system notifications as a whole.
+A browser shows system notifications only once you allow it: press the button on that page,
+which asks the browser. A kind switched off is not shown, kept or counted. These are App
+settings: they are kept in this machine's `~/.hmz/settings.yaml`, under `notifications`, and hold
+for every daemon, never one daemon apart.
+
## From another machine {#over-ssh}
`hmz web` only listens on the machine it runs on. To use it from your laptop while `hmz` runs on
-a server, forward a port over ssh:
+a server, the simplest way is to add the server as a [daemon](#daemons) of the page on your
+laptop. To serve the page from the server itself instead, forward a port over ssh:
```sh
ssh -L 8765:127.0.0.1:8765 you@devbox
@@ -423,9 +551,29 @@ says.
### The sidebar says why the host let it go
The host holding the runs went: it was closed, or it let this window go. The page reaches the
-runs again by itself a moment later, starting a host where none is, and the foot of the sidebar
+runs again by itself a moment later, starting a host where none is, and the daemon bar
says `idle` or how many are `live` again once it has.
+### A daemon says `failed`
+
+`ssh` could not reach the host, or `hmz web` could not start there; the daemon bar's menu, and
+**Settings → Daemons**, say what it said. Check that `ssh ` works from a terminal here
+without asking for anything -- a password or a host key to accept cannot be answered from the
+page -- then press **Connect**.
+
+### A daemon will not update its `hmz`
+
+The host's daemon has epics running, or the page could not tell whether it has, so its `hmz` is
+left as it is. Once they have ended, press **Connect** again; or update it on the host yourself,
+`uv tool install --force 'hmz=='`.
+
+### No system notifications
+
+They come only while the page is hidden or not focused, and in a browser only once it is
+allowed: press the button in **Settings → Notifications**, and check that the browser's site
+settings, and your system's, let it notify. Check too that **Settings → Notifications** has the
+kind, and system notifications, switched on.
+
### A way in is marked `(at a terminal)`
It runs the CLI's own sign-in, which needs a terminal. Make that account from `/settings` in
@@ -435,4 +583,5 @@ It runs the CLI's own sign-in, which needs a terminal. Make that account from `/
- [Watching a run (the monitor)](/user/monitor): the same run, drawn in the terminal.
- [Leaving it running](/user/leaving): how a run outlives every window onto it.
+- [The macOS app](/user/desktop): the same page in a window of its own.
- [Web reference](/reference/web): the command line, every route the page uses, and the stream.
diff --git a/specs/cli.md b/specs/cli.md
index a2a47a3b..825f5fc3 100644
--- a/specs/cli.md
+++ b/specs/cli.md
@@ -113,7 +113,8 @@ def tools(argv: list[str]) -> int: ...
a browser on it unless told `--no-open`, and MUST exit zero once interrupted and one, saying
why, where the port cannot be had. A terminate or a hangup MUST stop it as an interrupt does,
and so MUST its standard input closing where it is told `--attached`: whatever started it
- takes it down by going, however it went.
+ takes it down by going, however it went -- another machine's `hmz web` reaching this one's
+ daemon over ssh included.
- `hmz update` MUST move this install to the newest release `coganchor.releases` fetches, with
whatever installed it -- `uv tool upgrade` for a uv tool, `pipx upgrade` for a pipx one, and
otherwise pip, or uv's pip where there is no pip, asked for the extras whose packages are all
diff --git a/specs/daemon.md b/specs/daemon.md
index 75d828ce..1eb51dab 100644
--- a/specs/daemon.md
+++ b/specs/daemon.md
@@ -17,7 +17,7 @@ class Daemon:
protocol: int = 0 # the frontends' protocol a host speaks; 0 for an older humanize's
alive: bool # property
def link(self, name: str = "", kind: str = "sdk", *, replay: bool = True) -> Link: ...
- def status(self) -> dict[str, Any]: ...
+ def status(self) -> dict[str, Any]: ... # its host's, as `Host.status` says it
def detach(self) -> int: ...
def stop(self, *, seconds: float = 20.0) -> bool: ...
def kill(self, *, seconds: float = 20.0) -> bool: ...
diff --git a/specs/desktop.md b/specs/desktop.md
index 37595ce9..1b120bb0 100644
--- a/specs/desktop.md
+++ b/specs/desktop.md
@@ -3,8 +3,8 @@
The macOS app in `desktop/`: one window on the page `hmz web` serves, which it starts as its own
child and stops as it quits. It is a frame around that page and nothing more: it draws nothing
of humanize's but how it is getting there and what went wrong, and is answerable only for
-having the `hmz` of its own release installed, starting it, showing its page, leaving nothing
-running behind it, and keeping itself up to date.
+having the `hmz` of its own release installed, starting it, showing its page and what it
+notifies, leaving nothing running behind it, and keeping itself up to date.
## API
@@ -39,6 +39,9 @@ invoke('status', {after}) // once it is not starting, and not `after`, the stat
invoke('retry') // starts again, where it failed, stopped or found no hmz
invoke('keep') // starts the hmz there, where installing its own failed and `keep` was said
invoke('update_now') // installs its own now, where it is waiting
+
+// What `hmz web`'s page asks of it, and nothing else.
+invoke('notify', {title, body, address}) // a notification; clicked, the page opens `address`
```
`HUMANIZE_HMZ` is for development and tests: it names the `hmz` to run, nothing else is looked
@@ -123,8 +126,12 @@ either: it runs the first `hmz` it finds.
Reload, Actual Size, Zoom In, Zoom Out and Full Screen; and the documentation under Help. It
MUST NOT take a shortcut the page binds: Cmd+K, Cmd+Shift+O and Cmd+. are the page's.
- MUST NOT put a script of its own into `hmz web`'s page but what says whether the window is
- full screen, nor grant it any of the app's commands, nor anything but to drag and zoom the
- window.
+ full screen, nor grant it any of the app's commands but `notify`, nor anything but that and to
+ drag and zoom the window.
+- MUST show what `notify` is handed as a notification of macOS's own, its title and body as
+ text, asking macOS for leave the first time; and a click on one MUST bring the window forward
+ and open its address on `hmz web`'s page, only where it is a `#` address of that page's own
+ origin.
### Leaving
diff --git a/specs/runtime/doing.md b/specs/runtime/doing.md
index 9b3370a0..0a6c6347 100644
--- a/specs/runtime/doing.md
+++ b/specs/runtime/doing.md
@@ -76,7 +76,7 @@ class Host:
*, replay: bool = True) -> str: ... # the frontend's client id
def detach(self, client: str) -> None: ...
def asked(self, client: str, said: dict[str, Any]) -> dict[str, Any]: ...
- def status(self) -> dict[str, Any]: ...
+ def status(self) -> dict[str, Any]: ... # beside what runs: pending, ended, epic, task
def away_for(self, role: str) -> bool: ...
def printed(self, text: str) -> None: ...
attached: int; idle: bool; closed: bool; epic: Path | None # properties
@@ -374,6 +374,10 @@ class Fallbacks:
that stops or ends with any waiting, MUST say so. Claims MUST NOT gate what is said to agents.
- A run MUST be started with the backup targets its frontend chose, or the defaults where
it chose none, and how it was started MUST say which.
+- `status` MUST say, beside what is running, every question waiting on a person --
+ `{question, role, text, mode}`, `mode` being `ask`, or `listen` for a chat waiting on its next
+ turn -- how the last run ended, `{how, why, epic, at}` or null, and the epic and task of the
+ run going or the last, so that a frontend can tell what happened without attaching.
- One run MUST be going at a time, and any frontend MAY start, stop or force one; forcing MUST
close every conversation of a stopping run under whatever turn is open, and say how many.
- An aside MUST be read-only, carry no skills, say nothing to the run, answer only the frontend
diff --git a/specs/web.md b/specs/web.md
index b186dc59..5cb1bf91 100644
--- a/specs/web.md
+++ b/specs/web.md
@@ -3,8 +3,9 @@
The web interface `hmz web` serves: every workspace's runs on this machine, drawn in a browser on
it. Where the terminal interface is about the directory it is opened in, this is about the
machine, as the daemon is: one more frontend of the runs each workspace's host holds, beside
-every terminal interface opened on them. It runs flows and reads them back, defines no flows,
-drives no agents and keeps no store of its own.
+every terminal interface opened on them -- and, through the `hmz web` it starts on each over ssh,
+of other machines' daemons. It runs flows and reads them back, defines no flows, drives no agents
+and keeps no store of its own.
## API
@@ -23,6 +24,49 @@ def serve(
class Site(ThreadingHTTPServer):
port: int # property
address: str # property: http://127.0.0.1:/?key=
+ notices: Notices
+ remotes: Remotes
+# notices.py -- what this machine's runs did that a person would want told
+class Notices:
+ def since(self, after: int = 0) -> list[dict[str, Any]]: ... # each numbered after `after`
+ def heard(self, callback: Callable[[dict[str, Any]], None]) -> None: ... # each as it comes
+# remote.py -- the daemons of other machines, each reached over ssh
+class Remotes:
+ def state(self, name: str) -> dict[str, Any]: ... # {state, detail, version}
+ def reached(self, name: str) -> tuple[int, str]: ... # its port forwarded here, and its key
+ def connect(self, name: str) -> None: ...
+ def close(self) -> None: ...
+```
+
+```text
+GET /api/notices?after= {notices, last}, of this machine's runs alone
+GET /api/notices/stream `notice` events, resumed from Last-Event-ID
+ a notice {id, kind, workspace, epic, flow, task, role, text, at}
+ its kind done | failed | stopped | asked | listening
+GET /api/daemons Local, then each SSH daemon, with how it stands
+POST /api/daemons one added or changed
+POST /api/daemons/remove one removed
+POST /api/daemons/import ssh config's hosts not yet added, those named
+GET /api/daemons/hosts ssh config's hosts not yet added
+POST /api/daemons//connect reached again
+GET /api/daemons//state idle | connecting | installing | up | failed
+ /d//api/... that daemon's /api/..., proxied; /d/local/api/ is /api/
+GET /api/notifications {items, unread}: every daemon's notices, each with
+ its daemon and whether it is read
+GET /api/notifications/stream `notification` events
+POST /api/notifications/read {ids} or {all}
+POST /api/notifications/clear
+GET /api/notifications/settings {kinds: {done, failed, stopped, asked, listening}, system}
+POST /api/notifications/settings
+```
+
+```yaml
+# settings.yaml, Local's alone
+daemons: # each SSH daemon; Local is always there and never written
+ - {name, host, user, port, identity_file, proxy_jump, options: [[key, value]], alias, config}
+notifications: # every kind, and the system, on unless turned off
+ kinds: {done: true, failed: true, stopped: true, asked: true, listening: true}
+ system: true
```
`listening` attaches to the runs and listens without answering; `Site.serve_forever` answers
@@ -207,5 +251,59 @@ until `Site.shutdown`. `serve` is both, until it is interrupted.
and the desktop app alike.
- MUST draw its chrome around the window controls of the desktop app when it is shown in it,
and nothing else differently.
-- MUST make every view an address that can be kept and opened again, and MUST say, rather than
- hide, a refusal where it happened.
+- MUST make every view an address that can be kept and opened again, the daemon it is of
+ included, and MUST say, rather than hide, a refusal where it happened.
+
+### Daemons
+
+- MUST keep the daemons in Local's settings: `Local`, this machine's, always there and never
+ removed or renamed, and every other an SSH host -- set up a part at a time as a runtime's ssh
+ host is, or imported from the ssh config, those already added skipped -- named by letters,
+ digits, `.`, `_` and `-`, `local` being Local's alone.
+- MUST reach an SSH daemon by running `hmz web --no-open --attached --port 0` there over ssh, as
+ its entry says, and forwarding a free port of this machine's loopback to the address it prints,
+ holding ssh's input open so that it goes as this does; MUST try again, waiting longer each
+ time, where it drops; and MUST stop everything it started there as it goes.
+- MUST find the `hmz` there as a login shell finds it, `~/.local/bin` included, and where none is
+ or it is not this one's version, install this one's with `uv tool install --force --python
+ '>=3.12' 'hmz=='`, getting uv there with astral's installer first where it has none,
+ saying `Installing` while it does. It MUST NOT replace one while that machine's daemon holds
+ running epics, nor where it cannot tell, saying why and reaching nothing; and a development
+ build, `0.0.0`, MUST install nothing.
+- MUST hand a page an SSH daemon's `/api/` under `/d//api/` of its own origin --
+ server-sent events and uploads streamed through, and the key and cookie that daemon's `hmz web`
+ takes put in here, never handed to the page -- and nothing else of it; `/d/local/api/` MUST be
+ `/api/`. One not reached MUST be refused as unavailable, saying how it stands.
+- MUST draw the sidebar's foot as the daemon bar: the daemon shown, its host, how it stands and
+ how many epics it runs. A click on it MUST open the picker -- every daemon and how it stands,
+ one picked switched to, `Connect` on one that failed, `Manage` for the daemons in Settings --
+ with the machine's own choices below it.
+- MUST show the daemon picked alone -- its workspaces, epics, flows, settings and everything
+ else but the notifications -- carrying it in every address as `#/@/...`, an address
+ without one being Local's, and MUST draw everything again of the daemon switched to.
+- MUST group Settings as `App`, what is this page's and Local's alone -- themes, daemons and
+ notifications -- and `Daemon`, every page a daemon keeps -- general, accounts, fallback,
+ runtimes, backup, workspace and usage -- read and written of the daemon picked on them, the
+ one shown at first. Each Daemon page MUST offer `Batch apply`, which writes what that page
+ saves to every daemon picked and says how each went; MUST NOT carry what only a secret sets,
+ and MUST say so where it is left out.
+
+### Notifications
+
+- MUST tell, of this machine's runs, every epic that ends -- `done`, `stopped`, or `failed`
+ where it failed, crashed, ran out of budget or was refused -- every question a flow asks a
+ person, `asked`, and every chat waiting on its next turn, `listening`: each a notice, numbered
+ in order and kept to a bound, served after a number and as server-sent events resumed from the
+ last heard, `hmz web` reached over ssh included. It MUST work them out of each host's status as
+ the daemon answers it, never by attaching, so that no host is kept for them.
+- MUST gather, on Local alone, the notices of every daemon reached into one list of
+ notifications, each naming its daemon and whether it is read, the newest few hundred kept, and
+ MUST keep out of it, before it is kept or sent, every kind turned off.
+- MUST offer them, whichever daemon is shown, behind a bell with how many are unread: each
+ opening its epic on its daemon, marked read one or all, and cleared; and each new one as a card
+ that goes after a while.
+- MUST tell the system of a new one where the page is hidden or not focused, unless that is
+ turned off: in the desktop app by its `notify`, and in a browser by its notifications, leave
+ for which is asked only by a click in Settings.
+- MUST keep which kinds are told and whether the system is in Local's settings alone, never a
+ daemon's.
diff --git a/src/hmz/runtime/doing/hosting.py b/src/hmz/runtime/doing/hosting.py
index 8b112654..a8fdda1e 100644
--- a/src/hmz/runtime/doing/hosting.py
+++ b/src/hmz/runtime/doing/hosting.py
@@ -182,6 +182,12 @@ def _json(model: Any) -> Any:
return json.loads(model.model_dump_json())
+def _epic_of(shown: _Run | None) -> str:
+ """The name of the epic a run is written into, or "" before it has one."""
+ epic = shown.run.epic if shown is not None else None
+ return epic.name if epic is not None else ""
+
+
def _spoken(given: Mapping[str, Any]) -> dict[str, str]:
"""What each role was given, as the words `-a` or `-e` would spell it."""
return {
@@ -347,6 +353,8 @@ def __init__(self, hmz: Hmz) -> None:
self._current: _Run | None = None
self._stopping: _Run | None = None
self._last: _Run | None = None
+ #: How the last run to end here ended, for whoever asks from outside the runs.
+ self._ended: dict[str, Any] | None = None
self._runs = 0
self._starting = False
self._questions: list[_Question] = []
@@ -569,14 +577,25 @@ def status(self) -> dict[str, Any]:
Returns:
How many frontends are attached and who, the state and number of the run, the
- flow, what it may spend and has spent -- both None with nothing running -- and the
- flows running, as refs and as calls.
+ flow, its epic and task, what it may spend and has spent -- both None with nothing
+ running -- the questions it waits on, how the last run to end ended, and the flows
+ running, as refs and as calls.
"""
with self._lock:
running = self._current
shown = running or self._stopping or self._last
clients = self._clients_of()
state = self._state()
+ pending = [
+ {
+ "question": one.id,
+ "role": one.role,
+ "text": one.text,
+ "mode": one.mode,
+ }
+ for one in self._questions
+ ]
+ ended = dict(self._ended) if self._ended is not None else None
calls = self._calls(now=time.monotonic())
return {
"attached": len(clients),
@@ -584,6 +603,10 @@ def status(self) -> dict[str, Any]:
"state": state,
"run": shown.number if shown is not None else 0,
"flow": shown.run.flow if shown is not None else "",
+ "epic": _epic_of(shown),
+ "task": shown.started.get("task", "") if shown is not None else "",
+ "pending": pending,
+ "ended": ended,
"budget": _json(running.run.budget) if running is not None else None,
"usage": _json(running.run.usage) if running is not None else None,
"flows": [one["ref"] for one in calls],
@@ -1030,6 +1053,15 @@ def _drives(self, current: _Run) -> None:
# arrive is the one it ended for.
if not self._frontends and not current.stopped_by:
self._keeps = True
+ self._ended = {
+ "how": how,
+ "why": why,
+ "epic": _epic_of(current),
+ "at": time.time(),
+ "run": current.number,
+ "flow": current.run.flow,
+ "task": current.started.get("task", ""),
+ }
self._record(
{
"type": "ended",
diff --git a/src/hmz/web/__init__.py b/src/hmz/web/__init__.py
index 5c6d7197..1444f609 100644
--- a/src/hmz/web/__init__.py
+++ b/src/hmz/web/__init__.py
@@ -59,7 +59,10 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]:
from hmz.coganchor import releases
+ from .notices import Notices
+ from .notifications import Notifications
from .places import Places
+ from .remote import Remotes
from .runs import Runs
from .site import Site
from .verses import fetches
@@ -67,14 +70,25 @@ def listening(*, port: int = 0, apart: bool = True) -> Generator[Site]:
with contextlib.ExitStack() as held_open:
places = Places(Path.cwd(), apart=apart)
held_open.callback(places.close)
- site = Site(port, places=places, runs=Runs(places))
+ notices = Notices(places.statuses)
+ site = Site(port, places=places, runs=Runs(places), notices=notices)
# What each index lists now, fetched once in the background as the interface starts.
fetches(places.hmz)
# And which release of humanize is the newest, where what is kept has got old.
releases.refresh()
held_open.callback(site.server_close)
+ # Every daemon on another machine let go of as the block ends, and what reached it.
+ site.remotes = Remotes()
+ held_open.callback(site.remotes.close)
+ # Every daemon's notices, as this machine's interface tells them.
+ site.notifications = Notifications(site)
+ held_open.callback(site.notifications.close)
# Set first as the block ends, which is what ends every stream being sent.
held_open.callback(site.going.set)
+ # And before it, what ends the notices' streams at once rather than once they wake.
+ held_open.callback(notices.close)
+ # What stands as it starts to answer is where the notices start from.
+ notices.start()
yield site
diff --git a/src/hmz/web/daemons.py b/src/hmz/web/daemons.py
new file mode 100644
index 00000000..5d867160
--- /dev/null
+++ b/src/hmz/web/daemons.py
@@ -0,0 +1,294 @@
+"""The daemons a page may switch between: this machine's, and each ssh host written down.
+
+`Local` is always there and never written down. Every other daemon is an ssh host, kept in
+humanize's one settings file under `daemons` with what `ssh` is told to reach it -- the same
+fields an ssh runtime has, checked as one is, but for its workdir and fallback -- typed in a
+part at a time or brought in from the ssh config. How each stands is the remote reach's to
+say, where this site has one; without one, Local is up and every other daemon is idle.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING, Any, cast
+
+from .routing import Refusal, routes
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from .routing import Asked
+
+__all__ = ["LOCAL", "ROUTES", "found", "listed", "saved"]
+
+#: The daemon of this machine, which is always there and never written down.
+LOCAL = "local"
+
+#: The key of the settings file the daemons are kept under.
+_KEY = "daemons"
+
+#: What a daemon's name is spelled with: never starting with a dot, so never `.` or `..`.
+_NAMED = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
+
+#: What a daemon holds beside its name, as `ssh` is told it.
+_FIELDS = (
+ "host",
+ "user",
+ "port",
+ "identity_file",
+ "proxy_jump",
+ "options",
+ "alias",
+ "config",
+)
+
+#: What a write of one takes.
+_TAKES = frozenset({"name", "fields", "replace"})
+
+
+def _checked(name: str, fields: dict[str, Any]) -> dict[str, Any]:
+ """One daemon as it is written down, checked as an ssh runtime is.
+
+ Args:
+ name: What it is called.
+ fields: The rest of it, `options` as `[[KEYWORD, VALUE], ...]` or by keyword.
+
+ Returns:
+ It, `options` as pairs.
+
+ Raises:
+ ValueError: If it cannot be one.
+ """
+ from hmz.coganchor.machines.store import SSH, SSHRuntime, new
+
+ if not _NAMED.fullmatch(name) or name.lower() == LOCAL:
+ raise ValueError(
+ f"A daemon cannot be called {name!r}: say a letter or digit, then letters, "
+ f"digits, dots, dashes and underscores, and not {LOCAL}."
+ )
+ if extra := sorted(set(fields) - set(_FIELDS)):
+ raise ValueError(f"A daemon takes no {', '.join(extra)}.")
+ given = dict(fields)
+ options: object = given.get("options", [])
+ if isinstance(options, list):
+ pairs = cast("list[object]", options)
+ if not all(
+ isinstance(one, list) and len(cast("list[object]", one)) == 2 # noqa: PLR2004
+ for one in pairs
+ ):
+ raise ValueError("Say options as pairs of a keyword and its value.")
+ given["options"] = {
+ str(key): str(value) for key, value in cast("list[list[object]]", pairs)
+ }
+ one = cast("SSHRuntime", new(SSH, name, **given))
+ return {
+ "name": one.name,
+ **{field: getattr(one, field) for field in _FIELDS if field != "options"},
+ "options": [[key, value] for key, value in one.options.items()],
+ }
+
+
+def _valid(held: dict[str, Any]) -> list[dict[str, Any]]:
+ """Every ssh daemon a reading of the settings file holds, in order, each checked.
+
+ One that cannot be one -- written wrong by hand, or under a name taken before it -- is
+ left out, and so is gone the next time the daemons are written.
+ """
+ there: object = held.get(_KEY)
+ kept: list[dict[str, Any]] = []
+ for one in cast("list[object]", there) if isinstance(there, list) else []:
+ fields = dict(cast("dict[str, Any]", one)) if isinstance(one, dict) else {}
+ name = fields.pop("name", None)
+ if not isinstance(name, str):
+ continue
+ try:
+ entry = _checked(name, fields)
+ except ValueError:
+ continue
+ if all(entry["name"] != each["name"] for each in kept):
+ kept.append(entry)
+ return kept
+
+
+def saved() -> list[dict[str, Any]]:
+ """Every ssh daemon written down, in order; one that cannot be one is left out."""
+ from hmz.coganchor import settings
+
+ return _valid(settings.read())
+
+
+def found(name: str) -> dict[str, Any] | None:
+ """The ssh daemon written down under a name, or None where there is none."""
+ return next((one for one in saved() if one["name"] == name), None)
+
+
+def listed(site: Any) -> dict[str, Any]:
+ """Local, then every ssh daemon, each with how it stands."""
+ from hmz.coganchor import __version__
+
+ remotes = getattr(site, "remotes", None)
+ local: dict[str, Any] = {
+ "name": LOCAL,
+ **dict.fromkeys(_FIELDS, ""),
+ "port": 0,
+ "options": [],
+ "label": "Local",
+ "state": "up",
+ "detail": "",
+ "version": __version__,
+ }
+ daemons = [local]
+ for one in saved():
+ stands = (
+ dict(remotes.state(one["name"]))
+ if remotes is not None
+ else {"state": "idle", "detail": "", "version": ""}
+ )
+ daemons.append(one | {"label": one["name"]} | stands)
+ return {"daemons": daemons}
+
+
+def _changes(change: Callable[[list[dict[str, Any]]], None]) -> None:
+ """Makes one change to the daemons written down, and to nothing else in the file.
+
+ Raises:
+ Refusal: If the file cannot be written.
+ """
+ from hmz.coganchor import settings
+
+ def changing(held: dict[str, Any]) -> None:
+ daemons = _valid(held)
+ change(daemons)
+ held[_KEY] = daemons
+
+ try:
+ settings.changes(changing)
+ except OSError as why:
+ raise Refusal(f"The daemons could not be written: {why}", 500) from why
+
+
+def _write(asked: Asked) -> dict[str, Any]:
+ if extra := sorted(set(asked.body) - _TAKES):
+ raise Refusal(f"A daemon is written with no {', '.join(extra)}.")
+ name = asked.text("name")
+ replacing = asked.body.get("replace", False)
+ if not isinstance(replacing, bool):
+ raise Refusal("Say replace as true or false.")
+ try:
+ entry = _checked(name, asked.named("fields"))
+ except ValueError as why:
+ raise Refusal(str(why)) from why
+
+ def change(daemons: list[dict[str, Any]]) -> None:
+ at = next((i for i, one in enumerate(daemons) if one.get("name") == name), -1)
+ if at < 0:
+ daemons.append(entry)
+ elif replacing:
+ daemons[at] = entry
+ else:
+ raise Refusal(f"There is a daemon called {name} already.", 409)
+
+ _changes(change)
+ return listed(asked.site)
+
+
+def _remove(asked: Asked) -> dict[str, Any]:
+ if extra := sorted(set(asked.body) - {"name"}):
+ raise Refusal(f"A daemon is removed with no {', '.join(extra)}.")
+ name = asked.text("name")
+ if name.lower() == LOCAL:
+ raise Refusal("Local is always there, and cannot be removed.")
+
+ def change(daemons: list[dict[str, Any]]) -> None:
+ kept = [one for one in daemons if one.get("name") != name]
+ if len(kept) == len(daemons):
+ raise Refusal(f"There is no daemon called {name}.", 404)
+ daemons[:] = kept
+
+ _changes(change)
+ return listed(asked.site)
+
+
+def _named(alias: str) -> str:
+ """What a host of the ssh config is called as a daemon: anything no name holds a dash."""
+ return re.sub(r"[^A-Za-z0-9._-]", "-", alias).lstrip("._-")
+
+
+def _offered(aliases: list[str]) -> dict[str, dict[str, Any]]:
+ """Each alias that can be brought in as a daemon not written down yet, as it would be.
+
+ Of two aliases that would be called the same, the first is offered.
+ """
+ daemons = saved()
+ names = {one["name"] for one in daemons}
+ taken = {one["alias"] for one in daemons}
+ offered: dict[str, dict[str, Any]] = {}
+ for alias in aliases:
+ if alias in taken:
+ continue
+ try:
+ one = _checked(_named(alias), {"alias": alias})
+ except ValueError:
+ continue
+ if one["name"] not in names:
+ names.add(one["name"])
+ offered[alias] = one
+ return offered
+
+
+def _hosts(_asked: Asked) -> dict[str, Any]:
+ import dataclasses
+
+ from hmz.coganchor.machines import sshconfig
+
+ try:
+ return {
+ "hosts": [
+ dataclasses.asdict(sshconfig.resolve(alias)) | {"name": one["name"]}
+ for alias, one in _offered(sshconfig.aliases()).items()
+ ]
+ }
+ except OSError as why:
+ raise Refusal(f"The ssh config could not be read: {why}", 503) from why
+
+
+def _imports(asked: Asked) -> dict[str, Any]:
+ from hmz.coganchor.machines import sshconfig
+
+ if extra := sorted(set(asked.body) - {"aliases"}):
+ raise Refusal(f"An import takes no {', '.join(extra)}.")
+ wanted = asked.texts("aliases")
+ if not wanted:
+ raise Refusal("Say aliases as the hosts to bring in, by their Host.")
+ there = sshconfig.aliases()
+ if missing := [one for one in wanted if one not in there]:
+ raise Refusal(f"Your ssh config has no host {', '.join(missing)}.", 404)
+ for alias in wanted:
+ try:
+ _checked(_named(alias), {"alias": alias})
+ except ValueError as why:
+ raise Refusal(f"{alias} cannot be a daemon: {why}") from why
+ added: list[str] = []
+
+ def change(daemons: list[dict[str, Any]]) -> None:
+ names = {one["name"] for one in daemons}
+ taken = {one["alias"] for one in daemons}
+ for alias in dict.fromkeys(wanted):
+ one = _checked(_named(alias), {"alias": alias})
+ if alias not in taken and one["name"] not in names:
+ daemons.append(one)
+ names.add(one["name"])
+ added.append(one["name"])
+
+ _changes(change)
+ return listed(asked.site) | {"added": added}
+
+
+#: What a page reads of the daemons, and does to them.
+ROUTES = routes(
+ ("GET", "/api/daemons", lambda asked: listed(asked.site)),
+ ("POST", "/api/daemons", _write),
+ ("POST", "/api/daemons/remove", _remove),
+ ("GET", "/api/daemons/hosts", _hosts),
+ ("POST", "/api/daemons/import", _imports),
+)
diff --git a/src/hmz/web/notices.py b/src/hmz/web/notices.py
new file mode 100644
index 00000000..b8b936d3
--- /dev/null
+++ b/src/hmz/web/notices.py
@@ -0,0 +1,273 @@
+"""What happened to the runs of this machine worth telling somebody about, as notices.
+
+An epic ending, a question for a person, a chat waiting for its next turn: each is a notice,
+worked out of how every workspace's runs stand -- asked of each host every couple of seconds,
+as `Places` asks what runs, and never by attaching to it, which would keep a host that would
+otherwise go. What stood when this started is where it starts from rather than news. The last
+few are kept, each numbered after the one before and after any this machine numbered before it
+was started again, so that a page -- or another machine's interface -- carries on from the last
+one it heard.
+"""
+
+from __future__ import annotations
+
+import collections
+import contextlib
+import threading
+import time
+from typing import TYPE_CHECKING, Any
+
+from .routing import Refusal, Stream, routes
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+
+ from .routing import Asked
+
+__all__ = ["ROUTES", "Notices"]
+
+#: How often how the runs stand is asked, in seconds.
+_EVERY = 2.0
+
+#: How many notices are kept.
+_KEPT = 200
+
+#: How long a stream says nothing before it says it is still there, in seconds.
+_QUIET = 15.0
+
+#: The kind of notice each way an epic can end is.
+_ENDED = {
+ "done": "done",
+ "stopped": "stopped",
+ "failed": "failed",
+ "crashed": "failed",
+ "budget": "failed",
+ "refused": "failed",
+}
+
+#: The kind of notice a question waited on is, by its mode.
+_ASKED = {"ask": "asked", "listen": "listening"}
+
+
+class Notices:
+ """The notices of this machine's runs, worked out of how they stand as that changes.
+
+ Args:
+ statuses: How the runs of each workspace stand, by its path, as a host's `status`
+ says: asked once a look, and never of a host it would keep.
+ every: How often to look once started, in seconds.
+ """
+
+ def __init__(
+ self,
+ statuses: Callable[[], dict[str, dict[str, Any]]],
+ *,
+ every: float = _EVERY,
+ ) -> None:
+ self._statuses = statuses
+ self._every = every
+ self._changed = threading.Condition()
+ self._kept: collections.deque[dict[str, Any]] = collections.deque(maxlen=_KEPT)
+ self._last = 0
+ self._listeners: list[Callable[[dict[str, Any]], None]] = []
+ #: How each workspace last stood, where its host said: what is news is against this.
+ self._stood: dict[str, dict[str, Any]] | None = None
+ #: When it first looked: an epic that ended before then is no news, wherever it is.
+ self._began = 0.0
+ self._closed = threading.Event()
+
+ @property
+ def last(self) -> int:
+ """The id of the newest notice, or 0 where there is none."""
+ with self._changed:
+ return self._last
+
+ def start(self) -> None:
+ """Looks every so often, on a thread of its own, until closed."""
+ threading.Thread(target=self._looking, daemon=True, name="hmz-notices").start()
+
+ def look(self) -> list[dict[str, Any]]:
+ """Asks how the runs stand, once, and tells what is new since the last look.
+
+ The first look is where it starts from: nothing that stood then is news.
+
+ Returns:
+ The notices that look made, oldest first.
+ """
+ statuses = self._statuses()
+ made: list[dict[str, Any]] = []
+ with self._changed:
+ first = self._stood is None
+ if first:
+ self._began = time.time()
+ stood = self._stood if self._stood is not None else {}
+ for workspace, status in statuses.items():
+ # A host that would not answer says neither: unknown, rather than nothing.
+ if "pending" not in status or "ended" not in status:
+ continue
+ if not first:
+ was = stood.get(workspace, {})
+ made += _noticed(workspace, was, status, self._began)
+ stood[workspace] = {
+ # Which host it was, apart: another one numbers its questions afresh.
+ "host": (status.get("pid"), status.get("started")),
+ "ended": status.get("ended"),
+ "pending": list(status.get("pending") or []),
+ }
+ self._stood = stood
+ for one in made:
+ self._last = one["id"] = max(self._last + 1, int(time.time() * 1000))
+ self._kept.append(one)
+ if made:
+ self._changed.notify_all()
+ listeners = list(self._listeners)
+ for one in made:
+ for listener in listeners:
+ with contextlib.suppress(Exception):
+ listener(dict(one))
+ return made
+
+ def since(self, after: int | str = 0) -> list[dict[str, Any]]:
+ """The notices kept after one, oldest first.
+
+ Args:
+ after: The id of the last one heard, or 0 for every one kept.
+
+ Raises:
+ Refusal: If `after` is not a whole number.
+ """
+ try:
+ heard = int(after or 0)
+ except ValueError as why:
+ raise Refusal("Say after as a whole number.") from why
+ with self._changed:
+ return [dict(one) for one in self._kept if one["id"] > heard]
+
+ def heard(self, listener: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
+ """Tells a listener each notice as it is made, on the thread that looks.
+
+ Args:
+ listener: Told each notice; what it raises is not this one's.
+
+ Returns:
+ What stops telling it.
+ """
+ with self._changed:
+ self._listeners.append(listener)
+
+ def unheard() -> None:
+ with self._changed, contextlib.suppress(ValueError):
+ self._listeners.remove(listener)
+
+ return unheard
+
+ def stream(self) -> Stream:
+ """Every notice after the last one a page heard, then each as it comes.
+
+ A page that heard none hears only those made from now on.
+ """
+ return Stream(self._events)
+
+ def close(self) -> None:
+ """Stops looking, and ends every stream."""
+ self._closed.set()
+ with self._changed:
+ self._changed.notify_all()
+
+ def _events(
+ self, last: str, going: threading.Event
+ ) -> Iterator[tuple[str, str, Any] | None]:
+ # Where it starts from is as it is asked for, not as it is first read.
+ return self._after(int(last) if last.isdigit() else self.last, going)
+
+ def _after(
+ self, after: int, going: threading.Event
+ ) -> Iterator[tuple[str, str, Any] | None]:
+ while not (going.is_set() or self._closed.is_set()):
+ with self._changed:
+ fresh = [one for one in self._kept if one["id"] > after]
+ if not fresh:
+ self._changed.wait(_QUIET)
+ fresh = [one for one in self._kept if one["id"] > after]
+ if not fresh:
+ yield None
+ continue
+ for one in fresh:
+ after = one["id"]
+ yield str(after), "notice", dict(one)
+
+ def _looking(self) -> None:
+ while True:
+ with contextlib.suppress(Exception):
+ self.look()
+ if self._closed.wait(self._every):
+ return
+
+
+def _noticed(
+ workspace: str, was: dict[str, Any], now: dict[str, Any], began: float
+) -> list[dict[str, Any]]:
+ """What is news in how one workspace's runs stand now against how they stood.
+
+ Args:
+ workspace: Whose runs.
+ was: How they last stood, or nothing where they were not seen before.
+ now: How they stand now.
+ began: When the first look was: an epic that ended before is no news.
+ """
+ made: list[dict[str, Any]] = []
+ ended = now.get("ended")
+ if ended and ended != was.get("ended") and float(ended.get("at") or began) >= began:
+ why = str(ended.get("why") or "").strip()
+ made.append(
+ {
+ "kind": _ENDED.get(str(ended.get("how")), "failed"),
+ "workspace": workspace,
+ "epic": ended.get("epic") or "",
+ "flow": ended.get("flow") or now.get("flow") or "",
+ "task": ended.get("task") or now.get("task") or "",
+ "role": "",
+ # Why in a line: of a crash, the last of its traceback.
+ "text": why.splitlines()[-1] if why else "",
+ "at": ended.get("at") or time.time(),
+ }
+ )
+ same = was.get("host") == (now.get("pid"), now.get("started"))
+ stood: list[dict[str, Any]] = (was.get("pending") or []) if same else []
+ pending: list[dict[str, Any]] = now.get("pending") or []
+ asked = {one.get("question") for one in stood}
+ for one in pending:
+ kind = _ASKED.get(one.get("mode", ""))
+ if kind is None or one.get("question") in asked:
+ continue
+ made.append(
+ {
+ "kind": kind,
+ "workspace": workspace,
+ "epic": now.get("epic") or "",
+ "flow": now.get("flow") or "",
+ "task": now.get("task") or "",
+ "role": one.get("role") or "",
+ "text": one.get("text") or "",
+ "at": time.time(),
+ }
+ )
+ return made
+
+
+def _notices(asked: Asked) -> dict[str, Any]:
+ notices: Notices = asked.site.notices
+ found = notices.since(asked.query.get("after", "0"))
+ return {"notices": found, "last": notices.last}
+
+
+def _stream(asked: Asked) -> Stream:
+ notices: Notices = asked.site.notices
+ return notices.stream()
+
+
+#: What a page reads of the notices.
+ROUTES = routes(
+ ("GET", "/api/notices", _notices),
+ ("GET", "/api/notices/stream", _stream),
+)
diff --git a/src/hmz/web/notifications.py b/src/hmz/web/notifications.py
new file mode 100644
index 00000000..b30391de
--- /dev/null
+++ b/src/hmz/web/notifications.py
@@ -0,0 +1,396 @@
+"""Notifications: what every daemon's notices come to, on this machine's interface alone.
+
+Each `hmz web` serves its own machine's notices -- an epic ended, a flow asking a person, a
+chat waiting for its next turn. The interface a person has open is told them of every daemon at
+once: this machine's as they are heard, and each SSH daemon's, while it is reached, off the
+stream its own interface serves. Each is told by which daemon said it, kept only where the kinds
+a person wants to be told of say so, and kept a while -- the last few hundred -- with whether it
+was read, for every page this interface serves to follow from where it last heard.
+
+Which kinds are told, and whether the system is asked to show them too, are the app's own
+settings, kept under `notifications` in this machine's `settings.yaml` and never a daemon's.
+"""
+
+from __future__ import annotations
+
+import collections
+import contextlib
+import http.client
+import json
+import threading
+import time
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Any, cast
+
+from .routing import Refusal, Stream, routes
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+
+ from .routing import Asked
+
+__all__ = ["KINDS", "ROUTES", "Notifications", "daemons", "preferences"]
+
+#: Every kind of notice there is: an epic done, failed or stopped, a person asked, and a chat
+#: listening for its next turn.
+KINDS = ("done", "failed", "stopped", "asked", "listening")
+
+#: How many notifications are kept.
+_KEPT = 200
+
+#: How often the daemons there are, and which are reached, are looked at again.
+_LOOKING = 2.0
+
+#: How long a stream says nothing before it says it is still there.
+_QUIET = 15.0
+
+#: How long a daemon's interface may say nothing before its stream is given up on: it says it is
+#: still there more often than this.
+_SILENT = 60.0
+
+
+def preferences(held: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Which notifications are wanted, as `settings.yaml` holds them: every one, where it is silent.
+
+ Args:
+ held: Everything `settings.yaml` holds; read afresh where None.
+
+ Returns:
+ `kinds`, whether each kind is told, and `system`, whether the system is asked to show
+ them too.
+ """
+ if held is None:
+ from hmz.coganchor import settings
+
+ held = settings.read()
+ said: object = held.get("notifications")
+ wanted = cast("dict[str, Any]", said) if isinstance(said, dict) else {}
+ kinds: object = wanted.get("kinds")
+ each = cast("dict[str, Any]", kinds) if isinstance(kinds, dict) else {}
+ return {
+ "kinds": {kind: each.get(kind) is not False for kind in KINDS},
+ "system": wanted.get("system") is not False,
+ }
+
+
+def daemons(held: dict[str, Any] | None = None) -> list[str]:
+ """The name of every SSH daemon `settings.yaml` holds, as it holds them.
+
+ Args:
+ held: Everything `settings.yaml` holds; read afresh where None.
+ """
+ if held is None:
+ from hmz.coganchor import settings
+
+ held = settings.read()
+ said: object = held.get("daemons")
+ entries = cast("list[object]", said) if isinstance(said, list) else []
+ named = [
+ cast("dict[str, Any]", one).get("name")
+ for one in entries
+ if isinstance(one, dict)
+ ]
+ return [str(one) for one in named if one and one != "local"]
+
+
+class Notifications:
+ """Every daemon's notices, as this machine's interface tells them.
+
+ Hears this machine's own as they come, and follows each SSH daemon's while it is reached:
+ the notices held as `site.notices` and the daemons reached through `site.remotes`, each
+ looked for again as long as it is missing.
+
+ Args:
+ site: What holds this machine's notices and the daemons reached, where it has them.
+ wanted: Which notifications are wanted, asked afresh of every notice.
+ named: The SSH daemons there are, asked afresh every little while.
+ """
+
+ def __init__(
+ self,
+ site: Any,
+ *,
+ wanted: Callable[[], dict[str, Any]] = preferences,
+ named: Callable[[], list[str]] = daemons,
+ ) -> None:
+ self._site = site
+ self._wanted = wanted
+ self._named = named
+ self._changed = threading.Condition()
+ self._kept: collections.deque[dict[str, Any]] = collections.deque(maxlen=_KEPT)
+ self._told = 0
+ self._closed = threading.Event()
+ self._hearing: object = None
+ #: Each daemon followed, by name: the connection its stream comes over, once there is one.
+ self._following: dict[str, list[http.client.HTTPConnection]] = {}
+ #: The last notice heard of each daemon, by name: where its stream is taken up again.
+ self._last: dict[str, str] = {}
+ self._hears()
+ threading.Thread(
+ target=self._looks, daemon=True, name="hmz-web-notifications"
+ ).start()
+
+ def told(self, daemon: str, notice: dict[str, Any]) -> dict[str, Any] | None:
+ """Keeps one daemon's notice as a notification, where its kind is wanted.
+
+ Args:
+ daemon: Which said it: `local`, or an SSH daemon's name.
+ notice: The notice, as its daemon serves it.
+
+ Returns:
+ The notification, or None where its kind is not wanted.
+ """
+ if not self._wanted()["kinds"].get(str(notice.get("kind")), True):
+ return None
+ with self._changed:
+ self._told += 1
+ made = notice | {"id": str(self._told), "daemon": daemon, "read": False}
+ self._kept.append(made)
+ self._changed.notify_all()
+ return dict(made)
+
+ def listed(self) -> dict[str, Any]:
+ """Every notification kept, newest first, and how many are unread."""
+ with self._changed:
+ items = [dict(one) for one in reversed(self._kept)]
+ return {"items": items, "unread": sum(not one["read"] for one in items)}
+
+ def read(self, ids: list[str] | None = None) -> dict[str, Any]:
+ """Marks notifications read: those named, or every one where none are.
+
+ Returns:
+ As `listed`.
+ """
+ with self._changed:
+ for one in self._kept:
+ if ids is None or one["id"] in ids:
+ one["read"] = True
+ return self.listed()
+
+ def clear(self) -> dict[str, Any]:
+ """Forgets every notification kept.
+
+ Returns:
+ As `listed`.
+ """
+ with self._changed:
+ self._kept.clear()
+ return self.listed()
+
+ def stream(self) -> Stream:
+ """Every notification after the one a page last heard, then each as it comes."""
+ return Stream(self._events)
+
+ def close(self) -> None:
+ """Stops following every daemon, and ends every stream."""
+ self._closed.set()
+ with self._changed:
+ following = [one for held in self._following.values() for one in held]
+ self._changed.notify_all()
+ for connection in following:
+ with contextlib.suppress(OSError):
+ connection.close()
+
+ def _events(
+ self, last: str, going: threading.Event
+ ) -> Iterator[tuple[str, str, Any] | None]:
+ with self._changed:
+ told = self._told
+ # One heard of an interface before this, which counted from the start again, has
+ # heard none of this one's. Where it starts from is as it is asked for, not as it is
+ # first read.
+ heard = int(last) if last.isdigit() else told
+ return self._after(heard if heard <= told else 0, going)
+
+ def _after(
+ self, after: int, going: threading.Event
+ ) -> Iterator[tuple[str, str, Any] | None]:
+ while not (going.is_set() or self._closed.is_set()):
+ with self._changed:
+ fresh = [dict(one) for one in self._kept if int(one["id"]) > after]
+ if not fresh:
+ self._changed.wait(_QUIET)
+ fresh = [dict(one) for one in self._kept if int(one["id"]) > after]
+ if not fresh:
+ yield None
+ continue
+ for one in fresh:
+ after = int(one["id"])
+ yield one["id"], "notification", one
+
+ def _hears(self) -> None:
+ """Hears this machine's own notices, once there are any to hear."""
+ notices = getattr(self._site, "notices", None)
+ if notices is None or self._hearing is notices:
+ return
+ self._hearing = notices
+ notices.heard(self._heard)
+
+ def _heard(self, said: dict[str, Any] | list[dict[str, Any]]) -> None:
+ for notice in said if isinstance(said, list) else [said]:
+ self.told("local", notice)
+
+ def _looks(self) -> None:
+ """Every little while: this machine's notices, and every SSH daemon reached, followed."""
+ while not self._closed.wait(_LOOKING):
+ # Whatever went wrong is looked at again in a while.
+ with contextlib.suppress(Exception):
+ self._hears()
+ self._reaches(getattr(self._site, "remotes", None))
+
+ def _reaches(self, remotes: Any) -> None:
+ """Follows every SSH daemon reached that is not followed yet."""
+ if remotes is None:
+ return
+ for name in self._named():
+ if name in self._following or remotes.state(name).get("state") != "up":
+ continue
+ with self._changed:
+ self._following[name] = []
+ threading.Thread(
+ target=self._follows,
+ args=(remotes, name),
+ daemon=True,
+ name=f"hmz-web-notices-{name}",
+ ).start()
+
+ def _follows(self, remotes: Any, name: str) -> None:
+ """Follows one daemon's notices while it is reached, from where it was last heard."""
+ try:
+ port, key = remotes.reached(name)
+ connection = http.client.HTTPConnection("127.0.0.1", port, timeout=_SILENT)
+ with self._changed:
+ if self._closed.is_set():
+ return
+ self._following[name].append(connection)
+ cookie = _signs_in(connection, key)
+ # What it said before it was first reached was said to nobody here, and is not
+ # told; one started again since it was last heard counts from the start again.
+ now = str(_asks(connection, cookie, "/api/notices")["last"])
+ heard = self._last.get(name)
+ if heard is None or (
+ heard.isdigit() and now.isdigit() and int(heard) > int(now)
+ ):
+ self._last[name] = now if heard is None else "0"
+ connection.request(
+ "GET",
+ "/api/notices/stream",
+ headers={"Cookie": cookie, "Last-Event-ID": self._last[name]},
+ )
+ answer = connection.getresponse()
+ if answer.status != HTTPStatus.OK:
+ return
+ event, ident, data = "", "", list[str]()
+ while not self._closed.is_set():
+ line = answer.readline()
+ if not line:
+ return
+ said = line.decode().rstrip("\r\n")
+ if said.startswith("id:"):
+ ident = said[3:].strip()
+ elif said.startswith("event:"):
+ event = said[6:].strip()
+ elif said.startswith("data:"):
+ data.append(said[5:].removeprefix(" "))
+ elif not said:
+ if event == "notice" and data:
+ notice = json.loads("\n".join(data))
+ self._last[name] = ident or str(notice.get("id", ""))
+ self.told(name, notice)
+ event, ident, data = "", "", list[str]()
+ except Exception: # noqa: BLE001 -- followed again once it is reached again
+ time.sleep(_LOOKING)
+ finally:
+ with self._changed:
+ for connection in self._following.pop(name, []):
+ with contextlib.suppress(OSError):
+ connection.close()
+
+
+def _signs_in(connection: http.client.HTTPConnection, key: str) -> str:
+ """Lets this in to a daemon's interface with its key, as a browser is let in.
+
+ Returns:
+ The cookie it is handed, named for the port that interface listens on there.
+
+ Raises:
+ OSError: If it would not let this in.
+ """
+ connection.request("GET", f"/?key={key}")
+ answer = connection.getresponse()
+ answer.read()
+ cookie = (answer.getheader("Set-Cookie") or "").partition(";")[0]
+ if not cookie:
+ raise OSError(f"the daemon's interface would not let this in: {answer.status}")
+ return cookie
+
+
+def _asks(
+ connection: http.client.HTTPConnection, cookie: str, path: str
+) -> dict[str, Any]:
+ """What a daemon's interface answers one read with."""
+ connection.request("GET", path, headers={"Cookie": cookie})
+ answer = connection.getresponse()
+ body = answer.read()
+ if answer.status != HTTPStatus.OK:
+ raise OSError(f"the daemon's interface answered {answer.status}")
+ return cast("dict[str, Any]", json.loads(body))
+
+
+def _held(asked: Asked) -> Notifications:
+ held = getattr(asked.site, "notifications", None)
+ if held is None:
+ raise Refusal("Notifications are not kept here.", 404)
+ return cast("Notifications", held)
+
+
+def _read(asked: Asked) -> dict[str, Any]:
+ if asked.body.get("all") is True:
+ return _held(asked).read()
+ said: object = asked.body.get("ids")
+ if not isinstance(said, list) or not all(
+ isinstance(one, str | int) and not isinstance(one, bool)
+ for one in cast("list[object]", said)
+ ):
+ raise Refusal("Say ids as a list, or all as true.")
+ return _held(asked).read([str(one) for one in cast("list[object]", said)])
+
+
+def _prefers(asked: Asked) -> dict[str, Any]:
+ said = asked.body
+ if extra := sorted(set(said) - {"kinds", "system"}):
+ raise Refusal(f"Notifications take no {', '.join(extra)}.")
+ if not isinstance(said.get("kinds", {}), dict):
+ raise Refusal("kinds is whether each kind is told, by name.")
+ kinds = asked.named("kinds")
+ if extra := sorted(set(kinds) - set(KINDS)):
+ raise Refusal(f"There is no kind {', '.join(extra)}.")
+ if not all(isinstance(one, bool) for one in kinds.values()):
+ raise Refusal("Each kind is true or false.")
+ if "system" in said and not isinstance(said["system"], bool):
+ raise Refusal("system is true or false.")
+
+ def change(held: dict[str, Any]) -> None:
+ now = preferences(held)
+ now["kinds"] |= kinds
+ if "system" in said:
+ now["system"] = said["system"]
+ held["notifications"] = now
+
+ from hmz.coganchor import settings
+
+ try:
+ return preferences(settings.changes(change))
+ except OSError as why:
+ raise Refusal(f"The settings cannot be written: {why}", 409) from why
+
+
+#: What a page reads of the notifications, and does to them.
+ROUTES = routes(
+ ("GET", "/api/notifications", lambda asked: _held(asked).listed()),
+ ("GET", "/api/notifications/stream", lambda asked: _held(asked).stream()),
+ ("POST", "/api/notifications/read", _read),
+ ("POST", "/api/notifications/clear", lambda asked: _held(asked).clear()),
+ ("GET", "/api/notifications/settings", lambda _: preferences()),
+ ("POST", "/api/notifications/settings", _prefers),
+)
diff --git a/src/hmz/web/places.py b/src/hmz/web/places.py
index 129f2b39..05df3a47 100644
--- a/src/hmz/web/places.py
+++ b/src/hmz/web/places.py
@@ -249,18 +249,43 @@ def _looking(self) -> None:
if self._apart:
self._asks_the_daemon()
- def _asks_the_daemon(self) -> None:
- """What the daemon says runs in each workspace no link here follows."""
- running: dict[str, dict[str, Any]] = {}
+ def statuses(self) -> dict[str, dict[str, Any]]:
+ """How the runs of every workspace held on this machine stand, attaching to none.
+
+ Apart, what each host the daemon holds says of itself; otherwise, what the runs held
+ in this process say, of each workspace a page has reached.
+
+ Returns:
+ Each host's status, by the whole path of its workspace.
+ """
+ said: dict[str, dict[str, Any]] = {}
+ if not self._apart:
+ with self._lock:
+ hosts = [
+ (str(one.workspace), one.hosting)
+ for one in self._places.values()
+ if one.hosting is not None
+ ]
+ for workspace, hosting in hosts:
+ with contextlib.suppress(Exception):
+ said[workspace] = hosting.status()
+ return said
with contextlib.suppress(Exception):
for one in self._daemon.daemons():
with contextlib.suppress(Exception):
- status = one.status()
- running[str(Path(one.workspace).resolve())] = {
- "state": status.get("state") or "idle",
- "run": status.get("run") or 0,
- "flow": status.get("flow") or "",
- }
+ said[str(Path(one.workspace).resolve())] = one.status()
+ return said
+
+ def _asks_the_daemon(self) -> None:
+ """What the daemon says runs in each workspace no link here follows."""
+ running = {
+ workspace: {
+ "state": status.get("state") or "idle",
+ "run": status.get("run") or 0,
+ "flow": status.get("flow") or "",
+ }
+ for workspace, status in self.statuses().items()
+ }
with self._lock:
self._running = running
diff --git a/src/hmz/web/proxy.py b/src/hmz/web/proxy.py
new file mode 100644
index 00000000..c09c0c68
--- /dev/null
+++ b/src/hmz/web/proxy.py
@@ -0,0 +1,137 @@
+"""A request of this interface's page, asked of another machine's interface instead.
+
+What a page asks of a daemon on another machine, under `/d//api/...`, is asked of that
+machine's own interface at `/api/...`, through the port forwarded to it, and its answer handed
+back as it comes -- a stream of events as each event comes, a file sent or downloaded a piece
+at a time. The request is let in there as that interface's own page's: with its key, and saying
+it comes from its own origin. Nothing of this interface's own key or cookies goes with it, and
+nothing of what that one sets comes back.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import http.client
+from typing import TYPE_CHECKING
+
+from .routing import Refusal
+
+if TYPE_CHECKING:
+ from http.server import BaseHTTPRequestHandler
+
+__all__ = ["forwards"]
+
+#: What a request carries on: what it sends and what it asks for, never who it is.
+_ASKED = (
+ "Accept",
+ "Content-Type",
+ "Content-Length",
+ "If-None-Match",
+ "Last-Event-ID",
+ "X-Hmz-Filename",
+)
+
+#: What an answer does not carry back: what is the connection's own, and the other's cookies.
+_KEPT = frozenset(
+ {
+ "connection",
+ "keep-alive",
+ "transfer-encoding",
+ "set-cookie",
+ "server",
+ "date",
+ }
+)
+
+#: What would end a header's line early, and so start another the other side never sent.
+_SPLITS = frozenset("\r\n")
+
+#: How much is carried at a time.
+_CHUNK = 1 << 16
+
+#: How long, in seconds, the other side may say nothing before it is let go. A stream of
+#: events says something more often than this.
+_PATIENCE = 300.0
+
+
+def forwards(
+ handler: BaseHTTPRequestHandler,
+ port: int,
+ said: dict[str, str],
+ method: str,
+ path: str,
+) -> None:
+ """Asks the interface on a port what a request asked, and answers the request with it.
+
+ Args:
+ handler: The request, not yet answered.
+ port: Where the other interface is reached, on this machine's loopback.
+ said: What a request to it carries to be let in, by header.
+ method: The request's method.
+ path: What to ask for there: its path and query.
+
+ Raises:
+ Refusal: If it could not be asked, before anything of an answer was sent.
+ """
+ connection = http.client.HTTPConnection("127.0.0.1", port, timeout=_PATIENCE)
+ try:
+ try:
+ answer = _asks(handler, connection, said, method, path)
+ except (OSError, http.client.HTTPException) as why:
+ raise Refusal("The daemon did not answer.", 502) from why
+ handler.send_response(answer.status, answer.reason)
+ for name, value in answer.getheaders():
+ # A header that would break a line of this answer is not carried, whoever sent it.
+ if name.lower() not in _KEPT and not _SPLITS.intersection(name + value):
+ handler.send_header(name, value)
+ if answer.getheader("Content-Length") is None:
+ # Without a length, the answer ends where the connection does.
+ handler.close_connection = True
+ handler.end_headers()
+ while chunk := answer.read1(_CHUNK):
+ handler.wfile.write(chunk)
+ handler.wfile.flush()
+ except (OSError, http.client.HTTPException):
+ # Cut off once answering had begun: nothing is left to say but to stop.
+ handler.close_connection = True
+ finally:
+ connection.close()
+
+
+def _asks(
+ handler: BaseHTTPRequestHandler,
+ connection: http.client.HTTPConnection,
+ said: dict[str, str],
+ method: str,
+ path: str,
+) -> http.client.HTTPResponse:
+ """Sends the request on, what it sends a piece at a time, and reads how it is answered."""
+ if handler.headers.get("Transfer-Encoding") is not None:
+ raise Refusal("Say how long what is sent is.", 411)
+ try:
+ length = int(handler.headers.get("Content-Length") or 0)
+ except ValueError as why:
+ raise Refusal("Say how long what is sent is.", 411) from why
+ if length < 0:
+ raise Refusal("Say how long what is sent is.", 411)
+ connection.putrequest(method, path, skip_host=True, skip_accept_encoding=True)
+ for name in _ASKED:
+ value = handler.headers.get(name)
+ if value is not None:
+ connection.putheader(name, value)
+ for name, value in said.items():
+ connection.putheader(name, value)
+ connection.endheaders()
+ left = length
+ # An answer given before everything was sent -- a refusal -- is still the answer.
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
+ while left:
+ chunk = handler.rfile.read(min(left, _CHUNK))
+ if not chunk:
+ raise Refusal("What was sent was cut short.", 400)
+ connection.send(chunk)
+ left -= len(chunk)
+ if left:
+ # What was not read of what was sent is no next request.
+ handler.close_connection = True
+ return connection.getresponse()
diff --git a/src/hmz/web/remote.py b/src/hmz/web/remote.py
new file mode 100644
index 00000000..adb62409
--- /dev/null
+++ b/src/hmz/web/remote.py
@@ -0,0 +1,684 @@
+"""Daemons on other machines, reached over ssh and answered through this interface.
+
+Each daemon written down under `daemons` in humanize's settings is a host that `ssh` reaches.
+What is reached there is that machine's own web interface: `hmz web --attached`, started over
+ssh and stopped by the ssh going, with a port of this machine's loopback forwarded on to it. A
+page asks this interface, which asks that one with its key: the remote interface keeps its own
+key, its own runs and its own refusals, and nothing of it is served to anything but this page.
+
+An `hmz` missing there, or of another version than this one, is installed at this version with
+`uv tool install`, uv itself fetched first where the host has none -- but never in place of one
+the host's daemon may be running epics under, which keep running the release they started
+with. A development build, `0.0.0`, installs nothing: it uses whatever `hmz` is there.
+
+A daemon is reached when a page first asks for it, or is told to connect, and reached again,
+waiting longer each time, whenever it is lost.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import dataclasses
+import queue
+import re
+import socket
+import subprocess
+import threading
+import time
+from typing import TYPE_CHECKING, Any, cast
+
+from .routing import Refusal, routes
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator, Sequence
+ from typing import IO
+
+ from hmz.coganchor.transport import Road
+
+ from .routing import Asked
+
+__all__ = ["ROUTES", "Remotes", "forwarding", "headers", "road", "said"]
+
+#: The release of uv fetched onto a host that has none, through astral's installer for it:
+#: the one the desktop app pins.
+UV = "0.12.24"
+
+#: What a daemon's state may be.
+IDLE, CONNECTING, INSTALLING, UP, FAILED = (
+ "idle",
+ "connecting",
+ "installing",
+ "up",
+ "failed",
+)
+
+#: What every command there runs with: where `uv tool` and astral's installer put what they
+#: install, which a shell that is not a login one may not have on its `PATH`.
+_PATH = 'export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"; '
+
+#: What is asked of a host before anything is started there: the version of its `hmz`, and
+#: whether its daemon is running -- by the process `daemon.json` names in `humanize-` of
+#: any temporary directory one may have been started with, as the desktop app tells --
+#: `unknown` where that cannot be read.
+_PROBE = (
+ _PATH
+ + r"""
+echo "hmz: $(hmz --version 2>/dev/null | sed -n 's/^hmz //p' | tail -n 1)"
+daemon=none
+for under in "$TMPDIR" "$TEMP" "$TMP" "$(getconf DARWIN_USER_TEMP_DIR 2>/dev/null)" \
+ /tmp /var/tmp; do
+ [ -n "$under" ] || continue
+ record="${under%/}/humanize-$(id -u)/daemon.json"
+ [ -e "$record" ] || continue
+ if [ ! -r "$record" ]; then daemon=unknown; continue; fi
+ pid=$(sed -n 's/.*"pid": *\([0-9][0-9]*\).*/\1/p' "$record" | head -n 1)
+ if [ -z "$pid" ]; then daemon=unknown
+ elif kill -0 "$pid" 2>/dev/null || ps -p "$pid" >/dev/null 2>&1; then
+ daemon=running; break
+ fi
+done
+echo "daemon: $daemon"
+"""
+)
+
+#: Installs `hmz` at a version, `{version}`, fetching uv first where there is none.
+_INSTALL = _PATH + (
+ "export UV_NO_MODIFY_PATH=1; "
+ "if ! command -v uv >/dev/null 2>&1; then "
+ f"echo 'Getting uv {UV}'; "
+ "if command -v curl >/dev/null 2>&1; "
+ f"then curl -LsSf https://astral.sh/uv/{UV}/install.sh | sh; "
+ f"else wget -qO- https://astral.sh/uv/{UV}/install.sh | sh; fi; "
+ "command -v uv >/dev/null 2>&1 || exit 1; fi; "
+ "exec uv tool install --force --python '>=3.12' 'hmz=={version}'"
+)
+
+#: Starts the interface there, for as long as the ssh holding its standard input lives.
+_SERVE = _PATH + "exec hmz web --no-open --attached --port 0"
+
+#: The line it says where it listens with.
+_ADDRESS = re.compile(r"hmz web: http://127\.0\.0\.1:(\d+)/\?key=(\S+)")
+
+#: What `ssh` is told for every command here before what the host's own settings and humanize's
+#: say beside them: nothing is asked of anybody, since nobody is at a terminal to answer.
+_UNASKED = ("-o", "BatchMode=yes", "-o", "ConnectTimeout=20")
+
+#: How long, in seconds, asking a host what it has may take.
+_PROBING = 60.0
+
+#: How long installing `hmz` there may take.
+_INSTALLING = 900.0
+
+#: How long the interface there may take to say where it listens.
+_STARTING = 60.0
+
+#: How long the forwarded port may take to be listened on.
+_FORWARDING = 20.0
+
+#: How long a page asking for a daemon not yet reached waits for it.
+_WAITING = 15.0
+
+#: The wait before reaching a daemon again, doubled each time it fails, up to the last.
+_BACKOFF = (1.0, 60.0)
+
+#: How long a process is given to stop before it is killed.
+_STOPPING = 3.0
+
+
+class _Failed(Exception): # noqa: N818 -- the sentence a daemon's state says
+ """Reaching a daemon went wrong, as its state says it."""
+
+
+def _here_version() -> str:
+ """This humanize's version, as PyPI has it."""
+ from importlib.metadata import version
+
+ return version("hmz")
+
+
+def _entries() -> list[dict[str, Any]]:
+ """Every daemon written down, as humanize's settings hold them."""
+ from hmz.coganchor import settings
+
+ said = settings.read().get("daemons")
+ if not isinstance(said, list):
+ return []
+ return [
+ cast("dict[str, Any]", one)
+ for one in cast("list[object]", said)
+ if isinstance(one, dict)
+ ]
+
+
+def _entry(name: str) -> dict[str, Any] | None:
+ """The daemon written down by that name, or None."""
+ if name == "local": # This machine, whatever is written down.
+ return None
+ return next((one for one in _entries() if one.get("name") == name), None)
+
+
+def road(said: dict[str, Any]) -> Road:
+ """How a command is run on a daemon's host, as its entry says to reach it.
+
+ Raises:
+ ValueError: For an entry that says no host, or says one wrongly.
+ """
+ from hmz.coganchor.machines.store import SSHRuntime
+ from hmz.coganchor.transport import Road, Target, ssh_flags
+
+ options: Any = said.get("options") or []
+ if isinstance(options, dict):
+ options = cast("dict[Any, Any]", options).items()
+ try:
+ pairs = cast("list[tuple[Any, Any]]", list(options))
+ runtime = SSHRuntime(
+ name="daemon",
+ host=str(said.get("host") or ""),
+ user=str(said.get("user") or ""),
+ port=int(said.get("port") or 0),
+ identity_file=str(said.get("identity_file") or ""),
+ proxy_jump=str(said.get("proxy_jump") or ""),
+ options={str(key): str(value) for key, value in pairs},
+ alias=str(said.get("alias") or ""),
+ config=str(said.get("config") or ""),
+ )
+ except (TypeError, ValueError) as why:
+ raise ValueError(str(why).removeprefix("daemon: ")) from why
+ target = Target(
+ "ssh", host=runtime.login(), port=runtime.port, options=runtime.settings()
+ )
+ road = Road.to(target)
+ # After what the host's own settings say, which win, and before what humanize assumes.
+ own = 1 + len(ssh_flags(target.options))
+ prefix = (*road.prefix[:own], *_UNASKED, *road.prefix[own:])
+ return dataclasses.replace(road, prefix=prefix)
+
+
+def forwarding(on: Road, local: int, remote: int) -> list[str]:
+ """The `ssh` that forwards a port of this machine's loopback to one of the host's.
+
+ A connection of its own rather than one riding a shared master, whose forwards outlive
+ the client that asked for them; and one that lasts as long as its standard input, which
+ whatever started it holds, so that it goes with it however that goes.
+ """
+ ssh, *options, host = on.prefix
+ alone = (
+ ssh,
+ "-o",
+ "ControlMaster=no",
+ "-o",
+ "ControlPath=none",
+ "-o",
+ "ExitOnForwardFailure=yes",
+ *options,
+ "-L",
+ f"127.0.0.1:{local}:127.0.0.1:{remote}",
+ host,
+ )
+ return dataclasses.replace(on, prefix=alone).line(["cat"])
+
+
+def said(lines: Sequence[str]) -> dict[str, str]:
+ """What a host answered the probe with, by what each line says it is."""
+ found: dict[str, str] = {}
+ for line in lines:
+ key, colon, value = line.partition(":")
+ if colon and key in ("hmz", "daemon"):
+ found[key] = value.strip()
+ return found
+
+
+def headers(port: int, key: str) -> dict[str, str]:
+ """What a request to an interface listening on `port` carries to be let in, as its page's."""
+ return {
+ "Host": f"127.0.0.1:{port}",
+ "Origin": f"http://127.0.0.1:{port}",
+ "Sec-Fetch-Site": "same-origin",
+ "Cookie": f"hmz-{port}={key}",
+ }
+
+
+def _free(wanted: int) -> int:
+ """A port free on this machine's loopback: `wanted` where it is, else any.
+
+ The one the other interface listens on where it can be had, so that a cookie for one is
+ named as a cookie for the other.
+ """
+ for port in (wanted, 0):
+ with socket.socket() as trying:
+ try:
+ trying.bind(("127.0.0.1", port))
+ except OSError:
+ continue
+ return int(trying.getsockname()[1])
+ raise _Failed("No port is free here to forward.")
+
+
+def _last(lines: Sequence[str], otherwise: str) -> str:
+ """The last line said, or what is said otherwise."""
+ return next((line for line in reversed(lines) if line.strip()), otherwise).strip()
+
+
+class _Remote:
+ """One daemon, reached and reached again on a thread of its own until it is closed."""
+
+ def __init__(self, name: str, version: str, changed: threading.Condition) -> None:
+ self.name = name
+ self._version = version
+ self._changed = changed
+ self.state = IDLE
+ self.detail = ""
+ self.there = ""
+ self.port = 0
+ self.remote = 0
+ self.key = ""
+ self.entry: dict[str, Any] | None = None
+ #: What is said of it while it is up: why its hmz is not this one's, where it is not.
+ self._note = ""
+ self._children: set[subprocess.Popen[str]] = set()
+ self._closed = threading.Event()
+ self._again = threading.Event()
+ self._thread: threading.Thread | None = None
+
+ def seen(self) -> dict[str, str]:
+ return {"state": self.state, "detail": self.detail, "version": self.there}
+
+ def starts(self, said: dict[str, Any], *, now: bool) -> None:
+ """Reaches it, as `said` is its entry.
+
+ Again at once where that has changed, or where it failed and is wanted `now` rather
+ than after its wait.
+ """
+ if self._thread is None or not self._thread.is_alive():
+ self._closed.clear()
+ self._thread = threading.Thread(
+ target=self._keeps, daemon=True, name=f"hmz-web-remote-{self.name}"
+ )
+ self._thread.start()
+ elif (now and self.state == FAILED) or said != self.entry:
+ self._again.set()
+
+ def close(self) -> None:
+ self._closed.set()
+ self._again.set()
+ with self._changed:
+ children = list(self._children)
+ for child in children:
+ _stops(child)
+ if self._thread is not None:
+ self._thread.join(timeout=_STOPPING * 3)
+
+ def _says(self, state: str, detail: str = "") -> None:
+ with self._changed:
+ self.state, self.detail = state, detail
+ self._changed.notify_all()
+
+ def _keeps(self) -> None:
+ """Reaches it, holds it while it lasts, and reaches it again after a wait."""
+ wait = _BACKOFF[0]
+ while not self._closed.is_set():
+ self._again.clear()
+ self._says(CONNECTING)
+ try:
+ web, forward = self._reaches()
+ except Exception as why: # noqa: BLE001 -- whatever it was is what it says
+ self._ends()
+ if self._closed.is_set():
+ break
+ self._says(FAILED, str(why))
+ else:
+ self._says(UP, self._note)
+ wait = _BACKOFF[0]
+ while not self._again.wait(0.25):
+ if web.poll() is not None or forward.poll() is not None:
+ break
+ self._ends()
+ if self._closed.is_set():
+ break
+ if self._again.is_set():
+ continue
+ self._says(CONNECTING, "Lost")
+ self._again.wait(wait)
+ wait = min(wait * 2, _BACKOFF[1])
+ self._says(IDLE)
+
+ def _reaches(self) -> tuple[subprocess.Popen[str], subprocess.Popen[str]]:
+ """Reaches it.
+
+ Probes the host, installs `hmz` there where it must, starts the interface there and
+ forwards a port to it.
+
+ Returns:
+ The interface there, and the forward.
+
+ Raises:
+ _Failed: Saying what went wrong.
+ """
+ self.entry = _entry(self.name)
+ if self.entry is None:
+ raise _Failed(f"No daemon is called {self.name}.")
+ try:
+ on = road(self.entry)
+ except ValueError as why:
+ raise _Failed(str(why)) from why
+ version = self._installed(on)
+ self.there = version
+ web = self._started(on.line(["sh", "-c", _SERVE]), apart=True)
+ address = self._listens(web)
+ self.remote, self.key = int(address[1]), address[2]
+ self.port = _free(self.remote)
+ forward = self._started(forwarding(on, self.port, self.remote))
+ told = _drained(forward)
+ ending = time.monotonic() + _FORWARDING
+ while not _listened(self.port):
+ if forward.poll() is not None:
+ time.sleep(0.1) # For what it said last to be read.
+ raise _Failed(_last(told, "The port could not be forwarded."))
+ if self._closed.is_set() or time.monotonic() > ending:
+ raise _Failed("The port was not forwarded in time.")
+ time.sleep(0.05)
+ return web, forward
+
+ def _installed(self, on: Road) -> str:
+ """The version of `hmz` there, once it is this one where it may be made so."""
+ self._note = ""
+ found = self._probed(on)
+ there = found.get("hmz", "")
+ wanted = self._version
+ if wanted in ("0.0.0", there):
+ if not there:
+ raise _Failed(f"hmz is not installed on {self.name}.")
+ return there
+ if there and found.get("daemon") != "none":
+ if found.get("daemon") == "running":
+ why = f"hmz {there} there holds running epics; hmz {wanted} waits"
+ else:
+ why = f"Whether hmz {there} there runs epics is unknown; not replaced"
+ self._note = why
+ return there
+ self._says(INSTALLING, f"hmz {wanted}")
+ lines: list[str] = []
+ installing = self._started(
+ on.line(["sh", "-c", _INSTALL.format(version=wanted)])
+ )
+ for line in _lines(installing, _INSTALLING):
+ lines.append(line)
+ if line.strip():
+ self._says(INSTALLING, line.strip())
+ if installing.wait() != 0:
+ raise _Failed(_last(lines, f"hmz {wanted} could not be installed."))
+ there = self._probed(on).get("hmz", "")
+ if there != wanted:
+ raise _Failed(f"hmz {wanted} was installed, and hmz {there} answers.")
+ return there
+
+ def _probed(self, on: Road) -> dict[str, str]:
+ probing = self._started(on.line(["sh", "-c", _PROBE]))
+ lines = list(_lines(probing, _PROBING))
+ if probing.wait() != 0 or "daemon" not in said(lines):
+ raise _Failed(_last(lines, f"{self.name} did not answer."))
+ return said(lines)
+
+ def _listens(self, web: subprocess.Popen[str]) -> re.Match[str]:
+ """Where the interface there listens, once it says."""
+ stdout = web.stdout
+ assert stdout is not None # noqa: S101 -- it was started with one
+ assert web.stderr is not None # noqa: S101
+ heard: queue.Queue[str | None] = queue.Queue()
+ errors = _drained(web, web.stderr)
+
+ def reads() -> None:
+ # Read to its end, which it says nothing more before.
+ with contextlib.suppress(OSError, ValueError), stdout:
+ for line in stdout:
+ heard.put(line)
+ heard.put(None)
+
+ threading.Thread(target=reads, daemon=True, name="hmz-web-remote").start()
+ ending = time.monotonic() + _STARTING
+ while True:
+ try:
+ line = heard.get(timeout=min(1.0, max(0.0, ending - time.monotonic())))
+ except queue.Empty:
+ if time.monotonic() >= ending:
+ raise _Failed(
+ _last(errors, "hmz web there did not say where it listens.")
+ ) from None
+ # What it says while it comes up, said as how it stands.
+ if errors:
+ self._says(CONNECTING, _last(errors, ""))
+ continue
+ if line is None:
+ web.wait()
+ raise _Failed(_last(errors, "hmz web there stopped."))
+ found = _ADDRESS.search(line)
+ if found:
+ return found
+
+ def _started(
+ self, argv: list[str], *, apart: bool = False
+ ) -> subprocess.Popen[str]:
+ """A process, which is stopped as this daemon is closed or lost.
+
+ Its standard input is held open until then, so that it goes with this one however
+ that goes; what it says on stderr is read `apart` from its stdout, or with it.
+ """
+ with self._changed:
+ if self._closed.is_set():
+ raise _Failed("Closed.")
+ try:
+ child = subprocess.Popen(
+ argv,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE if apart else subprocess.STDOUT,
+ text=True,
+ errors="replace",
+ start_new_session=True,
+ )
+ except OSError as why:
+ raise _Failed(f"ssh could not be run: {why}") from why
+ self._children.add(child)
+ return child
+
+ def _ends(self) -> None:
+ """Stops and reaps every process started for it."""
+ with self._changed:
+ children = list(self._children)
+ self._children.clear()
+ for child in children:
+ _stops(child)
+
+
+def _lines(child: subprocess.Popen[str], patience: float) -> Iterator[str]:
+ """Each line a process says, until it ends or has taken longer than `patience`."""
+ assert child.stdout is not None # noqa: S101 -- it was started with one
+ timer = threading.Timer(patience, child.kill)
+ timer.start()
+ try:
+ with child.stdout:
+ yield from (line.rstrip("\n") for line in child.stdout)
+ finally:
+ timer.cancel()
+
+
+def _drained(child: subprocess.Popen[str], said: IO[str] | None = None) -> list[str]:
+ """The last lines a process says, read as it says them so that it is never held up.
+
+ Args:
+ child: The process.
+ said: What of it is read: its stdout, by default.
+ """
+ lines: list[str] = []
+ held = said or child.stdout
+ assert held is not None # noqa: S101 -- it was started with one
+
+ def drains() -> None:
+ with held, contextlib.suppress(OSError, ValueError):
+ for line in held:
+ lines.append(line.rstrip("\n"))
+ del lines[:-20]
+
+ threading.Thread(target=drains, daemon=True, name="hmz-web-remote").start()
+ return lines
+
+
+def _listened(port: int) -> bool:
+ """Whether something listens on a port of this machine's loopback."""
+ with socket.socket() as trying:
+ trying.settimeout(1.0)
+ return trying.connect_ex(("127.0.0.1", port)) == 0
+
+
+def _stops(child: subprocess.Popen[str]) -> None:
+ """Stops a process and reaps it: its standard input closed, then terminated, then killed."""
+ if child.stdin is not None:
+ with contextlib.suppress(OSError):
+ child.stdin.close()
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ child.wait(timeout=1.0)
+ if child.poll() is None:
+ with contextlib.suppress(OSError):
+ child.terminate()
+ try:
+ child.wait(timeout=_STOPPING)
+ except subprocess.TimeoutExpired:
+ child.kill()
+ child.wait()
+
+
+class Remotes:
+ """Every daemon on another machine, as each is reached.
+
+ Args:
+ version: The version of `hmz` installed where another is found; this one's by default.
+ """
+
+ def __init__(self, version: str | None = None) -> None:
+ self._version = version or _here_version()
+ self._changed = threading.Condition()
+ self._held: dict[str, _Remote] = {}
+ self._closed = False
+
+ def state(self, name: str) -> dict[str, str]:
+ """How a daemon stands: `{state, detail, version}`.
+
+ Its state is `idle` where it has not been asked for, then `connecting`, `installing`,
+ `up` or `failed`.
+ """
+ with self._changed:
+ held = self._held.get(name)
+ return held.seen() if held else {"state": IDLE, "detail": "", "version": ""}
+
+ def connect(self, name: str) -> dict[str, str]:
+ """Starts reaching a daemon, or reaches it again at once where it failed.
+
+ Returns:
+ How it stands.
+
+ Raises:
+ Refusal: If no daemon is written down by that name.
+ """
+ self._starts(name, now=True)
+ return self.state(name)
+
+ def reached(self, name: str) -> tuple[int, str]:
+ """Where a daemon is reached here, and the key it lets in.
+
+ Reached first where it was not, waiting a while for it.
+
+ Returns:
+ The port of this machine's loopback forwarded to it, and its key.
+
+ Raises:
+ Refusal: If it is not up, with its state; or if none is called that.
+ """
+ held = self._up(name)
+ return held.port, held.key
+
+ def asks(self, name: str) -> tuple[int, dict[str, str]]:
+ """Where a daemon is reached here, and what a request to it carries to be let in.
+
+ Raises:
+ Refusal: As `reached` does.
+ """
+ held = self._up(name)
+ return held.port, headers(held.remote, held.key)
+
+ def close(self) -> None:
+ """Stops reaching every daemon, every process started for one stopped and reaped."""
+ with self._changed:
+ self._closed = True
+ held = list(self._held.values())
+ stopping = [threading.Thread(target=one.close) for one in held]
+ for one in stopping:
+ one.start()
+ for one in stopping:
+ one.join()
+
+ def _starts(self, name: str, *, now: bool) -> _Remote:
+ """A daemon, being reached: again where what reaches it has changed.
+
+ Raises:
+ Refusal: If none is written down by that name -- one that was is let go of.
+ """
+ said = _entry(name)
+ with self._changed:
+ if self._closed:
+ raise Refusal("This interface is stopping.", 503)
+ if said is None:
+ gone = self._held.pop(name, None)
+ if gone is not None:
+ threading.Thread(target=gone.close, daemon=True).start()
+ raise Refusal("There is no daemon by that name.", 404)
+ held = self._held.setdefault(
+ name, _Remote(name, self._version, self._changed)
+ )
+ held.starts(said, now=now)
+ return held
+
+ def _up(self, name: str) -> _Remote:
+ """A daemon once it is up, waiting a while for one being reached.
+
+ Raises:
+ Refusal: If it is not up, with its state; or if none is called that.
+ """
+ held = self._starts(name, now=False)
+ with self._changed:
+ # Not past a failure, which is reached again after its wait rather than at once.
+ self._changed.wait_for(
+ lambda: held.state in (UP, FAILED, INSTALLING), timeout=_WAITING
+ )
+ if held.state == UP:
+ return held
+ raise Refusal(
+ held.detail or f"{name} is not reached yet.", 503, **held.seen()
+ )
+
+
+def _local() -> dict[str, str]:
+ return {"state": UP, "detail": "", "version": _here_version()}
+
+
+def _remotes(asked: Asked) -> Remotes:
+ """The daemons on other machines, where the interface reaches any by the name asked."""
+ if asked.site.remotes is None or _entry(asked.matched["name"]) is None:
+ raise Refusal("There is no daemon by that name.", 404)
+ return asked.site.remotes
+
+
+def _state(asked: Asked) -> dict[str, str]:
+ name = asked.matched["name"]
+ return _local() if name == "local" else _remotes(asked).state(name)
+
+
+def _connect(asked: Asked) -> dict[str, str]:
+ name = asked.matched["name"]
+ return _local() if name == "local" else _remotes(asked).connect(name)
+
+
+#: How a page reads how a daemon stands, and has it reached.
+ROUTES = routes(
+ ("GET", "/api/daemons/{name}/state", _state),
+ ("POST", "/api/daemons/{name}/connect", _connect),
+)
diff --git a/src/hmz/web/site.py b/src/hmz/web/site.py
index e6204457..2285eed9 100644
--- a/src/hmz/web/site.py
+++ b/src/hmz/web/site.py
@@ -25,6 +25,7 @@
import secrets
import shutil
import socket
+import socketserver
import string
import sys
import tempfile
@@ -39,11 +40,16 @@
from . import (
accounts,
+ daemons,
fallbacks,
flows,
held,
+ notices,
+ notifications,
paths,
places,
+ proxy,
+ remote,
runs,
runtimes,
settings,
@@ -54,7 +60,10 @@
if TYPE_CHECKING:
from collections.abc import Generator
+ from .notices import Notices
+ from .notifications import Notifications
from .places import Places
+ from .remote import Remotes
from .runs import Runs
__all__ = ["Site"]
@@ -68,11 +77,18 @@
*accounts.ROUTES,
*fallbacks.ROUTES,
*runtimes.ROUTES,
+ *daemons.ROUTES,
*paths.ROUTES,
*places.ROUTES,
*verses.ROUTES,
+ *notices.ROUTES,
+ *remote.ROUTES,
+ *notifications.ROUTES,
)
+#: What a daemon is called in the paths a page asks another machine's daemon by.
+_DAEMON = frozenset(string.ascii_letters + string.digits + "._-")
+
#: How many ports any would do for are tried before one free on both loopbacks is given up on.
_PORTS = 8
@@ -120,7 +136,21 @@
_LONGEST = 200
-class Site(ThreadingHTTPServer):
+class _Bound(ThreadingHTTPServer):
+ """A server bound without asking the network what its loopback is called.
+
+ `HTTPServer.server_bind` looks the address up by reverse DNS, for a name this interface
+ never uses -- a lookup that can hang for half a minute where the resolver is slow.
+ """
+
+ def server_bind(self) -> None:
+ socketserver.TCPServer.server_bind(self)
+ host, port = self.server_address[:2]
+ self.server_name = str(host)
+ self.server_port = int(port)
+
+
+class Site(_Bound):
"""The server, holding what its routes are answered from.
Listens on IPv4's loopback itself and on IPv6's beside it, on the same port, so that a
@@ -131,12 +161,15 @@ class Site(ThreadingHTTPServer):
port: The port to listen on, or 0 for any free one on both.
places: Every workspace on this machine, and the runs held for each as this follows them.
runs: The runs written down, of every workspace.
+ notices: What happened to the runs worth telling somebody about.
"""
daemon_threads = True
allow_reuse_address = True
- def __init__(self, port: int, *, places: Places, runs: Runs) -> None:
+ def __init__(
+ self, port: int, *, places: Places, runs: Runs, notices: Notices
+ ) -> None:
self._beside: _Beside | None = None
for left in reversed(range(_PORTS)):
super().__init__(("127.0.0.1", port), _Handler)
@@ -152,6 +185,11 @@ def __init__(self, port: int, *, places: Places, runs: Runs) -> None:
self.places = places
self.hmz: Any = places.hmz
self.runs = runs
+ self.notices = notices
+ #: The daemons on other machines, as each is reached, once `listening` has them.
+ self.remotes: Remotes | None = None
+ #: Every daemon's notices, as this interface tells them, once it is listening.
+ self.notifications: Notifications | None = None
#: Set as the server stops, which is what ends every stream it is sending.
self.going = threading.Event()
self._key = secrets.token_urlsafe(32)
@@ -204,7 +242,7 @@ def server_close(self) -> None:
self._beside.server_close()
-class _Beside(ThreadingHTTPServer):
+class _Beside(_Bound):
"""IPv6's loopback, answered as the site it stands beside answers IPv4's.
Args:
@@ -282,8 +320,13 @@ def _routes(self, method: str, path: str, query: dict[str, str]) -> None:
raise Refusal(
"Open the address hmz web printed, on the machine it runs on.", 403
)
+ # This machine's own daemon, named as a page names every daemon it asks.
+ if path.startswith("/d/local/api/"):
+ path = path.removeprefix("/d/local")
if path.startswith("/api/"):
self._api(method, path, query)
+ elif path.startswith("/d/"):
+ self._daemon(method, path, query)
elif method != "GET":
raise Refusal("Only the page's own files are here.", 405)
elif path == "/" and "key" in query:
@@ -352,13 +395,39 @@ def _signs_in(self, key: str) -> None:
)
self._ends(0)
- def _api(self, method: str, path: str, query: dict[str, str]) -> None:
+ def _admits(self) -> None:
+ """Refuses a request to the API from a browser not let in, or from another page."""
if not self._signed_in():
raise Refusal(
"Open the address hmz web printed to let this browser in.", 401
)
if not self._asked_here():
raise Refusal("Use this server's own page to make this request.", 403)
+
+ def _daemon(self, method: str, path: str, query: dict[str, str]) -> None:
+ """The API of a daemon by name, `/d//api/...`.
+
+ This machine's own for `local`, and otherwise asked of the interface on the machine
+ it names.
+ """
+ name, _, rest = path.removeprefix("/d/").partition("/")
+ rest = f"/{rest}"
+ if not name or not set(name) <= _DAEMON or not rest.startswith("/api/"):
+ raise Refusal("There is nothing here by that name.", 404)
+ if name == "local":
+ self._api(method, rest, query)
+ return
+ self._admits()
+ if method == "POST" and not self._from_its_page():
+ raise Refusal("Use this server's own page to make this request.", 403)
+ if self.site.remotes is None:
+ raise Refusal("There is no daemon by that name.", 404)
+ port, said = self.site.remotes.asks(name)
+ asked = urllib.parse.urlsplit(self.path).query
+ proxy.forwards(self, port, said, method, rest + (f"?{asked}" if asked else ""))
+
+ def _api(self, method: str, path: str, query: dict[str, str]) -> None:
+ self._admits()
for route in ROUTES:
matched = route.match(method, path)
if matched is None:
@@ -529,6 +598,9 @@ def _downloads(self, answer: Download) -> None:
def _streams(self, answer: Stream, query: dict[str, str]) -> None:
"""Server-sent events, for as long as the browser listens and the server is up."""
last = self.headers.get("Last-Event-ID") or query.get("last", "")
+ # Where it starts from is settled before the browser hears it is answered: what comes
+ # once it has heard so is never before the start.
+ events = answer.events(last, self.site.going)
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
@@ -539,7 +611,7 @@ def _streams(self, answer: Stream, query: dict[str, str]) -> None:
self.close_connection = True
self.wfile.write(b"retry: 2000\n\n")
self.wfile.flush()
- for said in answer.events(last, self.site.going):
+ for said in events:
if said is None:
self.wfile.write(b": quiet\n\n")
else:
diff --git a/src/hmz/web/static/api.js b/src/hmz/web/static/api.js
index 1bdd983b..7df0421d 100644
--- a/src/hmz/web/static/api.js
+++ b/src/hmz/web/static/api.js
@@ -1,6 +1,68 @@
// How the page asks the server anything: one wrapper for reading and writing, which turns a
// refusal into the sentence the server gave, and one store for what is read again and again,
-// shared by every view of it.
+// shared by every view of it. Everything is asked of the daemon the address names -- `#/@/…`,
+// Local where it names none -- under `/d//api/…`, save what is the same for every daemon.
+
+/** What is asked of Local whichever daemon the page is on: the daemons, and the notifications. */
+const GLOBAL = /^\/api\/(daemons|notifications)(\/|\?|$)/
+
+/** The daemon the address names, or '' where it names none. */
+function named(address) {
+ const match = /^#?\/?@([^/?]*)/.exec(address)
+ try {
+ return match ? decodeURIComponent(match[1]) : ''
+ } catch {
+ return match[1]
+ }
+}
+
+let naming = named(location.hash)
+/** How many times the page has switched daemons: what was asked before the last is not told. */
+let era = 0
+const switched = new Set()
+
+/** The daemon the page is on. */
+export function daemon() {
+ return naming || 'local'
+}
+
+/** Where an address of the API is asked: of the daemon the page is on, unless it is global. */
+export function scoped(path) {
+ return path.startsWith('/api/') && !GLOBAL.test(path) ? `/d/${encodeURIComponent(daemon())}${path}` : path
+}
+
+/** The address of a view of the page, on the daemon the page is on, as the address names it. */
+export function hash(path) {
+ if (path.startsWith('#')) path = path.slice(1)
+ if (path.startsWith('/@')) return `#${path}`
+ return naming ? `#/@${encodeURIComponent(naming)}${path}` : `#${path}`
+}
+
+/** Switches the page to another daemon, on the view given, a new epic where none is. */
+export function toDaemon(name, path = '/new') {
+ location.hash = name === 'local' ? path : `/@${encodeURIComponent(name)}${path}`
+}
+
+/** Tells `fn` the daemon the page switched to, each time it does, until what it returns is called. */
+export function onDaemon(fn) {
+ switched.add(fn)
+ return () => switched.delete(fn)
+}
+
+// Ahead of the page's own routing, which is told by the event to draw everything again.
+window.addEventListener('hashchange', () => {
+ const was = daemon()
+ naming = named(location.hash)
+ if (daemon() === was) return
+ era += 1
+ // What was read of the last daemon is not this one's: read again, once whoever leaves has.
+ for (const read of reads.values()) Object.assign(read, { text: undefined, data: undefined, error: null, failures: 0, stale: true })
+ setTimeout(() => {
+ for (const read of reads.values()) if (read.stale) fetchNow(read)
+ })
+ for (const fn of [...switched]) fn(daemon())
+ window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { daemon: daemon() } }))
+})
/** A refusal: the server's own sentence, the status it came with, and what else it said. */
export class Refused extends Error {
@@ -13,13 +75,13 @@ export class Refused extends Error {
/** Reads one answer, or throws the server's sentence. */
export async function get(url) {
- return answered(await fetch(url, { headers: { Accept: 'application/json' } }))
+ return answered(await fetch(scoped(url), { headers: { Accept: 'application/json' } }))
}
/** Writes, sending JSON, and reads the answer, or throws the server's sentence. */
export async function post(url, body = {}) {
return answered(
- await fetch(url, {
+ await fetch(scoped(url), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
@@ -30,7 +92,7 @@ export async function post(url, body = {}) {
/** Sends a file as it is, under its name, and reads the answer, or throws the server's sentence. */
export async function send(url, file, kind) {
return answered(
- await fetch(url, {
+ await fetch(scoped(url), {
method: 'POST',
headers: { 'Content-Type': kind, 'X-Hmz-Filename': encodeURIComponent(file.name), Accept: 'application/json' },
body: file,
@@ -97,12 +159,15 @@ export function refresh(url) {
async function fetchNow(read) {
clearTimeout(read.timer)
+ read.stale = false
// A page nobody looks at reads nothing again until looked at -- but what it has never read
// it reads, so a page opened in a tab behind is drawn once it is looked at.
if (document.hidden && read.text !== undefined) return
+ const mine = era
try {
- const response = await fetch(read.url, { headers: { Accept: 'application/json' } })
+ const response = await fetch(scoped(read.url), { headers: { Accept: 'application/json' } })
const text = await response.text()
+ if (mine !== era) return
if (response.status === 401) window.dispatchEvent(new CustomEvent('hmz:out'))
if (!response.ok) {
let said = null
@@ -122,6 +187,7 @@ async function fetchNow(read) {
for (const watcher of read.watchers) watcher.told(read.data, null)
}
} catch (error) {
+ if (mine !== era) return
read.failures += 1
read.error = error
for (const watcher of read.watchers) watcher.told(read.data, error)
diff --git a/src/hmz/web/static/app.js b/src/hmz/web/static/app.js
index 12da1ee4..6d976281 100644
--- a/src/hmz/web/static/app.js
+++ b/src/hmz/web/static/app.js
@@ -2,9 +2,10 @@
// epics on the left, and whichever view the address names beside it -- a new epic, its graph, one session's
// thread, the flows -- with finding a run and the settings opened over it as dialogs. Routed
// by the address's fragment, so that every view of the page is an address that can be kept,
-// shared or opened again, and the server serves one page for all of them.
+// shared or opened again, and the server serves one page for all of them. An address names the
+// daemon it is of first, `#/@/…`, or names none and is of Local.
-import { get } from './api.js'
+import { daemon, get, hash } from './api.js'
import { fill, h, problem } from './dom.js'
import { mountSidebar } from './sidebar.js'
import { kept, toggleSidebar } from './shell.js'
@@ -44,16 +45,16 @@ let behind = ''
/** What the view drawn was drawn for, so a dialog opened over it leaves it standing. */
let shown = ''
-/** Where the page is: the parts of the address's path, and its query. */
+/** Where the page is: the parts of the address's path, after the daemon it names, and its query. */
export function where() {
- const raw = location.hash.replace(/^#/, '') || '/new'
+ const raw = location.hash.replace(/^#/, '').replace(/^\/?@[^/?]*/, '') || '/new'
const url = new URL(raw.startsWith('/') ? raw : `/${raw}`, 'http://page')
return { parts: url.pathname.split('/').filter(Boolean).map(decodeURIComponent), query: url.searchParams }
}
-/** Goes to another view of the page. */
+/** Goes to another view of the page, on the daemon it is on. */
export function go(path) {
- location.hash = path
+ location.hash = hash(path)
}
/** Puts the query of the view the page is on in the address, without a step in its history. */
@@ -68,7 +69,7 @@ export function stays(query) {
const said = new URLSearchParams()
for (const [name, value] of Object.entries(query)) if (value !== '' && value !== undefined && value !== null) said.set(name, value)
const text = said.toString()
- history.replaceState(null, '', `#/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`)
+ history.replaceState(null, '', hash(`/${parts.map(encodeURIComponent).join('/')}${text ? `?${text}` : ''}`))
routedTo = location.hash
}
@@ -222,14 +223,36 @@ window.addEventListener('hmz:out', () => {
)
})
+// Another daemon: everything drawn of the last is drawn again of this one, as the address
+// routed next says -- told before that routing, which api.js is told of first.
+window.addEventListener('hmz:daemon', () => {
+ if (mounted) mounted.leave()
+ mounted = null
+ shown = ''
+ opened = ''
+ behind = ''
+ named().catch(() => {})
+})
+
+/** Names the page by the machine the daemon it is on is. */
+function named() {
+ const asked = daemon()
+ return get('/api/held').then((context) => {
+ if (asked === daemon()) document.title = `humanize · ${context.machine}`
+ return context
+ })
+}
+
window.addEventListener('hashchange', route)
-get('/api/held')
+named()
.then((context) => {
- document.title = `humanize · ${context.machine}`
mountSidebar(document.getElementById('sidebar'), context, where)
route()
})
.catch((error) => {
- if (!out) fill(page, h('div', { class: 'view' }, problem(error)))
+ if (out) return
+ // A daemon out of reach: said where the view would be, the sidebar there to go elsewhere.
+ mountSidebar(document.getElementById('sidebar'), {}, where)
+ fill(page, h('div', { class: 'view' }, problem(error)))
})
diff --git a/src/hmz/web/static/dom.js b/src/hmz/web/static/dom.js
index 2f767c3d..e3cf55f5 100644
--- a/src/hmz/web/static/dom.js
+++ b/src/hmz/web/static/dom.js
@@ -250,6 +250,7 @@ const ICONS = {
home: ['M3 11l9-8 9 8', 'M5 10v10h14V10'],
copy: ['M9 9h11v11H9Z', 'M5 15H4V4h11v1'],
open: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
+ bell: ['M6 16v-5a6 6 0 0 1 12 0v5l2 2H4Z', 'M10 21h4'],
}
/** An icon, drawn in the colour of the text around it and said by nothing: its button says it. */
diff --git a/src/hmz/web/static/graph.js b/src/hmz/web/static/graph.js
index bebabf55..03924578 100644
--- a/src/hmz/web/static/graph.js
+++ b/src/hmz/web/static/graph.js
@@ -4,6 +4,7 @@
// role to another, as many times as it did. While the run goes, the node working is lit, the
// last handover is the red one, and a line on every lane says where now is.
+import { hash } from './api.js'
import { flowName, h, iconButton, lasting, svg, when } from './dom.js'
import { copy, menuOn } from './ui.js'
@@ -285,7 +286,7 @@ export function graph({ open }) {
}
function agentNode(agent, box, focused, epic, going) {
- const base = `#/epics/${encodeURIComponent(epic)}`
+ const base = hash(`/epics/${encodeURIComponent(epic)}`)
const head = h(
'button',
{ class: 'node-head', type: 'button', 'aria-pressed': String(focused) },
diff --git a/src/hmz/web/static/held.js b/src/hmz/web/static/held.js
index 73ea02bf..391748a6 100644
--- a/src/hmz/web/static/held.js
+++ b/src/hmz/web/static/held.js
@@ -3,7 +3,7 @@
// out -- the same the terminal interface draws. A workspace is followed only while a view of it
// is open. What a view asks of a workspace's runs goes through `ask`.
-import { post } from './api.js'
+import { onDaemon, post, scoped } from './api.js'
/** The most records kept per workspace, and about how many bytes of them, as the server keeps. */
const KEPT = 20000
@@ -38,7 +38,7 @@ export function follow(workspace, told) {
setTimeout(() => {
if (one.listeners.size) return
one.close()
- followed.delete(workspace)
+ if (followed.get(workspace) === one) followed.delete(workspace)
}, 5000)
}
}
@@ -77,7 +77,7 @@ function following(workspace) {
function connect() {
const query = new URLSearchParams({ workspace })
if (lastId && state.epoch) query.set('last', lastId)
- const mine = new EventSource(`/api/held/stream?${query}`)
+ const mine = new EventSource(scoped(`/api/held/stream?${query}`))
one.source = mine
// A stream let go of, or replaced, says nothing more to the page.
const on = (name, handle) => mine.addEventListener(name, (event) => one.source === mine && handle(event))
@@ -145,6 +145,15 @@ function following(workspace) {
return one
}
+// Another daemon's workspaces are others: every stream of the last goes, and nobody is told it.
+onDaemon(() => {
+ for (const one of followed.values()) {
+ one.listeners.clear()
+ one.close()
+ }
+ followed.clear()
+})
+
// A browser holds only so many connections to one server: a tab nobody looks at lets its
// streams go after a while, and picks each up from the last record it heard once looked at.
let resting = 0
diff --git a/src/hmz/web/static/importing.js b/src/hmz/web/static/importing.js
index b7ca6ea7..d62e7782 100644
--- a/src/hmz/web/static/importing.js
+++ b/src/hmz/web/static/importing.js
@@ -2,7 +2,7 @@
// the disk, or dropped anywhere on the window, and opened once it is in. One already on this
// machine, named by its path -- as the desktop app opens a file -- is `#/epics/import?path=`.
-import { refresh, send } from './api.js'
+import { daemon, hash, refresh, send } from './api.js'
import { h, iconButton, problem } from './dom.js'
/** What an epic's archive is named with, and sent as. */
@@ -29,18 +29,21 @@ function failed(...why) {
/** How an import refused is said: the server's sentence, and the epic it names where that is here already. */
export function refusal(error) {
const name = error.said && error.said.name
- return [problem(error), name ? h('a', { href: `#/epics/${encodeURIComponent(name)}`, 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null]
+ return [problem(error), name ? h('a', { href: hash(`/epics/${encodeURIComponent(name)}`), 'data-hmz': 'imported-already', onclick: () => say() }, 'Open') : null]
}
/** Sends one archive, saying so as it goes, and opens the epic it was once it is in. */
export async function importFile(file) {
if (file.size > LARGEST) return failed(problem('That is more than a request here may send.'))
say(h('span', {}, `Importing ${file.name}…`))
+ const into = daemon()
try {
const answer = await send('/api/runs/import', file, KIND)
say()
+ // Opened on the daemon it went into, unless the page has since gone to another.
+ if (into !== daemon()) return
refresh('/api/runs')
- location.hash = `/epics/${encodeURIComponent(answer.name)}`
+ location.hash = hash(`/epics/${encodeURIComponent(answer.name)}`)
} catch (error) {
failed(refusal(error))
}
diff --git a/src/hmz/web/static/notifications.js b/src/hmz/web/static/notifications.js
new file mode 100644
index 00000000..8e425f03
--- /dev/null
+++ b/src/hmz/web/static/notifications.js
@@ -0,0 +1,191 @@
+// The notifications, of every daemon at once rather than the one shown: a bell with how many are
+// unread, a menu of them newest first, a card for each as it comes, and -- while the page is not
+// looked at -- one the system shows, by the macOS app or by the browser. Each opens what it is
+// about: the epic going, for a question or a chat's next turn, or the epic that ended.
+
+import { get, post } from './api.js'
+import { ago, fill, h, headline, icon, iconButton } from './dom.js'
+import { inApp, menu, section } from './shell.js'
+
+/** What each kind is called, and the mark it is drawn with. */
+const KINDS = {
+ done: ['Done', '✔', 'done'],
+ failed: ['Failed', '✕', 'failed'],
+ stopped: ['Stopped', '■', 'stopped'],
+ asked: ['Question', '?', 'running'],
+ listening: ['Chat turn', '›', 'running'],
+}
+
+/** How long a card stays, and how many stand at once. */
+const SHOWN = 6000
+const CARDS = 3
+
+/** The notifications held, newest first. */
+let items = []
+let button = null
+let count = null
+let drawMenu = null
+let cards = null
+
+/** The bell, made once and the feed started with it: hidden while the server has no notifications. */
+export function bell() {
+ if (button) return button
+ count = h('span', { class: 'notify-count', 'data-hmz': 'notification-count', hidden: true })
+ button = h('button', { class: 'icon-btn notify-bell', type: 'button', 'aria-label': 'Notifications', title: 'Notifications', 'data-hmz': 'notifications', hidden: true }, icon('bell'), count)
+ menu(button, (close) => {
+ const list = h('div', { class: 'notify-list' })
+ drawMenu = () => drawList(list, close)
+ drawMenu()
+ const read = h('button', { class: 'btn quiet small', type: 'button', 'data-hmz': 'notifications-read', onclick: () => markRead(null) }, 'Mark read')
+ const clear = h('button', { class: 'btn quiet small', type: 'button', 'data-hmz': 'notifications-clear', onclick: clearAll }, 'Clear')
+ return [h('div', { class: 'notify-menu', 'data-hmz': 'notification-menu' }, h('div', { class: 'notify-head' }, section('Notifications'), read, clear), list)]
+ }, { label: 'Notifications' })
+ start()
+ return button
+}
+
+/** How long a failed read waits before the next, which doubles up to five minutes. */
+let wait = 10000
+/** What was heard since the list was last asked for, which an older answer must not drop. */
+let fresh = new Map()
+
+async function start() {
+ try {
+ await reread()
+ } catch (error) {
+ // A server without notifications has no bell, nor a browser not let in; else it is asked again.
+ if (error.status !== 404 && error.status !== 401) {
+ setTimeout(start, wait)
+ wait = Math.min(wait * 2, 300000)
+ }
+ return
+ }
+ button.hidden = false
+ const stream = new EventSource('/api/notifications/stream')
+ stream.addEventListener('notification', (event) => heard(JSON.parse(event.data)))
+ // Read again whenever the stream opens, for what came before it did, and as the page is
+ // looked at again, for what another tab read or cleared.
+ stream.addEventListener('open', () => reread().catch(() => {}))
+ document.addEventListener('visibilitychange', () => document.hidden || reread().catch(() => {}))
+}
+
+async function reread() {
+ fresh = new Map()
+ const answer = await get('/api/notifications')
+ const known = new Set((answer.items || []).map((one) => one.id))
+ items = [...[...fresh.values()].reverse().filter((one) => !known.has(one.id)), ...(answer.items || [])]
+ draw()
+}
+
+function heard(item) {
+ fresh.set(item.id, item)
+ if (items.some((one) => one.id === item.id)) return
+ items.unshift(item)
+ draw()
+ card(item)
+ system(item)
+}
+
+function draw() {
+ const unread = items.filter((one) => !one.read).length
+ count.hidden = !unread
+ count.textContent = unread > 99 ? '99+' : String(unread)
+ if (drawMenu && button.getAttribute('aria-expanded') === 'true') drawMenu()
+}
+
+function drawList(list, close) {
+ if (!items.length) return fill(list, h('p', { class: 'notify-none' }, 'Nothing yet'))
+ fill(
+ list,
+ items.map((item) =>
+ h(
+ 'button',
+ { class: ['choice', 'notify-item', !item.read && 'notify-unread'], type: 'button', role: 'menuitem', 'data-hmz': 'notification', onclick: () => (close(), opens(item)) },
+ mark(item),
+ h('span', { class: 'choice-text' }, h('span', { class: 'choice-label' }, `${kindOf(item)} · ${daemonOf(item)}`), h('span', { class: 'choice-about' }, about(item)), h('span', { class: 'notify-when' }, ago(item.at))),
+ ),
+ ),
+ )
+}
+
+function mark(item) {
+ const [, glyph, tone] = KINDS[item.kind] || ['', '·', 'idle']
+ return h('span', { class: `choice-mark tone-${tone}`, 'aria-hidden': 'true' }, h('span', { class: 'glyph' }, glyph))
+}
+
+const kindOf = (item) => (KINDS[item.kind] || [item.kind])[0]
+const daemonOf = (item) => (!item.daemon || item.daemon === 'local' ? 'Local' : item.daemon)
+const about = (item) => [item.flow, item.text || (item.task ? headline(item.task) : item.epic)].filter(Boolean).join(' · ')
+
+/** Where a notification opens: the epic going for what waits on a person, else the epic itself. */
+export function addressOf(item) {
+ const at = !item.daemon || item.daemon === 'local' ? '#/' : `#/@${encodeURIComponent(item.daemon)}/`
+ if ((item.kind === 'asked' || item.kind === 'listening' || !item.epic) && item.workspace) return `${at}live?${new URLSearchParams({ workspace: item.workspace })}`
+ return item.epic ? `${at}epics/${encodeURIComponent(item.epic)}` : `${at}live`
+}
+
+function opens(item) {
+ if (items.some((one) => one.id === item.id && !one.read)) markRead([item.id])
+ location.hash = addressOf(item)
+}
+
+async function markRead(ids) {
+ for (const one of items) if (!ids || ids.includes(one.id)) one.read = true
+ draw()
+ try {
+ await post('/api/notifications/read', ids ? { ids } : { all: true })
+ } catch {
+ // Read here alone, until the list is read again.
+ }
+}
+
+async function clearAll() {
+ items = []
+ draw()
+ try {
+ await post('/api/notifications/clear')
+ } catch {
+ // Cleared here; read back as they are when the page is looked at again.
+ }
+}
+
+/** A card for a notification as it comes, at the foot of the window, gone after a moment. */
+function card(item) {
+ if (!cards) {
+ cards = h('div', { class: 'notify-toasts', 'data-hmz': 'notification-toasts', role: 'status', 'aria-live': 'polite' })
+ document.body.append(cards)
+ }
+ const shut = iconButton('close', 'Dismiss', { class: 'notify-shut' })
+ const one = h(
+ 'div',
+ { class: 'notify-card', 'data-hmz': 'notification-toast' },
+ h('button', { class: 'notify-open', type: 'button', onclick: () => (one.remove(), opens(item)) }, mark(item), h('span', { class: 'choice-text' }, h('span', { class: 'choice-label' }, `${kindOf(item)} · ${daemonOf(item)}`), h('span', { class: 'choice-about' }, about(item)))),
+ shut,
+ )
+ shut.addEventListener('click', () => one.remove())
+ cards.prepend(one)
+ while (cards.children.length > CARDS) cards.lastChild.remove()
+ setTimeout(() => one.remove(), SHOWN)
+}
+
+/** One the system shows, while the page is not looked at and the settings let it. */
+async function system(item) {
+ if (!document.hidden && document.hasFocus()) return
+ // Read as one comes, rare as they are, so a change in Settings holds at once.
+ const said = await get('/api/notifications/settings').catch(() => null)
+ if (!said || !said.system) return
+ const title = `${kindOf(item)} · ${daemonOf(item)}`
+ const body = about(item)
+ const address = addressOf(item)
+ if (inApp) {
+ window.__TAURI_INTERNALS__?.invoke('notify', { title, body, address })?.catch?.(() => {})
+ return
+ }
+ if (!('Notification' in window) || Notification.permission !== 'granted') return
+ const shown = new Notification(title, { body, tag: String(item.id) })
+ shown.onclick = () => {
+ window.focus()
+ opens(item)
+ shown.close()
+ }
+}
diff --git a/src/hmz/web/static/paths.js b/src/hmz/web/static/paths.js
index ccae0649..2fed069c 100644
--- a/src/hmz/web/static/paths.js
+++ b/src/hmz/web/static/paths.js
@@ -14,8 +14,9 @@ import { follow } from './shell.js'
* @param {string} [made.placeholder]
* @param {string} [made.id]
* @param {(path: string) => void} [made.changed] Told the path whenever it changes.
+ * @param {string} [made.from] Whose paths: '' for the daemon the page is on, else `/d/`.
*/
-export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {} }) {
+export function pathPicker({ value = '', files = false, label, placeholder = files ? '~/file' : '~/folder', id, changed = () => {}, from = '' }) {
const list = `paths-${Math.random().toString(36).slice(2, 8)}`
const input = h('input', { type: 'text', value, placeholder, 'aria-label': label, id, list, autocomplete: 'off', spellcheck: 'false', 'data-hmz': 'path-input' })
const offered = h('datalist', { id: list })
@@ -37,7 +38,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil
clearTimeout(typed)
typed = setTimeout(async () => {
try {
- const { paths } = await get(`/api/paths?typed=${encodeURIComponent(input.value)}`)
+ const { paths } = await get(`${from}/api/paths?typed=${encodeURIComponent(input.value)}`)
fill(offered, paths.map((one) => h('option', { value: one })))
} catch {
fill(offered)
@@ -48,7 +49,7 @@ export function pathPicker({ value = '', files = false, label, placeholder = fil
async function show(dir) {
let read
try {
- read = await get(`/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`)
+ read = await get(`${from}/api/files?at=${encodeURIComponent(dir)}${files ? '&files=1' : ''}${hidden ? '&hidden=1' : ''}`)
} catch (error) {
if (dir !== '~') return show('~')
fill(panel, h('p', { class: 'error' }, error.message))
diff --git a/src/hmz/web/static/pickers.js b/src/hmz/web/static/pickers.js
index c875a906..eb4fc2b0 100644
--- a/src/hmz/web/static/pickers.js
+++ b/src/hmz/web/static/pickers.js
@@ -3,7 +3,7 @@
// runs as, a model that account runs and an effort that model takes; an environment is a
// backend, the runtime of it, and a directory there. Each comes to what `-a` or `-e` spells.
-import { get, post } from './api.js'
+import { daemon, get, hash, onDaemon, post } from './api.js'
import { fill, h, iconButton } from './dom.js'
import { pathPicker } from './paths.js'
@@ -17,26 +17,41 @@ const failed = new Map()
/** Reads the CLIs installed again -- as a workspace runs them -- and tells every picker. */
let reading = ''
+/** Whose CLIs they are: '' for the daemon the page is on, else `/d/`, as Settings picks. */
+let base = ''
-export async function readBackends(workspace = reading) {
+export async function readBackends(workspace = reading, from = '') {
reading = workspace
- backends = await get(`/api/backends?workspace=${encodeURIComponent(workspace)}`)
+ base = from
+ const of = daemon()
+ const read = await get(`${from}/api/backends?workspace=${encodeURIComponent(workspace)}`)
+ // Read of a daemon the page has since left: another's CLIs.
+ if (of !== daemon()) return read
+ backends = read
for (const one of told) one()
return backends
}
+// Another daemon's CLIs and accounts are others.
+onDaemon(() => {
+ backends = null
+ reading = ''
+ asking.clear()
+ failed.clear()
+})
+
/** Asks a CLI what it runs as an account, once however many pickers ask. */
function ask(cli, account) {
const key = `${cli}@${account}`
if (!asking.has(key)) {
asking.set(
key,
- post('/api/accounts/models', { cli, name: account })
+ post(`${base}/api/accounts/models`, { cli, name: account })
.then(() => failed.delete(key))
.catch((error) => failed.set(key, error.message))
.finally(() => {
asking.delete(key)
- return readBackends()
+ return readBackends(reading, base)
}),
)
}
@@ -151,7 +166,7 @@ export function agentPicker(role, kept, changed, { effort: withEffort = true } =
account.addEventListener('change', () => {
if (account.value === '+') {
account.value = chosen.account
- location.hash = '/settings/accounts'
+ location.hash = hash('/settings/accounts')
return
}
Object.assign(chosen, { account: account.value, model: '', effort: '' })
@@ -249,7 +264,7 @@ export function envPicker(role, kept, offered, runtimes) {
})
machine.addEventListener('change', () => {
if (machine.value === '+') {
- location.hash = '/settings/runtimes'
+ location.hash = hash('/settings/runtimes')
draw()
return
}
diff --git a/src/hmz/web/static/places.js b/src/hmz/web/static/places.js
index fefe8ac5..f25e7521 100644
--- a/src/hmz/web/static/places.js
+++ b/src/hmz/web/static/places.js
@@ -2,12 +2,14 @@
// opened here, the one `hmz web` was started in first. Picking one is picking where an epic
// runs, whose flows are on offer, and whose settings are read.
-import { get, post } from './api.js'
+import { get, onDaemon, post } from './api.js'
import { fill, h, icon } from './dom.js'
import { pathPicker } from './paths.js'
import { choice, dragged, follow, menu, section } from './shell.js'
let known = null
+// Another daemon's workspaces are others.
+onDaemon(() => (known = null))
/** Every workspace there is to offer, read again where asked to. */
export async function workspaces(again = false) {
@@ -97,10 +99,14 @@ export function workspacePicker(chosen, changed) {
}
})
- workspaces().then((answer) => {
- list = answer.workspaces
- if (!path) path = answer.here
- set(path)
- })
+ workspaces().then(
+ (answer) => {
+ list = answer.workspaces
+ if (!path) path = answer.here
+ set(path)
+ },
+ // A daemon that cannot be reached: the view it is on says why.
+ () => {},
+ )
return { node, set, value: () => path }
}
diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js
index 479e45a8..27a72c38 100644
--- a/src/hmz/web/static/sidebar.js
+++ b/src/hmz/web/static/sidebar.js
@@ -1,14 +1,17 @@
// The sidebar, as Codex and Claude keep theirs: a new epic, finding one, the flows, and every
// workspace of this machine as a folder of its epics, newest first -- each epic opened out into
-// its agents, and each agent into the sessions it opened. Under them, the machine, how many
-// epics are going on it, and a menu of the settings and what the epics spent.
+// its agents, and each agent into the sessions it opened. Under them, the daemon the page is of,
+// how many epics are going on it, and a picker of the daemons beside the settings and what the
+// epics spent.
-import { address, get, poll, post, refresh } from './api.js'
+import * as api from './api.js'
+import { address, daemon, get, hash, poll, post, refresh, scoped } from './api.js'
import { ask } from './held.js'
import { pick } from './importing.js'
import { copy, menuOn, toast } from './ui.js'
import { ago, fill, flowName, h, headline, icon, iconButton, when } from './dom.js'
import { sessionsOf } from './epic.js'
+import { bell } from './notifications.js'
import { nameOf } from './places.js'
import { choice, dragged, keep, kept, menu, section, settleSidebar, toggleSidebar } from './shell.js'
@@ -26,7 +29,6 @@ const MARKS = { running: '●', stopping: '◐', done: '✔', failed: '✕', sto
export function mountSidebar(root, context, where) {
const list = h('nav', { class: 'side-runs', 'aria-label': 'Epics', 'data-hmz': 'epics' }, h('p', { class: 'side-quiet', role: 'status' }, 'Reading the epics…'))
const more = h('button', { class: 'side-more', type: 'button', hidden: true }, 'Show more')
- const state = h('span', { class: 'side-state', 'data-hmz': 'state' })
// How many installed flows have a newer release, as the indexes were fetched as it started.
const updates = h('span', { class: 'side-badge', 'data-hmz': 'updates', hidden: true })
poll('/api/flowverses', 60000, (answer) => {
@@ -35,44 +37,27 @@ export function mountSidebar(root, context, where) {
updates.textContent = String(count)
updates.title = count ? `${count} flow${count === 1 ? ' has' : 's have'} a newer release` : ''
})
- const account = h(
- 'button',
- { class: 'side-account', type: 'button', 'data-hmz': 'machine-menu' },
- h('span', { class: 'side-avatar', 'aria-hidden': 'true' }, (context.machine || 'h').slice(0, 1).toUpperCase()),
- h('span', { class: 'side-account-text' }, h('b', {}, context.machine || 'This machine'), state),
- // A newer release of humanize, as PyPI said when it was last asked.
- context.newer ? h('span', { class: 'side-badge', 'data-hmz': 'newer', title: `hmz ${context.newer} is out` }, '↑') : null,
- )
- menu(
- account,
- (close) => [
- context.newer ? choice({ label: 'Update', about: `hmz ${context.newer}`, glyph: 'up', act: () => copy('hmz update', 'Copied hmz update'), close }) : null,
- choice({ label: 'Import', glyph: 'file', act: pick, close }),
- choice({ label: 'Settings', glyph: 'gear', href: '#/settings/general', close }),
- choice({ label: 'Usage', glyph: 'chart', href: '#/settings/usage', close }),
- choice({ label: 'Download', glyph: 'download', href: '#/download', close }),
- ],
- { up: true, label: 'Machine' },
- )
+ const bar = daemonBar(context)
fill(
root,
dragged(
h(
'div',
{ class: 'side-top' },
- h('a', { class: 'brand', href: '#/new', 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')),
+ h('a', { class: 'brand', href: hash('/new'), 'aria-label': 'humanize, a new epic' }, h('img', { src: '/mark.svg', alt: '', width: '24', height: '24' }), h('strong', {}, 'humanize')),
+ bell(),
iconButton('sidebar', 'Hide sidebar', { class: 'side-fold', onclick: () => toggleSidebar(false), 'aria-controls': 'sidebar' }),
),
),
h(
'div',
{ class: 'side-nav' },
- h('a', { href: '#/new', 'data-nav': 'new' }, icon('new'), h('span', {}, 'New epic'), h('kbd', {}, '⇧⌘O')),
- h('a', { href: '#/search', 'data-nav': 'search' }, icon('search'), h('span', {}, 'Search epics'), h('kbd', {}, '⌘K')),
- h('a', { href: '#/flows', 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates),
+ h('a', { href: hash('/new'), 'data-nav': 'new' }, icon('new'), h('span', {}, 'New epic'), h('kbd', {}, '⇧⌘O')),
+ h('a', { href: hash('/search'), 'data-nav': 'search' }, icon('search'), h('span', {}, 'Search epics'), h('kbd', {}, '⌘K')),
+ h('a', { href: hash('/flows'), 'data-nav': 'flows' }, icon('flows'), h('span', {}, 'Flows'), updates),
),
h('div', { class: 'side-scroll' }, list, more),
- h('div', { class: 'side-foot' }, account),
+ h('div', { class: 'side-foot' }, bar.el),
)
// The band across its top, above the row.
dragged(root, '')
@@ -122,7 +107,7 @@ export function mountSidebar(root, context, where) {
else link.removeAttribute('aria-current')
}
const going = data.runs.filter((one) => one.held).length
- fill(state, h('span', { class: ['dot', going && 'live'], 'aria-hidden': 'true' }), going ? `${going} live` : 'idle')
+ bar.going(going)
// Every workspace offered, each with its epics: those going first, then the latest.
const groups = new Map()
for (const one of places ? places.workspaces : []) groups.set(one.path, { path: one.path, runs: [] })
@@ -149,20 +134,20 @@ export function mountSidebar(root, context, where) {
{ class: ['side-folder', chosen === group.path && 'current'] },
toggle,
h('span', { class: 'side-folder-name', title: group.path }, icon('folder'), h('span', { class: 'side-label' }, nameOf(group.path))),
- iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = `/new?workspace=${encodeURIComponent(group.path)}`) }),
+ iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = hash(`/new?workspace=${encodeURIComponent(group.path)}`)) }),
)
const where = encodeURIComponent(group.path)
menuOn(folder, () => [
{ head: group.path },
- { label: 'New epic here', glyph: 'new', href: `#/new?workspace=${where}` },
- { label: 'Flows here', glyph: 'flows', href: `#/flows?workspace=${where}` },
- { label: 'Search here', glyph: 'search', href: `#/search?workspace=${where}` },
+ { label: 'New epic here', glyph: 'new', href: hash(`/new?workspace=${where}`) },
+ { label: 'Flows here', glyph: 'flows', href: hash(`/flows?workspace=${where}`) },
+ { label: 'Search here', glyph: 'search', href: hash(`/search?workspace=${where}`) },
'-',
{ label: 'Copy path', glyph: 'copy', act: () => copy(group.path, 'Path copied') },
{ label: open ? 'Collapse' : 'Expand', glyph: 'chevron', act: () => toggle.click() },
'-',
- { label: 'Settings', glyph: 'gear', href: `#/settings/workspace?workspace=${where}` },
- { label: 'Usage', glyph: 'chart', href: `#/settings/usage?workspace=${where}` },
+ { label: 'Settings', glyph: 'gear', href: hash(`/settings/workspace?workspace=${where}`) },
+ { label: 'Usage', glyph: 'chart', href: hash(`/settings/usage?workspace=${where}`) },
group.runs.length ? null : { label: 'Forget', danger: true, act: () => forget(group.path) },
])
return h(
@@ -185,7 +170,7 @@ export function mountSidebar(root, context, where) {
function item(run, here, parts) {
const shown = (opened.has(run.name) || run.name === here) && !folded.has(run.name)
- const link = `#/epics/${encodeURIComponent(run.name)}`
+ const link = hash(`/epics/${encodeURIComponent(run.name)}`)
const toggle = h('button', { class: 'side-toggle', type: 'button', 'aria-expanded': String(shown), 'aria-label': shown ? 'Collapse' : 'Expand' }, icon('chevron'))
toggle.addEventListener('click', () => {
if (shown) {
@@ -224,19 +209,21 @@ export function mountSidebar(root, context, where) {
const stale = !read || read.how !== run.how || (run.held && Date.now() - read.at > 3000)
if (stale && !(read && read.asking)) {
details.set(run.name, { ...(read || {}), asking: true })
+ const of = daemon()
get(`/api/runs/${encodeURIComponent(run.name)}`).then(
(detail) => {
+ if (of !== daemon()) return
details.set(run.name, { how: run.how, at: Date.now(), detail })
- draw()
+ if (data) draw()
},
- () => details.set(run.name, { how: run.how, at: Date.now(), detail: null }),
+ () => of === daemon() && details.set(run.name, { how: run.how, at: Date.now(), detail: null }),
)
}
const detail = read && read.detail
const sessions = detail ? sessionsOf(detail) : []
const agents = run.agents.map((one) => ({ role: one.role, runs: one.runs, sessions: sessions.filter((session) => session.role === one.role) }))
if (!agents.length) return h('p', { class: 'side-quiet nested' }, 'No agents')
- const base = `#/epics/${encodeURIComponent(run.name)}`
+ const base = hash(`/epics/${encodeURIComponent(run.name)}`)
const logged = run.name === here && parts[2] === 'logs' && !parts[3]
const mythed = run.name === here && parts[2] === 'exomyth'
return h(
@@ -312,12 +299,25 @@ export function mountSidebar(root, context, where) {
}
watch()
- window.addEventListener('hashchange', () => data && draw())
+ window.addEventListener('hashchange', () => {
+ // The links that stay go on to the daemon the address names, as it names it.
+ for (const link of root.querySelectorAll('.side-top .brand, .side-nav a')) link.setAttribute('href', hash(`/${link.dataset.nav || 'new'}`))
+ if (data) draw()
+ })
+ // Another daemon's epics and workspaces: nothing of the last one's is drawn until they are read.
+ window.addEventListener('hmz:daemon', () => {
+ data = null
+ places = null
+ limit = PAGE
+ details.clear()
+ fill(list, h('p', { class: 'side-quiet', role: 'status' }, 'Reading the epics…'))
+ watch()
+ })
}
/** What can be done to one epic, from wherever it is listed. */
export function epicMenu(run) {
- const base = `#/epics/${encodeURIComponent(run.name)}`
+ const base = hash(`/epics/${encodeURIComponent(run.name)}`)
// Going, whether a host holds it or another process does -- `hmz exec`, say.
const going = run.held || run.how === 'running'
const resumes = run.resumable && !going && run.how !== 'done'
@@ -326,13 +326,13 @@ export function epicMenu(run) {
{ label: 'Open', glyph: 'open', href: base },
{ label: 'Open log', glyph: 'thread', href: `${base}/logs` },
{ label: 'Exomyth', glyph: 'chart', href: `${base}/exomyth` },
- resumes ? { label: 'Resume', glyph: 'resume', href: `#/new?flow=${encodeURIComponent(run.flow)}&resume=${encodeURIComponent(run.name)}&workspace=${encodeURIComponent(run.workspace)}` } : null,
- { label: 'New epic like it', glyph: 'new', href: `#/new?flow=${encodeURIComponent(run.flow)}&workspace=${encodeURIComponent(run.workspace)}` },
+ resumes ? { label: 'Resume', glyph: 'resume', href: hash(`/new?flow=${encodeURIComponent(run.flow)}&resume=${encodeURIComponent(run.name)}&workspace=${encodeURIComponent(run.workspace)}`) } : null,
+ { label: 'New epic like it', glyph: 'new', href: hash(`/new?flow=${encodeURIComponent(run.flow)}&workspace=${encodeURIComponent(run.workspace)}`) },
'-',
{ label: 'Copy link', glyph: 'link', act: () => copy(`${location.origin}/${base}`, 'Link copied') },
{ label: 'Copy task', glyph: 'copy', act: () => copy(run.task, 'Task copied') },
{ label: 'Copy name', glyph: 'copy', act: () => copy(run.name, 'Name copied') },
- going ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) },
+ going ? null : { label: 'Export', glyph: 'download', act: () => (location.href = scoped(`/api/runs/${encodeURIComponent(run.name)}/bundle`)) },
{ label: 'Upload', glyph: 'up', title: backedSaid(run.backed), act: () => upload(run.name) },
run.held ? '-' : null,
run.held ? { label: 'Stop', glyph: 'stop', danger: true, act: () => ask(run.workspace, 'stop').then(() => toast('Stopping'), (error) => toast(error.message)) } : null,
@@ -351,6 +351,121 @@ export function backedSaid(backed) {
.join(' · ')
}
+/** How a daemon stands, as its dot says it, and the word for it where it is not up. */
+const STATES = { up: 'up', connecting: 'connecting', installing: 'installing', failed: 'failed', idle: 'not connected' }
+
+/** The daemon the page is of: the address's `@`, or Local without one. */
+function current() {
+ if (api.daemon) return api.daemon()
+ let first = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0]
+ try {
+ first = decodeURIComponent(first)
+ } catch {
+ // Not an address's own escaping: read as it is.
+ }
+ return first.startsWith('@') && first.length > 1 ? first.slice(1) : 'local'
+}
+
+/** Moves the page to another daemon, a new epic on it. */
+function switchTo(name) {
+ if (api.toDaemon) return api.toDaemon(name)
+ location.hash = `/@${encodeURIComponent(name)}/new`
+ // Where the page does not tell of a switch itself, it is told here.
+ if (!api.onDaemon) window.dispatchEvent(new CustomEvent('hmz:daemon', { detail: { name } }))
+}
+
+/** What a daemon is called on the page. */
+function named(name) {
+ return name === 'local' ? 'Local' : name
+}
+
+/**
+ * The foot of the sidebar: the daemon the page is of -- its name, its host, how it stands and
+ * how many epics are going on it -- opening a picker of every daemon and the machine's choices.
+ */
+function daemonBar(context) {
+ const local = { name: 'local', host: context.machine || '', state: 'up' }
+ let daemons = [local]
+ // How many epics are going, and on which daemon they were counted.
+ let live = { of: '', count: 0 }
+ const avatar = h('span', { class: 'side-avatar', 'aria-hidden': 'true' })
+ const title = h('b', { 'data-hmz': 'daemon-name' })
+ const host = h('span', { class: 'side-host', 'data-hmz': 'daemon-host' })
+ const state = h('span', { class: 'side-state', 'data-hmz': 'state' })
+ const el = h(
+ 'button',
+ { class: 'side-account', type: 'button', 'data-hmz': 'daemon-bar' },
+ avatar,
+ h('span', { class: 'side-account-text' }, h('span', { class: 'side-daemon' }, title, host), state),
+ // A newer release of humanize, as PyPI said when it was last asked.
+ context.newer ? h('span', { class: 'side-badge', 'data-hmz': 'newer', title: `hmz ${context.newer} is out` }, '↑') : null,
+ )
+ const entry = () => daemons.find((one) => one.name === current()) || { name: current(), host: '', state: 'idle' }
+ const said = (one) => (Object.hasOwn(STATES, one.state) ? one.state : 'idle')
+ const draw = () => {
+ const one = entry()
+ const how = said(one)
+ const up = how === 'up'
+ avatar.textContent = named(one.name).slice(0, 1).toUpperCase()
+ title.textContent = named(one.name)
+ host.textContent = one.host || ''
+ el.title = [named(one.name), one.host, one.detail].filter(Boolean).join(' · ')
+ el.dataset.state = how
+ const going = live.of === one.name ? live.count : 0
+ fill(state, h('span', { class: ['dot', `daemon-${how}`, up && going && 'live'], 'aria-hidden': 'true' }), up ? (going ? `${going} live` : 'idle') : STATES[how])
+ }
+ // Every daemon, as the machine serving the page has them; Local alone where it keeps none.
+ poll('/api/daemons', 10000, (answer, error) => {
+ const listed = Array.isArray(answer) ? answer : answer && answer.daemons
+ if (Array.isArray(listed)) daemons = listed.some((one) => one.name === 'local') ? listed : [local, ...listed]
+ else if (error && error.status === 404) daemons = [local]
+ draw()
+ })
+ const connect = (name) => {
+ post(`/api/daemons/${encodeURIComponent(name)}/connect`, {}).then(() => {
+ toast(`Connecting ${named(name)}`)
+ refresh('/api/daemons')
+ }, (error) => toast(error.message))
+ }
+ menu(
+ el,
+ (close) => [
+ section('Daemons'),
+ daemons.flatMap((one) => [
+ choice({ label: named(one.name), about: [one.host, STATES[said(one)]].filter(Boolean).join(' · '), chosen: one.name === current(), act: () => {
+ settleSidebar()
+ if (one.name !== current()) switchTo(one.name)
+ }, close }),
+ said(one) === 'failed' ? choice({ label: 'Connect', about: named(one.name), glyph: 'resume', act: () => connect(one.name), close }) : null,
+ ]),
+ choice({ label: 'Manage', glyph: 'sliders', href: hash('/settings/daemons'), close }),
+ section('Machine'),
+ context.newer ? choice({ label: 'Update', about: `hmz ${context.newer}`, glyph: 'up', act: () => copy('hmz update', 'Copied hmz update'), close }) : null,
+ choice({ label: 'Import', glyph: 'file', act: pick, close }),
+ choice({ label: 'Settings', glyph: 'gear', href: hash('/settings/general'), close }),
+ choice({ label: 'Usage', glyph: 'chart', href: hash('/settings/usage'), close }),
+ choice({ label: 'Download', glyph: 'download', href: hash('/download'), close }),
+ ],
+ { up: true, label: 'Daemon' },
+ )
+ // The picker is what opened after it, once the menu has put it there.
+ el.addEventListener('click', () => {
+ const pop = el.nextElementSibling
+ if (pop && pop.matches('.popover')) pop.dataset.hmz = 'daemon-picker'
+ })
+ addEventListener('hashchange', draw)
+ addEventListener('hmz:daemon', draw)
+ draw()
+ return {
+ el,
+ /** Says how many epics are going on the daemon the page is of. */
+ going(count) {
+ live = { of: current(), count }
+ draw()
+ },
+ }
+}
+
/** Forgets a workspace nothing ran in, which takes it out of the list. */
function forget(path) {
post('/api/workspaces/forget', { workspace: path }).then(() => {
diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css
index fecebe47..2f2794c6 100644
--- a/src/hmz/web/static/style.css
+++ b/src/hmz/web/static/style.css
@@ -3845,3 +3845,285 @@ input[aria-invalid='true'] {
-webkit-user-select: none;
user-select: none;
}
+
+/* daemon bar */
+.side-daemon {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ min-width: 0;
+}
+
+.side-daemon b {
+ flex: 0 1 auto;
+ min-width: 3ch;
+}
+
+/* The bar's words take what the row leaves, so a short name is never cut. */
+.side-account .side-account-text {
+ flex: 1 1 auto;
+}
+
+.side-host {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font: 11.5px var(--mono);
+ color: var(--ink-3);
+}
+
+.dot.daemon-connecting,
+.dot.daemon-installing {
+ background: var(--budget);
+}
+
+.dot.daemon-failed {
+ background: var(--fail);
+}
+
+.dot.daemon-idle {
+ background: transparent;
+ box-shadow: inset 0 0 0 1.5px var(--ink-3);
+}
+
+.side-account[data-state='failed'] .side-avatar {
+ background: var(--fail);
+}
+
+/* settings daemons */
+.settings-group {
+ margin: 12px 10px 4px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+
+.settings-nav h2 + .settings-group {
+ margin-top: 0;
+}
+
+.settings-daemon {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 20px;
+ border-bottom: 1px solid var(--line);
+}
+
+.settings-daemon select {
+ min-width: 0;
+ max-width: 100%;
+}
+
+.batch-dialog {
+ width: min(calc(100vw - 32px), 420px);
+ max-height: calc(100vh - 32px);
+ overflow-y: auto;
+ padding: 16px 20px;
+ color: var(--ink);
+ background: var(--paper);
+ border: 2px solid var(--ink);
+ box-shadow: 8px 8px 0 var(--cyan);
+}
+
+.batch-dialog::backdrop {
+ background: var(--scrim);
+}
+
+.batch-dialog h3 {
+ margin: 0 0 4px;
+}
+
+.batch-results {
+ margin: 12px 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ gap: 4px;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 860px) {
+ .settings-group {
+ margin: 0 4px 0 8px;
+ align-self: center;
+ white-space: nowrap;
+ }
+
+ .settings-daemon {
+ padding: 8px 12px;
+ }
+}
+
+/* settings: daemons, notifications */
+.daemon-state .glyph {
+ margin-right: 6px;
+}
+
+.daemon-up .glyph {
+ color: var(--done);
+}
+
+.daemon-failed .glyph {
+ color: var(--fail);
+}
+
+.daemon-connecting .glyph,
+.daemon-installing .glyph {
+ color: var(--ink-3);
+}
+
+.daemon-detail {
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.notification-kinds {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+}
+
+.daemons-table td:last-child .row {
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+}
+
+/* A phone's width: each daemon a block of its own, its buttons under it. */
+@media (max-width: 860px) {
+ .daemons-table thead {
+ display: none;
+ }
+
+ .daemons-table,
+ .daemons-table tbody,
+ .daemons-table tr,
+ .daemons-table td {
+ display: block;
+ }
+
+ .daemons-table tr {
+ padding: 8px 0;
+ }
+
+ .daemons-table td {
+ border: 0;
+ padding: 2px 0;
+ }
+
+ .daemons-table td:last-child .row {
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ }
+}
+
+/* notifications */
+
+.notify-bell {
+ position: relative;
+ margin-left: auto;
+}
+
+.notify-count {
+ position: absolute;
+ top: 1px;
+ right: 0;
+ min-width: 16px;
+ padding: 0 4px;
+ background: var(--pink);
+ color: var(--on-fill);
+ font: 700 10px/16px var(--mono);
+ text-align: center;
+ pointer-events: none;
+}
+
+.notify-menu {
+ width: min(340px, calc(100vw - 32px));
+}
+
+.notify-head {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.notify-head .popover-head {
+ flex: 1;
+}
+
+.notify-item .glyph,
+.notify-open .glyph {
+ color: var(--tone);
+}
+
+.notify-item.notify-unread .choice-label::after {
+ content: '';
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-left: 7px;
+ vertical-align: 1px;
+ background: var(--cyan);
+}
+
+.notify-when,
+.notify-none {
+ font: 11px var(--mono);
+ color: var(--ink-3);
+}
+
+.notify-none {
+ margin: 0;
+ padding: 10px 8px;
+}
+
+.notify-toasts {
+ position: fixed;
+ z-index: 75;
+ right: 16px;
+ bottom: 16px;
+ display: grid;
+ gap: 8px;
+ width: min(340px, calc(100vw - 32px));
+}
+
+.notify-toasts:empty {
+ display: none;
+}
+
+.notify-card {
+ display: flex;
+ align-items: flex-start;
+ background: var(--raised);
+ border: 2px solid var(--ink);
+ box-shadow: 4px 4px 0 var(--pink);
+ animation: notify-in 0.18s ease-out;
+}
+
+.notify-open {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ gap: 8px;
+ padding: 10px;
+ border: 0;
+ background: none;
+ color: var(--ink);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.notify-open:hover {
+ background: var(--hover);
+}
+
+@keyframes notify-in {
+ from {
+ transform: translateY(8px);
+ opacity: 0;
+ }
+}
diff --git a/src/hmz/web/static/views/download.js b/src/hmz/web/static/views/download.js
index c6c61b6d..1bcef689 100644
--- a/src/hmz/web/static/views/download.js
+++ b/src/hmz/web/static/views/download.js
@@ -4,7 +4,7 @@
// epics, as an import would, opening it. What the target holds is fetched afresh as it opens,
// which can take a while.
-import { get, post, refresh } from '../api.js'
+import { get, hash, post, refresh } from '../api.js'
import { go, shut, stays } from '../app.js'
import { ago, badge, empty, fill, flowName, h, headline, icon, iconButton, problem, when } from '../dom.js'
import { nameOf } from '../places.js'
@@ -22,7 +22,7 @@ export function mount(root, { query }) {
get('/api/settings').then((settings) => {
const names = settings.backup.targets.map((one) => one.name)
if (!names.length) {
- fill(body, empty('No backup target.', ' Add one, and the epics it holds are downloaded from here.', h('a', { class: 'btn primary', href: '#/settings/backup' }, 'Backup')))
+ fill(body, empty('No backup target.', ' Add one, and the epics it holds are downloaded from here.', h('a', { class: 'btn primary', href: hash('/settings/backup') }, 'Backup')))
return
}
const chosen = names.includes(query.get('target')) ? query.get('target') : names[0]
@@ -79,7 +79,7 @@ export function mount(root, { query }) {
function row(target, one) {
const action = one.here
- ? h('a', { class: 'btn small quiet', href: `#/epics/${encodeURIComponent(one.epic)}` }, 'Open')
+ ? h('a', { class: 'btn small quiet', href: hash(`/epics/${encodeURIComponent(one.epic)}`) }, 'Open')
: h('button', { class: 'btn small', type: 'button', onclick: (event) => pull(target, one, event.currentTarget) }, 'Download')
return h(
'div',
diff --git a/src/hmz/web/static/views/epic.js b/src/hmz/web/static/views/epic.js
index dc80f711..b544c693 100644
--- a/src/hmz/web/static/views/epic.js
+++ b/src/hmz/web/static/views/epic.js
@@ -9,7 +9,7 @@
// and the side questions asked beside it.
import { asides, board, outworlders, question, saying } from '../acting.js'
-import { get } from '../api.js'
+import { get, hash, scoped } from '../api.js'
import { go } from '../app.js'
import { acting, ago, badge, flowName, pathText, confirming, copier, empty, fill, h, headline, icon, lasting, problem, secondsOf, when } from '../dom.js'
import { ask } from '../held.js'
@@ -80,7 +80,7 @@ export function mount(root, { parts, query }) {
{ class: 'view-pad' },
starting || !none
? h('p', { class: 'loading', role: 'status' }, 'Waiting for the epic to be written down…')
- : empty('No epic is going here.', ' Start one, or open an epic from the sidebar.', h('a', { class: 'btn primary', href: '#/new' }, 'New epic')),
+ : empty('No epic is going here.', ' Start one, or open an epic from the sidebar.', h('a', { class: 'btn primary', href: hash('/new') }, 'New epic')),
),
),
)
@@ -91,7 +91,7 @@ export function mount(root, { parts, query }) {
if (!name) {
// The run going is named now: the address says which, and it is followed by name.
name = detail.name
- history.replaceState(null, '', `#/epics/${encodeURIComponent(name)}`)
+ history.replaceState(null, '', hash(`/epics/${encodeURIComponent(name)}`))
landing = chatting(detail)
stop()
stop = watch(name, detail.workspace, told)
@@ -106,7 +106,7 @@ export function mount(root, { parts, query }) {
if (opened) {
// A chat is a conversation, and is opened on it, as a chat app opens on one.
landing = false
- history.replaceState(null, '', `#/epics/${encodeURIComponent(name)}/sessions/${encodeURIComponent(opened.key)}`)
+ history.replaceState(null, '', hash(`/epics/${encodeURIComponent(name)}/sessions/${encodeURIComponent(opened.key)}`))
mode = modeOf(['epics', name, 'sessions', opened.key])
}
const model = run ? live(held, run, detail) : written(detail, traced)
@@ -129,14 +129,14 @@ export function mount(root, { parts, query }) {
const actions = []
if (going && run.state === 'running') actions.push(confirming({ label: 'Stop', ask: 'Stop this epic?', yes: 'Stop', no: 'Cancel', act: () => ask(workspace(), 'stop') }))
else if (going && run.state === 'stopping') actions.push(confirming({ label: 'Force stop', ask: 'Close every session now?', yes: 'Force stop', no: 'Cancel', act: () => ask(workspace(), 'force') }))
- if (!going && detail.picks_up) actions.push(h('a', { class: 'btn', href: `#/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}` }, icon('resume'), 'Resume'))
- actions.push(h('a', { class: 'icon-btn', href: `#/epics/${encodeURIComponent(detail.name)}/exomyth`, 'aria-label': 'Exomyth', title: 'Exomyth', 'data-hmz': 'exomyth-open' }, icon('chart')))
- if (!going) actions.push(h('a', { class: 'icon-btn', href: `/api/runs/${encodeURIComponent(detail.name)}/bundle`, download: '', 'aria-label': 'Export', title: 'Export' }, icon('download')))
+ if (!going && detail.picks_up) actions.push(h('a', { class: 'btn', href: hash(`/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}`) }, icon('resume'), 'Resume'))
+ actions.push(h('a', { class: 'icon-btn', href: hash(`/epics/${encodeURIComponent(detail.name)}/exomyth`), 'aria-label': 'Exomyth', title: 'Exomyth', 'data-hmz': 'exomyth-open' }, icon('chart')))
+ if (!going) actions.push(h('a', { class: 'icon-btn', href: scoped(`/api/runs/${encodeURIComponent(detail.name)}/bundle`), download: '', 'aria-label': 'Export', title: 'Export' }, icon('download')))
// How each target last took it, said on hover; read again as the epic is.
actions.push(h('button', { class: 'icon-btn', type: 'button', 'aria-label': 'Upload', title: backedSaid(detail.backed) || 'Upload', 'data-hmz': 'upload', onclick: () => upload(detail.name) }, icon('up')))
actions.push(copier('Copy link', () => location.href))
actions.push(panel.toggle)
- const base = `#/epics/${encodeURIComponent(detail.name)}`
+ const base = hash(`/epics/${encodeURIComponent(detail.name)}`)
const sub = [
badge(state),
h('span', { class: 'mono', title: detail.flow }, flowName(detail.flow)),
@@ -348,7 +348,7 @@ export function mount(root, { parts, query }) {
dock.querySelector('.composer, .dock-note')?.remove()
if (composer) dock.append(composer)
else if (detail.picks_up)
- dock.append(h('p', { class: 'dock-note' }, 'Ended · ', h('a', { href: `#/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}` }, 'Resume')))
+ dock.append(h('p', { class: 'dock-note' }, 'Ended · ', h('a', { href: hash(`/new?flow=${encodeURIComponent(detail.flow)}&resume=${encodeURIComponent(detail.name)}`) }, 'Resume')))
// Imported from a workspace this machine has not got as it was: why it is not picked up here.
else if (detail.foreign) dock.append(h('p', { class: 'dock-note', 'data-hmz': 'foreign' }, `Imported · not resumable here: ${detail.foreign}`))
})
@@ -403,7 +403,7 @@ export function mount(root, { parts, query }) {
function overview(detail, run, model, focus, going, figures, standing) {
const agent = model.agents.find((one) => one.role === focus)
- const base = `#/epics/${encodeURIComponent(detail.name)}`
+ const base = hash(`/epics/${encodeURIComponent(detail.name)}`)
const parts = []
if (agent) {
parts.push(
@@ -609,7 +609,7 @@ function placed(env) {
/** The agents as a list rather than a graph: the working first, then in the flow's order. */
function listOf(model, epic, focus) {
- const base = `#/epics/${encodeURIComponent(epic)}`
+ const base = hash(`/epics/${encodeURIComponent(epic)}`)
const order = [...model.agents].sort((a, b) => Number(b.working) - Number(a.working))
return order.map((agent) =>
h(
@@ -697,7 +697,7 @@ function facts(detail) {
h('dd', {}, budgeted(detail.budget)),
h('dt', {}, 'sessions'),
h('dd', {}, detail.sessions.length ? sessionsOf(detail).map((one) => h('div', {}, h('span', { class: 'mono' }, one.key), ` · ${one.backend}`, one.provider ? `@${one.provider}` : '', h('div', { class: 'dim mono' }, one.ident))) : 'none'),
- detail.picked_up ? [h('dt', {}, 'resumed from'), h('dd', {}, h('a', { href: `#/epics/${encodeURIComponent(detail.picked_up)}` }, detail.picked_up))] : null,
+ detail.picked_up ? [h('dt', {}, 'resumed from'), h('dd', {}, h('a', { href: hash(`/epics/${encodeURIComponent(detail.picked_up)}`) }, detail.picked_up))] : null,
detail.profile ? [h('dt', {}, 'profiled'), h('dd', {}, 'yes')] : null,
)
}
diff --git a/src/hmz/web/static/views/exomyth/view.js b/src/hmz/web/static/views/exomyth/view.js
index 6f60f235..7914f02d 100644
--- a/src/hmz/web/static/views/exomyth/view.js
+++ b/src/hmz/web/static/views/exomyth/view.js
@@ -4,7 +4,7 @@
// what it carried, or a stretch of time. An epic still going is read again every few seconds,
// for as long as it is looked at.
-import { get, poll, refresh } from '../../api.js'
+import { get, hash, poll, refresh } from '../../api.js'
import { stays } from '../../app.js'
import { badge, copier, fill, h, headline, icon, iconButton, problem } from '../../dom.js'
import { bar, sidePanel, tabbed } from '../../shell.js'
@@ -53,7 +53,7 @@ export function mount(root, name, query) {
// ---------------------------------------------------------------- the bar and the tools
function drawHead() {
- const link = `#/epics/${encodeURIComponent(name)}`
+ const link = hash(`/epics/${encodeURIComponent(name)}`)
const title = h('span', { class: 'crumbs' }, h('a', { href: link }, detail ? headline(detail.task) : name), h('span', { 'aria-hidden': 'true' }, ' / '), h('span', {}, 'Exomyth'))
const sub = model
? [badge(model.live ? 'running' : (detail && detail.how) || 'done'), h('span', {}, `${counted(model.summary.processes)} processes`), h('span', {}, `${counted(model.summary.spans)} spans`), h('span', { class: 'mono' }, lasted(model.end))]
diff --git a/src/hmz/web/static/views/flows.js b/src/hmz/web/static/views/flows.js
index f661338f..83530ee4 100644
--- a/src/hmz/web/static/views/flows.js
+++ b/src/hmz/web/static/views/flows.js
@@ -5,7 +5,7 @@
// from, fetched or written into, and -- one opened -- every flow its index lists, every release
// of each, installing, switching and uninstalling one; with adding, fetching and removing one.
-import { get, poll, post, refresh } from '../api.js'
+import { get, hash, poll, post, refresh } from '../api.js'
import { stays } from '../app.js'
import { acting, confirming, empty, fill, h, icon, permitted, problem } from '../dom.js'
import { workspacePicker } from '../places.js'
@@ -135,7 +135,7 @@ export function mount(root, { query }) {
one.about ? h('span', { class: 'card-about' }, one.about) : null,
)
card.addEventListener('click', () => show(one.name))
- const newEpic = `#/new?flow=${encodeURIComponent(one.name)}&workspace=${encodeURIComponent(workspace)}`
+ const newEpic = hash(`/new?flow=${encodeURIComponent(one.name)}&workspace=${encodeURIComponent(workspace)}`)
return menuOn(card, () => [
{ head: one.name },
{ label: 'New epic', glyph: 'new', href: newEpic },
@@ -172,7 +172,7 @@ export function mount(root, { query }) {
if (chosen !== name) return
const installed = ((verses && verses.installed) || []).find((one) => one.flow === name)
const update = ((verses && verses.updates) || []).find((one) => one.flow === name)
- const actions = [h('a', { class: 'btn primary', href: `#/new?flow=${encodeURIComponent(flow.name)}&workspace=${encodeURIComponent(workspace)}` }, icon('new'), 'New epic')]
+ const actions = [h('a', { class: 'btn primary', href: hash(`/new?flow=${encodeURIComponent(flow.name)}&workspace=${encodeURIComponent(workspace)}`) }, icon('new'), 'New epic')]
if (update) actions.push(acting(`Update to ${update.to}`, () => change('/api/flowverses/install', { flow: name, version: update.to }), { busy: 'Updating…' }))
if (!name.startsWith('@local/')) actions.push(acting('Copy here', () => change('/api/flows/fork', { flow: name, workspace }), { kind: 'btn quiet', busy: 'Copying…' }))
if (installed) actions.push(confirming({ label: 'Uninstall', ask: `Uninstall ${name}?`, yes: 'Uninstall', no: 'Cancel', act: () => change('/api/flowverses/uninstall', { flow: name }) }))
diff --git a/src/hmz/web/static/views/import.js b/src/hmz/web/static/views/import.js
index 622aafa9..bc71ca29 100644
--- a/src/hmz/web/static/views/import.js
+++ b/src/hmz/web/static/views/import.js
@@ -4,7 +4,7 @@
// In a browser it is asked first: any page elsewhere can link here, and the page would carry
// its key.
-import { post, refresh } from '../api.js'
+import { hash, post, refresh } from '../api.js'
import { fill, h } from '../dom.js'
import { refusal } from '../importing.js'
import { bar, inApp } from '../shell.js'
@@ -21,7 +21,7 @@ export function mount(root, { query }) {
if (left) return
refresh('/api/runs')
// In place of this address, so that going back never imports it again.
- location.replace(`#/epics/${encodeURIComponent(answer.name)}/exomyth`)
+ location.replace(hash(`/epics/${encodeURIComponent(answer.name)}/exomyth`))
},
(error) => {
if (!left) fill(body, refusal(error))
@@ -33,7 +33,7 @@ export function mount(root, { query }) {
fill(
body,
h('p', { class: 'path selectable', title: path }, path),
- h('p', { class: 'row' }, h('button', { class: 'btn primary', type: 'button', autofocus: true, onclick: go }, 'Import'), h('a', { class: 'btn quiet', href: '#/new' }, 'Cancel')),
+ h('p', { class: 'row' }, h('button', { class: 'btn primary', type: 'button', autofocus: true, onclick: go }, 'Import'), h('a', { class: 'btn quiet', href: hash('/new') }, 'Cancel')),
)
return () => {
left = true
diff --git a/src/hmz/web/static/views/new.js b/src/hmz/web/static/views/new.js
index 0365c099..f6931613 100644
--- a/src/hmz/web/static/views/new.js
+++ b/src/hmz/web/static/views/new.js
@@ -4,7 +4,7 @@
// Options on the box, set up as this directory last ran that flow, the way `/flow` in the
// terminal interface sets one up. Picking a run up again is the same box, on that run.
-import { get, post } from '../api.js'
+import { get, hash, post } from '../api.js'
import { covered, go, stays } from '../app.js'
import { composer } from '../composer.js'
import { acting, fill, h, icon, permitted, problem, secondsOf } from '../dom.js'
@@ -235,7 +235,7 @@ export function mount(root, { query }) {
// Back from making an account or a runtime, the pickers offer it.
const back = () => {
- if (!location.hash.startsWith('#/settings')) readBackends(workspace).catch(() => {})
+ if (!location.hash.startsWith(hash('/settings'))) readBackends(workspace).catch(() => {})
}
window.addEventListener('hashchange', back)
return () => {
diff --git a/src/hmz/web/static/views/search.js b/src/hmz/web/static/views/search.js
index c9fbca0f..5c6c8440 100644
--- a/src/hmz/web/static/views/search.js
+++ b/src/hmz/web/static/views/search.js
@@ -3,7 +3,7 @@
// what it spent and how long it took -- found by how it went, by flow and by its task, a page
// at a time. The list `/epics` is in the terminal interface.
-import { address, poll } from '../api.js'
+import { address, hash, poll } from '../api.js'
import { shut, stays } from '../app.js'
import { nameOf, workspaces } from '../places.js'
import { epicMenu } from '../sidebar.js'
@@ -108,7 +108,7 @@ export function mount(root, { query }) {
{},
menuOn(h(
'a',
- { class: ['result', run.held && 'held'], href: `#/epics/${encodeURIComponent(run.name)}`, title: run.task, 'data-hmz': 'epic' },
+ { class: ['result', run.held && 'held'], href: hash(`/epics/${encodeURIComponent(run.name)}`), title: run.task, 'data-hmz': 'epic' },
h('span', { class: 'result-top' }, badge(run.how), h('span', { class: 'result-task' }, headline(run.task)), h('span', { class: 'result-when', title: when(run.began) }, ago(run.began))),
h(
'span',
diff --git a/src/hmz/web/static/views/settings.js b/src/hmz/web/static/views/settings.js
index b8480585..cffd0eb0 100644
--- a/src/hmz/web/static/views/settings.js
+++ b/src/hmz/web/static/views/settings.js
@@ -1,59 +1,236 @@
// What humanize remembers, as ChatGPT keeps its settings: a dialog over the page with its pages
-// down the side, in the order the terminal interface's `/settings` lists them -- what is true
-// wherever humanize runs, who its agents are and what takes over when one fails, where their
-// work goes, where epics are backed up, this one directory -- and last what the runs here spent; and, after what is true
-// wherever humanize runs, how this browser draws the page.
+// down the side, in two groups. The app's own -- how this page is drawn, which daemons it
+// reaches and what it tells you of them -- are this browser's, or this machine's, whichever
+// daemon is on screen. The daemon's -- what is true wherever humanize runs, who its agents are
+// and what takes over when one fails, where their work goes, where epics are backed up, one
+// directory, and last what the runs there spent -- are read from and written to the daemon
+// picked above them, the one on screen unless another is, and each page that saves can copy
+// what it holds to other daemons at once.
-import { shut } from '../app.js'
+import * as api from '../api.js'
+import { hash } from '../api.js'
+import { shut, stays } from '../app.js'
import { fill, h, iconButton, problem } from '../dom.js'
import { dragged } from '../shell.js'
-/** Every page, by the word in the address that opens it. */
+/** Every page, by the word in the address that opens it, in its group. */
const PAGES = {
- general: { label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') },
- themes: { label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') },
- accounts: { label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') },
- fallback: { label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') },
- runtimes: { label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') },
- backup: { label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') },
- workspace: { label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') },
- usage: { label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') },
+ themes: { group: 'app', label: 'Themes', about: 'Colours and pictures of this browser’s page', load: () => import('./settings/themes.js') },
+ daemons: { group: 'app', label: 'Daemons', about: 'Machines this page reaches', load: () => import('./settings/daemons.js') },
+ notifications: { group: 'app', label: 'Notifications', about: 'What every daemon tells you', load: () => import('./settings/notifications.js') },
+ general: { group: 'daemon', label: 'General', about: 'Display, side questions and reporting', load: () => import('./settings/general.js') },
+ accounts: { group: 'daemon', label: 'Accounts', about: 'Credentials agents run as', load: () => import('./settings/accounts.js') },
+ fallback: { group: 'daemon', label: 'Fallback', about: 'Where a failed turn goes next', load: () => import('./settings/fallback.js') },
+ runtimes: { group: 'daemon', label: 'Runtimes', about: 'Machines environments run on', load: () => import('./settings/runtimes.js') },
+ backup: { group: 'daemon', label: 'Backup', about: 'Git repositories epics go to', load: () => import('./settings/backup.js') },
+ workspace: { group: 'daemon', label: 'Workspace', about: 'This directory’s saved setup', load: () => import('./settings/workspace.js') },
+ usage: { group: 'daemon', label: 'Usage', about: 'Spending by day and flow', load: () => import('./settings/usage.js') },
+}
+
+const GROUPS = [
+ ['app', 'App'],
+ ['daemon', 'Daemon'],
+]
+
+/** The daemon the page is on, where the page knows of more than one. */
+function current() {
+ return (typeof api.daemon === 'function' && api.daemon()) || 'local'
+}
+
+/** Where a daemon's API is reached from here: as the page reaches it, or through this server. */
+function prefix(name) {
+ return name === current() ? '' : `/d/${encodeURIComponent(name)}`
+}
+
+/** What a daemon is called on screen. */
+function called(one) {
+ return one.label || (one.name === 'local' ? 'Local' : one.name)
+}
+
+/** Every daemon this page reaches: Local alone where this server keeps no list. */
+async function daemons() {
+ try {
+ const answer = await api.get('/api/daemons')
+ const listed = Array.isArray(answer) ? answer : answer.daemons
+ if (Array.isArray(listed) && listed.length) return listed
+ } catch {
+ // An older server, or none of its own: Local is all there is.
+ }
+ return [{ name: 'local' }]
}
export function mount(root, { parts, query }) {
const name = parts[1] in PAGES ? parts[1] : 'general'
const page = PAGES[name]
- const body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…'))
+ let body = h('div', { class: 'settings-body', 'data-hmz': `settings-${name}` }, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…'))
+ const links = {}
+ let chosen = query.get('daemon') || current()
+ const href = (key) => hash(`/settings/${key}${PAGES[key].group === 'daemon' && query.get('daemon') ? `?daemon=${encodeURIComponent(chosen)}` : ''}`)
+ const nav = h(
+ 'nav',
+ { class: 'settings-nav', 'aria-label': 'Settings pages' },
+ h('h2', {}, 'Settings'),
+ GROUPS.map(([group, label]) => [
+ h('h4', { class: 'settings-group' }, label),
+ Object.entries(PAGES)
+ .filter(([, one]) => one.group === group)
+ .map(([key, one]) => (links[key] = h('a', { href: href(key), 'aria-current': key === name ? 'page' : null }, one.label))),
+ ]),
+ )
+ const picker = h('select', { 'aria-label': 'Daemon', 'data-hmz': 'settings-daemon' }, h('option', { value: chosen }, called({ name: chosen })))
+ const head = h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut }))
root.classList.add('dialog-settings')
root.setAttribute('aria-label', 'Settings')
fill(
root,
// The top row of a narrow window, which it fills.
- dragged(
- h(
- 'nav',
- { class: 'settings-nav', 'aria-label': 'Settings pages' },
- h('h2', {}, 'Settings'),
- Object.entries(PAGES).map(([key, one]) => h('a', { href: `#/settings/${key}`, 'aria-current': key === name ? 'page' : null }, one.label)),
- ),
- ),
+ dragged(nav),
h(
'div',
{ class: 'settings-page' },
- h('div', { class: 'settings-head' }, h('div', {}, h('h3', {}, page.label), h('p', { class: 'dim' }, page.about)), iconButton('close', 'Close', { onclick: shut })),
+ head,
+ page.group === 'daemon' ? h('div', { class: 'settings-daemon' }, h('span', { class: 'label' }, 'Daemon'), picker) : null,
body,
),
)
+
+ let known = [{ name: 'local' }]
let leaving = null
let gone = false
- page
- .load()
- .then((module) => {
- if (!gone) leaving = module.draw(body, query, parts) || null
+ let drawn = 0
+
+ /** What every page on the daemon picked reads, writes and copies with. */
+ const scope = () => {
+ const base = prefix(chosen)
+ return {
+ name: chosen,
+ base,
+ at: (path) => `${base}${path}`,
+ // What a page puts in the address beside its own, so the daemon picked stays in it.
+ query: query.get('daemon') ? { daemon: chosen } : {},
+ batch: (posts, { off = '' } = {}) => batchButton(root, chosen, () => known, posts, off),
+ }
+ }
+
+ const draw = () => {
+ const mine = ++drawn
+ if (leaving) leaving()
+ leaving = null
+ // A body of its own: what the last daemon's page reads late is drawn where nobody sees it.
+ const fresh = body.cloneNode(false)
+ body.replaceWith(fresh)
+ body = fresh
+ fill(fresh, h('p', { class: 'loading', role: 'status' }, 'Reading the settings…'))
+ page
+ .load()
+ .then((module) => {
+ if (!gone && mine === drawn) leaving = module.draw(fresh, query, parts, scope()) || null
+ })
+ .catch((error) => fill(fresh, problem(error)))
+ }
+
+ if (page.group === 'daemon') {
+ daemons().then((listed) => {
+ if (gone) return
+ known = listed
+ if (!known.some((one) => one.name === chosen)) known = [...known, { name: chosen }]
+ fill(picker, known.map((one) => h('option', { value: one.name, selected: one.name === chosen }, called(one))))
})
- .catch((error) => fill(body, problem(error)))
+ picker.addEventListener('change', () => {
+ chosen = picker.value
+ // Kept in the address, so that this daemon's page is one that can be kept; what the
+ // page said of the last daemon's directories says nothing of this one's.
+ query = new URLSearchParams({ daemon: chosen })
+ stays({ daemon: chosen })
+ for (const key of Object.keys(PAGES)) links[key].href = href(key)
+ draw()
+ })
+ }
+ draw()
return () => {
gone = true
if (leaving) leaving()
+ for (const one of root.querySelectorAll('dialog')) one.close()
}
}
+
+/**
+ * Copies what a page holds to other daemons: a dialog of every daemon but the one it came from,
+ * ticked as they are wanted, and what each said once it was sent there.
+ *
+ * @param {HTMLElement} root Where the dialog is drawn.
+ * @param {string} from The daemon the page holds what it holds of.
+ * @param {() => {name: string}[]} known Every daemon there is, as last read.
+ * @param {(() => Array<[string, object]>) | null} posts What is sent: each address and body.
+ * @param {string} off Why nothing can be, where nothing can.
+ */
+function batchButton(root, from, known, posts, off) {
+ const button = h('button', { class: 'btn quiet', type: 'button', 'data-hmz': 'batch-apply', disabled: Boolean(off) }, 'Batch apply')
+ if (off) return h('span', { class: 'row' }, button, h('span', { class: 'hint', 'data-hmz': 'batch-off' }, off))
+ button.addEventListener('click', () => {
+ const others = known().filter((one) => one.name !== from)
+ const boxes = others.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-daemon': one.name }) }))
+ const all = h('input', { type: 'checkbox', 'data-hmz': 'batch-all' })
+ all.addEventListener('change', () => boxes.forEach(({ box }) => (box.checked = all.checked)))
+ for (const { box } of boxes) box.addEventListener('change', () => (all.checked = boxes.every((each) => each.box.checked)))
+ const results = h('ul', { class: 'batch-results', 'data-hmz': 'batch-results', 'aria-live': 'polite' })
+ const apply = h('button', { class: 'btn primary', type: 'button', 'data-hmz': 'batch-go' }, 'Apply')
+ const dialog = h(
+ 'dialog',
+ { class: 'batch-dialog', 'data-hmz': 'batch-dialog', 'aria-label': 'Batch apply' },
+ h('h3', {}, 'Batch apply'),
+ h('p', { class: 'dim' }, `From ${called({ name: from })}`),
+ others.length
+ ? h('div', { class: 'form' }, h('label', { class: 'check' }, all, h('b', {}, 'All')), boxes.map(({ one, box }) => h('label', { class: 'check' }, box, called(one))))
+ : h('p', { class: 'dim' }, 'No other daemon.'),
+ results,
+ h('div', { class: 'row' }, others.length ? apply : null, h('button', { class: 'btn quiet', type: 'button', onclick: () => dialog.close() }, 'Close')),
+ )
+ // Escape closes this dialog, not Settings under it, wherever the focus is.
+ const escape = (event) => {
+ if (event.key !== 'Escape') return
+ event.stopPropagation()
+ event.preventDefault()
+ dialog.close()
+ }
+ document.addEventListener('keydown', escape, true)
+ dialog.addEventListener('close', () => {
+ document.removeEventListener('keydown', escape, true)
+ dialog.remove()
+ })
+ apply.addEventListener('click', async () => {
+ const picked = boxes.filter(({ box }) => box.checked).map(({ one }) => one)
+ if (!picked.length) return
+ apply.disabled = true
+ fill(results)
+ let sent
+ try {
+ sent = await posts()
+ if (!sent.length) throw new Error('Nothing to apply.')
+ } catch (error) {
+ fill(results, h('li', {}, problem(error)))
+ apply.disabled = false
+ return
+ }
+ await Promise.all(
+ picked.map(async (one) => {
+ const line = h('li', { 'data-hmz': 'batch-result', 'data-daemon': one.name }, h('b', {}, called(one)), ' ', h('span', { class: 'dim' }, 'Applying…'))
+ results.append(line)
+ const base = prefix(one.name)
+ try {
+ for (const [path, said] of sent) await api.post(`${base}${path}`, said)
+ fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'ok' }, 'Applied'))
+ line.dataset.ok = 'true'
+ } catch (error) {
+ fill(line, h('b', {}, called(one)), ' ', h('span', { class: 'bad' }, error.message))
+ line.dataset.ok = 'false'
+ }
+ }),
+ )
+ apply.disabled = false
+ })
+ root.append(dialog)
+ dialog.showModal()
+ })
+ return button
+}
diff --git a/src/hmz/web/static/views/settings/accounts.js b/src/hmz/web/static/views/settings/accounts.js
index 87959725..88b82627 100644
--- a/src/hmz/web/static/views/settings/accounts.js
+++ b/src/hmz/web/static/views/settings/accounts.js
@@ -8,7 +8,7 @@
import { get, post } from '../../api.js'
import { acting, ago, confirming, fill, h, problem } from '../../dom.js'
-export function draw(holder) {
+export function draw(holder, query, parts, scope) {
const listing = h('section', { class: 'panel', 'data-hmz': 'accounts' })
const making = h('section', { class: 'panel', 'data-hmz': 'make-account' })
fill(
@@ -38,7 +38,7 @@ export function draw(holder) {
h(
'div',
{ class: 'row' },
- acting('Refresh models', async () => shown(await post('/api/accounts/models', { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }),
+ acting('Refresh models', async () => shown(await post(scope.at('/api/accounts/models'), { cli: one.cli, name: one.name })), { kind: 'btn small quiet', busy: 'Asking…' }),
one.serves.length ? copying(one) : null,
one.name ? h('button', { class: 'btn small quiet', type: 'button', onclick: () => correcting(one) }, 'Edit') : null,
one.name
@@ -47,7 +47,7 @@ export function draw(holder) {
ask: `Remove ${one.cli}@${one.name}, credentials and all?`,
yes: 'Remove',
small: true,
- act: async () => shown(await post('/api/accounts/remove', { cli: one.cli, name: one.name })),
+ act: async () => shown(await post(scope.at('/api/accounts/remove'), { cli: one.cli, name: one.name })),
})
: null,
),
@@ -56,7 +56,8 @@ export function draw(holder) {
)
fill(
listing,
- h('h2', {}, 'Accounts'),
+ // Nothing an account holds is ever shown, so nothing of it can be copied elsewhere.
+ h('header', {}, h('h2', {}, 'Accounts'), scope.batch(null, { off: 'Secrets stay put' })),
rows.length
? h(
'div',
@@ -74,7 +75,7 @@ export function draw(holder) {
'span',
{ class: 'row' },
into,
- acting('Copy', async () => shown(await post('/api/accounts/copy', { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }),
+ acting('Copy', async () => shown(await post(scope.at('/api/accounts/copy'), { cli: one.cli, name: one.name, into: into.value })), { kind: 'btn small quiet', busy: 'Copying…' }),
)
}
@@ -113,7 +114,7 @@ export function draw(holder) {
for (const input of form.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value
save.disabled = true
try {
- shown(await post('/api/accounts/correct', { cli: one.cli, name: one.name, answers: given }))
+ shown(await post(scope.at('/api/accounts/correct'), { cli: one.cli, name: one.name, answers: given }))
fill(said, h('span', { class: 'dim', role: 'status' }, 'Saved.'))
for (const input of form.querySelectorAll('input')) input.value = ''
} catch (error) {
@@ -203,7 +204,7 @@ export function draw(holder) {
for (const input of answers.querySelectorAll('input[data-env]')) if (input.value) given[input.dataset.env] = input.value
make.disabled = true
try {
- shown(await post('/api/accounts', { cli: cli.value, name: name.value.trim(), way: way.value, answers: given }))
+ shown(await post(scope.at('/api/accounts'), { cli: cli.value, name: name.value.trim(), way: way.value, answers: given }))
fill(said, h('span', { class: 'dim', role: 'status' }, `Made ${cli.value}@${name.value.trim()}.`))
name.value = ''
for (const input of answers.querySelectorAll('input')) input.value = ''
@@ -217,7 +218,7 @@ export function draw(holder) {
drawWays()
}
- get('/api/accounts')
+ get(scope.at('/api/accounts'))
.then((answer) => {
shown(answer)
drawMaking()
diff --git a/src/hmz/web/static/views/settings/backup.js b/src/hmz/web/static/views/settings/backup.js
index 1380af93..c118ed94 100644
--- a/src/hmz/web/static/views/settings/backup.js
+++ b/src/hmz/web/static/views/settings/backup.js
@@ -20,7 +20,7 @@ function whenSaid(one) {
return said.length ? said.join(' · ') : 'Manual'
}
-export function draw(holder) {
+export function draw(holder, query, parts, scope) {
const listing = h('section', { class: 'panel', 'data-hmz': 'backups' })
const writing = h('section', { class: 'panel', 'data-hmz': 'write-backup' })
fill(holder, listing, writing)
@@ -35,7 +35,7 @@ export function draw(holder) {
/** Writes every target down, as they are with one change made, and lists them again. */
const save = async (targets, defaults) => {
- data = (await post('/api/settings', { backup: { targets, defaults } })).backup
+ data = (await post(scope.at('/api/settings'), { backup: { targets, defaults } })).backup
drawListing()
}
@@ -85,7 +85,7 @@ export function draw(holder) {
)
fill(
listing,
- h('h2', {}, 'Targets'),
+ h('header', {}, h('h2', {}, 'Targets'), scope.batch(() => [['/api/settings', { backup: data }]])),
rows.length
? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Repository'), h('th', {}, 'When'), h('th', {}, ''), h('th', {}, ''))), h('tbody', {}, rows)))
: h('p', { class: 'dim' }, 'None yet: no epic is backed up.'),
@@ -147,7 +147,7 @@ export function draw(holder) {
fill(writing, h('h2', {}, editing ? `Edit ${editing.name}` : 'New target'), form)
}
- get('/api/settings')
+ get(scope.at('/api/settings'))
.then(shown)
.catch((error) => fill(listing, problem(error)))
}
diff --git a/src/hmz/web/static/views/settings/daemons.js b/src/hmz/web/static/views/settings/daemons.js
new file mode 100644
index 00000000..5341280a
--- /dev/null
+++ b/src/hmz/web/static/views/settings/daemons.js
@@ -0,0 +1,267 @@
+// The daemons the page can show: Local, this machine's own, which is always there, and every
+// SSH host written down beside it -- each with how far reaching it has got. A new one is written
+// a part at a time, as a runtime's SSH host is, or brought in from the hosts an SSH config names;
+// one is reached again, changed, or taken away, asked first.
+
+import { get, post } from '../../api.js'
+import { acting, confirming, empty, fill, h, icon, iconButton, problem } from '../../dom.js'
+import { pathPicker } from '../../paths.js'
+
+/** How far reaching each state has got, by a glyph, so nothing is said by colour alone. */
+const STATES = {
+ up: '●',
+ connecting: '◐',
+ installing: '◐',
+ failed: '✕',
+ idle: '○',
+}
+
+export function draw(holder) {
+ const listing = h('section', { class: 'panel', 'data-hmz': 'daemons' })
+ const writing = h('section', { class: 'panel', 'data-hmz': 'write-daemon' })
+ const importing = h('section', { class: 'panel', 'data-hmz': 'import-daemons' })
+ fill(holder, listing, writing, importing)
+ let daemons = []
+ let editing = null
+ let again = null
+ let reading = 0
+ let gone = false
+ const states = new Map()
+
+ /**
+ * Reads the daemons again, and draws them; while one is still being reached, again soon. The
+ * same daemons as before have only their states drawn again, so a question asked on a row, or
+ * a refusal said there, stays.
+ */
+ async function read() {
+ clearTimeout(again)
+ const mine = ++reading
+ const answer = await get('/api/daemons')
+ if (gone || mine !== reading) return
+ const was = daemons.map((one) => one.name).join('\n')
+ daemons = (answer.daemons || []).filter((one) => one.name !== 'local')
+ if (was && was === daemons.map((one) => one.name).join('\n')) for (const one of daemons) fill(states.get(one.name), shownState(stateOf(one)))
+ else drawListing()
+ if (daemons.some((one) => ['connecting', 'installing'].includes(stateOf(one).state))) again = setTimeout(() => read().catch(() => {}), 2000)
+ }
+
+ function drawListing() {
+ const local = h(
+ 'tr',
+ { 'data-hmz': 'daemon', 'data-name': 'local' },
+ h('td', {}, h('b', {}, 'Local')),
+ h('td', { class: 'mono dim' }, 'this machine'),
+ h('td', {}, shownState({ state: 'up' })),
+ h('td', {}),
+ )
+ states.clear()
+ const rows = daemons.map((one) =>
+ h(
+ 'tr',
+ { 'data-hmz': 'daemon', 'data-name': one.name },
+ h('td', {}, h('b', {}, one.name)),
+ h('td', { class: 'mono' }, reached(one)),
+ (states.set(one.name, h('td', {}, shownState(stateOf(one)))), states.get(one.name)),
+ h(
+ 'td',
+ {},
+ h(
+ 'div',
+ { class: 'row' },
+ acting(
+ 'Connect',
+ async () => {
+ await post(`/api/daemons/${encodeURIComponent(one.name)}/connect`)
+ await read()
+ },
+ { kind: 'btn small quiet', busy: 'Connecting…' },
+ ),
+ h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'),
+ confirming({
+ label: 'Remove',
+ ask: `Remove ${one.name}?`,
+ yes: 'Remove',
+ small: true,
+ act: async () => {
+ await post('/api/daemons/remove', { name: one.name })
+ if (editing && editing.name === one.name) (editing = null), drawWriting()
+ await read()
+ },
+ }),
+ ),
+ ),
+ ),
+ )
+ fill(
+ listing,
+ h('h2', {}, 'Daemons'),
+ h('div', { class: 'table-wrap' }, h('table', { class: 'daemons-table' }, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'State'), h('th', {}, ''))), h('tbody', {}, local, rows))),
+ )
+ }
+
+ function edit(one) {
+ editing = one
+ drawWriting()
+ writing.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+
+ function drawWriting() {
+ const was = editing || {}
+ const text = (id, value, placeholder, extra = {}) => h('input', { type: 'text', id, value: value || '', placeholder, autocomplete: 'off', spellcheck: 'false', ...extra })
+ const name = text('daemon-name', was.name, 'gpu-box', { required: true, disabled: Boolean(editing) })
+ const host = text('daemon-host', was.host || was.alias, 'box.example')
+ const user = text('daemon-user', was.user, 'me')
+ const port = h('input', { type: 'number', id: 'daemon-port', min: '1', max: '65535', step: '1', value: was.port ? String(was.port) : '', placeholder: '22' })
+ const key = pathPicker({ value: was.identity_file || '', files: true, label: 'Identity file', id: 'daemon-identity', placeholder: '~/.ssh/id_ed25519' })
+ const jump = text('daemon-jump', was.proxy_jump, 'user@jump')
+ const options = pairs(was.options || [], 'daemon-options')
+ const part = (label, id, input, wide = false) => h('div', { class: ['field', wide && 'wide'] }, h('label', { for: id }, label), input)
+ fill(
+ writing,
+ h('h2', {}, editing ? `Edit ${editing.name}` : 'New daemon'),
+ h(
+ 'form',
+ { class: 'form', onsubmit: (event) => event.preventDefault() },
+ h(
+ 'div',
+ { class: 'grid' },
+ part('Name', 'daemon-name', name),
+ part('Host', 'daemon-host', host),
+ part('User', 'daemon-user', user),
+ part('Port', 'daemon-port', port),
+ part('Identity file', 'daemon-identity', key.node),
+ part('Proxy jump', 'daemon-jump', jump),
+ part('SSH options', 'daemon-options', options.node, true),
+ ),
+ h(
+ 'div',
+ { class: 'row' },
+ acting(
+ editing ? 'Save' : 'Add',
+ async () => {
+ const fields = {
+ host: host.value.trim(),
+ user: user.value.trim(),
+ identity_file: key.value(),
+ proxy_jump: jump.value.trim(),
+ options: options.read(),
+ }
+ if (port.value !== '') fields.port = Number(port.value)
+ // What an edited daemon was brought in from its ssh config by, kept as it was.
+ if (editing) for (const kept of ['alias', 'config']) if (editing[kept]) fields[kept] = editing[kept]
+ const called = name.value.trim()
+ await post('/api/daemons', { name: called, fields, replace: Boolean(editing) })
+ // Renamed: the daemon under its old name goes.
+ if (editing && editing.name !== called) await post('/api/daemons/remove', { name: editing.name })
+ editing = null
+ drawWriting()
+ await read()
+ },
+ { kind: 'btn primary', busy: 'Saving…' },
+ ),
+ editing ? h('button', { class: 'btn quiet', type: 'button', onclick: () => ((editing = null), drawWriting()) }, 'New daemon') : null,
+ ),
+ ),
+ )
+ }
+
+ function drawImporting() {
+ const reading = acting(
+ 'Read SSH config',
+ async () => {
+ const hosts = ((await get('/api/daemons/hosts')).hosts || []).map((one) => (typeof one === 'string' ? { alias: one } : one))
+ const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', 'data-alias': one.alias }) }))
+ fill(
+ importing,
+ h('h2', {}, 'SSH config'),
+ hosts.length
+ ? [
+ h(
+ 'div',
+ { class: 'form' },
+ boxes.map(({ one, box }) => h('label', { class: 'check' }, box, h('b', { class: 'mono' }, one.alias), one.host ? h('span', { class: 'dim' }, reached(one)) : null)),
+ ),
+ h(
+ 'p',
+ { class: 'row' },
+ acting(
+ 'Import',
+ async () => {
+ const aliases = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias)
+ if (!aliases.length) return
+ await post('/api/daemons/import', { aliases })
+ await read()
+ drawImporting()
+ },
+ { kind: 'btn primary', busy: 'Importing…' },
+ ),
+ ),
+ ]
+ : h('p', { class: 'dim' }, 'Every host it names is added already.'),
+ )
+ },
+ { kind: 'btn', busy: 'Reading…' },
+ )
+ fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as daemons.'), reading)
+ }
+
+ read()
+ .catch((error) => {
+ if (error.status === 404) return fill(holder, empty('Unavailable', 'This humanize does not keep daemons.'))
+ fill(listing, h('h2', {}, 'Daemons'), problem(error))
+ })
+ .then(() => {
+ if (holder.contains(writing)) drawWriting(), drawImporting()
+ })
+ return () => {
+ gone = true
+ clearTimeout(again)
+ }
+}
+
+/** A daemon's state, as the list says it: `state` an object, or a word beside `detail`. */
+function stateOf(one) {
+ if (one.state && typeof one.state === 'object') return one.state
+ return { state: one.state || 'idle', detail: one.detail || '', version: one.version || '' }
+}
+
+function shownState({ state, detail = '', version = '' }) {
+ return h(
+ 'div',
+ { class: ['daemon-state', `daemon-${state}`], 'data-hmz': 'daemon-state' },
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, STATES[state] || '·'),
+ h('span', {}, state),
+ version ? h('span', { class: 'dim' }, ` ${version}`) : null,
+ detail ? h('div', { class: 'dim daemon-detail' }, detail) : null,
+ )
+}
+
+/** Where a daemon is reached, as somebody would write it. */
+function reached(one) {
+ return `${one.user ? `${one.user}@` : ''}${one.host || one.alias || one.name}${one.port && one.port !== 22 ? `:${one.port}` : ''}`
+}
+
+/** SSH options, a name and its value a row, in order and any name more than once: one more added, any taken away. */
+function pairs(values, id) {
+ const rows = h('div', { class: 'list-rows', id, 'data-hmz': 'pair-editor' })
+ const add = h('button', { class: 'btn small quiet', type: 'button' }, icon('plus'), 'Add')
+ const row = (key, value) => {
+ const line = h(
+ 'div',
+ { class: 'list-row' },
+ h('input', { type: 'text', value: key, placeholder: 'Name', 'aria-label': 'Option', class: 'pair-key' }),
+ h('input', { type: 'text', value, placeholder: 'Value', 'aria-label': 'Value', class: 'pair-value' }),
+ iconButton('close', 'Remove', { class: 'small', onclick: () => line.remove() }),
+ )
+ rows.append(line)
+ }
+ for (const [key, value] of values) row(key, String(value))
+ add.addEventListener('click', () => {
+ row('', '')
+ rows.lastElementChild.querySelector('input').focus()
+ })
+ return {
+ node: h('div', { class: 'list-editor' }, rows, add),
+ read: () => [...rows.children].map((line) => [line.querySelector('.pair-key').value.trim(), line.querySelector('.pair-value').value.trim()]).filter(([key]) => key),
+ }
+}
diff --git a/src/hmz/web/static/views/settings/fallback.js b/src/hmz/web/static/views/settings/fallback.js
index ded517ce..88218a2b 100644
--- a/src/hmz/web/static/views/settings/fallback.js
+++ b/src/hmz/web/static/views/settings/fallback.js
@@ -6,7 +6,7 @@ import { get, post } from '../../api.js'
import { acting, confirming, fill, h, iconButton, lasting, problem } from '../../dom.js'
import { placePicker, readBackends } from '../../pickers.js'
-export function draw(holder) {
+export function draw(holder, query, parts, scope) {
const listing = h('section', { class: 'panel', 'data-hmz': 'fallbacks' })
const writing = h('section', { class: 'panel', 'data-hmz': 'write-fallback' })
const places = h('datalist', { id: 'places' })
@@ -36,14 +36,15 @@ export function draw(holder) {
'div',
{ class: 'row' },
h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'),
- confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post('/api/fallbacks/clear', { spec: one.spec })) }),
+ confirming({ label: 'Clear', ask: `Let ${one.spec} fall back nowhere again?`, yes: 'Clear', small: true, act: async () => shown(await post(scope.at('/api/fallbacks/clear'), { spec: one.spec })) }),
),
),
),
)
fill(
listing,
- h('h2', {}, 'Steps'),
+ // Each step written over the one of its place there, or added; none there taken away.
+ h('header', {}, h('h2', {}, 'Steps'), scope.batch(() => data.steps.map(({ spec, to, tries, policy, timeout }) => ['/api/fallbacks', { spec, to, tries, policy, timeout }]))),
rows.length
? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Place'), h('th', {}, 'Tried'), h('th', {}, 'Then'), h('th', {}, ''))), h('tbody', {}, rows)))
: h('p', { class: 'dim' }, 'No steps. A failed turn fails where it ran.'),
@@ -103,7 +104,7 @@ export function draw(holder) {
'Save',
async () => {
const to = [...chain.children].map((line) => line.picked.value()).filter(Boolean)
- const answer = await post('/api/fallbacks', { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) })
+ const answer = await post(scope.at('/api/fallbacks'), { spec: spec.value(), to, tries: Number(tries.value || 0), policy: policy.value, timeout: Number(timeout.value || 0) })
editing = null
shown(answer)
},
@@ -118,7 +119,7 @@ export function draw(holder) {
}
let pickers = []
- Promise.all([get('/api/fallbacks'), readBackends()])
+ Promise.all([get(scope.at('/api/fallbacks')), readBackends(undefined, scope.base)])
.then(([answer]) => shown(answer))
.catch((error) => fill(listing, problem(error)))
}
diff --git a/src/hmz/web/static/views/settings/general.js b/src/hmz/web/static/views/settings/general.js
index 0f24f520..4d062288 100644
--- a/src/hmz/web/static/views/settings/general.js
+++ b/src/hmz/web/static/views/settings/general.js
@@ -5,7 +5,7 @@ import { get, post } from '../../api.js'
import { acting, fill, h, problem } from '../../dom.js'
import { agentPicker, readBackends } from '../../pickers.js'
-export function draw(holder) {
+export function draw(holder, query, parts, scope) {
const shown = (settings) => {
const details = h('input', { type: 'checkbox', checked: settings.details })
// Who a side question goes to: the flow's first agent, or one picked as an agent is picked.
@@ -21,16 +21,13 @@ export function draw(holder) {
h('option', { value: 'yes', selected: settings.reports === true }, 'On'),
h('option', { value: 'no', selected: settings.reports === false }, 'Off'),
)
- const save = acting(
- 'Save',
- async () => {
- const said = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked }
- if (reports.value) said.reports = reports.value === 'yes'
- // Saved where it stands: the button says so, rather than the form being drawn again over it.
- await post('/api/settings', said)
- },
- { kind: 'btn primary', busy: 'Saving…', done: 'Saved' },
- )
+ const said = () => {
+ const given = { btw: whom.value === 'chosen' ? picker.value() : '', details: details.checked }
+ if (reports.value) given.reports = reports.value === 'yes'
+ return given
+ }
+ // Saved where it stands: the button says so, rather than the form being drawn again over it.
+ const save = acting('Save', () => post(scope.at('/api/settings'), said()), { kind: 'btn primary', busy: 'Saving…', done: 'Saved' })
fill(
holder,
h(
@@ -42,14 +39,14 @@ export function draw(holder) {
h('div', { class: 'field' }, h('span', { class: 'label' }, 'Details'), h('label', { class: 'check' }, details, 'Tool calls and thinking')),
h('div', { class: 'field', 'data-hmz': 'btw-agent' }, h('span', { class: 'label' }, 'btw agent'), whom, picker.node, h('span', { class: 'hint' }, 'Who a side question about a whole epic is asked of.')),
h('div', { class: 'field' }, h('span', { class: 'label' }, 'Error reports'), reports, h('span', { class: 'hint' }, 'Whether humanize sends a report of its own crashes. Nothing anybody typed, no file and no credential is ever sent.')),
- h('div', { class: 'row' }, save),
+ h('div', { class: 'row' }, save, scope.batch(() => [['/api/settings', said()]])),
),
),
)
}
let picker = null
let backends = null
- Promise.all([get('/api/settings'), readBackends()])
+ Promise.all([get(scope.at('/api/settings')), readBackends(undefined, scope.base)])
.then(([settings, read]) => {
backends = read
shown(settings)
diff --git a/src/hmz/web/static/views/settings/notifications.js b/src/hmz/web/static/views/settings/notifications.js
new file mode 100644
index 00000000..58fed786
--- /dev/null
+++ b/src/hmz/web/static/views/settings/notifications.js
@@ -0,0 +1,109 @@
+// Which notifications may appear, of every daemon at once: an epic done, failed or stopped, a
+// question to you, a chat turn over -- and whether they are shown by the system as well as on
+// the page. This app's own, kept on this machine, never a daemon's. A browser shows them only
+// once you allow it, from a click here; the desktop app needs nothing more.
+
+import { get, post } from '../../api.js'
+import { empty, fill, h, problem } from '../../dom.js'
+import { inApp } from '../../shell.js'
+
+/** Every kind, by its word in the settings, and what the page calls it. */
+const KINDS = [
+ ['done', 'Done'],
+ ['failed', 'Failed'],
+ ['stopped', 'Stopped'],
+ ['asked', 'Questions'],
+ ['listening', 'Chat turns'],
+]
+
+/** What the browser says of showing notifications, as the page says it. */
+const PERMISSIONS = { granted: 'Allowed', denied: 'Blocked', default: 'Not asked' }
+
+export function draw(holder) {
+ const shown = (settings) => {
+ const kinds = { ...Object.fromEntries(KINDS.map(([kind]) => [kind, true])), ...(settings.kinds || {}) }
+ let system = settings.system !== false
+ const said = h('span', { class: 'hint', 'aria-live': 'polite', 'data-hmz': 'notifications-said' })
+ const save = async () => {
+ said.replaceChildren()
+ try {
+ await post('/api/notifications/settings', { kinds, system })
+ said.textContent = 'Saved'
+ } catch (error) {
+ said.replaceChildren(problem(error))
+ }
+ }
+ const toggle = (label, checked, changed, hook) => {
+ const box = h('input', { type: 'checkbox', checked, 'data-hmz': hook })
+ box.addEventListener('change', () => {
+ changed(box.checked)
+ save()
+ })
+ return h('label', { class: 'check' }, box, label)
+ }
+ const allowing = h('div', { class: 'row', 'data-hmz': 'notifications-permission' })
+ const drawAllowing = () => {
+ if (inApp) return fill(allowing)
+ if (!('Notification' in window)) return fill(allowing, h('span', { class: 'dim' }, 'Unsupported'))
+ const now = Notification.permission
+ fill(
+ allowing,
+ h('span', { class: 'dim', 'data-hmz': 'permission-state' }, PERMISSIONS[now] || now),
+ now === 'granted' || now === 'denied'
+ ? null
+ : h(
+ 'button',
+ {
+ class: 'btn small',
+ type: 'button',
+ 'data-hmz': 'allow-notifications',
+ onclick: async () => {
+ try {
+ // A promise where it is one; older Safari answers by calling back instead.
+ await Notification.requestPermission(drawAllowing)
+ } finally {
+ drawAllowing()
+ }
+ },
+ },
+ 'Allow',
+ ),
+ )
+ }
+ drawAllowing()
+ fill(
+ holder,
+ h(
+ 'section',
+ { class: 'panel notification-settings' },
+ h(
+ 'div',
+ { class: 'form' },
+ h(
+ 'div',
+ { class: 'field' },
+ h('span', { class: 'label' }, 'Kinds'),
+ h(
+ 'div',
+ { class: 'notification-kinds' },
+ KINDS.map(([kind, label]) => toggle(label, kinds[kind], (on) => (kinds[kind] = on), `notify-${kind}`)),
+ ),
+ h('span', { class: 'hint' }, 'Of every daemon, as they happen.'),
+ ),
+ h(
+ 'div',
+ { class: 'field' },
+ h('span', { class: 'label' }, 'System'),
+ toggle('On', system, (on) => (system = on), 'notify-system'),
+ allowing,
+ h('span', { class: 'hint' }, inApp ? 'Shown by macOS while the window is away.' : 'Shown by the browser while the page is away.'),
+ ),
+ said,
+ ),
+ ),
+ )
+ }
+ get('/api/notifications/settings')
+ .then(shown)
+ .catch((error) => fill(holder, error.status === 404 ? empty('Unavailable', 'This humanize does not keep notifications.') : problem(error)))
+}
diff --git a/src/hmz/web/static/views/settings/runtimes.js b/src/hmz/web/static/views/settings/runtimes.js
index a27ad2f4..bbdd4fb7 100644
--- a/src/hmz/web/static/views/settings/runtimes.js
+++ b/src/hmz/web/static/views/settings/runtimes.js
@@ -3,13 +3,13 @@
// reaching it asks; a new one is written from its backend's own fields, or brought in from
// the hosts an ssh config names.
-import { get, post } from '../../api.js'
+import { get, onDaemon, post } from '../../api.js'
import { acting, confirming, fill, h, icon, iconButton, problem } from '../../dom.js'
import { pathPicker } from '../../paths.js'
const GIB = 2 ** 30
-export function draw(holder) {
+export function draw(holder, query, parts, scope) {
const listing = h('section', { class: 'panel', 'data-hmz': 'runtimes' })
const writing = h('section', { class: 'panel', 'data-hmz': 'write-runtime' })
const importing = h('section', { class: 'panel', 'data-hmz': 'import-runtimes' })
@@ -43,20 +43,21 @@ export function draw(holder) {
acting(
'Check',
async () => {
- checked[key] = await post('/api/runtimes/check', { backend: one.backend, name: one.name })
+ checked[key] = await post(scope.at('/api/runtimes/check'), { backend: one.backend, name: one.name })
drawListing()
},
{ kind: 'btn small quiet', busy: 'Asking…' },
),
h('button', { class: 'btn small quiet', type: 'button', onclick: () => edit(one) }, 'Edit'),
- confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post('/api/runtimes/remove', { backend: one.backend, name: one.name })) }),
+ confirming({ label: 'Remove', ask: `Remove ${one.name}?`, yes: 'Remove', small: true, act: async () => shown(await post(scope.at('/api/runtimes/remove'), { backend: one.backend, name: one.name })) }),
),
),
)
})
fill(
listing,
- h('h2', {}, 'Runtimes'),
+ // Each runtime written over the one of its name there, or added; none there taken away.
+ h('header', {}, h('h2', {}, 'Runtimes'), scope.batch(() => data.runtimes.map(({ backend, name, made, ...fields }) => ['/api/runtimes', { backend, name, fields, replace: true }]))),
rows.length
? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Backend'), h('th', {}, 'Name'), h('th', {}, 'Address'), h('th', {}, 'Resources'), h('th', {}, ''))), h('tbody', {}, rows)))
: h('p', { class: 'dim' }, 'None yet: every environment is this machine, or spelled out where it is named.'),
@@ -79,7 +80,7 @@ export function draw(holder) {
fill(fields)
for (const [key, empty] of Object.entries(blank)) {
const value = editing ? editing[key] : empty
- const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes })
+ const [input, read] = field(key, empty, value, { backend: backend.value, name: name.value.trim(), runtimes: data.runtimes, from: scope.base })
readers[key] = read
fields.append(h('div', { class: ['field', (Array.isArray(empty) || (empty && typeof empty === 'object')) && 'wide'] }, h('label', { for: `runtime-${key}` }, labelOf(key)), input))
}
@@ -103,7 +104,7 @@ export function draw(holder) {
async () => {
const given = {}
for (const [key, read] of Object.entries(readers)) given[key] = read()
- const answer = await post('/api/runtimes', { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) })
+ const answer = await post(scope.at('/api/runtimes'), { backend: backend.value, name: name.value.trim(), fields: given, replace: Boolean(editing) })
editing = null
shown(answer)
},
@@ -121,7 +122,7 @@ export function draw(holder) {
const read = acting(
'Read SSH config',
async () => {
- const { hosts } = await get('/api/runtimes/hosts')
+ const { hosts } = await get(scope.at('/api/runtimes/hosts'))
const boxes = hosts.map((one) => ({ one, box: h('input', { type: 'checkbox', disabled: one.saved }) }))
fill(
importing,
@@ -143,7 +144,7 @@ export function draw(holder) {
async () => {
const names = boxes.filter(({ box }) => box.checked).map(({ one }) => one.alias)
if (!names.length) return
- shown(await post('/api/runtimes/import', { names }))
+ shown(await post(scope.at('/api/runtimes/import'), { names }))
drawImporting()
},
{ kind: 'btn primary', busy: 'Importing…' },
@@ -158,7 +159,7 @@ export function draw(holder) {
fill(importing, h('h2', {}, 'SSH config'), h('p', { class: 'dim' }, 'Import hosts from your SSH config as runtimes.'), read)
}
- get('/api/runtimes')
+ get(scope.at('/api/runtimes'))
.then((answer) => {
shown(answer)
drawImporting()
@@ -194,11 +195,12 @@ function labelOf(key) {
return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}${key.endsWith('memory') ? ', GiB' : ''}`
}
-/** The SSH hosts your config names, read once, for the fields that name one. */
-let hosts = null
-function sshHosts() {
- hosts ||= get('/api/runtimes/hosts').then((answer) => answer.hosts.map((one) => one.alias), () => [])
- return hosts
+/** The SSH hosts each daemon's config names, read once, for the fields that name one. */
+const hosts = new Map()
+onDaemon(() => hosts.clear())
+function sshHosts(from) {
+ if (!hosts.has(from)) hosts.set(from, get(`${from}/api/runtimes/hosts`).then((answer) => answer.hosts.map((one) => one.alias), () => []))
+ return hosts.get(from)
}
/**
@@ -207,10 +209,10 @@ function sshHosts() {
* a directory browsed for, the runtimes to fall back to and where a harness runs picked from
* what is saved and put in order, options as names and values, and a list a row at a time.
*/
-function field(key, empty, value, { backend, name, runtimes }) {
+function field(key, empty, value, { backend, name, runtimes, from }) {
const id = `runtime-${key}`
if (key === 'identity_file' || key === 'config' || key === 'tls_dir') {
- const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' })
+ const picked = pathPicker({ value: value || '', files: key !== 'tls_dir', label: labelOf(key), id, from, placeholder: key === 'config' ? '~/.ssh/config' : key === 'tls_dir' ? '~/.docker' : '~/.ssh/id_ed25519' })
return [picked.node, () => picked.value()]
}
if (key === 'fallback') {
@@ -244,7 +246,7 @@ function field(key, empty, value, { backend, name, runtimes }) {
// A host your SSH config names, picked; any other written.
const offered = h('datalist', { id: `${id}-hosts` })
const input = h('input', { id, type: 'text', value: value || '', list: offered.id, autocomplete: 'off', placeholder: key === 'alias' ? 'A host in your SSH config' : key === 'host' ? 'box.example' : 'user@jump' })
- sshHosts().then((names) => fill(offered, names.map((one) => h('option', { value: one }))))
+ sshHosts(from).then((names) => fill(offered, names.map((one) => h('option', { value: one }))))
return [h('div', { class: 'row' }, input, offered), () => input.value.trim()]
}
const input = h('input', { id, type: 'text', value: value || '', autocomplete: 'off' })
diff --git a/src/hmz/web/static/views/settings/themes.js b/src/hmz/web/static/views/settings/themes.js
index 30cb807b..87dba5c1 100644
--- a/src/hmz/web/static/views/settings/themes.js
+++ b/src/hmz/web/static/views/settings/themes.js
@@ -4,6 +4,7 @@
// it is made on, each of its colours and a picture behind any of the large planes, shown on the
// page as it is changed and kept at once.
+import { hash } from '../../api.js'
import { confirming, fill, h, iconButton, problem } from '../../dom.js'
import { choice, menu } from '../../shell.js'
import { menuOn, toast } from '../../ui.js'
@@ -61,11 +62,11 @@ function list(holder) {
})
const fresh = async (from, name) => {
const one = await look.create(from, name)
- location.hash = `/settings/themes/${one.id}`
+ location.hash = hash(`/settings/themes/${one.id}`)
}
const freshPair = async ([light, dark], name) => {
const [one] = await look.createPair(light, dark, name)
- location.hash = `/settings/themes/${one.id}`
+ location.hash = hash(`/settings/themes/${one.id}`)
}
const use = (one) => {
look.choose(one.id)
@@ -75,7 +76,7 @@ function list(holder) {
const offered = (one) => [
{ head: look.label(one) },
{ label: 'Use', glyph: 'check', act: () => use(one) },
- look.OWN.includes(one) ? null : { label: 'Edit', glyph: 'new', href: `#/settings/themes/${one.id}` },
+ look.OWN.includes(one) ? null : { label: 'Edit', glyph: 'new', href: hash(`/settings/themes/${one.id}`) },
{ label: 'Duplicate', glyph: 'copy', act: () => fresh(one, `${one.name} copy`) },
{ label: 'Export', glyph: 'download', act: () => look.exported(one) },
]
@@ -107,7 +108,7 @@ function list(holder) {
'div',
{ class: 'row' },
h('button', { class: 'btn small', type: 'button', disabled: on, onclick: () => (look.usePair(pair.key), redraw()) }, 'Use pair'),
- pair.mine ? either('Edit', two, (one) => (location.hash = `/settings/themes/${one.id}`)) : null,
+ pair.mine ? either('Edit', two, (one) => (location.hash = hash(`/settings/themes/${one.id}`))) : null,
either('Duplicate', two, (one) => fresh(one, `${pair.name} ${one.base}`), () => freshPair(two, `${pair.name} copy`)),
either('Export', two, (one) => look.exported(one), () => look.exported(pair.key)),
pair.mine ? confirming({ label: 'Delete', ask: `Delete ${pair.name}?`, yes: 'Delete', small: true, act: () => look.removePair(pair.key).then(redraw) }) : null,
@@ -126,7 +127,7 @@ function list(holder) {
'div',
{ class: 'row' },
h('button', { class: 'btn small', type: 'button', disabled: on, onclick: () => use(one) }, 'Use'),
- h('a', { class: 'btn quiet small', href: `#/settings/themes/${one.id}` }, 'Edit'),
+ h('a', { class: 'btn quiet small', href: hash(`/settings/themes/${one.id}`) }, 'Edit'),
h('button', { class: 'btn quiet small', type: 'button', onclick: () => fresh(one, `${one.name} copy`) }, 'Duplicate'),
h('button', { class: 'btn quiet small', type: 'button', onclick: () => look.exported(one) }, 'Export'),
confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', small: true, act: () => look.remove(one.id).then(redraw) }),
@@ -209,7 +210,7 @@ function hex(colour) {
function editor(holder, id) {
const one = look.theme(id)
if (!one || look.OWN.includes(one)) {
- fill(holder, problem('No such theme'), h('p', { class: 'row' }, h('a', { class: 'btn', href: '#/settings/themes' }, 'Themes')))
+ fill(holder, problem('No such theme'), h('p', { class: 'row' }, h('a', { class: 'btn', href: hash('/settings/themes') }, 'Themes')))
return
}
// What the theme's colours are where it sets none: its base's, as a theme on it works them out.
@@ -233,7 +234,7 @@ function editor(holder, id) {
'div',
{ class: 'segmented', role: 'group', 'aria-label': 'Side', 'data-hmz': 'theme-side' },
look.members(one.pair).map((other) =>
- h('button', { type: 'button', 'aria-pressed': String(other.id === one.id), onclick: () => (location.hash = `/settings/themes/${other.id}`) }, other.base === 'dark' ? 'Dark' : 'Light'),
+ h('button', { type: 'button', 'aria-pressed': String(other.id === one.id), onclick: () => (location.hash = hash(`/settings/themes/${other.id}`)) }, other.base === 'dark' ? 'Dark' : 'Light'),
),
)
: null
@@ -350,12 +351,12 @@ function editor(holder, id) {
h(
'div',
{ class: 'row' },
- h('a', { class: 'btn', href: '#/settings/themes' }, 'Done'),
+ h('a', { class: 'btn', href: hash('/settings/themes') }, 'Done'),
one.pair
? h('button', { class: 'btn quiet', type: 'button', onclick: () => (look.usePair(one.pair), toast(`Using ${one.name}`)) }, 'Use pair')
: h('button', { class: 'btn quiet', type: 'button', onclick: () => (look.choose(one.id), toast(`Using ${one.name}`)) }, 'Use'),
h('button', { class: 'btn quiet', type: 'button', onclick: () => look.exported(one.pair || one) }, 'Export'),
- confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', act: () => (one.pair ? look.removePair(one.pair) : look.remove(one.id)).then(() => (location.hash = '/settings/themes')) }),
+ confirming({ label: 'Delete', ask: `Delete ${one.name}?`, yes: 'Delete', act: () => (one.pair ? look.removePair(one.pair) : look.remove(one.id)).then(() => (location.hash = hash('/settings/themes'))) }),
),
),
),
diff --git a/src/hmz/web/static/views/settings/usage.js b/src/hmz/web/static/views/settings/usage.js
index dfa5001a..699e05b2 100644
--- a/src/hmz/web/static/views/settings/usage.js
+++ b/src/hmz/web/static/views/settings/usage.js
@@ -2,19 +2,18 @@
// chart is drawn in plain HTML with a table beside it, so no figure rests on a colour or on a
// pointer hovering over it.
-import { address, poll } from '../../api.js'
+import { address, get, hash, poll } from '../../api.js'
import { stays } from '../../app.js'
import { badge, fill, flowName, h, problem } from '../../dom.js'
-import { workspaces } from '../../places.js'
/** How far back the page can look, in days. */
const RANGES = [7, 30, 90, 365]
-export function draw(holder, query) {
+export function draw(holder, query, parts, scope) {
let days = Number(query.get('days')) || 30
let workspace = query.get('workspace') || ''
const places = h('select', { 'aria-label': 'Workdir' }, h('option', { value: '' }, 'All workdirs'))
- workspaces().then((answer) => {
+ get(scope.at('/api/workspaces')).then((answer) => {
fill(places, h('option', { value: '' }, 'All workdirs'), answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace, title: one.path }, one.name)))
}, () => {})
places.addEventListener('change', () => {
@@ -28,7 +27,7 @@ export function draw(holder, query) {
let stop = () => {}
const watch = () => {
stop()
- stays({ days, workspace })
+ stays({ ...scope.query, days, workspace })
fill(
range,
RANGES.map((one) => {
@@ -40,7 +39,7 @@ export function draw(holder, query) {
return button
}),
)
- stop = poll(address('/api/usage', { days, workspace }), 30000, (data, error) => draw(data, error))
+ stop = poll(address(scope.at('/api/usage'), { days, workspace }), 30000, (data, error) => draw(data, error))
}
function draw(data, error) {
@@ -121,7 +120,7 @@ export function draw(holder, query) {
h(
'tr',
{},
- h('td', {}, h('a', { href: `#/search?flow=${encodeURIComponent(one.flow)}`, title: one.flow }, flowName(one.flow))),
+ h('td', {}, h('a', { href: hash(`/search?flow=${encodeURIComponent(one.flow)}`), title: one.flow }, flowName(one.flow))),
h('td', { class: 'num' }, one.runs),
h('td', { class: 'num' }, one.money),
h('td', {}, h('div', { class: 'share', title: share(one.cost, whole.cost) }, h('span', { style: { width: share(one.cost, whole.cost) } }))),
diff --git a/src/hmz/web/static/views/settings/workspace.js b/src/hmz/web/static/views/settings/workspace.js
index 6a5a8ac5..c9ff19fe 100644
--- a/src/hmz/web/static/views/settings/workspace.js
+++ b/src/hmz/web/static/views/settings/workspace.js
@@ -1,11 +1,10 @@
// What a workspace was last set up with: the flow it opens on and every flow set up there, and
// forgetting all of it -- which also stops it being offered, where no epic ran there.
-import { get, post } from '../../api.js'
+import { address, get, post } from '../../api.js'
import { confirming, fill, h, pathText, problem } from '../../dom.js'
-import { workspaces } from '../../places.js'
-export function draw(holder, query) {
+export function draw(holder, query, parts, scope) {
let workspace = query.get('workspace') || ''
const choosing = h('select', { 'aria-label': 'Workdir', 'data-hmz': 'settings-workdir' })
const body = h('div')
@@ -41,7 +40,7 @@ export function draw(holder, query) {
yes: 'Forget',
no: 'Cancel',
act: async () => {
- const answer = await post('/api/settings/forget', { workspace: settings.workspace })
+ const answer = await post(scope.at('/api/settings/forget'), { workspace: settings.workspace })
shown(answer, answer.forgot)
},
}),
@@ -51,12 +50,12 @@ export function draw(holder, query) {
}
function read() {
- get(`/api/settings?workspace=${encodeURIComponent(workspace)}`)
+ get(address(scope.at('/api/settings'), { workspace }))
.then((settings) => shown(settings))
.catch((error) => fill(body, problem(error)))
}
- workspaces().then((answer) => {
+ get(scope.at('/api/workspaces')).then((answer) => {
if (!workspace) workspace = answer.here
fill(choosing, answer.workspaces.map((one) => h('option', { value: one.path, selected: one.path === workspace }, one.path)))
read()
diff --git a/tests/integration/doubles_ssh.py b/tests/integration/doubles_ssh.py
new file mode 100644
index 00000000..a620212e
--- /dev/null
+++ b/tests/integration/doubles_ssh.py
@@ -0,0 +1,218 @@
+"""A stand-in for `ssh`, and the machine it reaches: this one, under a home of its own.
+
+The `ssh` put first on `PATH` takes what the real one is told -- its options, the destination,
+the command -- drops the options and the destination, and runs the command here, with `sh`, in
+the other machine's home: its own `HOME`, `HUMANIZE_HOME` and `TMPDIR`. A port it is told to
+forward with `-L` it forwards over TCP, for as long as it runs. A destination with `unreachable`
+in it is one it cannot reach, as `ssh` says so. Every time it is run it writes its pid and what
+it was told to `ssh.log` beside that home.
+
+The machine has an `hmz` in `~/.local/bin` which is this one, saying whichever version it is
+said to be installed at, or none, and a `uv` whose `tool install` installs the version asked.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import yaml
+
+if TYPE_CHECKING:
+ import pytest
+
+_SSH = """#!{python}
+import json, os, socket, subprocess, sys, threading
+
+HOME, HUMANIZE, TMP, LOG = {home!r}, {humanize!r}, {tmp!r}, {log!r}
+TAKES = set("BbcDEeFIiJLlmOopQRSWw")
+
+args, forwards, at = sys.argv[1:], [], 0
+while at < len(args) and args[at].startswith("-"):
+ flag = args[at][1:2]
+ if flag in TAKES:
+ value = args[at][2:] or args[at + 1]
+ at += 1 if args[at][2:] else 2
+ if flag == "L":
+ forwards.append(value)
+ else:
+ at += 1
+host, command = args[at], " ".join(args[at + 1:])
+with open(LOG, "a") as log:
+ log.write(json.dumps([os.getpid(), *sys.argv[1:]]) + "\\n")
+if "unreachable" in host:
+ print(f"ssh: Could not resolve hostname {{host}}: Name or service not known",
+ file=sys.stderr)
+ sys.exit(255)
+
+
+def pumps(source, sink):
+ try:
+ while chunk := source.recv(65536):
+ sink.sendall(chunk)
+ except OSError:
+ pass
+ for one in (source, sink):
+ try:
+ one.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+
+
+def serves(listening, to):
+ while True:
+ near, _ = listening.accept()
+ try:
+ far = socket.create_connection(to)
+ except OSError:
+ near.close()
+ continue
+ for there in ((near, far), (far, near)):
+ threading.Thread(target=pumps, args=there, daemon=True).start()
+
+
+for spec in forwards:
+ *bind, port, to, to_port = spec.split(":")
+ listening = socket.socket()
+ listening.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ listening.bind((bind[0] if bind else "127.0.0.1", int(port)))
+ except OSError as why:
+ print(f"bind: {{why}}", file=sys.stderr)
+ sys.exit(255)
+ listening.listen()
+ threading.Thread(
+ target=serves, args=(listening, (to, int(to_port))), daemon=True
+ ).start()
+
+env = dict(os.environ, HOME=HOME, HUMANIZE_HOME=HUMANIZE, TMPDIR=TMP)
+os.chdir(HOME)
+if not command:
+ threading.Event().wait()
+if not forwards:
+ os.execvpe("sh", ["sh", "-c", command], env)
+sys.exit(subprocess.run(["sh", "-c", command], env=env).returncode)
+"""
+
+_HMZ = """#!/bin/sh
+if [ ! -f "$HOME/.hmz-version" ]; then echo "sh: hmz: not found" >&2; exit 127; fi
+if [ "$1" = "--version" ]; then echo "hmz $(cat "$HOME/.hmz-version")"; exit 0; fi
+# What its interface says on stderr is also kept, a stack dumped on SIGABRT among it.
+if [ "$1" = "web" ]; then exec {python} -X faulthandler -m hmz "$@" 2>>"$HOME/web.err"; fi
+exec {python} -m hmz "$@"
+"""
+
+_UV = """#!/bin/sh
+echo "$@" >> "$HOME/uv.log"
+for said; do case "$said" in hmz==*) version="${said#hmz==}";; esac; done
+echo "Resolved 1 package in 3ms"
+printf '%s' "$version" > "$HOME/.hmz-version"
+echo "Installed 1 executable: hmz"
+"""
+
+
+@dataclass(frozen=True)
+class Host:
+ """The machine the stand-in reaches.
+
+ Attributes:
+ home: Its `HOME`.
+ humanize: Its `HUMANIZE_HOME`.
+ tmp: Its `TMPDIR`.
+ log: Where each `ssh` run writes its pid and what it was told.
+ """
+
+ home: Path
+ humanize: Path
+ tmp: Path
+ log: Path
+
+ def installs(self, version: str | None) -> None:
+ """Has its `hmz` say this version, or be missing."""
+ held = self.home / ".hmz-version"
+ if version is None:
+ held.unlink(missing_ok=True)
+ else:
+ held.write_text(version)
+
+ def runs_epics(self) -> None:
+ """Has its daemon running, which holds its epics: this process, as one alive."""
+ mine = self.tmp / f"humanize-{os.getuid()}"
+ mine.mkdir(mode=0o700, exist_ok=True)
+ (mine / "daemon.json").write_text(f'{{\n "pid": {os.getpid()}\n}}\n')
+
+ def installed(self) -> str:
+ """What `uv` was asked to install, one line a time."""
+ held = self.home / "uv.log"
+ return held.read_text() if held.exists() else ""
+
+ def pids(self) -> list[int]:
+ """Every `ssh` run so far, by pid."""
+ if not self.log.exists():
+ return []
+ return [json.loads(line)[0] for line in self.log.read_text().splitlines()]
+
+ def told(self) -> list[list[str]]:
+ """What every `ssh` run so far was told."""
+ if not self.log.exists():
+ return []
+ return [json.loads(line)[1:] for line in self.log.read_text().splitlines()]
+
+
+def faked(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str | None = "0.0.0"
+) -> Host:
+ """Puts the stand-in `ssh` first on `PATH`, reaching a machine of its own.
+
+ Args:
+ tmp_path: The test's own directory.
+ monkeypatch: What puts `PATH` back.
+ version: What its `hmz` says it is, or None for none at all.
+
+ Returns:
+ The machine.
+ """
+ there = tmp_path / "there"
+ host = Host(
+ there / "home",
+ there / "humanize",
+ # Short, as a socket's path under it must be.
+ Path(tempfile.mkdtemp(prefix="r", dir=os.environ["TMPDIR"])),
+ there / "ssh.log",
+ )
+ (host.home / ".local" / "bin").mkdir(parents=True)
+ host.humanize.mkdir()
+ made = tmp_path / "bin"
+ made.mkdir()
+ for at, script in (
+ (
+ made / "ssh",
+ _SSH.format(
+ python=sys.executable,
+ home=str(host.home),
+ humanize=str(host.humanize),
+ tmp=str(host.tmp),
+ log=str(host.log),
+ ),
+ ),
+ (host.home / ".local" / "bin" / "hmz", _HMZ.format(python=sys.executable)),
+ (host.home / ".local" / "bin" / "uv", _UV),
+ ):
+ at.write_text(script)
+ at.chmod(0o755)
+ monkeypatch.setenv("PATH", f"{made}{os.pathsep}{os.environ['PATH']}")
+ host.installs(version)
+ return host
+
+
+def written(*daemons: dict[str, object]) -> None:
+ """Writes these daemons down in this machine's settings, as `daemons`."""
+ from hmz.coganchor import settings
+
+ settings.changes(lambda held: held.__setitem__("daemons", list(daemons)))
+ assert yaml.safe_load(settings.where().read_text())["daemons"]
diff --git a/tests/integration/test_web_daemons.py b/tests/integration/test_web_daemons.py
new file mode 100644
index 00000000..e71b55ce
--- /dev/null
+++ b/tests/integration/test_web_daemons.py
@@ -0,0 +1,124 @@
+"""`hmz web`'s daemons: written down, brought in from the ssh config, and removed."""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+
+from hmz import home
+from tests.integration.doubles_web import Browser, served
+
+pytestmark = pytest.mark.skipif(shutil.which("ssh") is None, reason="needs ssh -G")
+
+
+@pytest.fixture
+def ssh_config() -> Path:
+ """An ssh config, in the test's own home, naming two hosts."""
+ at = Path.home() / ".ssh" / "config"
+ at.parent.mkdir(parents=True, exist_ok=True)
+ at.write_text(
+ "Host gpu\n HostName gpu.example\n User me\n Port 2222\n"
+ "Host box\n HostName box.example\n"
+ )
+ return at
+
+
+def _settings() -> dict[str, Any]:
+ return yaml.safe_load((home() / "settings.yaml").read_text())
+
+
+def test_a_daemon_is_added_edited_and_removed(ssh_config: Path) -> None:
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+
+ assert [
+ one["label"] for one in browser.get("/api/daemons").json()["daemons"]
+ ] == ["Local"]
+ added = browser.post(
+ "/api/daemons",
+ {
+ "name": "lab",
+ "fields": {"host": "lab.example", "options": [["Compression", "yes"]]},
+ },
+ )
+ assert added.status == 200, added.body
+ assert [(one["name"], one["state"]) for one in added.json()["daemons"]] == [
+ ("local", "up"),
+ ("lab", "idle"),
+ ]
+ assert (
+ browser.post(
+ "/api/daemons", {"name": "lab", "fields": {"host": "b"}}
+ ).status
+ == 409
+ )
+ assert (
+ browser.post(
+ "/api/daemons",
+ {"name": "lab", "fields": {"host": "b"}, "replace": True},
+ ).status
+ == 200
+ )
+ refused = browser.post(
+ "/api/daemons", {"name": "local", "fields": {"host": "b"}}
+ )
+ assert refused.status == 400
+ assert refused.json()["error"]
+ assert browser.post("/api/daemons", {"name": "x", "nope": 1}).status == 400
+
+ assert _settings()["daemons"] == [
+ {
+ "name": "lab",
+ "host": "b",
+ "user": "",
+ "port": 0,
+ "identity_file": "",
+ "proxy_jump": "",
+ "alias": "",
+ "config": "",
+ "options": [],
+ }
+ ]
+
+ removed = browser.post("/api/daemons/remove", {"name": "lab"})
+ assert [one["name"] for one in removed.json()["daemons"]] == ["local"]
+ assert browser.post("/api/daemons/remove", {"name": "local"}).status == 400
+
+
+def test_hosts_of_the_ssh_config_are_offered_and_imported(ssh_config: Path) -> None:
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+
+ hosts = browser.get("/api/daemons/hosts").json()["hosts"]
+ # Which hosts are named is read off the config in this home; what each comes to
+ # is `ssh -G`'s, which reads the config of the account's own home instead.
+ assert [(one["alias"], one["name"]) for one in hosts] == [
+ ("gpu", "gpu"),
+ ("box", "box"),
+ ]
+ imported = browser.post("/api/daemons/import", {"aliases": ["gpu"]})
+ assert imported.json()["added"] == ["gpu"]
+ again = browser.post("/api/daemons/import", {"aliases": ["gpu", "box"]})
+ assert again.json()["added"] == ["box"]
+ assert [one["name"] for one in again.json()["daemons"]] == [
+ "local",
+ "gpu",
+ "box",
+ ]
+ assert browser.get("/api/daemons/hosts").json()["hosts"] == []
+ assert (
+ browser.post("/api/daemons/import", {"aliases": ["elsewhere"]}).status
+ == 404
+ )
+ assert [one["alias"] for one in _settings()["daemons"]] == ["gpu", "box"]
+
+
+def test_a_stranger_is_told_nothing_of_the_daemons(ssh_config: Path) -> None:
+ with served() as site:
+ assert Browser(site).get("/api/daemons").status == 401
diff --git a/tests/integration/test_web_daemons_together.py b/tests/integration/test_web_daemons_together.py
new file mode 100644
index 00000000..e8ffdcd7
--- /dev/null
+++ b/tests/integration/test_web_daemons_together.py
@@ -0,0 +1,75 @@
+"""Another machine's daemon, reached over ssh, telling this page of its epics.
+
+A chat started on the other machine through this interface waits on its next turn there; the
+notice that says so comes back as a notification here, said to be that daemon's, and so does
+its stopping.
+"""
+
+from __future__ import annotations
+
+import time
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+from tests.integration.doubles_core import AGENT, install
+from tests.integration.doubles_ssh import faked, written
+from tests.integration.doubles_web import PATIENCE, Browser, served
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from tests.integration.doubles_ssh import Host
+
+
+@pytest.fixture
+def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Host:
+ """The stand-in `claude` on PATH, and the other machine, written down as `box`."""
+ (tmp_path / "here").mkdir()
+ install(tmp_path / "here", monkeypatch)
+ found = faked(tmp_path, monkeypatch)
+ written({"name": "box", "host": "box.example", "user": "me"})
+ return found
+
+
+def _told(browser: Browser, kind: str) -> dict[str, Any]:
+ """The first notification of that kind, once there is one."""
+ deadline = time.monotonic() + PATIENCE
+ while time.monotonic() < deadline:
+ items = browser.get("/api/notifications").json()["items"]
+ found = [one for one in items if one["kind"] == kind]
+ if found:
+ return found[0]
+ time.sleep(0.2)
+ raise AssertionError(f"no {kind} notification in {PATIENCE}s")
+
+
+def test_a_chat_on_another_machine_is_told_here(host: Host) -> None:
+ there = host.home / "project"
+ there.mkdir()
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ listed = browser.get("/api/daemons").json()["daemons"]
+ assert [one["name"] for one in listed] == ["local", "box"]
+ # Reached, which starts its interface and what follows its notices.
+ assert browser.get("/d/box/api/held").status == 200
+ started = browser.post(
+ f"/d/box/api/held/start?workspace={there}",
+ {"flow": "chat", "task": "hello there", "agents": {"assistant": AGENT}},
+ )
+ assert started.status == 200, started.body
+
+ listening = _told(browser, "listening")
+ assert listening["daemon"] == "box"
+ assert listening["workspace"] == str(there)
+ assert listening["task"] == "hello there"
+ assert not listening["read"]
+
+ read = browser.post("/api/notifications/read", {"all": True}).json()
+ assert read["unread"] == 0
+ assert (
+ browser.post("/d/box/api/held/stop", {"workspace": str(there)}).status
+ == 200
+ )
+ assert _told(browser, "stopped")["daemon"] == "box"
diff --git a/tests/integration/test_web_notices.py b/tests/integration/test_web_notices.py
new file mode 100644
index 00000000..53e65459
--- /dev/null
+++ b/tests/integration/test_web_notices.py
@@ -0,0 +1,150 @@
+"""`hmz web`: an epic ending, and a chat waiting on its next turn, said as notices."""
+
+from __future__ import annotations
+
+import contextlib
+import http.client
+import json
+import time
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+from tests.integration.doubles_core import AGENT, install, write_flow
+from tests.integration.doubles_web import PATIENCE, Browser, served
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Generator
+ from pathlib import Path
+
+#: A flow of one turn of the stand-in, which ends done.
+_ONCE = '''
+from hmz.flows import (
+ Agent, AgentCollection, EnvCollection, FlowContext, FlowParams, LocalEnv, flow,
+)
+
+
+class Agents(AgentCollection):
+ """The one."""
+
+ solo: Agent
+
+
+class Envs(EnvCollection):
+ """Where it works."""
+
+ workspace: LocalEnv
+
+
+@flow(agents=Agents, envs=Envs, params=FlowParams)
+async def once(
+ task: str, *, agents: Agents, envs: Envs, params: FlowParams, ctx: FlowContext
+) -> str:
+ """One turn, and done."""
+ one = agents["solo"]
+ return await one.run(task, session=await one.spawn(), env=envs["workspace"])
+'''
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+ """The stand-in `claude` alone on PATH, and a project to serve the runs of."""
+ return install(tmp_path, monkeypatch)
+
+
+@contextlib.contextmanager
+def _follows(
+ browser: Browser, last: str = ""
+) -> Generator[Callable[[], tuple[str, dict[str, Any]]]]:
+ """Listens to the notices' stream as a page does: each notice, by its id."""
+ connection = http.client.HTTPConnection(
+ "127.0.0.1", browser.site.port, timeout=PATIENCE
+ )
+ headers = {"Host": f"127.0.0.1:{browser.site.port}", "Cookie": browser.cookie}
+ if last:
+ headers["Last-Event-ID"] = last
+ connection.request("GET", "/api/notices/stream", headers=headers)
+ stream = connection.getresponse()
+
+ def next_notice() -> tuple[str, dict[str, Any]]:
+ ident = event = ""
+ while True:
+ line = stream.readline().decode().rstrip("\n")
+ if line.startswith("id: "):
+ ident = line[len("id: ") :]
+ elif line.startswith("event: "):
+ event = line[len("event: ") :]
+ elif line.startswith("data: ") and event == "notice":
+ return ident, json.loads(line[len("data: ") :])
+
+ try:
+ yield next_notice
+ finally:
+ connection.close()
+
+
+def _until(
+ next_notice: Callable[[], tuple[str, dict[str, Any]]], kind: str
+) -> tuple[str, dict[str, Any]]:
+ ending = time.monotonic() + PATIENCE
+ while time.monotonic() < ending:
+ ident, notice = next_notice()
+ if notice["kind"] == kind:
+ return ident, notice
+ raise AssertionError(f"no {kind} notice came")
+
+
+def test_a_chat_waiting_and_an_epic_ending_are_notices(project: Path) -> None:
+ write_flow(project / ".hmz" / "flows", "once", _ONCE)
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ assert browser.get("/api/notices").json() == {"notices": [], "last": 0}
+
+ with _follows(browser) as heard:
+ chat = {
+ "flow": "chat",
+ "task": "hello there",
+ "agents": {"assistant": AGENT},
+ }
+ assert browser.post("/api/held/start", chat).status == 200
+ _, listening = _until(heard, "listening")
+ assert (listening["workspace"], listening["flow"]) == (str(project), "chat")
+ assert (listening["task"], listening["role"]) == ("hello there", "human")
+ assert listening["epic"]
+
+ assert browser.post("/api/held/stop").status == 200
+ stopped_at, stopped = _until(heard, "stopped")
+ assert stopped["epic"] == listening["epic"]
+
+ started = browser.post(
+ "/api/held/start",
+ {
+ "flow": "@local/once",
+ "task": "do it",
+ "agents": {"solo": AGENT},
+ "budget": {"cost": 5},
+ },
+ )
+ assert started.status == 200, started.json()
+ _, done = _until(heard, "done")
+ assert (done["flow"], done["task"], done["text"]) == (
+ "@local/once",
+ "do it",
+ "",
+ )
+ assert done["epic"] != listening["epic"]
+
+ kept = browser.get("/api/notices").json()
+ assert [one["kind"] for one in kept["notices"]] == [
+ "listening",
+ "stopped",
+ "done",
+ ]
+ assert kept["last"] == kept["notices"][-1]["id"]
+ after = browser.get(f"/api/notices?after={stopped_at}").json()["notices"]
+ assert [one["kind"] for one in after] == ["done"]
+ assert browser.get("/api/notices?after=soon").status == 400
+ # Carried on from the last one heard, a stream hears what came after it.
+ with _follows(browser, stopped_at) as heard:
+ assert heard()[1]["kind"] == "done"
diff --git a/tests/integration/test_web_notifications.py b/tests/integration/test_web_notifications.py
new file mode 100644
index 00000000..a1f86cb7
--- /dev/null
+++ b/tests/integration/test_web_notifications.py
@@ -0,0 +1,267 @@
+"""The interface's notifications: this machine's notices and an SSH daemon's, told as one.
+
+The interface is served in this process; this machine's notices are a stand-in told what to
+say, and an SSH daemon is a stand-in for its interface, reached on a port of this machine as
+a forwarded one is: it lets in what comes with its key, says where its notices stand, and
+streams what it says after that.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import http.client
+import json
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import TYPE_CHECKING, Any, cast
+
+import yaml
+
+from hmz import home
+from tests.integration.doubles_web import PATIENCE, Browser, served
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Generator
+
+ from hmz.web.site import Site
+
+
+class _Notices:
+ """This machine's notices: what hears them, told each as it is said."""
+
+ def __init__(self) -> None:
+ self.hearing: list[Callable[[Any], object]] = []
+
+ def heard(self, callback: Callable[[Any], object]) -> None:
+ self.hearing.append(callback)
+
+ def since(self, after: str) -> list[dict[str, Any]]:
+ return []
+
+ def says(self, notice: dict[str, Any]) -> None:
+ for one in self.hearing:
+ one(notice)
+
+
+class _Daemon(ThreadingHTTPServer):
+ """An SSH daemon's interface, as its forwarded port reaches it."""
+
+ daemon_threads = True
+
+ def __init__(self) -> None:
+ super().__init__(("127.0.0.1", 0), _Answers)
+ #: What its stream says next, a notice at a time.
+ self.saying: list[dict[str, Any]] = []
+ #: The `Last-Event-ID` each stream was asked from.
+ self.asked: list[str] = []
+
+
+class _Answers(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ @property
+ def _daemon(self) -> _Daemon:
+ return cast("_Daemon", self.server)
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
+ pass
+
+ def do_GET(self) -> None:
+ if self.path == "/?key=sesame":
+ self.send_response(303)
+ self.send_header("Set-Cookie", "hmz-4242=sesame; HttpOnly; Path=/")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+ return
+ if "hmz-4242=sesame" not in self.headers.get("Cookie", ""):
+ self.send_error(401)
+ return
+ if self.path == "/api/notices":
+ body = json.dumps({"notices": [], "last": "5"}).encode()
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+ self._daemon.asked.append(self.headers.get("Last-Event-ID", ""))
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.end_headers()
+ self.close_connection = True
+ # Until the interface following it lets it go.
+ with contextlib.suppress(OSError):
+ self._says()
+
+ def _says(self) -> None:
+ while True:
+ while self._daemon.saying:
+ notice = self._daemon.saying.pop(0)
+ said = (
+ f"id: {notice['id']}\nevent: notice\ndata: {json.dumps(notice)}\n\n"
+ )
+ self.wfile.write(said.encode())
+ self.wfile.write(b": quiet\n\n")
+ self.wfile.flush()
+ time.sleep(0.05)
+
+
+class _Remotes:
+ """The SSH daemons reached: `gpu`, up on the stand-in's port."""
+
+ def __init__(self, daemon: _Daemon) -> None:
+ self._daemon = daemon
+
+ def state(self, name: str) -> dict[str, Any]:
+ return {"state": "up" if name == "gpu" else "idle", "detail": "", "version": ""}
+
+ def reached(self, name: str) -> tuple[int, str]:
+ return int(self._daemon.server_address[1]), "sesame"
+
+
+@contextlib.contextmanager
+def _told() -> Generator[tuple[Site, Browser, _Notices, _Daemon]]:
+ home().mkdir(parents=True, exist_ok=True)
+ (home() / "settings.yaml").write_text(
+ yaml.safe_dump({"daemons": [{"name": "gpu", "host": "gpu.example"}]})
+ )
+ daemon = _Daemon()
+ threading.Thread(target=daemon.serve_forever, daemon=True).start()
+ try:
+ with served() as site:
+ notices = _Notices()
+ site.notices = notices # pyright: ignore[reportAttributeAccessIssue]
+ site.remotes = _Remotes(daemon) # pyright: ignore[reportAttributeAccessIssue]
+ browser = Browser(site)
+ browser.signs_in()
+ _waits(lambda: bool(notices.hearing) and bool(daemon.asked), "followed")
+ yield site, browser, notices, daemon
+ finally:
+ daemon.shutdown()
+ daemon.server_close()
+
+
+def _waits(done: Callable[[], bool], what: str) -> None:
+ ending = time.monotonic() + PATIENCE
+ while not done():
+ assert time.monotonic() < ending, f"never {what}"
+ time.sleep(0.05)
+
+
+def _items(browser: Browser) -> list[dict[str, Any]]:
+ answer = browser.get("/api/notifications")
+ assert answer.status == 200, answer.body
+ return answer.json()["items"]
+
+
+def _notice(kind: str, epic: str, ident: str = "1") -> dict[str, Any]:
+ return {"id": ident, "kind": kind, "workspace": "/w", "epic": epic, "text": ""}
+
+
+def test_every_daemons_notices_are_told_as_one_by_who_said_them() -> None:
+ with _told() as (_, browser, notices, daemon):
+ notices.says(_notice("done", "here"))
+ daemon.saying.append(_notice("asked", "there", "6"))
+ _waits(lambda: len(_items(browser)) == 2, "told both")
+
+ items = _items(browser)
+
+ assert [(one["daemon"], one["epic"], one["kind"]) for one in items] == [
+ ("gpu", "there", "asked"),
+ ("local", "here", "done"),
+ ]
+ # What the daemon said before it was first reached is not told.
+ assert daemon.asked == ["5"]
+
+
+def test_a_kind_turned_off_is_never_told_and_written_down_as_off() -> None:
+ with _told() as (_, browser, notices, _):
+ answer = browser.post(
+ "/api/notifications/settings", {"kinds": {"listening": False}}
+ )
+
+ assert answer.status == 200, answer.body
+ assert answer.json() == browser.get("/api/notifications/settings").json()
+ written = yaml.safe_load((home() / "settings.yaml").read_text())
+ assert written["notifications"]["kinds"]["listening"] is False
+ assert written["notifications"]["system"] is True
+ assert written["daemons"] == [{"name": "gpu", "host": "gpu.example"}]
+ notices.says(_notice("listening", "chat"))
+ notices.says(_notice("failed", "broke"))
+ assert [one["kind"] for one in _items(browser)] == ["failed"]
+
+
+def test_settings_that_are_not_notifications_are_refused() -> None:
+ with _told() as (_, browser, _, _):
+ for wrong in (
+ {"kinds": {"x": True}},
+ {"kinds": {"done": "no"}},
+ {"kinds": False},
+ {"system": "yes"},
+ {"sound": True},
+ ):
+ answer = browser.post("/api/notifications/settings", wrong)
+ assert answer.status == 400, wrong
+ assert "notifications" not in (home() / "settings.yaml").read_text()
+
+
+def test_notifications_are_read_and_cleared() -> None:
+ with _told() as (_, browser, notices, _):
+ notices.says(_notice("done", "a"))
+ notices.says(_notice("stopped", "b"))
+ first = _items(browser)[-1]["id"]
+
+ answer = browser.post("/api/notifications/read", {"ids": [first]})
+ assert answer.json()["unread"] == 1
+ assert (
+ browser.post("/api/notifications/read", {"all": True}).json()["unread"] == 0
+ )
+ assert browser.post("/api/notifications/clear").json() == {
+ "items": [],
+ "unread": 0,
+ }
+
+
+def test_the_stream_tells_each_as_it_comes_and_takes_up_where_it_was_left() -> None:
+ with _told() as (site, browser, notices, _):
+ notices.says(_notice("done", "before"))
+ with _following(site, browser, "") as next_event:
+ notices.says(_notice("asked", "after"))
+ ident, event, data = next_event()
+ assert (event, data["epic"], data["daemon"], data["read"]) == (
+ "notification",
+ "after",
+ "local",
+ False,
+ )
+ with _following(site, browser, "1") as next_event:
+ assert next_event()[0] == ident
+
+
+@contextlib.contextmanager
+def _following(
+ site: Site, browser: Browser, last: str
+) -> Generator[Callable[[], tuple[str, str, Any]]]:
+ connection = http.client.HTTPConnection("127.0.0.1", site.port, timeout=PATIENCE)
+ headers = {"Cookie": browser.cookie} | ({"Last-Event-ID": last} if last else {})
+ connection.request("GET", "/api/notifications/stream", headers=headers)
+ stream = connection.getresponse()
+ assert stream.status == 200
+
+ def next_event() -> tuple[str, str, Any]:
+ ident = event = data = ""
+ while True:
+ line = stream.readline().decode().rstrip("\n")
+ if line.startswith("id: "):
+ ident = line[4:]
+ elif line.startswith("event: "):
+ event = line[7:]
+ elif line.startswith("data: "):
+ data = line[6:]
+ elif not line and event:
+ return ident, event, json.loads(data)
+
+ try:
+ yield next_event
+ finally:
+ connection.close()
diff --git a/tests/integration/test_web_remote.py b/tests/integration/test_web_remote.py
new file mode 100644
index 00000000..7afa4b76
--- /dev/null
+++ b/tests/integration/test_web_remote.py
@@ -0,0 +1,302 @@
+"""A daemon on another machine, reached over ssh and answered through this interface.
+
+The other machine is this one under a home of its own, reached by the stand-in `ssh` of
+`doubles_ssh`, running this `hmz web` there as the real one would run the one installed.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import http.client
+import os
+import re
+import signal
+import socket
+import subprocess
+import time
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+from hmz.web.remote import Remotes
+
+from .doubles_ssh import Host, faked, written
+from .doubles_web import PATIENCE, Answer, Browser, served
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from hmz.web.site import Site
+
+
+@pytest.fixture
+def host(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Host:
+ """The other machine, written down as `box`, with this version of `hmz`."""
+ found = faked(tmp_path, monkeypatch)
+ _THERE[:] = [found.home]
+ written(
+ {
+ "name": "box",
+ "host": "box.example",
+ "user": "me",
+ "options": [["Compression", "yes"]],
+ }
+ )
+ return found
+
+
+#: The other machine's home, for what its interface said when it never came up.
+_THERE: list[Path] = []
+
+
+def _processes() -> str:
+ """What runs of the stand-in's, and the stack of each interface there that never listened."""
+ listed = subprocess.run(
+ ["ps", "-axo", "pid,ppid,etime,command"],
+ capture_output=True,
+ text=True,
+ check=False,
+ ).stdout
+ mine = [line for line in listed.splitlines() if re.search(r"ssh|hmz|\bsh\b", line)]
+ for line in mine:
+ pid, ppid, *_ = line.split()
+ if int(ppid) == os.getpid() and "faulthandler -m hmz web" in line:
+ with contextlib.suppress(OSError):
+ os.kill(int(pid), signal.SIGABRT)
+ time.sleep(1)
+ said = [
+ (home / "web.err").read_text() for home in _THERE if (home / "web.err").exists()
+ ]
+ return "\n".join([*mine, *said])
+
+
+def _stands(remotes: Remotes, name: str, *states: str) -> dict[str, str]:
+ """How a daemon stands, once it stands as one of `states`."""
+ ending = time.monotonic() + PATIENCE
+ while (said := remotes.state(name))["state"] not in states:
+ assert time.monotonic() < ending, (
+ f"{name} never came to {states}: {said}\n{_processes()}"
+ )
+ time.sleep(0.05)
+ return said
+
+
+def _asked(browser: Browser, path: str) -> Answer:
+ """What the interface answers a read with, once the daemon is reached."""
+ ending = time.monotonic() + PATIENCE
+ while (answer := browser.get(path)).status == 503:
+ assert time.monotonic() < ending, f"{answer.body!r}\n{_processes()}"
+ time.sleep(0.1)
+ return answer
+
+
+def _gone(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return True
+ return False
+
+
+def test_a_page_reads_and_writes_a_daemon_on_another_machine(host: Host) -> None:
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ listed = _asked(browser, "/d/box/api/workspaces")
+ assert listed.status == 200, listed.body
+ assert str(host.home) in str(listed.json())
+ state = browser.get("/api/daemons/box/state").json()
+ assert state == {"state": "up", "detail": "", "version": "0.0.0"}
+ # Written there, and not here.
+ kept = host.home / "project"
+ kept.mkdir()
+ wrote = browser.post("/d/box/api/workspaces", {"workspace": str(kept)})
+ assert wrote.status == 200, wrote.body
+ assert wrote.json()["kept"] == str(kept)
+ assert str(kept) in (host.humanize / "settings.yaml").read_text()
+ # A file sent there a piece at a time, refused there for what it is.
+ sent = browser.sends(
+ "/d/box/api/runs/import", b"not an epic" * 1000, "x.hmzepic"
+ )
+ assert sent.status not in (502, 503), sent.body
+ assert "error" in sent.json()
+ # Its runs, as a page reads them.
+ assert _asked(browser, "/d/box/api/runs").status == 200
+ # And what it was told: nothing asked of anybody, the host's own options first.
+ told = host.told()[0]
+ assert told.index("Compression=yes") < told.index("BatchMode=yes")
+ assert told.index("BatchMode=yes") < told.index("BatchMode=no")
+ assert "me@box.example" in told
+
+
+def test_local_is_this_machine_by_either_path(host: Host) -> None:
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ assert (
+ browser.get("/d/local/api/workspaces").json()
+ == browser.get("/api/workspaces").json()
+ )
+ assert browser.get("/api/daemons/local/state").json()["state"] == "up"
+ assert browser.get("/d/nobody/api/runs").status == 404
+ assert browser.get("/api/daemons/nobody/state").status == 404
+ assert not host.pids()
+
+
+def test_only_the_page_let_in_reaches_another_machine(host: Host) -> None:
+ with served() as site:
+ browser = Browser(site)
+ assert browser.get("/d/box/api/runs").status == 401
+ browser.signs_in()
+ assert (
+ browser.get("/d/box/api/runs", **{"Sec-Fetch-Site": "cross-site"}).status
+ == 403
+ )
+ assert (
+ browser.post(
+ "/d/box/api/workspaces", {}, Origin="http://elsewhere.example"
+ ).status
+ == 403
+ )
+ assert not host.pids()
+
+
+def test_a_stream_of_events_comes_through_as_it_comes(host: Host) -> None:
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ _asked(browser, "/d/box/api/runs")
+ connection = http.client.HTTPConnection(
+ "127.0.0.1", site.port, timeout=PATIENCE
+ )
+ try:
+ connection.request(
+ "GET",
+ "/d/box/api/held/stream",
+ headers={"Host": f"127.0.0.1:{site.port}", "Cookie": browser.cookie},
+ )
+ stream = connection.getresponse()
+ assert stream.status == 200
+ assert stream.getheader("Content-Type", "").startswith("text/event-stream")
+ assert stream.getheader("Set-Cookie") is None
+ events: list[str] = []
+ while not events:
+ line = stream.readline().decode()
+ assert line, "the stream ended before any event"
+ if line.startswith("event: "):
+ events.append(line.removeprefix("event: ").strip())
+ finally:
+ connection.close()
+
+
+def test_a_daemon_that_cannot_be_reached_says_why(
+ host: Host, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ del host
+ written({"name": "gone", "host": "unreachable.example"})
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ connected = browser.post("/api/daemons/gone/connect")
+ assert connected.status == 200
+ remotes = _remotes(site)
+ said = _stands(remotes, "gone", "failed")
+ assert "Could not resolve hostname" in said["detail"]
+ refused = browser.get("/d/gone/api/runs")
+ assert refused.status == 503
+ assert refused.json()["state"] == "failed"
+
+
+def test_closing_stops_and_reaps_every_process_started(host: Host) -> None:
+ remotes = Remotes()
+ remotes.connect("box")
+ _stands(remotes, "box", "up")
+ port, key = remotes.reached("box")
+ assert key
+ pids = host.pids()
+ # The probe, the interface there, the forward.
+ assert len(pids) == 3
+ forward = host.told()[-1]
+ assert "ControlPath=none" in forward
+ assert any(word.startswith(f"127.0.0.1:{port}:127.0.0.1:") for word in forward)
+ remotes.close()
+ assert all(_gone(pid) for pid in pids)
+ assert remotes.state("box")["state"] == "idle"
+ with socket.socket() as again:
+ assert again.connect_ex(("127.0.0.1", port)) != 0
+
+
+def test_another_version_there_is_replaced_with_this_one(host: Host) -> None:
+ host.installs("1.0.0")
+ remotes = Remotes(version="1.2.3")
+ try:
+ remotes.connect("box")
+ said = _stands(remotes, "box", "up", "failed")
+ assert said == {"state": "up", "detail": "", "version": "1.2.3"}
+ assert host.installed().split() == [
+ "tool",
+ "install",
+ "--force",
+ "--python",
+ ">=3.12",
+ "hmz==1.2.3",
+ ]
+ finally:
+ remotes.close()
+
+
+def test_one_missing_there_is_installed(host: Host) -> None:
+ host.installs(None)
+ remotes = Remotes(version="1.2.3")
+ try:
+ remotes.connect("box")
+ assert _stands(remotes, "box", "up", "failed")["version"] == "1.2.3"
+ finally:
+ remotes.close()
+
+
+def test_one_running_epics_there_is_left_alone(host: Host) -> None:
+ host.installs("1.0.0")
+ host.runs_epics()
+ remotes = Remotes(version="1.2.3")
+ try:
+ remotes.connect("box")
+ said = _stands(remotes, "box", "up", "failed")
+ assert said["state"] == "up"
+ assert said["version"] == "1.0.0"
+ assert "running epics" in said["detail"]
+ assert not host.installed()
+ finally:
+ remotes.close()
+
+
+def test_a_development_build_installs_nothing(host: Host) -> None:
+ host.installs(None)
+ remotes = Remotes(version="0.0.0")
+ try:
+ remotes.connect("box")
+ said = _stands(remotes, "box", "failed")
+ assert said["detail"] == "hmz is not installed on box."
+ assert not host.installed()
+ finally:
+ remotes.close()
+
+
+def test_a_daemon_no_longer_written_down_is_let_go(host: Host) -> None:
+ with served() as site:
+ browser = Browser(site)
+ browser.signs_in()
+ assert _asked(browser, "/d/box/api/runs").status == 200
+ pids = host.pids()
+ written({"name": "other", "host": "other.example"})
+ assert browser.get("/d/box/api/runs").status == 404
+ assert browser.get("/api/daemons/box/state").status == 404
+ ending = time.monotonic() + PATIENCE
+ while not all(_gone(pid) for pid in pids):
+ assert time.monotonic() < ending, "what reached it was never stopped"
+ time.sleep(0.05)
+
+
+def _remotes(site: Site) -> Any:
+ assert site.remotes is not None
+ return site.remotes
diff --git a/tests/system/web/doubles_browser.py b/tests/system/web/doubles_browser.py
index c3c3b623..cd08156e 100644
--- a/tests/system/web/doubles_browser.py
+++ b/tests/system/web/doubles_browser.py
@@ -17,7 +17,7 @@
from tests.integration.doubles_web import PATIENCE
if TYPE_CHECKING:
- from collections.abc import Generator
+ from collections.abc import Callable, Generator
from playwright.sync_api import Page
@@ -52,6 +52,7 @@ def opened(
dark: bool = False,
user_agent: str | None = None,
init: str | None = None,
+ routed: Callable[[Page], object] | None = None,
) -> Generator[Page]:
"""The page, in a browser let in by the address, until the block ends.
@@ -63,6 +64,7 @@ def opened(
dark: Whether the system the browser runs on asks for dark colours.
user_agent: What the browser says it is, where not its own: as the macOS app's window.
init: A script run in the page before any of its own.
+ routed: Told the page before it is opened, to answer some of its requests itself.
Yields:
The page, drawn and with its sidebar up.
@@ -87,6 +89,8 @@ def opened(
if init:
context.add_init_script(init)
page = context.new_page()
+ if routed:
+ routed(page)
failed: list[str] = []
# Bar the browser's word that a resize took more than one frame to settle: a
# notice, not a failure, which WebKit raises as an error on a busy machine.
diff --git a/tests/system/web/test_web_daemon_bar.py b/tests/system/web/test_web_daemon_bar.py
new file mode 100644
index 00000000..70c4952e
--- /dev/null
+++ b/tests/system/web/test_web_daemon_bar.py
@@ -0,0 +1,115 @@
+"""The daemon bar at the foot of `hmz web`'s sidebar, and the picker it opens.
+
+The bar names the daemon the page is of, its host and how it stands; the picker lists every
+daemon the machine serving the page keeps, switches to one, connects one that failed and
+opens where they are managed. The daemons are answered here, as the machine would.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import TYPE_CHECKING
+
+import pytest
+from playwright.sync_api import expect
+
+from tests.integration.doubles_core import install
+from tests.integration.doubles_web import served
+from tests.system.web.doubles_browser import ENGINES, opened
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+pytestmark = pytest.mark.parametrize("engine", ENGINES)
+
+_DAEMONS = [
+ {"name": "local", "host": "here", "state": "up"},
+ {"name": "lab", "host": "lab.example", "state": "up"},
+ {"name": "farm", "host": "farm.example", "state": "failed"},
+]
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path:
+ """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve."""
+ return install(tmp_path, monkeypatch)
+
+
+def _answering(status: int, body: object) -> str:
+ """A script answering the daemons, and connecting one, as a machine would, before the page asks.
+
+ The page is let in by the address as it opens, so the answer is given in the page itself.
+ """
+ return f"""
+ window.hmzConnected = []
+ const fetched = window.fetch.bind(window)
+ window.fetch = (url, how) => {{
+ const path = new URL(String(url), location.href).pathname
+ const json = (code, said) => new Response(JSON.stringify(said), {{
+ status: code, headers: {{ 'Content-Type': 'application/json' }},
+ }})
+ if (path === '/api/daemons') return Promise.resolve(json({status}, {json.dumps(body)}))
+ const connect = /^\\/api\\/daemons\\/([^/]+)\\/connect$/.exec(path)
+ if (connect) {{
+ window.hmzConnected.push(connect[1])
+ return Promise.resolve(json(200, {{}}))
+ }}
+ return fetched(url, how)
+ }}
+ """
+
+
+def test_the_bar_names_the_daemon_and_its_picker_switches(
+ project: Path, engine: str
+) -> None:
+ with (
+ served() as site,
+ opened(site, engine=engine, init=_answering(200, _DAEMONS)) as page,
+ ):
+ bar = page.locator("[data-hmz=daemon-bar]")
+ expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("Local")
+ expect(bar.locator("[data-hmz=daemon-host]")).to_have_text("here")
+ expect(bar.locator("[data-hmz=state]")).to_have_text("idle")
+
+ bar.click()
+ picker = page.locator("[data-hmz=daemon-picker]")
+ expect(picker).to_have_attribute("role", "menu")
+ expect(picker.locator(".choice-label")).to_have_text(
+ [
+ *["Local", "lab", "farm", "Connect", "Manage"],
+ *["Import", "Settings", "Usage", "Download"],
+ ]
+ )
+ expect(picker.locator(".choice.chosen .choice-label")).to_have_text("Local")
+
+ # One that failed is connected again.
+ picker.get_by_role("menuitem", name="Connect").click()
+ expect(picker).to_have_count(0)
+ assert page.evaluate("window.hmzConnected") == ["farm"]
+
+ bar.click()
+ picker.get_by_role("menuitem", name=re.compile(r"^lab")).click()
+ page.wait_for_url(re.compile(r".*#/@lab/new$"))
+ expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("lab")
+ expect(bar.locator("[data-hmz=daemon-host]")).to_have_text("lab.example")
+
+ bar.click()
+ picker.get_by_role("menuitem", name="Manage").click()
+ page.wait_for_url(re.compile(r".*#/@lab/settings/daemons$"))
+
+
+def test_without_daemons_the_bar_is_local(project: Path, engine: str) -> None:
+ missing = {"error": "Nothing is here."}
+ with (
+ served() as site,
+ opened(site, engine=engine, width=375, init=_answering(404, missing)) as page,
+ ):
+ page.locator("[data-hmz=bar] .bar-side").click()
+ bar = page.locator("[data-hmz=daemon-bar]")
+ expect(bar.locator("[data-hmz=daemon-name]")).to_have_text("Local")
+ bar.click()
+ picker = page.locator("[data-hmz=daemon-picker]")
+ expect(picker.locator(".choice-label")).to_have_text(
+ ["Local", "Manage", "Import", "Settings", "Usage", "Download"]
+ )
diff --git a/tests/system/web/test_web_notification_centre.py b/tests/system/web/test_web_notification_centre.py
new file mode 100644
index 00000000..00aa581c
--- /dev/null
+++ b/tests/system/web/test_web_notification_centre.py
@@ -0,0 +1,240 @@
+"""`hmz web`'s notification centre: the bell, its menu, the cards, and the system's own.
+
+The global notifications are the local server's, of every daemon; here they are answered by
+the browser itself, routed before the page asks, so the page is tested alone: a bell with how
+many are unread, a menu of them newest first that marks them read and clears them, a card for
+each one heard on the stream, and -- while the page is not looked at -- one the system shows,
+by the browser's `Notification` or by the macOS app. Each in Chromium and WebKit.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+import pytest
+from playwright.sync_api import expect
+
+from tests.integration.doubles_web import served
+from tests.system.web.doubles_browser import ENGINES, opened
+
+if TYPE_CHECKING:
+ from playwright.sync_api import Page, Route
+
+pytestmark = pytest.mark.parametrize("engine", ENGINES)
+
+#: What the macOS app's window says it is.
+_APP = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15"
+ " (KHTML, like Gecko) humanize-desktop/0.0.0"
+)
+
+#: A page that is never looked at, which the browser's and the app's notifications are stubbed in.
+_AWAY = """
+Object.defineProperty(document, 'hidden', { get: () => true })
+document.hasFocus = () => false
+window.shown = []
+window.Notification = class {
+ static permission = 'granted'
+ constructor(title, options) { window.shown.push({ title, ...options }) }
+ close() {}
+}
+window.__TAURI_INTERNALS__ = {
+ invoke: (command, args) => { window.shown.push({ command, ...args }); return Promise.resolve() },
+}
+"""
+
+
+def _item(id_: int, kind: str, **more: Any) -> dict[str, Any]:
+ return {
+ "id": id_,
+ "kind": kind,
+ "daemon": "local",
+ "workspace": "/w",
+ "epic": f"e{id_}",
+ "flow": "chat",
+ "task": "Fix it",
+ "role": "",
+ "text": "",
+ "at": "2026-10-12T10:00:00Z",
+ "read": False,
+ **more,
+ }
+
+
+@dataclass
+class Feed:
+ """The server's notifications, as the page is answered them, and what it was sent."""
+
+ items: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]])
+ heard: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]])
+ system: bool = True
+ sent: list[tuple[str, Any]] = field(default_factory=list[tuple[str, Any]])
+ asked: int = 0
+
+ def routes(self, page: Page) -> None:
+ page.route("**/api/notifications", self._list)
+ page.route("**/api/notifications/stream", self._stream)
+ page.route("**/api/notifications/settings", self._settings)
+ page.route("**/api/notifications/read", self._written)
+ page.route("**/api/notifications/clear", self._written)
+
+ def _list(self, route: Route) -> None:
+ unread = sum(not one["read"] for one in self.items)
+ route.fulfill(json={"items": self.items, "unread": unread})
+
+ def _stream(self, route: Route) -> None:
+ # What is heard is told once, and kept with the rest from then on, as the server does.
+ said = "retry: 60000\n\n" + "".join(
+ f"event: notification\nid: {one['id']}\ndata: {json.dumps(one)}\n\n"
+ for one in self.heard
+ )
+ self.items[:0] = reversed(self.heard)
+ self.heard = []
+ route.fulfill(
+ status=200,
+ headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
+ body=said,
+ )
+
+ def _settings(self, route: Route) -> None:
+ self.asked += 1
+ kinds = dict.fromkeys(("done", "failed", "stopped", "asked", "listening"), True)
+ route.fulfill(json={"kinds": kinds, "system": self.system})
+
+ def _written(self, route: Route) -> None:
+ request = route.request
+ what = request.url.rsplit("/", 1)[1]
+ said: dict[str, Any] = request.post_data_json or {}
+ self.sent.append((what, said))
+ if what == "clear":
+ self.items = []
+ for one in self.items:
+ one["read"] = (
+ one["read"] or said.get("all") or one["id"] in said.get("ids", [])
+ )
+ route.fulfill(json={})
+
+
+def test_the_bell_counts_the_unread_and_its_menu_reads_and_clears_them(
+ engine: str,
+) -> None:
+ feed = Feed(
+ items=[
+ _item(2, "asked", text="Which branch?"),
+ _item(1, "done", daemon="devbox", read=True),
+ ]
+ )
+ with served() as site, opened(site, engine=engine, routed=feed.routes) as page:
+ bell = page.locator("[data-hmz=notifications]")
+ expect(bell).to_be_visible()
+ expect(bell.locator("[data-hmz=notification-count]")).to_have_text("1")
+
+ bell.click()
+ menu = page.locator("[data-hmz=notification-menu]")
+ expect(menu.locator("[data-hmz=notification] .choice-label")).to_have_text(
+ ["Question · Local", "Done · devbox"]
+ )
+ expect(menu.locator("[data-hmz=notification]").first).to_contain_text(
+ "Which branch?"
+ )
+ expect(menu.locator(".notify-unread")).to_have_count(1)
+
+ menu.locator("[data-hmz=notifications-read]").click()
+ expect(menu.locator(".notify-unread")).to_have_count(0)
+ expect(bell.locator("[data-hmz=notification-count]")).to_be_hidden()
+ assert feed.sent == [("read", {"all": True})]
+
+ menu.locator("[data-hmz=notifications-clear]").click()
+ expect(menu.locator("[data-hmz=notification]")).to_have_count(0)
+ expect(menu).to_contain_text("Nothing yet")
+ assert feed.sent[-1] == ("clear", {})
+
+
+def test_an_item_picked_is_read_and_opens_what_it_is_about(engine: str) -> None:
+ feed = Feed(items=[_item(3, "listening", daemon="devbox", workspace="/w/app")])
+ with served() as site, opened(site, engine=engine, routed=feed.routes) as page:
+ page.locator("[data-hmz=notifications]").click()
+ page.locator("[data-hmz=notification]").click()
+ expect(page.locator("[data-hmz=notification-menu]")).to_have_count(0)
+ assert page.evaluate("location.hash") == "#/@devbox/live?workspace=%2Fw%2Fapp"
+ assert feed.sent == [("read", {"ids": [3]})]
+
+
+def test_a_notification_heard_is_a_card_that_opens_it(engine: str) -> None:
+ feed = Feed(heard=[_item(4, "failed", daemon="devbox", epic="big-fix")])
+ with served() as site, opened(site, engine=engine, routed=feed.routes) as page:
+ card = page.locator(
+ "[data-hmz=notification-toasts] [data-hmz=notification-toast]"
+ )
+ expect(card).to_have_count(1)
+ expect(card).to_contain_text("Failed · devbox")
+ expect(page.locator("[data-hmz=notification-count]")).to_have_text("1")
+ card.locator(".notify-open").click()
+ assert page.evaluate("location.hash") == "#/@devbox/epics/big-fix"
+ expect(card).to_have_count(0)
+ expect(page.locator("[data-hmz=notification-count]")).to_be_hidden()
+
+
+def test_there_is_no_bell_where_the_server_has_no_notifications(engine: str) -> None:
+ def older(page: Page) -> None:
+ # A server of an older humanize, which has none to give.
+ page.route(
+ re.compile(r".*/api/notifications(/.*|\?.*)?$"),
+ lambda route: route.fulfill(status=404, json={"error": "Not found."}),
+ )
+
+ with served() as site, opened(site, engine=engine, routed=older) as page:
+ expect(page.locator("[data-hmz=sidebar] .brand")).to_be_visible()
+ expect(page.locator("[data-hmz=notifications]")).to_be_hidden()
+
+
+def test_the_bell_is_there_where_the_server_has_notifications(engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ expect(page.locator("[data-hmz=notifications]")).to_be_visible()
+
+
+@pytest.mark.parametrize("system", [True, False])
+def test_the_browser_shows_one_while_the_page_is_not_looked_at(
+ engine: str, system: bool
+) -> None:
+ feed = Feed(heard=[_item(5, "done", text="All green")], system=system)
+ with (
+ served() as site,
+ opened(site, engine=engine, init=_AWAY, routed=feed.routes) as page,
+ ):
+ expect(page.locator("[data-hmz=notification-toast]")).to_have_count(1)
+ if system:
+ page.wait_for_function("window.shown.length > 0")
+ assert page.evaluate("window.shown") == [
+ {"title": "Done · Local", "body": "chat · All green", "tag": "5"}
+ ]
+ else:
+ # The settings are read before the stream is opened: once they are answered, the
+ # page has had all it needs to show one, and must have shown none.
+ while not feed.asked:
+ page.wait_for_timeout(50)
+ page.wait_for_timeout(500)
+ assert page.evaluate("window.shown") == []
+
+
+def test_the_app_shows_one_through_its_own_notifications(engine: str) -> None:
+ feed = Feed(heard=[_item(6, "asked", text="Ship it?")])
+ with (
+ served() as site,
+ opened(
+ site, engine=engine, user_agent=_APP, init=_AWAY, routed=feed.routes
+ ) as page,
+ ):
+ expect(page.locator("[data-hmz=notification-toast]")).to_have_count(1)
+ page.wait_for_function("window.shown.length > 0")
+ assert page.evaluate("window.shown") == [
+ {
+ "command": "notify",
+ "title": "Question · Local",
+ "body": "chat · Ship it?",
+ "address": "#/live?workspace=%2Fw",
+ }
+ ]
diff --git a/tests/system/web/test_web_page.py b/tests/system/web/test_web_page.py
index fe5ea1e8..aa3706b2 100644
--- a/tests/system/web/test_web_page.py
+++ b/tests/system/web/test_web_page.py
@@ -423,10 +423,10 @@ def test_the_colours_are_a_pair_under_auto_or_a_theme(
project: Path, engine: str
) -> None:
with served() as site, opened(site, engine=engine) as page:
- # A theme is chosen only in Settings: the machine's menu offers none.
- page.locator("[data-hmz=machine-menu]").click()
+ # A theme is chosen only in Settings: the daemon picker offers none.
+ page.locator("[data-hmz=daemon-bar]").click()
expect(page.locator("[role=menu] .choice-label")).to_have_text(
- ["Import", "Settings", "Usage", "Download"]
+ ["Local", "Manage", "Import", "Settings", "Usage", "Download"]
)
page.keyboard.press("Escape")
go(page, "/settings/themes")
@@ -598,11 +598,13 @@ def test_a_newer_release_is_offered_under_the_machine(
releases.fetch()
with served() as site, opened(site, engine=engine) as page:
expect(page.locator("[data-hmz=newer]")).to_be_visible()
- page.locator("[data-hmz=machine-menu]").click()
+ page.locator("[data-hmz=daemon-bar]").click()
expect(page.locator("[role=menu] .choice-label")).to_have_text(
- ["Update", "Import", "Settings", "Usage", "Download"]
+ ["Local", "Manage", "Update", "Import", "Settings", "Usage", "Download"]
+ )
+ expect(page.locator("[role=menu] .choice-about").last).to_have_text(
+ "hmz 99.0.0"
)
- expect(page.locator("[role=menu] .choice-about")).to_have_text(["hmz 99.0.0"])
def test_a_flow_is_opened_and_started_from_the_flows(
@@ -662,7 +664,7 @@ def left(page: Page, selector: str) -> float:
)
expect(page.locator(f":is({clicked})[data-tauri-drag-region]")).to_have_count(0)
# A menu opened in a row is not what the window is dragged by.
- page.locator("[data-hmz=machine-menu]").click()
+ page.locator("[data-hmz=daemon-bar]").click()
expect(page.locator("[role=menu]")).to_have_attribute(
"data-tauri-drag-region", "false"
)
@@ -850,7 +852,7 @@ def test_an_epic_backed_up_elsewhere_is_downloaded_and_opened(
) -> None:
name = backed_up_elsewhere(tmp_path, monkeypatch)
with served() as site, opened(site, engine=engine) as page:
- page.locator("[data-hmz=machine-menu]").click()
+ page.locator("[data-hmz=daemon-bar]").click()
page.get_by_role("menuitem", name="Download").click()
stored = page.locator(f"[data-hmz=stored-epic][data-epic='{name}']")
@@ -1012,9 +1014,9 @@ def gone() -> None:
page.wait_for_url(opened_on)
expect(listed).to_have_count(1)
- # Picked again, from the machine's menu: it is here already, and is opened from there.
+ # Picked again, from the daemon picker: it is here already, and is opened from there.
go(page, "/new")
- page.locator("[data-hmz=machine-menu]").click()
+ page.locator("[data-hmz=daemon-bar]").click()
with page.expect_file_chooser() as choosing:
page.get_by_role("menuitem", name="Import").click()
choosing.value.set_files(archive)
diff --git a/tests/system/web/test_web_pointer.py b/tests/system/web/test_web_pointer.py
index 4550c4b7..54121e61 100644
--- a/tests/system/web/test_web_pointer.py
+++ b/tests/system/web/test_web_pointer.py
@@ -231,9 +231,9 @@ def test_every_popup_opens_whole_within_the_window(project: Path, engine: str) -
page.keyboard.press("Escape")
page.keyboard.press("Escape")
- # The machine's menu, at the foot of the window: over its button, as wide as it.
- page.locator("[data-hmz=machine-menu]").click()
- _whole(page, page.get_by_role("menu", name="Machine"))
+ # The daemon picker, at the foot of the window: over its button, as wide as it.
+ page.locator("[data-hmz=daemon-bar]").click()
+ _whole(page, page.get_by_role("menu", name="Daemon"))
page.keyboard.press("Escape")
# A path browsed for from the folder a new epic opens, in a window too short for it.
diff --git a/tests/system/web/test_web_scoping.py b/tests/system/web/test_web_scoping.py
new file mode 100644
index 00000000..b77ad352
--- /dev/null
+++ b/tests/system/web/test_web_scoping.py
@@ -0,0 +1,152 @@
+"""`hmz web`'s page on one daemon at a time: the daemon is the first part of every address.
+
+`#/@/` is a view of that daemon, and an address naming none is of Local. Whatever
+the page asks of the daemon it is on goes under `/d//api/`, every link it draws keeps the
+daemon its address named, and going to another daemon's address tells the page it switched.
+Each in Chromium and in WebKit.
+"""
+
+from __future__ import annotations
+
+import re
+import urllib.parse
+from typing import TYPE_CHECKING
+
+import pytest
+from playwright.sync_api import expect
+
+from tests.integration.doubles_core import install
+from tests.integration.doubles_web import served
+from tests.system.web.doubles_browser import ENGINES, go, opened
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from playwright.sync_api import Page, Request, Route
+
+pytestmark = pytest.mark.parametrize("engine", ENGINES)
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path:
+ """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve."""
+ return install(tmp_path, monkeypatch)
+
+
+def _asked(page: Page) -> list[str]:
+ """Every path of the API the page asks from here on, as it asks it."""
+ paths: list[str] = []
+
+ def heard(request: Request) -> None:
+ path = urllib.parse.urlsplit(request.url).path
+ # What is the same whichever daemon is shown is asked of the page's own server.
+ if "/api/" in path and not re.match(r"/api/(daemons|notifications)\b", path):
+ paths.append(path)
+
+ page.on("request", heard)
+ return paths
+
+
+def _other(page: Page) -> None:
+ """A daemon named `other`, which is Local answering under another name."""
+
+ def answered(route: Route) -> None:
+ route.continue_(url=route.request.url.replace("/d/other/api/", "/d/local/api/"))
+
+ page.route(re.compile(r".*/d/other/api/.*"), answered)
+
+
+def test_an_address_naming_local_draws_every_view_and_keeps_naming_it(
+ project: Path, engine: str
+) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ asked = _asked(page)
+
+ go(page, "/@local/new")
+ expect(page.locator("[data-hmz=flow-picker] b")).to_have_text("chat")
+ nav = page.locator(".side-nav a")
+ expect(nav).to_have_count(3)
+ for one in nav.all():
+ expect(one).to_have_attribute("href", re.compile(r"^#/@local/"))
+ expect(page.locator(".side-top .brand")).to_have_attribute(
+ "href", "#/@local/new"
+ )
+
+ page.locator(".side-nav a[data-nav=flows]").click()
+ expect(page.locator("[data-hmz=flows] .card").first).to_be_visible()
+ assert page.evaluate("location.hash").startswith("#/@local/flows")
+
+ go(page, "/@local/settings/general")
+ dialog = page.locator("[data-hmz=dialog-settings]")
+ expect(dialog).to_be_visible()
+ expect(dialog.locator("nav a").first).to_have_attribute(
+ "href", re.compile(r"^#/@local/settings/")
+ )
+ page.keyboard.press("Escape")
+ expect(dialog).to_have_count(0)
+ assert page.evaluate("location.hash").startswith("#/@local/flows")
+
+ assert asked
+ assert all(path.startswith("/d/local/api/") for path in asked), asked
+
+
+def test_an_epic_started_on_a_daemon_stays_on_it(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ asked = _asked(page)
+ go(page, "/@local/new?flow=chat")
+ page.get_by_role("button", name="Options").click()
+ expect(
+ page.locator("[data-role=assistant] [data-hmz=pick-model]")
+ ).to_have_value("claude-haiku-4-5")
+ page.locator("[data-hmz=composer-text]").fill("hello there")
+ page.locator("[data-hmz=composer-text]").press("Enter")
+ page.wait_for_url(re.compile(r".*#/@local/epics/[^/]+/sessions/assistant%2F1$"))
+ expect(page.locator("[data-hmz=thread]")).to_contain_text("did: hello there")
+
+ # The epic's stream, and its links, are the daemon's.
+ assert any(path == "/d/local/api/held/stream" for path in asked), asked
+ expect(page.locator("[data-hmz=epic]").first).to_have_attribute(
+ "href", re.compile(r"^#/@local/epics/")
+ )
+ page.get_by_role("button", name="Stop").click()
+ page.get_by_role("button", name="Stop").click()
+ expect(page.locator("[data-hmz=bar] .badge")).to_have_text(
+ re.compile("stopped")
+ )
+
+
+def test_another_daemon_is_switched_to_and_back(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ _other(page)
+ page.evaluate(
+ """() => {
+ window.switched = []
+ addEventListener('hmz:daemon', (event) => switched.push(event.detail.daemon))
+ }"""
+ )
+ asked = _asked(page)
+
+ # The same daemon, named, is no switch.
+ go(page, "/@local/new")
+ assert page.evaluate("switched") == []
+
+ go(page, "/@other/flows")
+ assert page.evaluate("switched") == ["other"]
+ expect(page.locator("[data-hmz=flows] .card").first).to_be_visible()
+ expect(page.locator(".side-nav a[data-nav=new]")).to_have_attribute(
+ "href", "#/@other/new"
+ )
+ expect(page.locator("[data-hmz=workspace]").first).to_be_visible()
+ since = len(asked)
+ page.locator(".side-nav a[data-nav=new]").click()
+ expect(page.locator("[data-hmz=flow-picker] b")).to_have_text("chat")
+ assert page.evaluate("location.hash").startswith("#/@other/new?")
+ later = asked[since:]
+ assert later
+ assert all(path.startswith("/d/other/api/") for path in later), later
+
+ go(page, "/new")
+ assert page.evaluate("switched") == ["other", "local"]
+ expect(page.locator(".side-nav a[data-nav=flows]")).to_have_attribute(
+ "href", "#/flows"
+ )
diff --git a/tests/system/web/test_web_settings_daemons.py b/tests/system/web/test_web_settings_daemons.py
new file mode 100644
index 00000000..d70aeb28
--- /dev/null
+++ b/tests/system/web/test_web_settings_daemons.py
@@ -0,0 +1,178 @@
+"""Settings, per daemon.
+
+The app's own pages apart from a daemon's, which daemon those read and write, and what a page
+holds copied to several daemons at once.
+
+The daemons are the browser's to fake: the list of them is answered in the page's browser, and
+so is everything sent to the remote ones -- `gpu` mirrors what Local reads and takes whatever
+is written to it, and `lab` refuses everything, as a daemon that is not up does.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import TYPE_CHECKING, Any
+
+import pytest
+from playwright.sync_api import expect
+
+from tests.integration.doubles_core import install
+from tests.integration.doubles_web import served
+from tests.system.web.doubles_browser import ENGINES, go, opened
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from playwright.sync_api import Page, Route
+
+pytestmark = pytest.mark.parametrize("engine", ENGINES)
+
+#: Every daemon the page is told of: Local, one that is up and one that is not.
+_DAEMONS = {
+ "daemons": [
+ {"name": "local", "state": "up"},
+ {"name": "gpu", "host": "gpu.example", "state": "up"},
+ {"name": "lab", "host": "lab.example", "state": "failed"},
+ ]
+}
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path:
+ """The stand-in `claude` alone on PATH, asked what it runs, and a project to serve."""
+ return install(tmp_path, monkeypatch)
+
+
+def _daemons(page: Page) -> list[tuple[str, str, Any]]:
+ """Fakes every daemon but Local, keeping what each was asked: its name, method and body."""
+ asked: list[tuple[str, str, Any]] = []
+
+ def listed(route: Route) -> None:
+ route.fulfill(json=_DAEMONS)
+
+ def remote(route: Route) -> None:
+ request = route.request
+ found = re.match(r".*/d/([^/]+)(/api/.*)", request.url)
+ assert found
+ name, path = found.groups()
+ if name == "local":
+ # The page's own daemon, which it asks as it asks everything: no fake of it.
+ route.fallback()
+ return
+ body = json.loads(request.post_data) if request.post_data else None
+ asked.append((name, f"{request.method} {path.split('?')[0]}", body))
+ if name == "lab":
+ route.fulfill(status=409, json={"error": "lab is not up."})
+ elif request.method == "POST":
+ route.fulfill(json={})
+ else:
+ route.fulfill(response=route.fetch(url=request.url.replace("/d/gpu", "")))
+
+ page.route(re.compile(r".*/api/daemons$"), listed)
+ page.route(re.compile(r".*/d/[^/]+/api/.*"), remote)
+ return asked
+
+
+def test_the_app_and_the_daemon_are_set_apart(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ go(page, "/settings/general")
+ nav = page.locator(".settings-nav")
+ expect(nav.locator(".settings-group")).to_have_text(["App", "Daemon"])
+ expect(nav.locator("a:visible").first).to_have_text("Themes")
+ expect(nav.locator("a:visible")).to_contain_text(
+ ["Themes", "General", "Accounts", "Fallback", "Runtimes", "Backup"]
+ )
+ picker = page.locator("[data-hmz=settings-daemon]")
+ expect(picker.locator("option")).to_have_text(["Local"])
+ go(page, "/settings/themes")
+ expect(page.locator("[data-hmz=settings-daemon]")).to_have_count(0)
+
+
+def test_the_daemon_picked_is_the_one_read_and_written(
+ project: Path, engine: str
+) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ asked = _daemons(page)
+ go(page, "/settings/general")
+ picker = page.locator("[data-hmz=settings-daemon]")
+ expect(picker.locator("option")).to_have_text(["Local", "gpu", "lab"])
+ expect(picker).to_have_value("local")
+ assert not asked
+
+ picker.select_option("gpu")
+ expect(page.locator("[data-hmz=btw-whom]")).to_be_visible()
+ assert ("gpu", "GET /api/settings", None) in asked
+ assert ("gpu", "GET /api/backends", None) in asked
+ assert "daemon=gpu" in page.url
+ expect(page.locator(".settings-nav a", has_text="Backup")).to_have_attribute(
+ "href", "#/settings/backup?daemon=gpu"
+ )
+
+ page.get_by_role("button", name="Save").click()
+ expect(page.get_by_role("button", name="Saved")).to_be_visible()
+ assert any(one[:2] == ("gpu", "POST /api/settings") for one in asked)
+
+ # Kept in the address, the daemon is the one the page opens on again.
+ asked.clear()
+ go(page, "/settings/backup?daemon=gpu")
+ expect(page.locator("[data-hmz=backups] h2")).to_have_text("Targets")
+ expect(page.locator("[data-hmz=settings-daemon]")).to_have_value("gpu")
+ assert ("gpu", "GET /api/settings", None) in asked
+
+
+def test_batch_apply_sends_the_page_to_each_daemon_ticked(
+ project: Path, engine: str
+) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ asked = _daemons(page)
+ go(page, "/settings/general")
+ page.locator("[data-hmz=settings-general] input[type=checkbox]").check()
+ page.locator("[data-hmz=batch-apply]").click()
+ dialog = page.locator("[data-hmz=batch-dialog]")
+ expect(dialog.locator("label.check")).to_have_text(["All", "gpu", "lab"])
+ dialog.locator("[data-hmz=batch-all]").check()
+ dialog.locator("[data-hmz=batch-go]").click()
+
+ results = dialog.locator("[data-hmz=batch-result]")
+ expect(results.and_(page.locator("[data-daemon=gpu]"))).to_contain_text(
+ "Applied"
+ )
+ expect(results.and_(page.locator("[data-daemon=lab]"))).to_contain_text(
+ "lab is not up."
+ )
+ sent = [one for one in asked if one[1] == "POST /api/settings"]
+ assert sorted(one[0] for one in sent) == ["gpu", "lab"]
+ assert all(one[2]["details"] is True for one in sent)
+
+ # Closed, it leaves Settings open; nothing was written on Local.
+ page.keyboard.press("Escape")
+ expect(dialog).to_have_count(0)
+ expect(page.locator("[data-hmz=settings-general]")).to_be_visible()
+
+
+def test_accounts_keep_their_secrets_on_their_daemon(
+ project: Path, engine: str
+) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ _daemons(page)
+ go(page, "/settings/accounts")
+ expect(page.locator("[data-hmz=batch-apply]")).to_be_disabled()
+ expect(page.locator("[data-hmz=batch-off]")).to_have_text("Secrets stay put")
+ go(page, "/settings/usage")
+ expect(page.locator("[data-hmz=usage] .panel").first).to_be_visible()
+ expect(page.locator("[data-hmz=batch-apply]")).to_have_count(0)
+
+
+def test_settings_fit_a_phone(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine, width=390, height=800) as page:
+ _daemons(page)
+ go(page, "/settings/runtimes")
+ expect(page.locator("[data-hmz=settings-daemon]")).to_be_visible()
+ page.locator("[data-hmz=batch-apply]").click()
+ dialog = page.locator("[data-hmz=batch-dialog]")
+ expect(dialog).to_be_visible()
+ box = dialog.bounding_box()
+ assert box
+ assert box["x"] >= 0
+ assert box["x"] + box["width"] <= 390
diff --git a/tests/system/web/test_web_settings_pages.py b/tests/system/web/test_web_settings_pages.py
new file mode 100644
index 00000000..33e889e8
--- /dev/null
+++ b/tests/system/web/test_web_settings_pages.py
@@ -0,0 +1,261 @@
+"""`hmz web`'s Daemons and Notifications settings pages, driven in a browser.
+
+The page is the real one, served in the test's own process; what it asks of the daemons and the
+notification settings is answered by fakes routed in the browser, which keep what is written to
+them, so that what the page sent is what is checked. Each test runs in Chromium and in WebKit.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import urllib.parse
+from typing import TYPE_CHECKING, Any
+
+import pytest
+from playwright.sync_api import expect
+
+from tests.integration.doubles_core import install
+from tests.integration.doubles_web import served
+from tests.system.web.doubles_browser import ENGINES, go, opened
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from playwright.sync_api import Page, Request, Route
+
+pytestmark = pytest.mark.parametrize("engine", ENGINES)
+
+
+@pytest.fixture
+def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> Path:
+ """The stand-in `claude` alone on PATH, and a project to serve."""
+ return install(tmp_path, monkeypatch)
+
+
+class _Daemons:
+ """The daemon registry, as the page asks it: kept in a list, and every write recorded."""
+
+ def __init__(self) -> None:
+ self.daemons: list[dict[str, Any]] = [
+ {
+ "name": "box",
+ "host": "box.example",
+ "user": "me",
+ "port": 22,
+ "state": "up",
+ }
+ ]
+ self.hosts = [{"alias": "gpu", "host": "gpu.example"}, {"alias": "cpu"}]
+ self.writes: list[tuple[str, Any]] = []
+
+ def listed(self) -> dict[str, Any]:
+ return {"daemons": [{"name": "local", "state": "up"}, *self.daemons]}
+
+ def answer(self, route: Route) -> None:
+ path = urllib.parse.urlsplit(route.request.url).path
+ body = json.loads(route.request.post_data or "{}")
+ if route.request.method == "POST":
+ self.writes.append((path, body))
+ if path == "/api/daemons/hosts":
+ said: Any = {"hosts": self.hosts}
+ elif path == "/api/daemons/import":
+ self.daemons += [{"name": one, "host": one} for one in body["aliases"]]
+ self.hosts = [
+ one for one in self.hosts if one["alias"] not in body["aliases"]
+ ]
+ said = self.listed()
+ elif path == "/api/daemons/remove":
+ self.daemons = [one for one in self.daemons if one["name"] != body["name"]]
+ said = self.listed()
+ elif path.endswith("/connect"):
+ route.fulfill(status=409, json={"error": "it did not answer"})
+ return
+ elif route.request.method == "POST":
+ self.daemons = [one for one in self.daemons if one["name"] != body["name"]]
+ self.daemons.append({"name": body["name"], **body["fields"]})
+ said = self.listed()
+ else:
+ said = self.listed()
+ route.fulfill(json=said)
+
+
+def _daemons(page: Page) -> _Daemons:
+ fake = _Daemons()
+ page.route("**/api/daemons**", fake.answer)
+ return fake
+
+
+def test_a_daemon_is_added_a_part_at_a_time(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ fake = _daemons(page)
+ go(page, "/settings/daemons")
+ rows = page.locator("[data-hmz=daemon]")
+ expect(rows).to_have_count(2)
+ expect(rows.first).to_contain_text("Local")
+ expect(rows.first.get_by_role("button")).to_have_count(0)
+ form = page.locator("[data-hmz=write-daemon]")
+ form.get_by_label("Name").fill("lab")
+ form.get_by_label("Host").fill("lab.example")
+ form.get_by_label("User").fill("ann")
+ form.get_by_label("Port").fill("2222")
+ form.locator("#daemon-identity").fill("~/.ssh/lab")
+ form.get_by_label("Proxy jump").fill("me@jump")
+ form.get_by_role("button", name="Add", exact=True).first.click()
+ form.locator(".pair-key").fill("ServerAliveInterval")
+ form.locator(".pair-value").fill("30")
+ form.get_by_role("button", name="Add", exact=True).last.click()
+ expect(rows).to_have_count(3)
+ expect(page.locator("[data-hmz=daemon][data-name=lab]")).to_contain_text(
+ "ann@lab.example:2222"
+ )
+ assert fake.writes[-1] == (
+ "/api/daemons",
+ {
+ "name": "lab",
+ "fields": {
+ "host": "lab.example",
+ "user": "ann",
+ "port": 2222,
+ "identity_file": "~/.ssh/lab",
+ "proxy_jump": "me@jump",
+ "options": [["ServerAliveInterval", "30"]],
+ },
+ "replace": False,
+ },
+ )
+
+
+def test_hosts_are_imported_from_the_ssh_config(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ fake = _daemons(page)
+ go(page, "/settings/daemons")
+ importing = page.locator("[data-hmz=import-daemons]")
+ importing.get_by_role("button", name="Read SSH config").click()
+ importing.locator("[data-alias=gpu]").check()
+ importing.get_by_role("button", name="Import").click()
+ expect(page.locator("[data-hmz=daemon][data-name=gpu]")).to_be_visible()
+ assert fake.writes[-1] == ("/api/daemons/import", {"aliases": ["gpu"]})
+
+
+def test_a_daemon_is_removed_once_confirmed(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ fake = _daemons(page)
+ go(page, "/settings/daemons")
+ row = page.locator("[data-hmz=daemon][data-name=box]")
+ row.get_by_role("button", name="Remove").click()
+ assert not fake.writes
+ row.get_by_role("button", name="Cancel").click()
+ row.get_by_role("button", name="Remove").click()
+ row.get_by_role("button", name="Remove").click()
+ expect(row).to_have_count(0)
+ assert fake.writes == [("/api/daemons/remove", {"name": "box"})]
+
+
+def test_a_refused_connection_is_said_where_it_happened(
+ project: Path, engine: str
+) -> None:
+ with served() as site, opened(site, engine=engine) as page:
+ _daemons(page)
+ go(page, "/settings/daemons")
+ row = page.locator("[data-hmz=daemon][data-name=box]")
+ row.get_by_role("button", name="Connect").click()
+ expect(row.locator(".error")).to_have_text("it did not answer")
+
+
+def test_without_the_endpoints_the_pages_say_unavailable(
+ project: Path, engine: str
+) -> None:
+ def older(page: Page) -> None:
+ # A server of an older humanize, which has neither.
+ page.route(
+ re.compile(r".*/api/(daemons|notifications)(/.*|\?.*)?$"),
+ lambda route: route.fulfill(status=404, json={"error": "Not found."}),
+ )
+
+ with served() as site, opened(site, engine=engine, routed=older) as page:
+ for name in ("daemons", "notifications"):
+ go(page, f"/settings/{name}")
+ body = page.locator(f"[data-hmz=settings-{name}]")
+ expect(body.locator(".empty")).to_contain_text("Unavailable")
+ expect(body.locator(".error")).to_have_count(0)
+
+
+def _posted(request: Request) -> bool:
+ return request.method == "POST"
+
+
+#: The browser's notifications, as a page that was never asked sees them, granting when asked.
+_NOTIFICATION = """
+window.Notification = class { constructor() {} }
+window.Notification.permission = 'default'
+window.Notification.requestPermission = async () => {
+ window.__asked = (window.__asked || 0) + 1
+ window.Notification.permission = 'granted'
+ return 'granted'
+}
+"""
+
+
+def test_notification_kinds_are_saved_as_they_are_toggled(
+ project: Path, engine: str
+) -> None:
+ with served() as site, opened(site, engine=engine, init=_NOTIFICATION) as page:
+ saved: list[Any] = []
+ settings = {"kinds": {"done": True, "failed": False}, "system": True}
+
+ def answer(route: Route) -> None:
+ if route.request.method == "POST":
+ saved.append(json.loads(route.request.post_data or "{}"))
+ settings.update(saved[-1])
+ route.fulfill(json=settings)
+
+ page.route("**/api/notifications/settings", answer)
+ go(page, "/settings/notifications")
+ expect(page.locator("[data-hmz=notify-failed]")).not_to_be_checked()
+ expect(page.locator("[data-hmz=notify-asked]")).to_be_checked()
+ for label in ("Done", "Failed", "Stopped", "Questions", "Chat turns"):
+ expect(page.get_by_label(label, exact=True)).to_be_visible()
+ with page.expect_request(_posted):
+ page.get_by_label("Questions", exact=True).uncheck()
+ expect(page.locator("[data-hmz=notifications-said]")).to_have_text("Saved")
+ with page.expect_request(_posted):
+ page.locator("[data-hmz=notify-system]").uncheck()
+ expect(page.locator("[data-hmz=notifications-said]")).to_have_text("Saved")
+ assert saved == [
+ {
+ "kinds": {
+ "done": True,
+ "failed": False,
+ "stopped": True,
+ "asked": False,
+ "listening": True,
+ },
+ "system": True,
+ },
+ {
+ "kinds": {
+ "done": True,
+ "failed": False,
+ "stopped": True,
+ "asked": False,
+ "listening": True,
+ },
+ "system": False,
+ },
+ ]
+
+
+def test_notifications_are_allowed_from_a_click(project: Path, engine: str) -> None:
+ with served() as site, opened(site, engine=engine, init=_NOTIFICATION) as page:
+ page.route(
+ "**/api/notifications/settings",
+ lambda route: route.fulfill(json={"kinds": {}, "system": True}),
+ )
+ go(page, "/settings/notifications")
+ state = page.locator("[data-hmz=permission-state]")
+ expect(state).to_have_text("Not asked")
+ page.locator("[data-hmz=allow-notifications]").click()
+ expect(state).to_have_text("Allowed")
+ expect(page.locator("[data-hmz=allow-notifications]")).to_have_count(0)
+ assert page.evaluate("window.__asked") == 1
diff --git a/tests/unit/runtime/doing/test_hosting.py b/tests/unit/runtime/doing/test_hosting.py
index 16b0843c..0b12a513 100644
--- a/tests/unit/runtime/doing/test_hosting.py
+++ b/tests/unit/runtime/doing/test_hosting.py
@@ -1039,6 +1039,57 @@ def test_a_question_is_put_to_everybody_and_answered_by_number(
run.released.set()
+def test_the_status_says_the_questions_waited_on_and_how_the_last_run_ended(
+ host: Host, where: Workspace, outworlders: Outworlders, tmp_path: Path
+) -> None:
+ alice = Told(host, "alice")
+ where.next_run = FakeRun(epic=tmp_path / "20260101-ralph")
+ run = _started(alice, where)
+ assert outworlders.ask is not None
+ said = host.status()
+ assert (said["epic"], said["task"], said["pending"], said["ended"]) == (
+ "20260101-ralph",
+ "fix it",
+ [],
+ None,
+ )
+
+ asking = Asking()(outworlders.ask, Question("which?", ("red", "blue")))
+ asked = alice.told("asked", run=1)
+
+ assert host.status()["pending"] == [
+ {
+ "question": asked["question"],
+ "role": "outworlder",
+ "text": "which?",
+ "mode": "ask",
+ }
+ ]
+ alice.asks("answer", question=asked["question"], text="1")
+ assert asking.done.wait(PATIENCE)
+ run.raises = BudgetExceeded("spent")
+ run.released.set()
+ alice.told("ended", run=1)
+ said = host.status()
+ assert said["pending"] == []
+ ended = said["ended"]
+ assert (
+ ended["how"],
+ ended["epic"],
+ ended["run"],
+ ended["flow"],
+ ended["task"],
+ ) == (
+ "budget",
+ "20260101-ralph",
+ 1,
+ "ralph",
+ "fix it",
+ )
+ assert "spent" in ended["why"]
+ assert ended["at"] > 0
+
+
def test_a_question_of_a_claimed_role_is_its_claimants(
host: Host, where: Workspace, outworlders: Outworlders
) -> None:
diff --git a/tests/unit/web/test_daemons.py b/tests/unit/web/test_daemons.py
new file mode 100644
index 00000000..5ae9634c
--- /dev/null
+++ b/tests/unit/web/test_daemons.py
@@ -0,0 +1,219 @@
+"""`hmz.web.daemons`: the daemons a page may switch between, written down and brought in."""
+
+from __future__ import annotations
+
+import copy
+from types import SimpleNamespace
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+from hmz.web.daemons import LOCAL, ROUTES, found, listed, saved
+from hmz.web.routing import Asked, Refusal
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+@pytest.fixture
+def held(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
+ """The settings file, as a dict that `read` and `changes` read and write."""
+ from hmz.coganchor import settings
+
+ file: dict[str, Any] = {"other": {"kept": True}}
+
+ def changes(change: Callable[[dict[str, Any]], None]) -> dict[str, Any]:
+ working = copy.deepcopy(file)
+ change(working)
+ file.clear()
+ file.update(working)
+ return file
+
+ monkeypatch.setattr(settings, "read", lambda: copy.deepcopy(file))
+ monkeypatch.setattr(settings, "changes", changes)
+ return file
+
+
+@pytest.fixture
+def config(monkeypatch: pytest.MonkeyPatch) -> list[str]:
+ """The hosts the ssh config names, each resolving to `.example`."""
+ from hmz.coganchor.machines import sshconfig
+
+ named = ["gpu", "box.one", "local", "a+b", "a-b", "__", "x$y"]
+
+ def resolve(alias: str, *_: object, **__: object) -> sshconfig.SSHHost:
+ return sshconfig.SSHHost(
+ alias=alias, host=f"{alias}.example", user="me", port=22
+ )
+
+ def aliases(*_: object) -> list[str]:
+ return list(named)
+
+ monkeypatch.setattr(sshconfig, "aliases", aliases)
+ monkeypatch.setattr(sshconfig, "resolve", resolve)
+ return named
+
+
+def _asks(method: str, path: str, body: dict[str, Any] | None = None) -> Any:
+ route = next(one for one in ROUTES if one.match(method, path) is not None)
+ return route.answers(Asked(site=SimpleNamespace(), body=body or {}))
+
+
+def test_local_is_first_and_up_with_nothing_written_down(held: dict[str, Any]) -> None:
+ daemons = listed(SimpleNamespace())["daemons"]
+
+ assert [(one["name"], one["label"], one["state"]) for one in daemons] == [
+ (LOCAL, "Local", "up")
+ ]
+
+
+def test_a_daemon_is_written_with_its_options_as_pairs(held: dict[str, Any]) -> None:
+ said = _asks(
+ "POST",
+ "/api/daemons",
+ {
+ "name": "gpu",
+ "fields": {
+ "host": "gpu.example",
+ "port": 2222,
+ "options": [["Compression", "yes"]],
+ },
+ },
+ )
+
+ assert [one["name"] for one in said["daemons"]] == [LOCAL, "gpu"]
+ assert said["daemons"][1]["state"] == "idle"
+ assert held["other"] == {"kept": True}
+ assert held["daemons"] == [
+ {
+ "name": "gpu",
+ "host": "gpu.example",
+ "user": "",
+ "port": 2222,
+ "identity_file": "",
+ "proxy_jump": "",
+ "alias": "",
+ "config": "",
+ "options": [["Compression", "yes"]],
+ }
+ ]
+ assert found("gpu") == held["daemons"][0]
+ assert found("nope") is None
+
+
+def test_one_there_already_is_replaced_only_when_asked(held: dict[str, Any]) -> None:
+ _asks("POST", "/api/daemons", {"name": "gpu", "fields": {"host": "a"}})
+
+ with pytest.raises(Refusal) as refused:
+ _asks("POST", "/api/daemons", {"name": "gpu", "fields": {"host": "b"}})
+ assert refused.value.status == 409
+ _asks(
+ "POST",
+ "/api/daemons",
+ {"name": "gpu", "fields": {"host": "b"}, "replace": True},
+ )
+
+ assert [one["host"] for one in saved()] == ["b"]
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ {"name": "local", "fields": {"host": "a"}},
+ {"name": "Local", "fields": {"host": "a"}},
+ {"name": "a b", "fields": {"host": "a"}},
+ {"name": "..", "fields": {"host": "a"}},
+ {"name": "gpu", "fields": {}},
+ {"name": "gpu", "fields": {"host": "a", "workdir": "/w"}},
+ {"name": "gpu", "fields": {"host": "a"}, "secret": "x"},
+ {"name": "gpu", "fields": {"host": "a", "port": "22"}},
+ {"name": "gpu", "fields": {"host": "a", "options": [["Port", "22"]]}},
+ ],
+)
+def test_what_cannot_be_a_daemon_is_refused(
+ held: dict[str, Any], body: dict[str, Any]
+) -> None:
+ with pytest.raises(Refusal):
+ _asks("POST", "/api/daemons", body)
+
+ assert "daemons" not in held
+
+
+def test_a_daemon_is_removed_and_local_never_is(held: dict[str, Any]) -> None:
+ _asks("POST", "/api/daemons", {"name": "gpu", "fields": {"host": "a"}})
+
+ with pytest.raises(Refusal):
+ _asks("POST", "/api/daemons/remove", {"name": "local"})
+ with pytest.raises(Refusal) as missing:
+ _asks("POST", "/api/daemons/remove", {"name": "nope"})
+ assert missing.value.status == 404
+ said = _asks("POST", "/api/daemons/remove", {"name": "gpu"})
+
+ assert [one["name"] for one in said["daemons"]] == [LOCAL]
+ assert held["daemons"] == []
+
+
+def test_one_written_wrong_by_hand_is_left_out(held: dict[str, Any]) -> None:
+ held["daemons"] = [
+ "nonsense",
+ {"name": "local", "host": "a"},
+ {"name": "ok", "host": "a"},
+ {"name": "ok", "host": "b"},
+ {"host": "nameless"},
+ ]
+
+ assert [(one["name"], one["host"]) for one in saved()] == [("ok", "a")]
+
+
+def test_each_stands_as_the_remote_reach_says(held: dict[str, Any]) -> None:
+ held["daemons"] = [{"name": "gpu", "host": "a"}]
+
+ def state(name: str) -> dict[str, str]:
+ return {"state": "failed", "detail": f"{name} down", "version": ""}
+
+ remotes = SimpleNamespace(state=state)
+
+ daemons = listed(SimpleNamespace(remotes=remotes))["daemons"]
+
+ assert daemons[0]["state"] == "up"
+ assert (daemons[1]["state"], daemons[1]["detail"]) == ("failed", "gpu down")
+
+
+def test_hosts_not_yet_added_are_offered(
+ held: dict[str, Any], config: list[str]
+) -> None:
+ held["daemons"] = [{"name": "g", "alias": "gpu"}]
+
+ hosts = _asks("GET", "/api/daemons/hosts")["hosts"]
+
+ # Of two called the same, the first; never one no daemon could be.
+ assert [(one["alias"], one["name"], one["host"]) for one in hosts] == [
+ ("box.one", "box.one", "box.one.example"),
+ ("a+b", "a-b", "a+b.example"),
+ ]
+
+
+def test_hosts_are_imported_by_alias_skipping_any_already_there(
+ held: dict[str, Any], config: list[str]
+) -> None:
+ held["daemons"] = [{"name": "gpu", "host": "a"}]
+
+ said = _asks("POST", "/api/daemons/import", {"aliases": ["gpu", "box.one"]})
+
+ assert said["added"] == ["box.one"]
+ assert [(one["name"], one["alias"], one["host"]) for one in saved()] == [
+ ("gpu", "", "a"),
+ ("box.one", "box.one", ""),
+ ]
+
+
+@pytest.mark.parametrize(
+ "body", [{"aliases": []}, {"aliases": ["elsewhere"]}, {"aliases": ["local"]}]
+)
+def test_an_import_of_nothing_or_of_a_host_it_cannot_be_is_refused(
+ held: dict[str, Any], config: list[str], body: dict[str, Any]
+) -> None:
+ with pytest.raises(Refusal):
+ _asks("POST", "/api/daemons/import", body)
+
+ assert "daemons" not in held
diff --git a/tests/unit/web/test_notices.py b/tests/unit/web/test_notices.py
new file mode 100644
index 00000000..7d28dba5
--- /dev/null
+++ b/tests/unit/web/test_notices.py
@@ -0,0 +1,274 @@
+"""`hmz.web.notices`: what is news in how the runs stand, against how they stood."""
+
+from __future__ import annotations
+
+import threading
+import time
+from typing import Any
+
+import pytest
+
+from hmz.web.notices import Notices
+from hmz.web.routing import Refusal
+
+
+class Statuses:
+ """How the runs stand, as each look is told: changed by the test between looks."""
+
+ def __init__(self) -> None:
+ self.now: dict[str, dict[str, Any]] = {}
+
+ def __call__(self) -> dict[str, dict[str, Any]]:
+ return {where: dict(status) for where, status in self.now.items()}
+
+ def stands(
+ self,
+ workspace: str = "/w",
+ *,
+ pending: list[dict[str, Any]] | None = None,
+ ended: dict[str, Any] | None = None,
+ ) -> None:
+ self.now[workspace] = {
+ "state": "running",
+ "flow": "ralph",
+ "epic": "e1",
+ "task": "fix it",
+ "pending": pending or [],
+ "ended": ended,
+ }
+
+
+def _ended(how: str, at: float | None = None, why: str = "") -> dict[str, Any]:
+ """An epic that ended as `how`, at `at` -- or now."""
+ return {
+ "how": how,
+ "why": why,
+ "epic": "e1",
+ "at": time.time() if at is None else at,
+ "flow": "ralph",
+ "task": "t",
+ }
+
+
+def _asked(question: str, mode: str = "ask") -> dict[str, Any]:
+ return {"question": question, "role": "outworlder", "text": "which?", "mode": mode}
+
+
+@pytest.fixture
+def statuses() -> Statuses:
+ return Statuses()
+
+
+@pytest.fixture
+def notices(statuses: Statuses) -> Notices:
+ return Notices(statuses)
+
+
+def test_what_stood_at_the_start_is_no_news(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands(pending=[_asked("q1")], ended=_ended("done"))
+
+ assert notices.look() == []
+ assert notices.look() == []
+ assert notices.since() == []
+ assert notices.last == 0
+
+
+@pytest.mark.parametrize(
+ ("how", "kind"),
+ [
+ ("done", "done"),
+ ("stopped", "stopped"),
+ ("failed", "failed"),
+ ("crashed", "failed"),
+ ("budget", "failed"),
+ ("refused", "failed"),
+ ],
+)
+def test_an_epic_ending_is_a_notice_of_how(
+ how: str, kind: str, statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands()
+ notices.look()
+ statuses.stands(ended=_ended(how, at=time.time() + 1, why="because"))
+
+ (made,) = notices.look()
+
+ assert made["kind"] == kind
+ assert (made["workspace"], made["epic"], made["flow"], made["task"]) == (
+ "/w",
+ "e1",
+ "ralph",
+ "t",
+ )
+ assert (made["role"], made["text"]) == ("", "because")
+ assert made["at"] > time.time()
+ assert made["id"] == notices.last
+ assert notices.look() == []
+
+
+def test_another_epic_ending_after_is_another_notice(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands(ended=_ended("done"))
+ notices.look()
+ statuses.stands(ended=_ended("failed", at=time.time() + 1))
+
+ assert [one["kind"] for one in notices.look()] == ["failed"]
+
+
+def test_a_question_is_asked_and_a_chat_waiting_is_listening(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands(pending=[_asked("q1")])
+ notices.look()
+ statuses.stands(
+ pending=[_asked("q1"), _asked("q2"), _asked("q3", "listen"), _asked("q4", "x")]
+ )
+
+ made = notices.look()
+
+ assert [one["kind"] for one in made] == ["asked", "listening"]
+ assert (made[0]["role"], made[0]["text"], made[0]["epic"]) == (
+ "outworlder",
+ "which?",
+ "e1",
+ )
+
+
+def test_a_workspace_first_seen_later_is_news(
+ statuses: Statuses, notices: Notices
+) -> None:
+ notices.look()
+ statuses.stands("/v", ended=_ended("done"))
+
+ assert [one["workspace"] for one in notices.look()] == ["/v"]
+
+
+def test_an_epic_that_ended_before_the_start_is_no_news_wherever_it_is_seen(
+ statuses: Statuses, notices: Notices
+) -> None:
+ notices.look()
+ statuses.stands("/v", ended=_ended("done", at=1.0))
+
+ assert notices.look() == []
+
+
+def test_a_crash_is_said_in_the_last_line_of_why(
+ statuses: Statuses, notices: Notices
+) -> None:
+ notices.look()
+ statuses.stands(ended=_ended("crashed", why="Traceback:\n here\nValueError: no\n"))
+
+ assert notices.look()[0]["text"] == "ValueError: no"
+
+
+def test_another_host_asking_a_question_of_the_same_id_is_news(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands(pending=[_asked("q1")])
+ statuses.now["/w"] |= {"pid": 1, "started": "then"}
+ notices.look()
+ statuses.stands(pending=[_asked("q1")])
+ statuses.now["/w"] |= {"pid": 2, "started": "now"}
+
+ assert [one["kind"] for one in notices.look()] == ["asked"]
+
+
+def test_a_host_that_would_not_answer_is_not_taken_for_one_that_went(
+ statuses: Statuses, notices: Notices
+) -> None:
+ ended = _ended("done", at=time.time() + 1)
+ statuses.stands()
+ notices.look()
+ statuses.stands(ended=ended)
+ notices.look()
+ statuses.now["/w"] = {"attached": 0, "flows": [], "calls": []}
+ notices.look()
+ statuses.stands(ended=ended)
+
+ assert notices.look() == []
+
+
+def test_notices_are_numbered_and_read_after_the_last_heard(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands()
+ notices.look()
+ statuses.stands(pending=[_asked("q1")])
+ (first,) = notices.look()
+ statuses.stands(pending=[_asked("q2")])
+ (second,) = notices.look()
+
+ assert second["id"] > first["id"]
+ assert [one["id"] for one in notices.since()] == [first["id"], second["id"]]
+ assert notices.since(str(first["id"])) == [second]
+ assert notices.since(second["id"]) == []
+ with pytest.raises(Refusal):
+ notices.since("soon")
+
+
+def _fails(_one: dict[str, Any]) -> None:
+ raise RuntimeError
+
+
+def test_a_listener_hears_each_notice_until_it_stops(
+ statuses: Statuses, notices: Notices
+) -> None:
+ heard: list[dict[str, Any]] = []
+ statuses.stands()
+ notices.look()
+ unheard = notices.heard(heard.append)
+ notices.heard(_fails)
+ statuses.stands(pending=[_asked("q1")])
+ notices.look()
+ unheard()
+ statuses.stands(pending=[_asked("q2")])
+ notices.look()
+
+ assert [one["kind"] for one in heard] == ["asked"]
+
+
+def test_only_the_last_two_hundred_are_kept(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands()
+ notices.look()
+ for one in range(205):
+ statuses.stands(pending=[_asked(f"q{one}")])
+ notices.look()
+
+ assert len(notices.since()) == 200
+
+
+def test_a_stream_carries_on_after_the_last_heard(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands()
+ notices.look()
+ statuses.stands(pending=[_asked("q1")])
+ (first,) = notices.look()
+ going = threading.Event()
+ events = notices.stream().events("0", going)
+
+ assert next(events) == (str(first["id"]), "notice", first)
+ statuses.stands(pending=[_asked("q2")])
+ (second,) = notices.look()
+ assert next(events) == (str(second["id"]), "notice", second)
+ notices.close()
+ assert list(events) == []
+
+
+def test_a_stream_heard_from_nothing_starts_from_now(
+ statuses: Statuses, notices: Notices
+) -> None:
+ statuses.stands()
+ notices.look()
+ statuses.stands(pending=[_asked("q1")])
+ notices.look()
+ events = notices.stream().events("", threading.Event())
+ statuses.stands(pending=[_asked("q2")])
+ (second,) = notices.look()
+
+ assert next(events) == (str(second["id"]), "notice", second)
diff --git a/tests/unit/web/test_notifications.py b/tests/unit/web/test_notifications.py
new file mode 100644
index 00000000..cf2ce631
--- /dev/null
+++ b/tests/unit/web/test_notifications.py
@@ -0,0 +1,194 @@
+"""`hmz.web.notifications`: every daemon's notices, kept, filtered, read and streamed."""
+
+from __future__ import annotations
+
+import threading
+from types import SimpleNamespace
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+from hmz.web.notifications import KINDS, ROUTES, Notifications, daemons, preferences
+from hmz.web.routing import Asked, Refusal
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Generator
+
+
+class _Notices:
+ """A stand-in for this machine's notices: what hears them, told each as it is said."""
+
+ def __init__(self) -> None:
+ self.hearing: list[Callable[[Any], object]] = []
+
+ def heard(self, callback: Callable[[Any], object]) -> None:
+ self.hearing.append(callback)
+
+ def says(self, said: Any) -> None:
+ for one in self.hearing:
+ one(said)
+
+
+def _notice(kind: str = "done", **more: Any) -> dict[str, Any]:
+ return {"id": "7", "kind": kind, "workspace": "/w", "epic": "e", "text": ""} | more
+
+
+@pytest.fixture
+def notices() -> _Notices:
+ return _Notices()
+
+
+@pytest.fixture
+def wanted() -> dict[str, Any]:
+ return preferences({})
+
+
+@pytest.fixture
+def held(notices: _Notices, wanted: dict[str, Any]) -> Generator[Notifications]:
+ made = Notifications(
+ SimpleNamespace(notices=notices), wanted=lambda: wanted, named=list
+ )
+ yield made
+ made.close()
+
+
+def test_every_kind_is_told_and_shown_where_nothing_is_set() -> None:
+ assert preferences({}) == {
+ "kinds": dict.fromkeys(KINDS, True),
+ "system": True,
+ }
+
+
+def test_a_kind_turned_off_is_off_and_anything_else_is_on() -> None:
+ said = {"notifications": {"kinds": {"done": False, "asked": "no"}, "system": False}}
+
+ assert preferences(said) == {
+ "kinds": dict.fromkeys(KINDS, True) | {"done": False},
+ "system": False,
+ }
+
+
+def test_settings_not_written_as_notifications_are_every_one_wanted() -> None:
+ assert preferences({"notifications": ["done"]}) == preferences({})
+
+
+def test_ssh_daemons_are_named_as_written_and_local_is_never_one() -> None:
+ held = {
+ "daemons": [
+ {"name": "gpu"},
+ {"name": "local"},
+ "box",
+ {"host": "x"},
+ {"name": "b"},
+ ]
+ }
+
+ assert daemons(held) == ["gpu", "b"]
+ assert daemons({}) == []
+
+
+def test_this_machines_notices_are_kept_as_local_newest_first(
+ held: Notifications, notices: _Notices
+) -> None:
+ notices.says(_notice("done", epic="a"))
+ notices.says([_notice("asked", epic="b")])
+
+ listed = held.listed()
+
+ assert [(one["daemon"], one["epic"], one["kind"]) for one in listed["items"]] == [
+ ("local", "b", "asked"),
+ ("local", "a", "done"),
+ ]
+ assert [one["id"] for one in listed["items"]] == ["2", "1"]
+ assert listed["unread"] == 2
+ assert not any(one["read"] for one in listed["items"])
+
+
+def test_a_kind_not_wanted_is_never_kept(
+ held: Notifications, wanted: dict[str, Any]
+) -> None:
+ wanted["kinds"]["listening"] = False
+
+ assert held.told("gpu", _notice("listening")) is None
+ assert held.told("gpu", _notice("failed")) is not None
+ assert [one["kind"] for one in held.listed()["items"]] == ["failed"]
+
+
+def test_only_the_last_few_hundred_are_kept(held: Notifications) -> None:
+ for _ in range(250):
+ held.told("local", _notice())
+
+ items = held.listed()["items"]
+
+ assert len(items) == 200
+ assert items[0]["id"] == "250"
+
+
+def test_notifications_are_read_by_id_or_all_at_once(held: Notifications) -> None:
+ first = held.told("local", _notice())
+ held.told("gpu", _notice())
+ assert first is not None
+
+ assert held.read([first["id"]])["unread"] == 1
+ assert held.read()["unread"] == 0
+
+
+def test_clearing_forgets_every_one_and_the_next_counts_on(
+ held: Notifications,
+) -> None:
+ held.told("local", _notice())
+
+ assert held.clear() == {"items": [], "unread": 0}
+ told = held.told("local", _notice())
+ assert told is not None
+ assert told["id"] == "2"
+
+
+def test_the_stream_takes_up_after_the_last_heard_then_tells_each_as_it_comes(
+ held: Notifications,
+) -> None:
+ held.told("local", _notice(epic="a"))
+ held.told("gpu", _notice(epic="b"))
+ going = threading.Event()
+ events = held.stream().events("1", going)
+
+ assert next(events) == ("2", "notification", held.listed()["items"][0])
+ threading.Timer(0.05, held.told, args=("gpu", _notice(epic="c"))).start()
+ said = next(events)
+ assert said is not None
+ ident, name, data = said
+
+ assert (ident, name, data["epic"], data["daemon"]) == (
+ "3",
+ "notification",
+ "c",
+ "gpu",
+ )
+
+
+def test_a_stream_heard_of_an_earlier_interface_starts_over(
+ held: Notifications,
+) -> None:
+ held.told("local", _notice())
+
+ events = held.stream().events("90", threading.Event())
+
+ said = next(events)
+ assert said is not None
+ assert said[0] == "1"
+
+
+def test_a_page_marks_read_by_ids_or_all_and_nothing_else(
+ held: Notifications,
+) -> None:
+ (read,) = [one for one in ROUTES if one.path == "/api/notifications/read"]
+ site = SimpleNamespace(notifications=held)
+ held.told("local", _notice())
+ held.told("local", _notice())
+
+ assert read.answers(Asked(site, body={"ids": [1]}))["unread"] == 1
+ assert read.answers(Asked(site, body={"all": True}))["unread"] == 0
+ with pytest.raises(Refusal):
+ read.answers(Asked(site, body={"ids": "1"}))
+ with pytest.raises(Refusal, match="not kept"):
+ read.answers(Asked(SimpleNamespace(), body={"all": True}))
diff --git a/tests/unit/web/test_remote.py b/tests/unit/web/test_remote.py
new file mode 100644
index 00000000..2ad215c7
--- /dev/null
+++ b/tests/unit/web/test_remote.py
@@ -0,0 +1,27 @@
+"""What a host says it has, and what a request to its interface carries to be let in."""
+
+from __future__ import annotations
+
+from hmz.web.remote import headers, said
+
+
+def test_a_probe_is_read_by_what_each_line_says() -> None:
+ assert said(
+ [
+ "Warning: Permanently added 'box' to the list of known hosts.",
+ "hmz: 0.5.0rc1",
+ "daemon: running",
+ "something: else",
+ ]
+ ) == {"hmz": "0.5.0rc1", "daemon": "running"}
+ assert said(["hmz: ", "daemon: none"]) == {"hmz": "", "daemon": "none"}
+ assert said([]) == {}
+
+
+def test_a_request_there_is_its_own_page_s() -> None:
+ assert headers(8123, "k3y") == {
+ "Host": "127.0.0.1:8123",
+ "Origin": "http://127.0.0.1:8123",
+ "Sec-Fetch-Site": "same-origin",
+ "Cookie": "hmz-8123=k3y",
+ }